Files
004.erp.generic/frontend/src/components/dashboard/WorkOrderDetailModal.jsx
mariomitte 5e7ec72a4e
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
fix: ispravljen prikaz Vozilo servisera u WorkOrderDetailModal
- Vozilo servisera sada prikazuje servicer_vehicle_* polja (registracija,
  marka/model, km start/end, trosak goriva) umjesto craneInfo podataka
- start_mileage/end_mileage (km dizalice na nalogu) premjesteni u Podaci dizalice
- servicer_vehicle_* polja dodana u form state, useEffect init i submit payload
- Trošak goriva (EUR) dodan kao novo polje u sekciji Vozilo servisera

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 11:12:14 +02:00

1187 lines
61 KiB
JavaScript

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 (
<>
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto p-4 pt-2"
contentClassName="flex items-start justify-center min-h-full"
panelClassName="w-full max-w-2xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
>
<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>
<p className="text-sm text-text-muted">Dizalica: {craneLabel}</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori modal"
>
</button>
</div>
<form onSubmit={(event) => event.preventDefault()} className="max-h-[calc(100vh-10rem)] space-y-4 overflow-y-auto px-5 py-4">
<div className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-base">
<WorkOrderImageCarousel
images={imageFiles}
emptyLabel="Nema povezanih slika za ovaj putni nalog"
/>
</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">Dokument i email</h4>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={handleDownloadPdf}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Kreiraj PDF
</button>
<button
type="button"
onClick={() => {
setWidgetError('');
setInvoiceModalOpen(true);
}}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Dodaj račun
</button>
<button
type="button"
onClick={() => {
setWidgetError('');
setEmailModalOpen(true);
}}
className="rounded-md bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-700"
>
Pošalji email
</button>
</div>
{widgetError && (
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{widgetError}
</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">Status</h4>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Status *</span>
<select
value={form.status}
onChange={(event) => {
setForm({
...form,
status: event.target.value,
});
}}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
disabled={!isEditing}
>
<option value="open">Otvoren</option>
<option value="closed">Zatvoren</option>
</select>
</label>
</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">Vozilo servisera</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">Registracija vozila servisera</span>
<input
type="text"
value={form.servicer_vehicle_registration}
onInput={(event) => setForm({ ...form, servicer_vehicle_registration: 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">Marka/Model vozila servisera</span>
<input
type="text"
value={form.servicer_vehicle_make_model}
onInput={(event) => setForm({ ...form, servicer_vehicle_make_model: 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">Početna km vozila servisera</span>
<input
type="number"
min="0"
value={form.servicer_vehicle_start_mileage}
onInput={(event) => setForm({ ...form, servicer_vehicle_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 vozila servisera</span>
<input
type="number"
min="0"
value={form.servicer_vehicle_end_mileage}
onInput={(event) => setForm({ ...form, servicer_vehicle_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">Trošak goriva (EUR)</span>
<input
type="number"
min="0"
step="0.01"
value={form.servicer_vehicle_fuel_cost}
onInput={(event) => setForm({ ...form, servicer_vehicle_fuel_cost: 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">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">
<label className="sm:col-span-2 flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Lokacija odredišta {form.has_travel_order ? '*' : ''}</span>
<div className="flex gap-2">
<input
type="text"
value={form.location}
onInput={(event) => setForm({ ...form, location: event.currentTarget.value })}
className="flex-1 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
<button
type="button"
onClick={() => {
setLocationQuery(form.location || '');
setLocationResults([]);
setSelectedLocation(null);
setMapError('');
setMapPickerOpen(true);
}}
disabled={!isEditing || submitting}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
Lokacija
</button>
</div>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Početak puta {form.has_travel_order ? '*' : ''}</span>
<input
type="datetime-local"
value={form.travel_start_at}
onInput={(event) => setForm({ ...form, travel_start_at: 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">Kraj puta {form.has_travel_order ? '*' : ''}</span>
<input
type="datetime-local"
value={form.travel_end_at}
onInput={(event) => setForm({ ...form, travel_end_at: 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>
<label className="mt-3 flex items-center gap-2 text-sm text-text-main">
<input
type="checkbox"
checked={form.has_travel_order}
onChange={(event) => {
const checked = event.currentTarget.checked;
setForm((prev) => ({
...prev,
has_travel_order: checked,
location: checked ? prev.location : '',
travel_start_at: checked ? prev.travel_start_at : '',
travel_end_at: checked ? prev.travel_end_at : '',
}));
}}
disabled={!isEditing}
/>
Putni nalog uključuje putovanje (lokacija i vrijeme)
</label>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Svrha naloga *</span>
<select
value={form.purpose}
onChange={(event) => setForm({ ...form, purpose: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
required
disabled={!isEditing}
>
<option value=""> Odaberi vrstu </option>
{normalizedPurposeOptions.map((purpose) => (
<option key={purpose.value} value={purpose.value}>{purpose.label}</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Napomena</span>
<textarea
value={form.notes}
onInput={(event) => setForm({ ...form, notes: event.currentTarget.value })}
className="min-h-24 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
disabled={!isEditing}
/>
</label>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<div className="flex justify-end gap-2 border-t border-border-hairline pt-3">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
Zatvori
</button>
{!isEditing ? (
<button
type="button"
onClick={() => setIsEditing(true)}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
>
Izmijeni
</button>
) : (
<>
<button
type="button"
onClick={() => setPhotoUploadOpen(true)}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
📷 Dodaj fotografije
</button>
<button
type="button"
onClick={submit}
disabled={submitting}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{submitting ? 'Spremam...' : 'Spremi izmjene'}
</button>
</>
)}
</div>
</form>
</div>
</ModalShell>
{mapPickerOpen && (
<ModalShell
onClose={() => setMapPickerOpen(false)}
overlayClassName="z-[60] overflow-y-auto p-4 pt-2"
contentClassName="flex items-start justify-center min-h-full"
panelClassName="w-full max-w-3xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
>
<div className="w-full">
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<h4 className="text-base font-semibold text-text-main">Odabir lokacije na karti</h4>
<button
type="button"
onClick={() => setMapPickerOpen(false)}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori kartu"
>
</button>
</div>
<div className="space-y-3 px-5 py-4">
<div className="flex gap-2">
<input
type="text"
value={locationQuery}
onInput={(event) => setLocationQuery(event.currentTarget.value)}
className="flex-1 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
placeholder="Upišite adresu ili naziv lokacije"
/>
<button
type="button"
onClick={searchLocation}
disabled={searchingLocation}
className="rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{searchingLocation ? 'Tražim...' : 'Pretraži'}
</button>
</div>
{mapError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{mapError}
</div>
)}
{locationResults.length > 0 && (
<div className="max-h-44 overflow-y-auto rounded-lg border border-border-hairline bg-canvas-base">
{locationResults.map((result) => (
<button
key={result.place_id}
type="button"
onClick={() => setSelectedLocation(result)}
className="block w-full border-b border-border-hairline px-3 py-2 text-left text-sm text-text-main hover:bg-canvas-deep last:border-b-0"
>
{result.display_name}
</button>
))}
</div>
)}
{selectedLocation && selectedMapEmbedUrl && (
<div className="space-y-2">
<p className="text-sm text-text-main">
Odabrano: <span className="font-medium">{selectedLocation.display_name}</span>
</p>
<iframe
title="Karta odabrane lokacije"
src={selectedMapEmbedUrl}
className="h-72 w-full rounded-lg border border-border-hairline"
/>
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-border-hairline px-5 py-4">
<button
type="button"
onClick={() => setMapPickerOpen(false)}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
Odustani
</button>
<button
type="button"
disabled={!selectedLocation}
onClick={() => {
if (!selectedLocation?.display_name) return;
setForm((prev) => ({ ...prev, location: selectedLocation.display_name }));
setMapPickerOpen(false);
}}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
Primijeni lokaciju
</button>
</div>
</div>
</ModalShell>
)}
{emailModalOpen && (
<ModalShell
onClose={() => setEmailModalOpen(false)}
overlayClassName="z-[60] p-4"
contentClassName="flex items-center justify-center min-h-full"
panelClassName="w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
>
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
<h4 className="text-sm font-semibold text-text-main">Pošalji email za putni nalog</h4>
<button
type="button"
onClick={() => setEmailModalOpen(false)}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori email modal"
>
</button>
</div>
<div className="space-y-3 px-4 py-3">
<label className="flex items-center gap-2 text-xs text-text-muted">
<input
type="checkbox"
checked={sendToAllTeamMembers}
onChange={(event) => setSendToAllTeamMembers(event.currentTarget.checked)}
/>
Pošalji svim članovima tima
</label>
<select
multiple
disabled={sendToAllTeamMembers || loadingTeamMembers}
value={selectedTeamMemberIds}
onChange={(event) => {
const nextIds = Array.from(event.currentTarget.selectedOptions).map((option) => option.value);
setSelectedTeamMemberIds(nextIds);
}}
className="min-h-28 w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main disabled:bg-canvas-deep"
>
{!loadingTeamMembers && teamMembers.length === 0 && (
<option disabled>Nema dostupnih članova tima</option>
)}
{teamMembers.map((member) => (
<option key={member.id} value={String(member.id)}>
{member.full_name} ({member.email})
</option>
))}
</select>
<p className="text-xs text-text-muted">
{loadingTeamMembers
? 'Učitavanje članova tima...'
: `Odabrano: ${sendToAllTeamMembers ? teamMembers.length : selectedTeamMemberIds.length}`}
</p>
<input
type="text"
value={emailPayload.subject}
onInput={(event) => setEmailPayload({ ...emailPayload, subject: event.currentTarget.value })}
placeholder="Naslov (opcionalno)"
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
/>
<textarea
value={emailPayload.message}
onInput={(event) => setEmailPayload({ ...emailPayload, message: event.currentTarget.value })}
placeholder="Poruka (opcionalno)"
className="min-h-24 w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
/>
</div>
<div className="flex justify-end gap-2 border-t border-border-hairline px-4 py-3">
<button
type="button"
onClick={() => setEmailModalOpen(false)}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Odustani
</button>
<button
type="button"
onClick={handleSendEmail}
disabled={emailSending}
className="rounded-md bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{emailSending ? 'Šaljem…' : 'Pošalji'}
</button>
</div>
</div>
</ModalShell>
)}
{invoiceModalOpen && (
<ModalShell
onClose={() => setInvoiceModalOpen(false)}
overlayClassName="z-[60] overflow-y-auto p-4 pt-16"
contentClassName="flex items-start justify-center min-h-full"
panelClassName="w-full max-w-2xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
>
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
<h4 className="text-sm font-semibold text-text-main">Računi za putni nalog</h4>
<button
type="button"
onClick={() => setInvoiceModalOpen(false)}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori modal računa"
>
</button>
</div>
<div className="grid gap-3 px-4 py-3 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
<span className="font-medium text-text-main">naziv_računa *</span>
<input
type="text"
value={invoiceForm.naziv_racuna}
onInput={(event) => setInvoiceForm((prev) => ({ ...prev, naziv_racuna: event.currentTarget.value }))}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">lokacija</span>
<input
type="text"
value={invoiceForm.lokacija}
onInput={(event) => setInvoiceForm((prev) => ({ ...prev, lokacija: event.currentTarget.value }))}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">datum *</span>
<input
type="date"
value={invoiceForm.datum}
onInput={(event) => setInvoiceForm((prev) => ({ ...prev, datum: event.currentTarget.value }))}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
/>
</label>
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
<span className="font-medium text-text-main">opis</span>
<textarea
value={invoiceForm.opis}
onInput={(event) => setInvoiceForm((prev) => ({ ...prev, opis: event.currentTarget.value }))}
className="min-h-20 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
/>
</label>
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
<span className="font-medium text-text-main">slika_računa</span>
<input
type="file"
accept="image/*"
onChange={(event) => {
const file = event.currentTarget.files && event.currentTarget.files[0] ? event.currentTarget.files[0] : null;
setInvoiceForm((prev) => ({ ...prev, imageFile: file }));
}}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
/>
</label>
</div>
<div className="max-h-52 overflow-y-auto border-t border-border-hairline px-4 py-3">
{invoices.length === 0 ? (
<p className="text-xs text-text-muted">Nema unesenih računa.</p>
) : (
<ul className="space-y-2">
{invoices.map((invoice) => (
<li key={invoice.id} className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-xs text-text-main">
<div className="font-semibold">{invoice.naziv_racuna}</div>
<div className="text-text-muted">{invoice.lokacija || '-'} | {invoice.datum || '-'}</div>
</li>
))}
</ul>
)}
</div>
<div className="flex justify-end gap-2 border-t border-border-hairline px-4 py-3">
<button
type="button"
onClick={() => setInvoiceModalOpen(false)}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Odustani
</button>
<button
type="button"
onClick={handleCreateInvoice}
disabled={invoiceSaving}
className="rounded-md bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{invoiceSaving ? 'Spremam…' : 'Spremi račun'}
</button>
</div>
</div>
</ModalShell>
)}
<WorkOrderPhotoUpload
open={photoUploadOpen}
workOrderId={workOrder?.id}
onClose={() => setPhotoUploadOpen(false)}
onBack={() => setPhotoUploadOpen(false)}
onSuccess={() => {
setPhotoUploadOpen(false);
if (workOrder?.id) {
fetchWorkOrderImages(workOrder.id, { w: 1280, q: 75, fmt: 'webp' })
.then((files) => setImageFiles(files.map((item) => item.optimized_url || item.original_url).filter(Boolean)))
.catch(() => {});
}
}}
/>
</>
);
}