Dodano je origin_location polje kroz backend model, serializer, admin i UI modal za kreiranje/uređivanje putnog naloga (default: Zagreb). Računi putnog naloga sada prihvaćaju slike i PDF datoteke, uz backend validaciju formata, podršku za serving PDF-a i prilagodbu preview prikaza. Za greške uploada računa dodan je error toast kako bi poruke validacije bile vidljive korisniku odmah u sučelju. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1060 lines
54 KiB
JavaScript
1060 lines
54 KiB
JavaScript
import { useEffect, useMemo, useState } from 'preact/hooks';
|
|
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
|
|
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
|
|
import ModalShell from '../ui/ModalShell';
|
|
import {
|
|
createWorkOrderInvoice,
|
|
fetchTeamMembers,
|
|
fetchWorkOrderInvoices,
|
|
fetchWorkOrderImages,
|
|
openWorkOrderInvoicesPdfPage,
|
|
sendWorkOrderEmail,
|
|
} from '../../stores/fleetDashboardStore';
|
|
import { showToast } from '../../stores/toastStore';
|
|
import { formatWorkOrderDisplayCode } 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 [isEditing, setIsEditing] = useState(false);
|
|
const [form, setForm] = useState({
|
|
has_travel_order: false,
|
|
display_code: '',
|
|
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: '',
|
|
origin_location: 'Zagreb',
|
|
location: '',
|
|
travel_start_at: '',
|
|
travel_end_at: '',
|
|
purpose: '',
|
|
notes: '',
|
|
});
|
|
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 [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 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);
|
|
setForm({
|
|
has_travel_order: !!(workOrder.travel_start_at || workOrder.travel_end_at || workOrder.origin_location || workOrder.location || workOrder.start_mileage != null || workOrder.end_mileage != null),
|
|
display_code: workOrder.display_code || '',
|
|
status: workOrder.status || 'open',
|
|
start_mileage: String(workOrder.start_mileage ?? ''),
|
|
end_mileage: workOrder.end_mileage == null ? '' : String(workOrder.end_mileage),
|
|
origin_location: workOrder.origin_location || 'Zagreb',
|
|
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),
|
|
});
|
|
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]);
|
|
|
|
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;
|
|
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 ((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;
|
|
}
|
|
const displayCode = String(form.display_code || '').trim().toUpperCase();
|
|
if (!displayCode) {
|
|
setError('Prikaz putnog naloga je obavezan.');
|
|
return;
|
|
}
|
|
if (!/^[A-Z]{2}[0-9]{6}$/.test(displayCode)) {
|
|
setError('Prikaz putnog naloga mora biti u formatu MT070626.');
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
await onSubmit(workOrder, {
|
|
display_code: displayCode,
|
|
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),
|
|
origin_location: form.origin_location.trim() || 'Zagreb',
|
|
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 putni nalog {formatWorkOrderDisplayCode(workOrder)}</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="mb-3 flex flex-col gap-1 text-sm">
|
|
<span className="font-medium text-text-main">Prikaz putnog naloga *</span>
|
|
<input
|
|
type="text"
|
|
value={form.display_code}
|
|
onInput={(event) => setForm({ ...form, display_code: event.currentTarget.value.toUpperCase() })}
|
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
|
pattern="[A-Za-z]{2}[0-9]{6}"
|
|
maxLength={8}
|
|
placeholder="MT070626"
|
|
disabled={!isEditing}
|
|
required
|
|
/>
|
|
</label>
|
|
<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">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 ishodišta</span>
|
|
<input
|
|
type="text"
|
|
value={form.origin_location}
|
|
onInput={(event) => setForm({ ...form, origin_location: 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="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,
|
|
origin_location: checked ? (prev.origin_location || 'Zagreb') : 'Zagreb',
|
|
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 / pdf_računa</span>
|
|
<input
|
|
type="file"
|
|
accept="image/*,.pdf,application/pdf"
|
|
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(() => {});
|
|
}
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|