prvi kod za live uporabu
Some checks failed
ERP CI Pipeline / test (push) Has been cancelled

This commit is contained in:
mariomitte
2026-07-09 21:21:17 +02:00
parent 857bb65d52
commit 5d39e048ba
239 changed files with 25151 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
import { showToast } from '../../stores/toastStore';
import ServiceContextSelector from './ServiceContextSelector';
const NAV_ITEMS = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'work-orders', label: 'Putni nalozi' },
{ key: 'service-records', label: 'Servisni zapisi' },
{ key: 'cranes', label: 'Dizalice' },
{ key: 'clients', label: 'Klijenti' },
];
export default function DashboardTopbar({
activeSection,
onSectionChange,
onOpenWorkOrderModal,
onOpenTaskModal,
pendingSyncCount,
onNewServiceRecord,
showSectionNav = true,
}) {
const sectionTitle = NAV_ITEMS.find((item) => item.key === activeSection)?.label || 'Dashboard';
return (
<header className="sticky top-0 z-20 border-b border-border-hairline bg-canvas-elevated/95 backdrop-blur">
{/* ── Gornji red ───────────────────────────────────────── */}
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
<div>
<p className="text-xs uppercase tracking-wide text-text-muted">Pregled sustava</p>
<h2 className="text-xl font-semibold text-text-main">{sectionTitle}</h2>
</div>
<div className="flex items-center gap-3">
{pendingSyncCount > 0 && (
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">
Offline queue: {pendingSyncCount}
</span>
)}
<button
type="button"
onClick={onOpenWorkOrderModal}
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"
>
+ Novi putni nalog
</button>
<button
type="button"
onClick={onOpenTaskModal}
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"
>
+ Novi radni zadatak
</button>
</div>
</div>
{/* ── Selector bar: Kupac + Dizalica ───────────────────── */}
<ServiceContextSelector onNewServiceRecord={onNewServiceRecord} />
{/* ── Navigacijski tabovi ──────────────────────────────── */}
{showSectionNav && (
<nav className="overflow-x-auto border-t border-border-hairline px-4 sm:px-6">
<ul className="flex min-w-max items-center gap-1 py-2 text-sm">
{NAV_ITEMS.map((item) => (
<li key={item.key}>
<button
type="button"
onClick={() => {
onSectionChange(item.key);
}}
className={
activeSection === item.key
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700'
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep'
}
>
{item.label}
</button>
</li>
))}
</ul>
</nav>
)}
</header>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
// src/components/dashboard/ServiceContextSelector.jsx
//
// Persistentni selektor "Servisni kontekst" — odabir kupca + dizalice.
// Stanje se čuva u localStorage i u serviceContextStore (reaktivno).
//
// USAGE:
// <ServiceContextSelector onNewServiceRecord={({ craneId, clientId }) => ...} />
import { useStore } from '@nanostores/preact';
import { useEffect, useState } from 'preact/hooks';
import { $clients, fetchClients } from '../../stores/clientStore';
import { $cranes } from '../../stores/fleetDashboardStore';
import { $accessToken } from '../../stores/authStore';
import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
import {
$selectedClientId, $selectedVehicleId,
hydrateServiceContext, setSelectedClient, setSelectedVehicle, clearServiceContext,
} from '../../stores/serviceContextStore';
export default function ServiceContextSelector({ onNewServiceRecord }) {
const clients = useStore($clients);
const cranes = useStore($cranes);
const accessToken = useStore($accessToken);
const selectedClientId = useStore($selectedClientId);
const selectedVehicleId = useStore($selectedVehicleId);
const [editMode, setEditMode] = useState(false);
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
hydrateServiceContext();
}, []);
useEffect(() => {
if (!accessToken) return;
if (clients.length > 0) return;
fetchClients();
}, [accessToken, clients.length]);
const fallbackClients = Array.from(
new Map(
cranes
.filter((item) => item?.client && item?.client_name)
.map((item) => [String(item.client), { id: item.client, name: item.client_name }])
).values()
);
const availableClients = clients.length > 0 ? clients : fallbackClients;
const clientCranes = cranes.filter((item) => String(item.client) === String(selectedClientId));
const selectedClient = availableClients.find((c) => String(c.id) === String(selectedClientId));
const selectedCrane = cranes.find((item) => String(item.id) === String(selectedVehicleId));
const showSelector = editMode || !selectedClientId;
function handleClientChange(e) {
setSelectedClient(e.target.value);
}
function handleVehicleChange(e) {
setSelectedVehicle(e.target.value);
}
function handleClearSelection() {
clearServiceContext();
setEditMode(false);
}
if (!hasMounted) return null;
return (
<div className="border-t border-border-hairline bg-canvas-base/60 px-4 py-2 sm:px-6">
{showSelector ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-text-muted">Servisni kontekst:</span>
<select
value={selectedClientId ?? ''}
onChange={handleClientChange}
className="rounded-md border border-border-hairline bg-canvas-base px-2 py-1 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-indigo-400"
>
<option value=""> Odaberi kupca </option>
{availableClients.length === 0 && (
<option value="" disabled>Nema dostupnih kupaca</option>
)}
{availableClients.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
{selectedClientId && (
<select
value={selectedVehicleId ?? ''}
onChange={handleVehicleChange}
className="rounded-md border border-border-hairline bg-canvas-base px-2 py-1 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-indigo-400"
>
<option value=""> Odaberi dizalicu </option>
{clientCranes.length > 0
? clientCranes.map((item) => (
<option key={item.id} value={item.id}>
{item.registration_number} {item.make} {item.model}
</option>
))
: <option disabled>Nema dizalica za ovog kupca</option>
}
</select>
)}
{selectedClientId && selectedVehicleId && (
<button
type="button"
onClick={() => setEditMode(false)}
className="rounded-md bg-indigo-600 px-3 py-1 text-xs font-semibold text-white hover:bg-indigo-700"
>
Potvrdi
</button>
)}
{editMode && (
<button
type="button"
onClick={() => setEditMode(false)}
className="rounded-md border border-border-hairline px-3 py-1 text-xs text-text-muted hover:bg-canvas-deep"
>
Odustani
</button>
)}
</div>
) : (
<div className="flex flex-wrap items-center gap-3">
<span className="text-xs text-text-muted">Servisni kontekst:</span>
<span className="rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-700">
{selectedClient?.name ?? '…'}
</span>
{selectedCrane && (
<>
<span className="text-xs text-text-muted">/</span>
<span className="rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-700">
{selectedCrane.registration_number} {selectedCrane.make} {selectedCrane.model}
</span>
</>
)}
{selectedVehicleId && onNewServiceRecord && (
<ServiceRecordCreateButton
onClick={() => {
onNewServiceRecord({ craneId: selectedVehicleId, clientId: selectedClientId });
}}
/>
)}
<button
type="button"
onClick={() => setEditMode(true)}
className="rounded-md border border-border-hairline px-2 py-1 text-xs text-text-muted hover:bg-canvas-deep"
>
Promijeni
</button>
<button
type="button"
onClick={handleClearSelection}
className="rounded-md border border-border-hairline px-2 py-1 text-xs text-text-muted hover:bg-red-50 hover:text-red-600"
>
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { useStore } from '@nanostores/preact';
import { $syncLog } from '../../stores/fleetDashboardStore';
import AnimatedDataTable from '../ui/AnimatedDataTable';
function rowStyle(status) {
if (status === 'success') return 'text-emerald-700';
if (status === 'failed') return 'text-red-700';
return 'text-amber-700';
}
function formatDate(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('hr-HR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date);
}
export default function SyncLogPanel() {
const rows = useStore($syncLog);
const syncColumns = [
{ key: 'time', label: 'Vrijeme', className: 'px-4 py-2' },
{ key: 'status', label: 'Status', className: 'px-4 py-2' },
{ key: 'type', label: 'Tip', className: 'px-4 py-2' },
{ key: 'detail', label: 'Detalj', className: 'px-4 py-2' },
];
return (
<article className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
<div className="border-b border-border-hairline px-4 py-3">
<h3 className="font-semibold text-text-main">Status sinkronizacije</h3>
<p className="text-xs text-text-muted">Što je poslano, što je ostalo u redu čekanja i što nije uspjelo.</p>
</div>
<AnimatedDataTable
columns={syncColumns}
rows={rows}
rowKey={(item) => item.id}
emptyMessage="Još nema zapisa sinkronizacije."
wrapperClassName="max-h-72 overflow-auto"
renderRow={(item) => (
<>
<td className="px-4 py-2 text-text-muted">{formatDate(item.created_at)}</td>
<td className={`px-4 py-2 font-semibold ${rowStyle(item.status)}`}>{item.status}</td>
<td className="px-4 py-2 text-text-main">{item.entity}</td>
<td className="px-4 py-2 text-text-muted">{item.detail || '-'}</td>
</>
)}
/>
</article>
);
}

View File

@@ -0,0 +1,188 @@
import { useEffect, useState } from 'preact/hooks';
import { createTask, getStatusLabel } from '../../stores/taskStore';
import { formatEntityCode, formatPurposeLabel } from '../../lib/displayIds';
const STATUS_OPTIONS = ['aktivan', 'servis', 'neaktivan'];
const INITIAL_FORM = {
title: '',
description: '',
status: 'aktivan',
work_order: '',
};
export default function TaskCreateModal({ open, workOrders = [], contextCrane = null, onClose, onSuccess }) {
const [form, setForm] = useState(INITIAL_FORM);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!open) {
setForm(INITIAL_FORM);
setSubmitting(false);
setError('');
}
}, [open]);
if (!open) return null;
function setField(name, value) {
setForm((prev) => ({ ...prev, [name]: value }));
}
async function submit(event) {
event.preventDefault();
setError('');
const title = String(form.title || '').trim();
if (!title) {
setError('Naslov zadatka je obavezan.');
return;
}
if (form.status === 'neaktivan' && !form.work_order) {
setError('Putni nalog je obavezan prije zatvaranja zadatka.');
return;
}
if (!contextCrane?.id) {
setError('Odaberite dizalicu u Servisnom kontekstu prije kreiranja zadatka.');
return;
}
setSubmitting(true);
try {
await createTask({
title,
description: String(form.description || '').trim(),
status: form.status || 'aktivan',
vehicle: contextCrane.id,
work_order: form.work_order || null,
});
await onSuccess?.();
onClose?.();
} catch (err) {
setError(err?.message || 'Neuspješno kreiranje zadatka.');
} finally {
setSubmitting(false);
}
}
return (
<div
className="fixed inset-0 z-50 overflow-y-auto bg-slate-950/15 p-4 pt-20 backdrop-blur-[2px]"
onClick={onClose}
>
<div className="flex min-h-full items-start justify-center">
<div
className="w-full max-w-xl max-h-[calc(100vh-2rem)] overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<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">Novi radni zadatak</h3>
<p className="text-xs text-text-muted">
Kontekst dizalice: {contextCrane?.registration_number || 'Nije odabrana'}
</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={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Naslov zadatka *</span>
<input
type="text"
value={form.title}
onInput={(event) => setField('title', event.currentTarget.value)}
disabled={submitting}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
required
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Opis</span>
<textarea
rows={3}
value={form.description}
onInput={(event) => setField('description', event.currentTarget.value)}
disabled={submitting}
className="resize-none rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
/>
</label>
<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">Status</span>
<select
value={form.status}
onChange={(event) => setField('status', event.currentTarget.value)}
disabled={submitting}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
{STATUS_OPTIONS.map((status) => (
<option key={status} value={status}>
{getStatusLabel(status)}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Povezani putni nalog</span>
<select
value={form.work_order}
onChange={(event) => setField('work_order', event.currentTarget.value)}
disabled={submitting}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
<option value=""> Bez povezanog naloga </option>
{workOrders.map((workOrder) => (
<option key={workOrder.id} value={String(workOrder.id)}>
{formatEntityCode('PN', workOrder.id)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {formatPurposeLabel(workOrder.purpose) || '-'}
</option>
))}
</select>
{workOrders.length === 0 && (
<span className="text-[11px] text-amber-600">
Nema dostupnih naloga za odabranu dizalicu.
</span>
)}
</label>
</div>
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<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"
disabled={submitting}
>
Odustani
</button>
<button
type="submit"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
disabled={submitting}
>
{submitting ? 'Spremanje...' : 'Kreiraj radni zadatak'}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
import { formatEntityCode } from '../../lib/displayIds';
function formatDate(value) {
if (!value) return '-';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleDateString('hr-HR');
}
function formatCost(value) {
const numeric = Number(value);
if (Number.isNaN(numeric)) return '-';
return `${numeric.toFixed(2)} EUR`;
}
export default function TaskServiceRecordsModal({
open,
task,
records = [],
workOrders = [],
onClose,
onOpenServiceRecord,
onAssignWorkOrder,
onCreateServiceRecord,
}) {
const [showWorkOrderEditor, setShowWorkOrderEditor] = useState(false);
const [selectedWorkOrderId, setSelectedWorkOrderId] = useState('');
const [assigningWorkOrder, setAssigningWorkOrder] = useState(false);
const [assignError, setAssignError] = useState('');
useEffect(() => {
if (!open || typeof window === 'undefined') return;
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [open]);
useEffect(() => {
if (!task) return;
setSelectedWorkOrderId(task.work_order ? String(task.work_order) : '');
setShowWorkOrderEditor(!task.work_order);
setAssignError('');
setAssigningWorkOrder(false);
}, [task?.id, task?.work_order]);
const currentWorkOrder = useMemo(
() => workOrders.find((item) => String(item.id) === String(task?.work_order || selectedWorkOrderId)) || null,
[workOrders, task?.work_order, selectedWorkOrderId]
);
const sortedWorkOrders = useMemo(
() => [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))),
[workOrders]
);
const handleWorkOrderChange = async (event) => {
const nextValue = String(event.currentTarget.value || '');
setSelectedWorkOrderId(nextValue);
setAssignError('');
setAssigningWorkOrder(true);
try {
await onAssignWorkOrder?.(task, nextValue || null);
setShowWorkOrderEditor(false);
} catch (error) {
setAssignError(error?.message || 'Neuspješno spremanje putnog naloga za radni zadatak.');
} finally {
setAssigningWorkOrder(false);
}
};
if (!open || !task) return null;
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-4xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl max-h-[calc(100vh-6rem)] overflow-hidden"
>
<div className="w-full flex flex-col h-full">
<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">Radni zadatak detalji</h3>
<p className="text-xs text-text-muted">
Radni zadatak: {task.title || '-'} Status: {task.status || '-'} Zapisa: {records.length}
</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"
>
</button>
</div>
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto p-4">
<div className="space-y-4">
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Putni nalozi</h4>
{currentWorkOrder && !showWorkOrderEditor ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded border border-border-hairline bg-canvas-elevated px-3 py-2">
<p className="text-sm text-text-main">
{formatEntityCode('PN', currentWorkOrder.id)} {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} {currentWorkOrder.status || '-'}
</p>
<button
type="button"
onClick={() => setShowWorkOrderEditor(true)}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Izmijeni putni nalog
</button>
</div>
) : (
<div className="space-y-2">
<select
value={selectedWorkOrderId}
onChange={handleWorkOrderChange}
disabled={assigningWorkOrder}
className="w-full rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main disabled:opacity-60"
>
<option value=""> Odaberi putni nalog </option>
{sortedWorkOrders.map((workOrder) => (
<option key={workOrder.id} value={String(workOrder.id)}>
{formatEntityCode('PN', workOrder.id)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {workOrder.status || '-'}
</option>
))}
</select>
<p className="text-xs text-text-muted">
{assigningWorkOrder ? 'Spremanje odabira putnog naloga...' : 'Odabir se automatski sprema u bazu.'}
</p>
</div>
)}
{assignError && (
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{assignError}
</div>
)}
</div>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<h4 className="text-sm font-semibold text-text-main">Povezani servisni zapisi</h4>
<button
type="button"
onClick={() => onCreateServiceRecord?.(task)}
className="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700"
>
+ Servisni zapis
</button>
</div>
{records.length === 0 ? (
<div className="rounded-lg border border-border-hairline bg-canvas-elevated px-4 py-3 text-sm text-text-muted">
Nema povezanih servisnih zapisa za odabrani radni zadatak.
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border-hairline">
<table className="w-full table-auto text-sm">
<thead className="bg-canvas-base text-left text-text-muted">
<tr>
<th className="px-3 py-2">Datum</th>
<th className="px-3 py-2">Opis</th>
<th className="px-3 py-2">Trošak</th>
<th className="px-3 py-2">Datoteke</th>
<th className="px-3 py-2">Akcija</th>
</tr>
</thead>
<tbody>
{records.map((record) => (
<tr key={record.id} className="border-t border-border-hairline">
<td className="px-3 py-2">{formatDate(record.service_date)}</td>
<td className="max-w-[420px] truncate px-3 py-2" title={record.description || ''}>
{record.description || '-'}
</td>
<td className="px-3 py-2">{formatCost(record.cost)}</td>
<td className="px-3 py-2">{record.files_count ?? (record.photos_count ?? 0)}</td>
<td className="px-3 py-2">
<button
type="button"
onClick={() => onOpenServiceRecord?.(record)}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Detalji
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
</div>
</ModalShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { animated, useTransition } from '@react-spring/web';
function getBrowserApiBase() {
const apiUrl = import.meta.env.PUBLIC_API_URL || '';
if (typeof window === 'undefined') {
return apiUrl || 'http://localhost:8001/api/';
}
try {
if (apiUrl) {
const parsed = new URL(apiUrl);
const internalHosts = new Set(['backend', 'localhost', '127.0.0.1']);
const isInternalDockerHost = parsed.hostname === 'backend';
if (!isInternalDockerHost) {
return parsed.toString();
}
}
} catch {
// fallback is handled below
}
return `${window.location.protocol}//${window.location.hostname}:8001/api/`;
}
export function resolveMediaUrl(pathOrUrl) {
if (!pathOrUrl || typeof window === 'undefined') return '';
let finalSrc = String(pathOrUrl);
const apiUrl = getBrowserApiBase();
const baseUrl = apiUrl.replace(/api\/?$/, '');
if (/^https?:\/\//i.test(finalSrc)) {
if (finalSrc.includes('backend:8000')) {
return finalSrc.replace('http://backend:8000/', baseUrl);
}
return finalSrc;
}
if (finalSrc.startsWith('/')) {
return `${baseUrl.replace(/\/$/, '')}${finalSrc}`;
}
return new URL(finalSrc, apiUrl).toString();
}
export default function WorkOrderImageCarousel({ images = [], editMode = false, emptyLabel = 'Nema priložene tehničke dokumentacije' }) {
const normalizedImages = useMemo(() => (
Array.isArray(images)
? images.filter(Boolean)
: (typeof images === 'string' && images ? [images] : [])
), [images]);
const [currentImgIdx, setCurrentImgIdx] = useState(0);
useEffect(() => {
setCurrentImgIdx(0);
}, [normalizedImages.length]);
useEffect(() => {
if (editMode || normalizedImages.length <= 1) return undefined;
const interval = window.setInterval(() => {
setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
}, 3000);
return () => window.clearInterval(interval);
}, [normalizedImages.length, editMode]);
if (normalizedImages.length === 0) {
return (
<div className="flex h-64 w-full flex-col items-center justify-center border-b border-border-hairline bg-canvas-deep font-mono text-[11px] uppercase tracking-wider text-text-muted/40">
<svg className="mb-2 h-8 w-8 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
{emptyLabel}
</div>
);
}
const prevSlide = (event) => {
event.preventDefault();
setCurrentImgIdx((prev) => (prev - 1 + normalizedImages.length) % normalizedImages.length);
};
const nextSlide = (event) => {
event.preventDefault();
setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
};
const finalSrc = resolveMediaUrl(normalizedImages[currentImgIdx] || '');
const imageTransitions = useTransition(currentImgIdx, {
from: { opacity: 0, transform: 'translate3d(8%,0,0) scale(1.01)' },
enter: { opacity: 1, transform: 'translate3d(0%,0,0) scale(1)' },
leave: { opacity: 0, transform: 'translate3d(-8%,0,0) scale(1.01)' },
config: { tension: 220, friction: 26 },
});
return (
<div className="group relative h-72 w-full overflow-hidden border-b border-border-hairline bg-canvas-base sm:h-80">
<div className="relative h-full w-full overflow-hidden">
{imageTransitions((style, idx) => (
<animated.img
key={idx}
src={resolveMediaUrl(normalizedImages[idx] || finalSrc)}
alt={`Dokumentacija s terena ${idx + 1}`}
style={style}
className="absolute inset-0 h-full w-full object-cover will-change-transform"
/>
))}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/40 to-transparent" />
{normalizedImages.length > 1 && (
<>
<button
type="button"
onClick={prevSlide}
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full border border-border-hairline bg-canvas-elevated/80 p-2 text-text-main opacity-0 shadow-lg transition-opacity duration-200 hover:bg-canvas-base group-hover:opacity-100"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
type="button"
onClick={nextSlide}
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full border border-border-hairline bg-canvas-elevated/80 p-2 text-text-main opacity-0 shadow-lg transition-opacity duration-200 hover:bg-canvas-base group-hover:opacity-100"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<div className="absolute inset-x-0 bottom-3 z-10 flex justify-center gap-1.5">
{normalizedImages.map((_, idx) => (
<button
key={idx}
type="button"
onClick={(event) => {
event.preventDefault();
setCurrentImgIdx(idx);
}}
className={`h-1.5 rounded-full transition-all duration-300 ${idx === currentImgIdx ? 'w-4 bg-brand-accent' : 'w-1.5 bg-white/40 hover:bg-white/70'}`}
/>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,258 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import {
downloadWorkOrderPdf,
downloadWorkOrderInvoicesPdf,
downloadWorkOrderServiceRecordsPdf,
fetchWorkOrderById,
fetchWorkOrderInvoices,
fetchWorkOrderTaskServiceContext,
} from '../../stores/fleetDashboardStore';
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
import { formatEntityCode } from '../../lib/displayIds';
import { resolveMediaUrl } from './WorkOrderImageCarousel';
function readWorkOrderIdFromQuery() {
if (typeof window === 'undefined') {
return '';
}
const params = new URLSearchParams(window.location.search);
return params.get('workOrderId') || '';
}
function InvoiceImagePreview({ imageUrl, alt }) {
const [broken, setBroken] = useState(false);
if (!imageUrl || broken) {
return (
<div className="flex h-28 w-28 items-center justify-center rounded-lg border border-border-hairline bg-canvas-deep text-center text-[11px] text-text-muted">
Bez slike
</div>
);
}
return (
<a href={imageUrl} target="_blank" rel="noopener noreferrer" className="inline-flex">
<img
src={imageUrl}
alt={alt}
onError={() => setBroken(true)}
className="h-28 w-28 rounded-lg border border-border-hairline object-cover"
/>
</a>
);
}
export default function WorkOrderInvoicesPdfPage() {
const authReady = useStore($authReady);
const token = useStore($accessToken);
const [workOrderId, setWorkOrderId] = useState('');
const [workOrder, setWorkOrder] = useState(null);
const [invoices, setInvoices] = useState([]);
const [taskContext, setTaskContext] = useState({ tasks: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
hydrateAuthFromStorage();
const id = readWorkOrderIdFromQuery();
setWorkOrderId(id);
}, []);
useEffect(() => {
if (authReady) {
fetchNotifications();
connectNotifications();
}
}, [authReady]);
useEffect(() => {
if (!workOrderId) {
setLoading(false);
setError('Nedostaje workOrderId u URL-u.');
return;
}
if (!authReady) {
return;
}
if (!token) {
setLoading(false);
setError('Sesija nije aktivna. Prijavite se ponovno.');
return;
}
let cancelled = false;
setLoading(true);
setError('');
(async () => {
try {
const [contextPayload, order, invoiceItems] = await Promise.all([
fetchWorkOrderTaskServiceContext(workOrderId),
fetchWorkOrderById(workOrderId),
fetchWorkOrderInvoices(workOrderId),
]);
if (cancelled) {
return;
}
setTaskContext(contextPayload);
setWorkOrder(order);
setInvoices(invoiceItems);
} catch (err) {
if (!cancelled) {
setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [workOrderId, authReady, token]);
const title = useMemo(() => {
if (!workOrder) {
return 'Računi putnog naloga';
}
return `${formatEntityCode('PN', workOrder.id)}`;
}, [workOrder]);
return (
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-text-main">{title}</h1>
<p className="text-sm text-text-muted">
{workOrder ? `Dizalica: ${workOrder.crane_label || workOrder.crane || '-'}` : 'Pregled računa prije kreiranja PDF-a.'}
</p>
</div>
<button
type="button"
onClick={() => downloadWorkOrderPdf(workOrderId)}
disabled={!workOrderId}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
Preuzmi PDF putnog naloga
</button>
</div>
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-text-main">Task i povezani servisni zapisi</h2>
<button
type="button"
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
disabled={!workOrderId}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
Preuzmi PDF servisnih zapisa
</button>
</div>
{loading && <p className="text-sm text-text-muted">Učitavanje task konteksta...</p>}
{!loading && !error && (!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0) && (
<p className="text-sm text-text-muted">Nema taskova povezanih s ovim putnim nalogom.</p>
)}
{!loading && !error && Array.isArray(taskContext.tasks) && taskContext.tasks.length > 0 && (
<div className="space-y-4">
{taskContext.tasks.map((task) => (
<article key={task.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="space-y-1 text-sm text-text-main">
<div><span className="font-semibold">Radni zadatak:</span> {task.title || '-'}</div>
<div><span className="font-semibold">Status:</span> {task.status || '-'}</div>
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
</div>
<div className="mt-3 space-y-3">
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
<p className="text-xs text-text-muted">Nema povezanih servisnih zapisa.</p>
) : (
task.service_records.map((record) => (
<div key={record.id} className="rounded border border-border-hairline px-3 py-2">
<div className="space-y-1 text-xs text-text-main">
<div><span className="font-semibold">Servis:</span> {record.description || '-'}</div>
<div><span className="font-semibold">Datum:</span> {record.service_date || '-'}</div>
<div><span className="font-semibold">Trošak:</span> {record.cost || '-'} EUR</div>
<div><span className="font-semibold">KM:</span> {record.mileage ?? '-'}</div>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{Array.isArray(record.photos) && record.photos.length > 0 ? (
record.photos.map((photo) => (
<a
key={photo.id}
href={resolveMediaUrl(photo.optimized_url || photo.image_url)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex"
>
<img
src={resolveMediaUrl(photo.optimized_url || photo.image_url)}
alt={`Servisna slika ${photo.id}`}
className="h-20 w-20 rounded border border-border-hairline object-cover"
/>
</a>
))
) : (
<span className="text-xs text-text-muted">Bez slika</span>
)}
</div>
</div>
))
)}
</div>
</article>
))}
</div>
)}
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-text-main">Računi</h2>
<button
type="button"
onClick={() => downloadWorkOrderInvoicesPdf(workOrderId)}
disabled={!workOrderId}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
Preuzmi račune putnog naloga
</button>
</div>
{loading && <p className="text-sm text-text-muted">Učitavanje računa...</p>}
{!loading && error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
{!loading && !error && invoices.length === 0 && (
<p className="text-sm text-text-muted">Za ovaj putni nalog nema unesenih računa.</p>
)}
{!loading && !error && invoices.length > 0 && (
<div className="space-y-3">
{invoices.map((invoice) => (
<article key={invoice.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-start">
<div className="space-y-1 text-sm text-text-main">
<div><span className="font-semibold">naziv_računa:</span> {invoice.naziv_racuna}</div>
<div><span className="font-semibold">lokacija:</span> {invoice.lokacija || '-'}</div>
<div><span className="font-semibold">datum:</span> {invoice.datum || '-'}</div>
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
</div>
<InvoiceImagePreview
imageUrl={resolveMediaUrl(invoice.image)}
alt={`Račun ${invoice.naziv_racuna}`}
/>
</div>
</article>
))}
</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,592 @@
import { useEffect, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import ModalShell from '../ui/ModalShell';
import { $user } from '../../stores/authStore';
const PURPOSE_OPTIONS = [
{ value: 'defektaza', label: 'Defektaža' },
{ value: 'kontrola', label: 'Kontrola' },
{ value: 'redovni_pregled', label: 'Redovni pregled' },
];
const INITIAL_FORM = {
has_travel_order: false,
start_mileage: '',
end_mileage: '',
servicer_vehicle_registration: '',
servicer_vehicle_make_model: '',
servicer_vehicle_start_mileage: '',
servicer_vehicle_end_mileage: '',
servicer_vehicle_fuel_cost: '',
location: '',
travel_start_at: '',
travel_end_at: '',
purpose: '',
notes: '',
status: 'open',
};
export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit }) {
const user = useStore($user);
const [form, setForm] = useState(INITIAL_FORM);
const [submitting, setSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = 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('');
useEffect(() => {
if (!open) {
setForm(INITIAL_FORM);
setSubmitting(false);
setErrorMessage('');
setMapPickerOpen(false);
setLocationQuery('');
setLocationResults([]);
setSelectedLocation(null);
setSearchingLocation(false);
setMapError('');
return;
}
setForm((prev) => ({
...prev,
start_mileage: selectedCrane ? String(selectedCrane.current_mileage ?? '') : prev.start_mileage,
servicer_vehicle_registration: user?.assigned_vehicle?.registration_number || '',
servicer_vehicle_make_model: `${user?.assigned_vehicle?.make || ''} ${user?.assigned_vehicle?.model || ''}`.trim(),
servicer_vehicle_start_mileage: user?.assigned_vehicle?.current_mileage == null ? '' : String(user.assigned_vehicle.current_mileage),
servicer_vehicle_end_mileage: '',
servicer_vehicle_fuel_cost: '0.00',
}));
}, [open, selectedCrane, user?.assigned_vehicle?.id]);
if (!open) return null;
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}`;
})();
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 (event) => {
event.preventDefault();
setErrorMessage('');
if (!selectedCrane) {
setErrorMessage('Odaberite dizalicu u Servisnom kontekstu prije kreiranja naloga.');
return;
}
if (!form.purpose.trim()) {
setErrorMessage('Svrha naloga je obavezna.');
return;
}
let startMileage = null;
let endMileage = null;
let servicerStartMileage = null;
let servicerEndMileage = null;
let servicerFuelCost = null;
let travelStart = null;
let travelEnd = null;
if (form.has_travel_order) {
if (!form.start_mileage || !form.location.trim()) {
setErrorMessage('Za putni nalog unesite početnu kilometražu i lokaciju.');
return;
}
startMileage = Number(form.start_mileage);
endMileage = form.end_mileage === '' ? null : Number(form.end_mileage);
if (Number.isNaN(startMileage) || startMileage < 0) {
setErrorMessage('Početna kilometraža mora biti valjan nenegativan broj.');
return;
}
if (endMileage != null && (Number.isNaN(endMileage) || endMileage < startMileage)) {
setErrorMessage('Završna kilometraža mora biti veća ili jednaka početnoj.');
return;
}
if (!form.travel_start_at || !form.travel_end_at) {
setErrorMessage('Unesite vrijeme početka i kraja puta.');
return;
}
travelStart = new Date(form.travel_start_at);
travelEnd = new Date(form.travel_end_at);
if (Number.isNaN(travelStart.getTime()) || Number.isNaN(travelEnd.getTime())) {
setErrorMessage('Vrijeme puta nije u ispravnom formatu.');
return;
}
if (travelEnd < travelStart) {
setErrorMessage('Vrijeme kraja puta ne može biti prije početka puta.');
return;
}
}
if (form.servicer_vehicle_start_mileage !== '') {
servicerStartMileage = Number(form.servicer_vehicle_start_mileage);
if (Number.isNaN(servicerStartMileage) || servicerStartMileage < 0) {
setErrorMessage('Početna kilometraža vozila servisera mora biti valjan nenegativan broj.');
return;
}
}
if (form.servicer_vehicle_end_mileage !== '') {
servicerEndMileage = Number(form.servicer_vehicle_end_mileage);
if (Number.isNaN(servicerEndMileage) || servicerEndMileage < 0) {
setErrorMessage('Završna kilometraža vozila servisera mora biti valjan nenegativan broj.');
return;
}
if (servicerStartMileage == null) {
setErrorMessage('Unesite početnu kilometražu vozila servisera ako unosite završnu.');
return;
}
if (servicerEndMileage <= servicerStartMileage) {
setErrorMessage('Završna kilometraža vozila servisera mora biti veća od početne.');
return;
}
}
if (form.servicer_vehicle_fuel_cost !== '') {
servicerFuelCost = Number(form.servicer_vehicle_fuel_cost);
if (Number.isNaN(servicerFuelCost) || servicerFuelCost < 0) {
setErrorMessage('Trošak goriva vozila servisera mora biti valjan nenegativan broj.');
return;
}
servicerFuelCost = Number(servicerFuelCost.toFixed(2));
}
setSubmitting(true);
try {
await onSubmit({
vehicle: selectedCrane.id,
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: servicerStartMileage,
servicer_vehicle_end_mileage: servicerEndMileage,
servicer_vehicle_fuel_cost: servicerFuelCost,
location: form.has_travel_order ? 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(),
status: form.status,
});
onClose();
} finally {
setSubmitting(false);
}
};
return (
<>
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto p-4 pt-20"
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 overflow-hidden max-h-[calc(100vh-2rem)]"
>
<div className="w-full flex flex-col">
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<h3 className="text-lg font-semibold text-text-main">Novi radni/putni nalog</h3>
<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={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Status *</span>
<select
value={form.status}
onChange={(event) => {
const nextStatus = event.target.value;
setForm({
...form,
status: nextStatus,
end_mileage: nextStatus === 'open' ? '' : form.end_mileage,
});
}}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
</label>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-3 text-sm font-semibold text-text-main">Podaci dizalice</h4>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Dizalica</span>
<div className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main">
{selectedCrane
? (selectedCrane.registration_number || `Dizalica #${selectedCrane.id}`)
: 'Nema odabrane dizalice u kontekstu'}
</div>
</div>
<div className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Trenutna km dizalice</span>
<div className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main">
{selectedCrane?.current_mileage ?? 0}
</div>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Početna kilometraža dizalice {form.has_travel_order ? '*' : ''}</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-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!form.has_travel_order}
required={form.has_travel_order}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Završna kilometraža dizalice</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-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!form.has_travel_order || form.status === 'open'}
/>
</label>
</div>
</div>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-3 text-sm font-semibold text-text-main">Vozilo servisera (obračun troškova)</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-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
placeholder="Npr. ZG-1234-AA"
disabled
/>
</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-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
placeholder="Npr. VW Caddy"
disabled
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Početna kilometraža 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-elevated px-3 py-2 text-text-main"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Završna kilometraža 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-elevated 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">Trošak goriva vozila servisera (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-elevated px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled
/>
</label>
</div>
{!user?.assigned_vehicle && (
<p className="mt-2 text-xs text-amber-700">
Korisnik nema dodijeljeno vozilo servisera, pa polja nisu popunjena.
</p>
)}
<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"
placeholder="Npr. Klijent Zagreb, servisna lokacija"
disabled={!form.has_travel_order}
required={form.has_travel_order}
/>
<button
type="button"
onClick={() => {
setLocationQuery(form.location || '');
setLocationResults([]);
setSelectedLocation(null);
setMapError('');
setMapPickerOpen(true);
}}
disabled={!form.has_travel_order || 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"
>
Prikaži kartu
</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={!form.has_travel_order}
required={form.has_travel_order}
/>
</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={!form.has_travel_order}
required={form.has_travel_order}
/>
</label>
</div>
<label className="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,
start_mileage: checked ? (prev.start_mileage || String(selectedCrane?.current_mileage ?? '')) : '',
end_mileage: checked ? prev.end_mileage : '',
location: checked ? prev.location : '',
travel_start_at: checked ? prev.travel_start_at : '',
travel_end_at: checked ? prev.travel_end_at : '',
}));
}}
/>
Putni nalog uključuje putovanje (lokacija i vrijeme)
</label>
<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
>
<option value=""> Odaberi vrstu </option>
{PURPOSE_OPTIONS.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"
/>
</label>
{errorMessage && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{errorMessage}
</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"
>
Odustani
</button>
<button
type="submit"
disabled={submitting || !selectedCrane}
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 nalog'}
</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={() => {
setForm((prev) => ({ ...prev, location: selectedLocation.display_name || prev.location }));
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"
>
Pohrani lokaciju
</button>
</div>
</div>
</ModalShell>
)}
</>
);
}