This commit is contained in:
83
frontend/src/components/dashboard/DashboardTopbar.jsx
Normal file
83
frontend/src/components/dashboard/DashboardTopbar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1036
frontend/src/components/dashboard/FleetDashboardShell.jsx
Normal file
1036
frontend/src/components/dashboard/FleetDashboardShell.jsx
Normal file
File diff suppressed because it is too large
Load Diff
172
frontend/src/components/dashboard/ServiceContextSelector.jsx
Normal file
172
frontend/src/components/dashboard/ServiceContextSelector.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/dashboard/SyncLogPanel.jsx
Normal file
57
frontend/src/components/dashboard/SyncLogPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
frontend/src/components/dashboard/TaskCreateModal.jsx
Normal file
188
frontend/src/components/dashboard/TaskCreateModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
frontend/src/components/dashboard/TaskServiceRecordsModal.jsx
Normal file
199
frontend/src/components/dashboard/TaskServiceRecordsModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1115
frontend/src/components/dashboard/WorkOrderDetailModal.jsx
Normal file
1115
frontend/src/components/dashboard/WorkOrderDetailModal.jsx
Normal file
File diff suppressed because it is too large
Load Diff
152
frontend/src/components/dashboard/WorkOrderImageCarousel.jsx
Normal file
152
frontend/src/components/dashboard/WorkOrderImageCarousel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
258
frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx
Normal file
258
frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
592
frontend/src/components/dashboard/WorkOrderModal.jsx
Normal file
592
frontend/src/components/dashboard/WorkOrderModal.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user