feat: async PDF cache i API media putanje za putne naloge
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

Uvodi background request tok za generiranje PDF-a s 24h cache-om i cleanup taskom preko Celery beat-a. Dodaje notification metadata download_url za ponovno preuzimanje bez regeneracije, API image endpointe za putne naloge i račune, te frontend integraciju za async download i produkcijski prikaz slika.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-17 16:42:02 +02:00
parent 5e7ec72a4e
commit e8330473cd
17 changed files with 726 additions and 231 deletions

View File

@@ -1,20 +1,16 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
import ModalShell from '../ui/ModalShell';
import {
createWorkOrderInvoice,
fetchVehicleById,
fetchTeamMembers,
updateVehicle,
fetchWorkOrderInvoices,
fetchWorkOrderImages,
openWorkOrderInvoicesPdfPage,
sendWorkOrderEmail,
} from '../../stores/fleetDashboardStore';
import { showToast } from '../../stores/toastStore';
import { $craneSerialByVehicle, cacheCraneSerial, getCachedCraneSerial } from '../../stores/craneSerialStore';
import { formatEntityCode } from '../../lib/displayIds';
const PURPOSE_OPTIONS = [
@@ -24,21 +20,22 @@ const PURPOSE_OPTIONS = [
];
export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmit }) {
const serialByVehicle = useStore($craneSerialByVehicle);
const [isEditing, setIsEditing] = useState(false);
const [form, setForm] = useState({
has_travel_order: false,
status: 'open',
start_mileage: '',
end_mileage: '',
servicer_vehicle_registration: '',
servicer_vehicle_make_model: '',
servicer_vehicle_start_mileage: '',
servicer_vehicle_end_mileage: '',
servicer_vehicle_fuel_cost: '',
location: '',
travel_start_at: '',
travel_end_at: '',
purpose: '',
notes: '',
crane_current_mileage: '',
crane_superstructure_working_hours: '',
crane_chassis_working_hours: '',
});
const [imageFiles, setImageFiles] = useState([]);
const [submitting, setSubmitting] = useState(false);
@@ -65,7 +62,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
opis: '',
imageFile: null,
});
const [resolvedCraneSerialNumber, setResolvedCraneSerialNumber] = useState('');
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [locationQuery, setLocationQuery] = useState('');
const [locationResults, setLocationResults] = useState([]);
@@ -73,15 +69,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
const [searchingLocation, setSearchingLocation] = useState(false);
const [mapError, setMapError] = useState('');
const targetCraneId = useMemo(
() => workOrder?.crane || workOrder?.vehicle || null,
[workOrder?.crane, workOrder?.vehicle]
);
const cachedCraneSerial = useMemo(
() => getCachedCraneSerial(targetCraneId),
[serialByVehicle, targetCraneId]
);
const craneLabel = useMemo(() => {
if (!workOrder) return '-';
return workOrder.craneLabel || workOrder.craneInfo?.display_name || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`;
@@ -103,9 +90,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
useEffect(() => {
if (!open || !workOrder) return;
setIsEditing(false);
const initialSerial = String(workOrder.craneInfo?.crane_serial_number || cachedCraneSerial || '').trim();
setResolvedCraneSerialNumber(initialSerial);
cacheCraneSerial(targetCraneId, initialSerial);
setForm({
has_travel_order: !!(workOrder.travel_start_at || workOrder.travel_end_at || workOrder.location || workOrder.start_mileage != null || workOrder.end_mileage != null),
status: workOrder.status || 'open',
@@ -121,9 +105,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
servicer_vehicle_start_mileage: workOrder.servicer_vehicle_start_mileage == null ? '' : String(workOrder.servicer_vehicle_start_mileage),
servicer_vehicle_end_mileage: workOrder.servicer_vehicle_end_mileage == null ? '' : String(workOrder.servicer_vehicle_end_mileage),
servicer_vehicle_fuel_cost: workOrder.servicer_vehicle_fuel_cost == null ? '' : String(workOrder.servicer_vehicle_fuel_cost),
crane_current_mileage: String(workOrder.craneInfo?.current_mileage ?? ''),
crane_superstructure_working_hours: String(workOrder.craneInfo?.superstructure_working_hours ?? ''),
crane_chassis_working_hours: String(workOrder.craneInfo?.chassis_working_hours ?? ''),
});
setSubmitting(false);
setError('');
@@ -155,38 +136,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setSelectedLocation(null);
setSearchingLocation(false);
setMapError('');
}, [open, workOrder, cachedCraneSerial, targetCraneId]);
useEffect(() => {
if (!open || !targetCraneId) {
return;
}
let cancelled = false;
(async () => {
try {
const freshCrane = await fetchVehicleById(targetCraneId);
if (cancelled || !freshCrane) {
return;
}
const incomingSerial = String(freshCrane.crane_serial_number || '').trim();
if (incomingSerial) {
cacheCraneSerial(targetCraneId, incomingSerial);
setResolvedCraneSerialNumber(incomingSerial);
}
setForm((prev) => ({
...prev,
crane_current_mileage: String(freshCrane.current_mileage ?? prev.crane_current_mileage ?? ''),
crane_superstructure_working_hours: String(freshCrane.superstructure_working_hours ?? prev.crane_superstructure_working_hours ?? ''),
crane_chassis_working_hours: String(freshCrane.chassis_working_hours ?? prev.crane_chassis_working_hours ?? ''),
}));
} catch {
// no-op: fallback remains workOrder.craneInfo values
}
})();
return () => {
cancelled = true;
};
}, [open, workOrder?.id, targetCraneId]);
}, [open, workOrder]);
useEffect(() => {
if (!emailModalOpen) return;
@@ -318,13 +268,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
const endMileage = form.end_mileage === '' ? null : Number(form.end_mileage);
let travelStart = null;
let travelEnd = null;
const craneCurrentMileage = form.crane_current_mileage === '' ? null : Number(form.crane_current_mileage);
const craneSuperstructureHours = form.crane_superstructure_working_hours === '' ? null : Number(form.crane_superstructure_working_hours);
const craneChassisHours = form.crane_chassis_working_hours === '' ? null : Number(form.crane_chassis_working_hours);
const existingCraneCurrentMileage = Number(workOrder?.craneInfo?.current_mileage ?? 0);
const existingCraneSuperstructureHours = Number(workOrder?.craneInfo?.superstructure_working_hours ?? 0);
const existingCraneChassisHours = Number(workOrder?.craneInfo?.chassis_working_hours ?? 0);
if (startMileage != null && (Number.isNaN(startMileage) || startMileage < 0)) {
setError('Početna kilometraža mora biti valjan nenegativan broj.');
return;
@@ -341,18 +284,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setError('Završna kilometraža mora biti veća ili jednaka početnoj.');
return;
}
if (craneCurrentMileage != null && (Number.isNaN(craneCurrentMileage) || craneCurrentMileage < 0)) {
setError('Trenutna km dizalice mora biti valjan nenegativan broj.');
return;
}
if (craneSuperstructureHours != null && (Number.isNaN(craneSuperstructureHours) || craneSuperstructureHours < 0)) {
setError('Radni sati nadogradnje moraju biti valjan nenegativan broj.');
return;
}
if (craneChassisHours != null && (Number.isNaN(craneChassisHours) || craneChassisHours < 0)) {
setError('Radni sati podvozja moraju biti valjan nenegativan broj.');
return;
}
if ((form.travel_start_at && !form.travel_end_at) || (!form.travel_start_at && form.travel_end_at)) {
setError('Unesite i početak i kraj puta, ili ostavite oba prazna.');
return;
@@ -380,43 +311,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setSubmitting(true);
try {
const hasCraneChanges = (
craneCurrentMileage != null
&& craneSuperstructureHours != null
&& craneChassisHours != null
&& (
craneCurrentMileage !== existingCraneCurrentMileage
|| craneSuperstructureHours !== existingCraneSuperstructureHours
|| craneChassisHours !== existingCraneChassisHours
)
);
if (hasCraneChanges && targetCraneId) {
let craneSerialNumber = String(resolvedCraneSerialNumber || '').trim();
if (!craneSerialNumber) {
craneSerialNumber = cachedCraneSerial;
}
if (!craneSerialNumber) {
const freshCrane = await fetchVehicleById(targetCraneId);
craneSerialNumber = String(freshCrane?.crane_serial_number || '').trim();
if (craneSerialNumber) {
cacheCraneSerial(targetCraneId, craneSerialNumber);
setResolvedCraneSerialNumber(craneSerialNumber);
}
}
if (!craneSerialNumber) {
// nastavi sa spremanjem sati i kilometraže za legacy dizalice bez SN
}
const cranePatch = {
current_mileage: craneCurrentMileage,
superstructure_working_hours: craneSuperstructureHours,
chassis_working_hours: craneChassisHours,
};
if (craneSerialNumber) {
cranePatch.crane_serial_number = craneSerialNumber;
}
await updateVehicle(targetCraneId, cranePatch);
}
await onSubmit(workOrder, {
status: form.status,
start_mileage: startMileage,
@@ -536,7 +430,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Uredi nalog {formatEntityCode('PN', workOrder.id)}</h3>
<h3 className="text-lg font-semibold text-text-main">Uredi putni nalog {formatEntityCode('PN', workOrder.id)}</h3>
<p className="text-sm text-text-muted">Dizalica: {craneLabel}</p>
</div>
<button
@@ -676,67 +570,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
</div>
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Podaci dizalice</h4>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Početna km dizalice (nalog)</span>
<input
type="number"
min="0"
value={form.start_mileage}
onInput={(event) => setForm({ ...form, start_mileage: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Završna km dizalice (nalog)</span>
<input
type="number"
min="0"
value={form.end_mileage}
onInput={(event) => setForm({ ...form, end_mileage: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Trenutna km dizalice</span>
<input
type="number"
min="0"
value={form.crane_current_mileage}
onInput={(event) => setForm({ ...form, crane_current_mileage: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Radni sati nadogradnje</span>
<input
type="number"
min="0"
value={form.crane_superstructure_working_hours}
onInput={(event) => setForm({ ...form, crane_superstructure_working_hours: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Radni sati podvozja</span>
<input
type="number"
min="0"
value={form.crane_chassis_working_hours}
onInput={(event) => setForm({ ...form, crane_chassis_working_hours: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
</div>
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Putni podaci</h4>
<div className="grid gap-4 sm:grid-cols-2">