patch oko teksta PDFa i UI tablica
This commit is contained in:
@@ -131,23 +131,20 @@ export default function ClientsSection({ onSetServiceContext = null }) {
|
||||
headClassName="text-xs uppercase tracking-wide text-text-muted"
|
||||
bodyClassName="divide-y divide-border-hairline"
|
||||
rowClassName="hover:bg-canvas-deep"
|
||||
isRowSelected={(item) => String(item.id) === String(selectedClientCraneId)}
|
||||
tableClassName="min-w-full text-sm"
|
||||
wrapperClassName="overflow-x-auto"
|
||||
renderRow={(item) => {
|
||||
const isSelected = String(item.id) === String(selectedClientCraneId);
|
||||
const cellClassName = isSelected ? 'bg-emerald-50' : '';
|
||||
const firstCellClassName = isSelected ? 'bg-emerald-50 text-emerald-800' : '';
|
||||
return (
|
||||
renderRow={(item) => (
|
||||
<>
|
||||
<td className={`py-2 pr-4 font-medium cursor-pointer ${firstCellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.registration_number}</td>
|
||||
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.make} {item.model} {item.year ? `(${item.year})` : ''}</td>
|
||||
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.current_mileage?.toLocaleString('hr-HR')} km</td>
|
||||
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>
|
||||
<td className="py-2 pr-4 font-medium cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>{item.registration_number}</td>
|
||||
<td className="py-2 pr-4 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>{item.make} {item.model} {item.year ? `(${item.year})` : ''}</td>
|
||||
<td className="py-2 pr-4 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>{item.current_mileage?.toLocaleString('hr-HR')} km</td>
|
||||
<td className="py-2 pr-4 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>
|
||||
{item.service_interval_km
|
||||
? `${(Math.floor(item.current_mileage / item.service_interval_km) + 1) * item.service_interval_km} km`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className={`py-2 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>
|
||||
<td className="py-2 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>
|
||||
<span className={item.is_company_vehicle
|
||||
? 'rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700'
|
||||
: 'rounded-full bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700'
|
||||
@@ -156,7 +153,7 @@ export default function ClientsSection({ onSetServiceContext = null }) {
|
||||
</span>
|
||||
</td>
|
||||
</>
|
||||
);}}
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -276,7 +276,7 @@ export default function ServiceRecordDetailModal({
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main">
|
||||
<p className="text-xs text-text-muted">Trošak / KM / Dizalica</p>
|
||||
<p>
|
||||
{formatCost(serviceRecord.cost)} • {serviceRecord.mileage ?? '-'} km • {serviceRecord.crane_registration || serviceRecord.vehicle_registration || '-'}
|
||||
{formatCost(serviceRecord.cost)} • {serviceRecord.mileage ?? '-'} km • {serviceRecord.crane_serial_number || serviceRecord.crane_registration || serviceRecord.vehicle_registration || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function AnimatedDataTable({
|
||||
headClassName = 'bg-canvas-deep text-left text-xs uppercase tracking-wide text-text-muted',
|
||||
bodyClassName = 'divide-y divide-border-hairline',
|
||||
rowClassName = 'hover:bg-canvas-deep',
|
||||
isRowSelected = () => false,
|
||||
wrapperClassName = 'overflow-x-auto',
|
||||
trail = 35,
|
||||
}) {
|
||||
@@ -23,9 +24,20 @@ export default function AnimatedDataTable({
|
||||
|
||||
const transitions = useTransition(rows, {
|
||||
keys: (row) => rowKey(row),
|
||||
from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
|
||||
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
|
||||
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
|
||||
from: { opacity: 0, transform: 'translate3d(0,8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||
enter: (row) => ({
|
||||
opacity: 1,
|
||||
transform: 'translate3d(0,0,0)',
|
||||
boxShadow: isRowSelected(row)
|
||||
? 'inset 0 0 0 2px rgba(16,185,129,0.75)'
|
||||
: 'inset 0 0 0 0px rgba(16,185,129,0)',
|
||||
}),
|
||||
update: (row) => ({
|
||||
boxShadow: isRowSelected(row)
|
||||
? 'inset 0 0 0 2px rgba(16,185,129,0.75)'
|
||||
: 'inset 0 0 0 0px rgba(16,185,129,0)',
|
||||
}),
|
||||
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||
trail,
|
||||
config: { tension: 230, friction: 26 },
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function ThemeToggle() {
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
type="button"
|
||||
className="p-2.5 rounded-s-xl bg-canvas-elevated border-y border-s border-border-hairline text-text-muted hover:text-text-main transition-all duration-200 focus:outline-none cursor-pointer flex items-center justify-center"
|
||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-lg border border-border-hairline bg-canvas-elevated text-text-muted hover:bg-canvas-base"
|
||||
aria-label="Prebaci temu"
|
||||
>
|
||||
{isDark ? (
|
||||
|
||||
@@ -85,6 +85,16 @@ const isDev = import.meta.env.DEV;
|
||||
navigator.serviceWorker.register('/service-worker.js').catch(function (error) {
|
||||
console.error('Service worker registration failed:', error);
|
||||
});
|
||||
window.addEventListener('online', function () {
|
||||
navigator.serviceWorker.ready
|
||||
.then(function (registration) {
|
||||
if (!registration.active) return;
|
||||
registration.active.postMessage('FLUSH_WRITE_QUEUE');
|
||||
})
|
||||
.catch(function () {
|
||||
// no-op, SW may not be ready yet
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,7 +104,7 @@ const isDev = import.meta.env.DEV;
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((key) => key.startsWith('erp-shell-'))
|
||||
.filter((key) => key.startsWith('erp-shell-') || key.startsWith('erp-api-'))
|
||||
.map((key) => caches.delete(key))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,74 +12,98 @@ const LS_VEHICLE = 'topbar_selected_vehicle';
|
||||
export const $selectedClientId = atom(null);
|
||||
export const $selectedVehicleId = atom(null);
|
||||
|
||||
function normalizeId(value) {
|
||||
return value ? String(value) : null;
|
||||
}
|
||||
|
||||
function emitServiceContextChanged(clientId, vehicleId) {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent('service-context:changed', {
|
||||
detail: {
|
||||
clientId: clientId || null,
|
||||
vehicleId: vehicleId || null,
|
||||
clientId: normalizeId(clientId),
|
||||
vehicleId: normalizeId(vehicleId),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/** Čita localStorage i puni atome. Idempotentno — sigurno pozvati više puta. */
|
||||
export function hydrateServiceContext() {
|
||||
function persistContext(clientId, vehicleId) {
|
||||
if (typeof window === 'undefined') return;
|
||||
const client = localStorage.getItem(LS_CLIENT);
|
||||
const vehicle = localStorage.getItem(LS_VEHICLE);
|
||||
$selectedClientId.set(client || null);
|
||||
$selectedVehicleId.set(vehicle || null);
|
||||
emitServiceContextChanged(client || null, vehicle || null);
|
||||
}
|
||||
|
||||
/** Postavi novog kupca i resetiraj odabrano vozilo. */
|
||||
export function setSelectedClient(id) {
|
||||
$selectedClientId.set(id || null);
|
||||
$selectedVehicleId.set(null);
|
||||
if (id) {
|
||||
localStorage.setItem(LS_CLIENT, id);
|
||||
} else {
|
||||
localStorage.removeItem(LS_CLIENT);
|
||||
}
|
||||
localStorage.removeItem(LS_VEHICLE);
|
||||
emitServiceContextChanged(id || null, null);
|
||||
}
|
||||
|
||||
/** Postavi odabrano vozilo. */
|
||||
export function setSelectedVehicle(id) {
|
||||
$selectedVehicleId.set(id || null);
|
||||
if (id) {
|
||||
localStorage.setItem(LS_VEHICLE, id);
|
||||
} else {
|
||||
localStorage.removeItem(LS_VEHICLE);
|
||||
}
|
||||
emitServiceContextChanged($selectedClientId.get(), id || null);
|
||||
}
|
||||
|
||||
/** Postavi cijeli servisni kontekst odjednom (kupac + vozilo). */
|
||||
export function setServiceContext(clientId, vehicleId) {
|
||||
$selectedClientId.set(clientId || null);
|
||||
$selectedVehicleId.set(vehicleId || null);
|
||||
|
||||
if (clientId) {
|
||||
localStorage.setItem(LS_CLIENT, clientId);
|
||||
} else {
|
||||
localStorage.removeItem(LS_CLIENT);
|
||||
}
|
||||
|
||||
if (vehicleId) {
|
||||
localStorage.setItem(LS_VEHICLE, vehicleId);
|
||||
} else {
|
||||
localStorage.removeItem(LS_VEHICLE);
|
||||
}
|
||||
emitServiceContextChanged(clientId || null, vehicleId || null);
|
||||
}
|
||||
|
||||
/** Čita localStorage i puni atome. Idempotentno — sigurno pozvati više puta. */
|
||||
export function hydrateServiceContext() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const client = normalizeId(localStorage.getItem(LS_CLIENT));
|
||||
const vehicle = normalizeId(localStorage.getItem(LS_VEHICLE));
|
||||
const currentClient = normalizeId($selectedClientId.get());
|
||||
const currentVehicle = normalizeId($selectedVehicleId.get());
|
||||
if (currentClient === client && currentVehicle === vehicle) {
|
||||
return;
|
||||
}
|
||||
$selectedClientId.set(client);
|
||||
$selectedVehicleId.set(vehicle);
|
||||
emitServiceContextChanged(client, vehicle);
|
||||
}
|
||||
|
||||
/** Postavi novog kupca i resetiraj odabrano vozilo. */
|
||||
export function setSelectedClient(id) {
|
||||
const nextClient = normalizeId(id);
|
||||
const currentClient = normalizeId($selectedClientId.get());
|
||||
const currentVehicle = normalizeId($selectedVehicleId.get());
|
||||
if (currentClient === nextClient && currentVehicle === null) {
|
||||
return;
|
||||
}
|
||||
$selectedClientId.set(nextClient);
|
||||
$selectedVehicleId.set(null);
|
||||
persistContext(nextClient, null);
|
||||
emitServiceContextChanged(nextClient, null);
|
||||
}
|
||||
|
||||
/** Postavi odabrano vozilo. */
|
||||
export function setSelectedVehicle(id) {
|
||||
const nextVehicle = normalizeId(id);
|
||||
const currentVehicle = normalizeId($selectedVehicleId.get());
|
||||
const currentClient = normalizeId($selectedClientId.get());
|
||||
if (currentVehicle === nextVehicle) {
|
||||
return;
|
||||
}
|
||||
$selectedVehicleId.set(nextVehicle);
|
||||
persistContext(currentClient, nextVehicle);
|
||||
emitServiceContextChanged(currentClient, nextVehicle);
|
||||
}
|
||||
|
||||
/** Postavi cijeli servisni kontekst odjednom (kupac + vozilo). */
|
||||
export function setServiceContext(clientId, vehicleId) {
|
||||
const nextClient = normalizeId(clientId);
|
||||
const nextVehicle = normalizeId(vehicleId);
|
||||
const currentClient = normalizeId($selectedClientId.get());
|
||||
const currentVehicle = normalizeId($selectedVehicleId.get());
|
||||
if (currentClient === nextClient && currentVehicle === nextVehicle) {
|
||||
return;
|
||||
}
|
||||
$selectedClientId.set(nextClient);
|
||||
$selectedVehicleId.set(nextVehicle);
|
||||
persistContext(nextClient, nextVehicle);
|
||||
emitServiceContextChanged(nextClient, nextVehicle);
|
||||
}
|
||||
|
||||
/** Poništi cijeli kontekst. */
|
||||
export function clearServiceContext() {
|
||||
if ($selectedClientId.get() === null && $selectedVehicleId.get() === null) {
|
||||
return;
|
||||
}
|
||||
$selectedClientId.set(null);
|
||||
$selectedVehicleId.set(null);
|
||||
localStorage.removeItem(LS_CLIENT);
|
||||
localStorage.removeItem(LS_VEHICLE);
|
||||
persistContext(null, null);
|
||||
emitServiceContextChanged(null, null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user