patch oko teksta PDFa i UI tablica
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled

This commit is contained in:
mariomitte
2026-07-12 03:25:03 +02:00
parent 3b54499b17
commit 4e3bf6dab0
40 changed files with 2149 additions and 331 deletions

View File

@@ -1,6 +1,6 @@
import { useStore } from '@nanostores/preact';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { animated, useSpring } from '@react-spring/web';
import { animated, useSpring, useTransition } from '@react-spring/web';
import DashboardTopbar from './DashboardTopbar';
import WorkOrderModal from './WorkOrderModal';
import TaskCreateModal from './TaskCreateModal';
@@ -112,6 +112,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const [taskScope, setTaskScope] = useState('all');
const [workOrderScope, setWorkOrderScope] = useState('context');
const [selectedCraneRowId, setSelectedCraneRowId] = useState(null);
const [expandedServiceTaskId, setExpandedServiceTaskId] = useState(null);
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
@@ -270,12 +271,50 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|| contextCrane?.client_name
|| null
), [clients, selectedClientId, contextCrane]);
const contextCraneLabel = useMemo(() => {
if (!hydratedContextCrane) return null;
const serial = String(hydratedContextCrane.crane_serial_number || '').trim();
if (serial) return `SN ${serial}`;
return hydratedContextCrane.registration_number || '-';
}, [hydratedContextCrane]);
const serviceRecordsForContext = useMemo(() => {
if (!selectedVehicleId) return [];
return [...serviceRecords]
.filter((record) => String(record.vehicle) === String(selectedVehicleId))
.sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || '')));
}, [serviceRecords, selectedVehicleId]);
const serviceTaskGroups = useMemo(() => {
if (!selectedVehicleId) return [];
const contextTasks = [...tasks]
.filter((task) => String(task.vehicle || '') === String(selectedVehicleId))
.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
const groups = contextTasks.map((task) => ({
id: String(task.id),
title: task.title || 'Radni zadatak',
status: task.status || '-',
task,
records: serviceRecordsForContext.filter((record) => String(record.task || '') === String(task.id)),
}));
const unassignedRecords = serviceRecordsForContext.filter((record) => !record.task);
if (unassignedRecords.length > 0) {
groups.push({
id: 'unassigned-task',
title: 'Bez radnog zadatka',
status: '-',
task: null,
records: unassignedRecords,
});
}
return groups;
}, [selectedVehicleId, tasks, serviceRecordsForContext]);
const serviceTaskTransitions = useTransition(serviceTaskGroups, {
keys: (group) => group.id,
from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
trail: 30,
config: { tension: 230, friction: 26 },
});
const scopedWorkOrders = useMemo(() => {
const sorted = [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
if (workOrderScope === 'all') {
@@ -363,6 +402,14 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
setCurrentPage(1);
}, [workOrderScope, hydratedSelectedVehicleId]);
useEffect(() => {
if (!expandedServiceTaskId) return;
const exists = serviceTaskGroups.some((group) => String(group.id) === String(expandedServiceTaskId));
if (!exists) {
setExpandedServiceTaskId(null);
}
}, [expandedServiceTaskId, serviceTaskGroups]);
useEffect(() => {
if (!selectedCraneRowId) return;
const exists = cranes.some((item) => String(item.id) === String(selectedCraneRowId));
@@ -421,6 +468,27 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
showToast('Servisni kontekst je postavljen.', 'success');
}
async function openTaskModalFromContext() {
if (!selectedClientId || !selectedVehicleId) {
showToast('Odaberite kupca i dizalicu u Servisnom kontekstu prije kreiranja radnog zadatka.', 'warning');
return;
}
try {
const availableCranes = await ensureVehiclesCatalog();
const selectedFromContext = availableCranes.find(
(item) => String(item.id) === String(selectedVehicleId)
);
if (!selectedFromContext) {
showToast('Odabrana dizalica nije dostupna u trenutnom kontekstu.', 'warning');
return;
}
} catch {
showToast('Ne mogu dohvatiti dizalice. Provjerite mrežu ili prijavu.', 'error');
return;
}
setIsTaskModalOpen(true);
}
const workOrderColumns = [
{ key: 'code', label: 'Putni nalog', className: 'px-4 py-3' },
{ key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' },
@@ -474,26 +542,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
}
setIsWorkOrderModalOpen(true);
}}
onOpenTaskModal={async () => {
if (!selectedClientId || !selectedVehicleId) {
showToast('Odaberite kupca i dizalicu u Servisnom kontekstu prije kreiranja radnog zadatka.', 'warning');
return;
}
try {
const availableCranes = await ensureVehiclesCatalog();
const selectedFromContext = availableCranes.find(
(item) => String(item.id) === String(selectedVehicleId)
);
if (!selectedFromContext) {
showToast('Odabrana dizalica nije dostupna u trenutnom kontekstu.', 'warning');
return;
}
} catch {
showToast('Ne mogu dohvatiti dizalice. Provjerite mrežu ili prijavu.', 'error');
return;
}
setIsTaskModalOpen(true);
}}
onOpenTaskModal={openTaskModalFromContext}
onNewServiceRecord={({ craneId }) => {
const crane = cranes.find((item) => String(item.id) === String(craneId));
openServiceRecordForCrane(crane ?? null);
@@ -640,8 +689,8 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
<h3 className="font-semibold text-text-main">Putni nalozi dizalica</h3>
<p className="text-xs text-text-muted">
{workOrderScope === 'context'
? (selectedContextClientName && hydratedContextCrane?.registration_number
? `Kontekst: ${selectedContextClientName} / ${hydratedContextCrane.registration_number}`
? (selectedContextClientName && contextCraneLabel
? `Kontekst: ${selectedContextClientName} / ${contextCraneLabel}`
: 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.')
: 'Prikaz svih putnih naloga.'}
</p>
@@ -777,12 +826,19 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
<div>
<h3 className="font-semibold text-text-main">Servisni zapisi</h3>
<p className="text-xs text-text-muted">
{selectedContextClientName && hydratedContextCrane?.registration_number
? `Kontekst: ${selectedContextClientName} / ${hydratedContextCrane.registration_number}`
{selectedContextClientName && contextCraneLabel
? `Kontekst: ${selectedContextClientName} / ${contextCraneLabel}`
: 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.'}
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={openTaskModalFromContext}
className="rounded-md bg-indigo-600 px-3 py-1 text-xs font-semibold text-white hover:bg-indigo-700"
>
+ Novi radni zadatak
</button>
{hydratedSelectedVehicleId && hydratedContextCrane && (
<ServiceRecordCreateButton onClick={() => openServiceRecordForCrane(hydratedContextCrane)} />
)}
@@ -795,55 +851,101 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
</button>
</div>
</div>
<AnimatedDataTable
columns={[
{ key: 'date', label: 'Datum', className: 'px-4 py-3' },
{ key: 'crane', label: 'Dizalica', className: 'px-4 py-3' },
{ key: 'task', label: 'Radni zadatak', className: 'px-4 py-3' },
{ key: 'description', label: 'Opis', className: 'px-4 py-3' },
{ key: 'cost', label: 'Trošak', className: 'px-4 py-3' },
{ key: 'files', label: 'Datoteke', className: 'px-4 py-3' },
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
]}
rows={serviceRecordsForContext}
rowKey={(record) => record.id}
loading={loading}
loadingMessage="Učitavanje..."
emptyMessage={!hydratedSelectedVehicleId
? 'Odaberite dizalicu za prikaz servisnih zapisa.'
: 'Nema aktivnih servisnih zapisa za odabranu dizalicu.'}
renderRow={(record) => (
<>
<td className="px-4 py-3">{formatDate(record.service_date)}</td>
<td className="px-4 py-3">{record.crane_registration || record.vehicle_registration || '-'}</td>
<td className="px-4 py-3">{record.task_label || '-'}</td>
<td className="max-w-md truncate px-4 py-3" title={record.description || ''}>
{record.description || '-'}
</td>
<td className="px-4 py-3">{formatCost(record.cost)}</td>
<td className="px-4 py-3">{record.files_count ?? (record.photos_count ?? 0)}</td>
<td className="space-x-2 px-4 py-3">
<button
type="button"
onClick={() => {
setServiceRecordBackTask(null);
setSelectedServiceRecord(record);
}}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Detalji
</button>
<button
type="button"
onClick={() => setPhotoUpload({ open: true, serviceRecordId: record.id })}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
📎 Dodaj datoteke
</button>
</td>
</>
)}
/>
{!hydratedSelectedVehicleId && (
<p className="px-4 py-6 text-center text-sm text-text-muted">
Odaberite dizalicu za prikaz servisnih zapisa.
</p>
)}
{hydratedSelectedVehicleId && loading && (
<p className="px-4 py-6 text-center text-sm text-text-muted">
Učitavanje...
</p>
)}
{hydratedSelectedVehicleId && !loading && serviceTaskGroups.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-text-muted">
Nema grupiranih radnih zadataka za odabranu dizalicu.
</p>
)}
{hydratedSelectedVehicleId && !loading && serviceTaskGroups.length > 0 && (
<div className="divide-y divide-border-hairline">
{serviceTaskTransitions((style, group) => {
const isOpen = String(expandedServiceTaskId || '') === String(group.id);
return (
<animated.div key={group.id} style={style}>
<button
type="button"
onClick={() => setExpandedServiceTaskId(isOpen ? null : group.id)}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-canvas-deep"
>
<div>
<span className="font-medium text-text-main">{group.title}</span>
<span className="ml-2 text-xs text-text-muted">{group.status}</span>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700">
{group.records.length} servisnih zapisa
</span>
<span className="text-text-muted">{isOpen ? '▲' : '▼'}</span>
</div>
</button>
{isOpen && (
<div className="border-t border-border-hairline bg-canvas-base/50 px-4 py-3">
{group.records.length === 0 ? (
<p className="text-sm text-text-muted">
Nema servisnih zapisa za odabrani radni zadatak.
</p>
) : (
<AnimatedDataTable
columns={[
{ key: 'date', label: 'Datum', className: 'px-4 py-3' },
{ key: 'crane', label: 'Dizalica', className: 'px-4 py-3' },
{ key: 'description', label: 'Opis', className: 'px-4 py-3' },
{ key: 'cost', label: 'Trošak', className: 'px-4 py-3' },
{ key: 'files', label: 'Datoteke', className: 'px-4 py-3' },
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
]}
rows={group.records}
rowKey={(record) => record.id}
rowClassName="hover:bg-canvas-deep"
renderRow={(record) => (
<>
<td className="px-4 py-3">{formatDate(record.service_date)}</td>
<td className="px-4 py-3">{record.crane_registration || record.vehicle_registration || '-'}</td>
<td className="max-w-md truncate px-4 py-3" title={record.description || ''}>
{record.description || '-'}
</td>
<td className="px-4 py-3">{formatCost(record.cost)}</td>
<td className="px-4 py-3">{record.files_count ?? (record.photos_count ?? 0)}</td>
<td className="space-x-2 px-4 py-3">
<button
type="button"
onClick={() => {
setServiceRecordBackTask(group.task);
setSelectedServiceRecord(record);
}}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Detalji
</button>
<button
type="button"
onClick={() => setPhotoUpload({ open: true, serviceRecordId: record.id })}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
📎 Dodaj datoteke
</button>
</td>
</>
)}
/>
)}
</div>
)}
</animated.div>
);
})}
</div>
)}
</article>
)}
@@ -884,43 +986,45 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
columns={vehicleColumns}
rows={cranes}
rowKey={(vehicle) => vehicle.id}
rowClassName="hover:bg-canvas-deep"
isRowSelected={(vehicle) => String(vehicle.id) === String(selectedCraneRowId)}
loading={loading}
loadingMessage="Učitavanje vozila..."
emptyMessage="Nema dostupnih vozila."
renderRow={(vehicle) => (
<>
<td
className={`px-4 py-3 font-medium ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50 text-emerald-800' : 'text-text-main'}`}
className="px-4 py-3 font-medium text-text-main"
onClick={() => setSelectedCraneRowId(vehicle.id)}
>
{vehicle.registration_number || '-'}
</td>
<td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)}
>
{vehicle.crane_serial_number || '-'}
</td>
<td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)}
>
{vehicle.make || '-'} {vehicle.model || ''}
</td>
<td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)}
>
{vehicle.client_name || '-'}
</td>
<td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)}
>
{vehicle.current_mileage ?? '-'}
</td>
<td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)}
>
<span className={vehicle.is_active ? 'text-emerald-700' : 'text-red-700'}>

