feat: async PDF cache i API media putanje za putne naloge
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:
@@ -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">
|
||||
|
||||
@@ -143,8 +143,9 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
}
|
||||
if (Array.isArray(invoices)) {
|
||||
invoices.forEach((invoice) => {
|
||||
if (invoice?.image) {
|
||||
paths.push(invoice.image);
|
||||
const invoiceImage = invoice?.image_url || invoice?.image;
|
||||
if (invoiceImage) {
|
||||
paths.push(invoiceImage);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -304,7 +305,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
|
||||
</div>
|
||||
<InvoiceImagePreview
|
||||
imageUrl={getAuthenticatedMediaSrc(invoice.image) || resolveMediaUrl(invoice.image)}
|
||||
imageUrl={getAuthenticatedMediaSrc(invoice.image_url || invoice.image) || resolveMediaUrl(invoice.image_url || invoice.image)}
|
||||
alt={`Račun ${invoice.naziv_racuna}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
import { downloadWorkOrderInvoicesPdf, downloadWorkOrderPdf, downloadWorkOrderServiceRecordsPdf } from '../../stores/fleetDashboardStore';
|
||||
import {
|
||||
downloadGeneratedPdfByUrl,
|
||||
downloadWorkOrderInvoicesPdf,
|
||||
downloadWorkOrderPdf,
|
||||
downloadWorkOrderServiceRecordsPdf,
|
||||
} from '../../stores/fleetDashboardStore';
|
||||
|
||||
function formatTimestamp(value) {
|
||||
if (!value) return '';
|
||||
@@ -57,6 +62,12 @@ function getEntityAction(notification, meta) {
|
||||
? 'Preuzmi PDF servisnih zapisa'
|
||||
: 'Preuzmi PDF putnog naloga',
|
||||
run() {
|
||||
if (metadata.download_url) {
|
||||
return downloadGeneratedPdfByUrl(
|
||||
metadata.download_url,
|
||||
metadata.filename || `${metadata.work_order_id}.${metadata.pdf_type || 'work-order'}.pdf`
|
||||
);
|
||||
}
|
||||
if (metadata.pdf_type === 'invoices') {
|
||||
return downloadWorkOrderInvoicesPdf(metadata.work_order_id);
|
||||
}
|
||||
|
||||
@@ -742,9 +742,14 @@ export async function downloadWorkOrderPdf(workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
showToast('Kreiran je zahtjev za PDF putnog naloga.', 'info');
|
||||
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/pdf/`, { responseType: 'blob' });
|
||||
saveBlobToFile(blob, `${workOrderId}.work-order.pdf`);
|
||||
showToast('PDF putnog naloga je preuzet.', 'success');
|
||||
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/pdf-request/`, {});
|
||||
if (payload?.status === 'ready' && payload?.download_url) {
|
||||
await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order.pdf`);
|
||||
showToast('PDF putnog naloga je preuzet iz cache-a.', 'success');
|
||||
return payload;
|
||||
}
|
||||
showToast('PDF putnog naloga se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function downloadWorkOrderInvoicesPdf(workOrderId) {
|
||||
@@ -752,9 +757,14 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
showToast('Kreiran je zahtjev za PDF računa putnog naloga.', 'info');
|
||||
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-pdf/`, { responseType: 'blob' });
|
||||
saveBlobToFile(blob, `${workOrderId}.work-order-invoices.pdf`);
|
||||
showToast('PDF računa putnog naloga je preuzet.', 'success');
|
||||
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-pdf-request/`, {});
|
||||
if (payload?.status === 'ready' && payload?.download_url) {
|
||||
await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order-invoices.pdf`);
|
||||
showToast('PDF računa putnog naloga je preuzet iz cache-a.', 'success');
|
||||
return payload;
|
||||
}
|
||||
showToast('PDF računa se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
|
||||
@@ -762,9 +772,23 @@ export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info');
|
||||
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/`, { responseType: 'blob' });
|
||||
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.pdf`);
|
||||
showToast('PDF servisnih zapisa putnog naloga je preuzet.', 'success');
|
||||
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {});
|
||||
if (payload?.status === 'ready' && payload?.download_url) {
|
||||
await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order-service-records.pdf`);
|
||||
showToast('PDF servisnih zapisa je preuzet iz cache-a.', 'success');
|
||||
return payload;
|
||||
}
|
||||
showToast('PDF servisnih zapisa se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') {
|
||||
if (!downloadUrl) {
|
||||
throw new Error('Nedostaje URL za preuzimanje PDF-a.');
|
||||
}
|
||||
const blob = await api.get(downloadUrl, { responseType: 'blob' });
|
||||
saveBlobToFile(blob, filename);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function openWorkOrderInvoicesPdfPage(workOrderId) {
|
||||
|
||||
Reference in New Issue
Block a user