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 = [ { value: 'defektaza', label: 'Defektaža' }, { value: 'kontrola', label: 'Kontrola' }, { value: 'redovni_pregled', label: 'Redovni pregled' }, ]; 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: '', 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); const [error, setError] = useState(''); const [emailSending, setEmailSending] = useState(false); const [widgetError, setWidgetError] = useState(''); const [emailModalOpen, setEmailModalOpen] = useState(false); const [teamMembers, setTeamMembers] = useState([]); const [loadingTeamMembers, setLoadingTeamMembers] = useState(false); const [selectedTeamMemberIds, setSelectedTeamMemberIds] = useState([]); const [sendToAllTeamMembers, setSendToAllTeamMembers] = useState(false); const [emailPayload, setEmailPayload] = useState({ subject: '', message: '', }); const [invoices, setInvoices] = useState([]); const [invoiceModalOpen, setInvoiceModalOpen] = useState(false); const [photoUploadOpen, setPhotoUploadOpen] = useState(false); const [invoiceSaving, setInvoiceSaving] = useState(false); const [invoiceForm, setInvoiceForm] = useState({ naziv_racuna: '', lokacija: '', datum: new Date().toISOString().slice(0, 10), opis: '', imageFile: null, }); const [resolvedCraneSerialNumber, setResolvedCraneSerialNumber] = useState(''); const [mapPickerOpen, setMapPickerOpen] = useState(false); const [locationQuery, setLocationQuery] = useState(''); const [locationResults, setLocationResults] = useState([]); const [selectedLocation, setSelectedLocation] = useState(null); 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}`; }, [workOrder]); const normalizedPurposeOptions = useMemo(() => { const known = new Set(PURPOSE_OPTIONS.map((item) => item.value)); const current = form.purpose?.trim(); if (current && !known.has(current)) { return [{ value: current, label: `${current} (postojeća vrijednost)` }, ...PURPOSE_OPTIONS]; } return PURPOSE_OPTIONS; }, [form.purpose]); useEffect(() => { if (!open || typeof window === 'undefined') return; window.scrollTo({ top: 0, behavior: 'smooth' }); }, [open]); 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', start_mileage: String(workOrder.start_mileage ?? ''), end_mileage: workOrder.end_mileage == null ? '' : String(workOrder.end_mileage), location: workOrder.location || '', travel_start_at: workOrder.travel_start_at ? String(workOrder.travel_start_at).slice(0, 16) : '', travel_end_at: workOrder.travel_end_at ? String(workOrder.travel_end_at).slice(0, 16) : '', purpose: workOrder.purpose || '', notes: workOrder.notes || '', servicer_vehicle_registration: workOrder.servicer_vehicle_registration || '', servicer_vehicle_make_model: workOrder.servicer_vehicle_make_model || '', 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(''); setWidgetError(''); setEmailSending(false); setEmailModalOpen(false); setTeamMembers([]); setLoadingTeamMembers(false); setSelectedTeamMemberIds([]); setSendToAllTeamMembers(false); setEmailPayload({ subject: '', message: '', }); setInvoices([]); setInvoiceModalOpen(false); setPhotoUploadOpen(false); setInvoiceSaving(false); setInvoiceForm({ naziv_racuna: '', lokacija: '', datum: new Date().toISOString().slice(0, 10), opis: '', imageFile: null, }); setMapPickerOpen(false); setLocationQuery(''); setLocationResults([]); 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]); useEffect(() => { if (!emailModalOpen) return; let cancelled = false; setLoadingTeamMembers(true); (async () => { try { const members = await fetchTeamMembers(); if (!cancelled) { setTeamMembers(members); } } catch (err) { if (!cancelled) { setWidgetError(err?.message || 'Neuspješno dohvaćanje članova tima.'); } } finally { if (!cancelled) { setLoadingTeamMembers(false); } } })(); return () => { cancelled = true; }; }, [emailModalOpen]); useEffect(() => { if (!open || !workOrder?.id) { setImageFiles([]); return; } let cancelled = false; setImageFiles([]); (async () => { try { const files = await fetchWorkOrderImages(workOrder.id, { w: 1280, q: 75, fmt: 'webp' }); if (!cancelled) { setImageFiles( files .map((item) => item.optimized_url || item.original_url) .filter(Boolean) ); } } catch { if (!cancelled) { setImageFiles([]); } } })(); return () => { cancelled = true; }; }, [open, workOrder?.id]); useEffect(() => { if (!open || !workOrder?.id) { setInvoices([]); return; } let cancelled = false; (async () => { try { const items = await fetchWorkOrderInvoices(workOrder.id); if (!cancelled) { setInvoices(items); } } catch { if (!cancelled) { setInvoices([]); } } })(); return () => { cancelled = true; }; }, [open, workOrder?.id]); const selectedMapEmbedUrl = (() => { if (!selectedLocation) return null; const lat = Number(selectedLocation.lat); const lon = Number(selectedLocation.lon); if (Number.isNaN(lat) || Number.isNaN(lon)) return null; const delta = 0.01; const bbox = `${lon - delta}%2C${lat - delta}%2C${lon + delta}%2C${lat + delta}`; return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat}%2C${lon}`; })(); if (!open || !workOrder) { return null; } const searchLocation = async () => { const query = locationQuery.trim(); if (!query) { setMapError('Unesite naziv lokacije za pretragu.'); return; } setMapError(''); setSearchingLocation(true); try { const response = await fetch( `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=8&q=${encodeURIComponent(query)}` ); if (!response.ok) { throw new Error('Pretraga lokacije nije uspjela.'); } const payload = await response.json(); const results = Array.isArray(payload) ? payload : []; setLocationResults(results); if (results.length === 0) { setMapError('Lokacija nije pronađena. Pokušajte s detaljnijim nazivom.'); } } catch (error) { setMapError(error?.message || 'Greška pri dohvaćanju karte.'); } finally { setSearchingLocation(false); } }; const submit = async () => { if (!isEditing) { return; } setError(''); const startMileage = form.start_mileage === '' ? null : Number(form.start_mileage); 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; } if (endMileage != null && (Number.isNaN(endMileage) || endMileage < 0)) { setError('Završna kilometraža mora biti valjan nenegativan broj.'); return; } if (endMileage != null && startMileage == null) { setError('Početna kilometraža je obavezna ako je unesena završna.'); return; } if (startMileage != null && endMileage != null && endMileage < startMileage) { 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; } if (form.travel_start_at && form.travel_end_at) { travelStart = new Date(form.travel_start_at); travelEnd = new Date(form.travel_end_at); if (Number.isNaN(travelStart.getTime()) || Number.isNaN(travelEnd.getTime())) { setError('Vrijeme puta nije u ispravnom formatu.'); return; } if (travelEnd < travelStart) { setError('Vrijeme kraja puta ne može biti prije početka puta.'); return; } if (!form.location.trim()) { setError('Lokacija odredišta je obavezna kada je putovanje uneseno.'); return; } } if (!form.purpose.trim()) { setError('Svrha naloga je obavezna.'); return; } 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, end_mileage: endMileage, servicer_vehicle_registration: form.servicer_vehicle_registration.trim(), servicer_vehicle_make_model: form.servicer_vehicle_make_model.trim(), servicer_vehicle_start_mileage: form.servicer_vehicle_start_mileage === '' ? null : Number(form.servicer_vehicle_start_mileage), servicer_vehicle_end_mileage: form.servicer_vehicle_end_mileage === '' ? null : Number(form.servicer_vehicle_end_mileage), servicer_vehicle_fuel_cost: form.servicer_vehicle_fuel_cost === '' ? null : Number(form.servicer_vehicle_fuel_cost), location: form.location.trim(), travel_start_at: travelStart ? travelStart.toISOString() : null, travel_end_at: travelEnd ? travelEnd.toISOString() : null, purpose: form.purpose.trim(), notes: form.notes.trim(), }); onClose(); } catch (err) { setError(err?.message || 'Spremanje putnog naloga nije uspjelo.'); } finally { setSubmitting(false); } }; const handleDownloadPdf = async () => { setWidgetError(''); try { openWorkOrderInvoicesPdfPage(workOrder.id); } catch (err) { setWidgetError(err?.message || 'Neuspješno preuzimanje PDF-a putnog naloga.'); } }; const handleCreateInvoice = async () => { if (!invoiceForm.naziv_racuna.trim()) { setWidgetError('Polje naziv_računa je obavezno.'); return; } if (!invoiceForm.datum) { setWidgetError('Polje datum je obavezno.'); return; } setWidgetError(''); setInvoiceSaving(true); try { await createWorkOrderInvoice(workOrder.id, invoiceForm); const refreshed = await fetchWorkOrderInvoices(workOrder.id); setInvoices(refreshed); setInvoiceForm({ naziv_racuna: '', lokacija: '', datum: new Date().toISOString().slice(0, 10), opis: '', imageFile: null, }); setInvoiceModalOpen(false); } catch (err) { setWidgetError(err?.message || 'Neuspješno spremanje računa.'); } finally { setInvoiceSaving(false); } }; const handleSendEmail = async () => { setWidgetError(''); setEmailSending(true); try { const recipients = sendToAllTeamMembers ? teamMembers.map((member) => member.email).filter(Boolean) : teamMembers .filter((member) => selectedTeamMemberIds.includes(String(member.id))) .map((member) => member.email) .filter(Boolean); if (recipients.length === 0) { setWidgetError('Odaberite barem jednog člana tima ili uključite slanje svima.'); return; } let failed = 0; for (const recipient of recipients) { try { await sendWorkOrderEmail( workOrder.id, { recipient, subject: emailPayload.subject.trim() || undefined, message: emailPayload.message.trim() || undefined, }, { toast: false } ); showToast(`Email je poslan: ${recipient}`, 'success'); } catch { failed += 1; showToast(`Neuspješno slanje: ${recipient}`, 'error'); } } if (failed > 0) { setWidgetError(`Slanje nije uspjelo za ${failed} primatelja.`); return; } setEmailModalOpen(false); } catch (err) { setWidgetError(err?.message || 'Neuspješno slanje emaila za putni nalog.'); } finally { setEmailSending(false); } }; return ( <>

Uredi nalog {formatEntityCode('PN', workOrder.id)}

Dizalica: {craneLabel}

event.preventDefault()} className="max-h-[calc(100vh-10rem)] space-y-4 overflow-y-auto px-5 py-4">

Dokument i email

{widgetError && (
{widgetError}
)}

Status

Vozilo servisera

Podaci dizalice

Putni podaci