View File

@@ -51,6 +51,14 @@ export default function ServiceContextSelector({ onNewServiceRecord }) {
const selectedClient = availableClients.find((c) => String(c.id) === String(selectedClientId));
const selectedCrane = cranes.find((item) => String(item.id) === String(selectedVehicleId));
const showSelector = editMode || !selectedClientId;
const selectedCraneLabel = (() => {
if (!selectedCrane) return '';
const serial = String(selectedCrane.crane_serial_number || '').trim();
if (serial) {
return `SN ${serial} ${selectedCrane.make || ''} ${selectedCrane.model || ''}`.trim();
}
return `${selectedCrane.registration_number || '-'} ${selectedCrane.make || ''} ${selectedCrane.model || ''}`.trim();
})();
function handleClientChange(e) {
setSelectedClient(e.target.value);
@@ -97,7 +105,7 @@ export default function ServiceContextSelector({ onNewServiceRecord }) {
{clientCranes.length > 0
? clientCranes.map((item) => (
<option key={item.id} value={item.id}>
{item.registration_number} {item.make} {item.model}
{item.crane_serial_number ? `SN ${item.crane_serial_number}` : (item.registration_number || '-')} {item.make} {item.model}
</option>
))
: <option disabled>Nema dizalica za ovog kupca</option>
@@ -137,7 +145,7 @@ export default function ServiceContextSelector({ onNewServiceRecord }) {
<>
<span className="text-xs text-text-muted">/</span>
<span className="rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-700">
{selectedCrane.registration_number} {selectedCrane.make} {selectedCrane.model}
{selectedCraneLabel}
</span>
</>
)}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { animated, useTransition } from '@react-spring/web';
import { api } from '../../services/apiClient';
function getBrowserApiBase() {
const apiUrl = import.meta.env.PUBLIC_API_URL || '';
@@ -44,6 +45,109 @@ export function resolveMediaUrl(pathOrUrl) {
return new URL(finalSrc, apiUrl).toString();
}
function isProtectedMediaUrl(url) {
return typeof url === 'string' && /\/api\/fleet\//i.test(url);
}
function revokeObjectUrls(entries = {}) {
Object.values(entries).forEach((url) => {
if (typeof url === 'string' && url.startsWith('blob:')) {
window.URL.revokeObjectURL(url);
}
});
}
export function useAuthenticatedMediaSources(mediaPaths = []) {
const [authenticatedSources, setAuthenticatedSources] = useState({});
const authenticatedSourcesRef = useRef({});
const resolvedMediaPaths = useMemo(() => {
const unique = new Set();
if (!Array.isArray(mediaPaths)) {
return [];
}
mediaPaths.forEach((item) => {
const resolved = resolveMediaUrl(item);
if (resolved) {
unique.add(resolved);
}
});
return Array.from(unique);
}, [mediaPaths]);
const resolvedMediaPathsKey = useMemo(() => resolvedMediaPaths.join('|'), [resolvedMediaPaths]);
useEffect(() => {
authenticatedSourcesRef.current = authenticatedSources;
}, [authenticatedSources]);
useEffect(() => {
let cancelled = false;
const protectedPaths = resolvedMediaPaths.filter((url) => isProtectedMediaUrl(url));
if (protectedPaths.length === 0) {
setAuthenticatedSources((previous) => {
revokeObjectUrls(previous);
return {};
});
return undefined;
}
(async () => {
const nextSources = {};
const createdObjectUrls = [];
const fetched = await Promise.all(
protectedPaths.map(async (url) => {
try {
const blob = await api.get(url, { responseType: 'blob' });
const blobUrl = window.URL.createObjectURL(blob);
createdObjectUrls.push(blobUrl);
return [url, blobUrl];
} catch {
return null;
}
})
);
fetched.forEach((entry) => {
if (!entry) return;
const [url, blobUrl] = entry;
nextSources[url] = blobUrl;
});
if (cancelled) {
createdObjectUrls.forEach((blobUrl) => window.URL.revokeObjectURL(blobUrl));
return;
}
setAuthenticatedSources((previous) => {
revokeObjectUrls(previous);
return nextSources;
});
})();
return () => {
cancelled = true;
};
}, [resolvedMediaPathsKey]);
useEffect(() => () => {
revokeObjectUrls(authenticatedSourcesRef.current);
}, []);
const getAuthenticatedMediaSrc = (pathOrUrl) => {
const resolved = resolveMediaUrl(pathOrUrl);
if (!resolved) {
return '';
}
return authenticatedSources[resolved] || resolved;
};
return {
getAuthenticatedMediaSrc,
};
}
export default function WorkOrderImageCarousel({ images = [], editMode = false, emptyLabel = 'Nema priložene tehničke dokumentacije' }) {
const normalizedImages = useMemo(() => (
Array.isArray(images)
@@ -51,6 +155,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
: (typeof images === 'string' && images ? [images] : [])
), [images]);
const [currentImgIdx, setCurrentImgIdx] = useState(0);
const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(normalizedImages);
useEffect(() => {
setCurrentImgIdx(0);
@@ -87,7 +192,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
};
const finalSrc = resolveMediaUrl(normalizedImages[currentImgIdx] || '');
const finalSrc = getAuthenticatedMediaSrc(normalizedImages[currentImgIdx] || '');
const imageTransitions = useTransition(currentImgIdx, {
from: { opacity: 0, transform: 'translate3d(8%,0,0) scale(1.01)' },
enter: { opacity: 1, transform: 'translate3d(0%,0,0) scale(1)' },
@@ -101,7 +206,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
{imageTransitions((style, idx) => (
<animated.img
key={idx}
src={resolveMediaUrl(normalizedImages[idx] || finalSrc)}
src={getAuthenticatedMediaSrc(normalizedImages[idx] || finalSrc)}
alt={`Dokumentacija s terena ${idx + 1}`}
style={style}
className="absolute inset-0 h-full w-full object-cover will-change-transform"

View File

@@ -11,7 +11,7 @@ import {
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
import { formatEntityCode } from '../../lib/displayIds';
import { resolveMediaUrl } from './WorkOrderImageCarousel';
import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
function readWorkOrderIdFromQuery() {
if (typeof window === 'undefined') {
@@ -121,6 +121,34 @@ export default function WorkOrderInvoicesPdfPage() {
return `${formatEntityCode('PN', workOrder.id)}`;
}, [workOrder]);
const mediaPaths = useMemo(() => {
const paths = [];
if (Array.isArray(taskContext.tasks)) {
taskContext.tasks.forEach((task) => {
if (!Array.isArray(task?.service_records)) return;
task.service_records.forEach((record) => {
if (!Array.isArray(record?.photos)) return;
record.photos.forEach((photo) => {
const candidate = photo?.optimized_url || photo?.image_url;
if (candidate) {
paths.push(candidate);
}
});
});
});
}
if (Array.isArray(invoices)) {
invoices.forEach((invoice) => {
if (invoice?.image) {
paths.push(invoice.image);
}
});
}
return paths;
}, [taskContext.tasks, invoices]);
const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(mediaPaths);
return (
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
@@ -144,7 +172,7 @@ export default function WorkOrderInvoicesPdfPage() {
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-text-main">Task i povezani servisni zapisi</h2>
<h2 className="text-lg font-semibold text-text-main">Servisni zapisi</h2>
<button
type="button"
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
@@ -185,13 +213,13 @@ export default function WorkOrderInvoicesPdfPage() {
record.photos.map((photo) => (
<a
key={photo.id}
href={resolveMediaUrl(photo.optimized_url || photo.image_url)}
href={getAuthenticatedMediaSrc(photo.optimized_url || photo.image_url)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex"
>
<img
src={resolveMediaUrl(photo.optimized_url || photo.image_url)}
src={getAuthenticatedMediaSrc(photo.optimized_url || photo.image_url)}
alt={`Servisna slika ${photo.id}`}
className="h-20 w-20 rounded border border-border-hairline object-cover"
/>
@@ -244,7 +272,7 @@ export default function WorkOrderInvoicesPdfPage() {
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
</div>
<InvoiceImagePreview
imageUrl={resolveMediaUrl(invoice.image)}
imageUrl={getAuthenticatedMediaSrc(invoice.image) || resolveMediaUrl(invoice.image)}
alt={`Račun ${invoice.naziv_racuna}`}
/>
</div>