feat: dozvoli dijeljenje putnog naloga izmedju razlicitih dizalica

Problem: serviser nije mogao odabrati putni nalog kreiran za drugu dizalicu
jer je frontend filtrirao naloge po aktivnom crane kontekstu, a backend
serializer je bacao ValidationError ako WO.vehicle != task.vehicle.

Promjene:
- TaskSerializer: uklonjena cross-crane validacija - WO moze pripadati
  drugoj dizalici od one na tasku (cross-crane sharing)
- FleetDashboardShell: serviceRecordWorkOrders i selectedTaskWorkOrders
  vise ne filtriraju po vehicle ID-u, prikazuju sve otvorene naloge
- TaskServiceRecordsModal: gumb i modal za izmjenu podataka dizalice
  (radni sati, kilometraza) premjesten iz WorkOrderDetailModal
- WorkOrderDetailModal: uklonjen gumb 'Izmijeni podatke dizalice'
- Dodana 3 nova backend testa za cross-crane WO dodjelu (15/15 prolaze)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-08-01 07:54:57 +02:00
parent 06321fc3d1
commit fd8c6d8040
5 changed files with 211 additions and 21 deletions

View File

@@ -3,6 +3,7 @@ import ModalShell from '../ui/ModalShell';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { getStatusLabel } from '../../stores/taskStore';
import { getTaskCraneDisplay, getTaskCraneOwner } from '../../lib/taskCraneDisplay';
import { updateVehicle, $vehicles } from '../../stores/fleetDashboardStore';
const STATUS_OPTIONS = ['aktivan', 'servis', 'spreman_za_zavrsetak', 'zavrsen', 'neaktivan'];
@@ -55,6 +56,10 @@ export default function TaskServiceRecordsModal({
status: 'aktivan',
scheduled_date: '',
});
const [craneDataModalOpen, setCraneDataModalOpen] = useState(false);
const [craneDataForm, setCraneDataForm] = useState({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
const [savingCraneData, setSavingCraneData] = useState(false);
const [craneDataError, setCraneDataError] = useState('');
useEffect(() => {
if (!open || typeof window === 'undefined') return;
@@ -76,8 +81,65 @@ export default function TaskServiceRecordsModal({
status: String(task.status || 'aktivan'),
scheduled_date: String(task.scheduled_date || ''),
});
setCraneDataModalOpen(false);
setCraneDataError('');
setSavingCraneData(false);
}, [task?.id, task?.work_order]);
const openCraneDataModal = () => {
const craneVehicleId = task?.vehicle;
if (!craneVehicleId) return;
const craneVehicle = ($vehicles?.get?.() || []).find((v) => String(v.id) === String(craneVehicleId)) || null;
setCraneDataForm({
superstructure_working_hours: String(craneVehicle?.superstructure_working_hours ?? ''),
chassis_working_hours: String(craneVehicle?.chassis_working_hours ?? ''),
current_mileage: String(craneVehicle?.current_mileage ?? ''),
});
setCraneDataError('');
setCraneDataModalOpen(true);
};
const handleSaveCraneData = async () => {
const craneVehicleId = task?.vehicle;
if (!craneVehicleId) {
setCraneDataError('Dizalica nije pronađena.');
return;
}
const superstructure = craneDataForm.superstructure_working_hours === '' ? undefined : Number(craneDataForm.superstructure_working_hours);
const chassis = craneDataForm.chassis_working_hours === '' ? undefined : Number(craneDataForm.chassis_working_hours);
const mileage = craneDataForm.current_mileage === '' ? undefined : Number(craneDataForm.current_mileage);
if (superstructure !== undefined && (Number.isNaN(superstructure) || superstructure < 0)) {
setCraneDataError('Radni sati superstrukture moraju biti pozitivan broj.');
return;
}
if (chassis !== undefined && (Number.isNaN(chassis) || chassis < 0)) {
setCraneDataError('Radni sati podvozja moraju biti pozitivan broj.');
return;
}
if (mileage !== undefined && (Number.isNaN(mileage) || mileage < 0)) {
setCraneDataError('Kilometraža mora biti pozitivan broj.');
return;
}
const payload = {};
if (superstructure !== undefined) payload.superstructure_working_hours = superstructure;
if (chassis !== undefined) payload.chassis_working_hours = chassis;
if (mileage !== undefined) payload.current_mileage = mileage;
if (Object.keys(payload).length === 0) {
setCraneDataError('Nije unesena niti jedna vrijednost za ažuriranje.');
return;
}
setSavingCraneData(true);
setCraneDataError('');
try {
await updateVehicle(craneVehicleId, payload);
setCraneDataModalOpen(false);
} catch (err) {
setCraneDataError(err?.message || 'Ažuriranje podataka dizalice nije uspjelo.');
} finally {
setSavingCraneData(false);
}
};
const currentWorkOrder = useMemo(
() => workOrders.find((item) => String(item.id) === String(task?.work_order || selectedWorkOrderId)) || null,
[workOrders, task?.work_order, selectedWorkOrderId]
@@ -166,6 +228,7 @@ export default function TaskServiceRecordsModal({
if (!open || !task) return null;
return (
<>
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto p-4 pt-2"
@@ -237,7 +300,19 @@ export default function TaskServiceRecordsModal({
</div>
<div>
<dt className="text-xs text-text-muted">Dizalica</dt>
<dd className="text-text-main">{getTaskCraneDisplay(task)}</dd>
<dd className="flex items-center gap-2 text-text-main">
<span>{getTaskCraneDisplay(task)}</span>
{task?.vehicle && (
<button
type="button"
onClick={openCraneDataModal}
className="rounded border border-border-hairline px-1.5 py-0.5 text-[10px] text-text-muted hover:bg-canvas-base"
title="Izmijeni radne sate i kilometražu dizalice"
>
🔧
</button>
)}
</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Putni nalog</dt>
@@ -462,5 +537,76 @@ export default function TaskServiceRecordsModal({
</div>
</div>
</ModalShell>
{craneDataModalOpen && (
<ModalShell open={craneDataModalOpen} onClose={() => setCraneDataModalOpen(false)} title="Izmijeni podatke dizalice">
<div className="p-4 space-y-4">
<p className="text-sm text-text-muted">
Ažurirajte radne sate i kilometražu za dizalicu: <strong>{getTaskCraneDisplay(task)}</strong>
</p>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Radni sati superstrukture</span>
<input
type="number"
min="0"
step="0.01"
value={craneDataForm.superstructure_working_hours}
onInput={(e) => setCraneDataForm((p) => ({ ...p, superstructure_working_hours: e.currentTarget.value }))}
disabled={savingCraneData}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
placeholder="npr. 1250.5"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Radni sati podvozja</span>
<input
type="number"
min="0"
step="0.01"
value={craneDataForm.chassis_working_hours}
onInput={(e) => setCraneDataForm((p) => ({ ...p, chassis_working_hours: e.currentTarget.value }))}
disabled={savingCraneData}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
placeholder="npr. 980.0"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Trenutna kilometraža (km)</span>
<input
type="number"
min="0"
step="1"
value={craneDataForm.current_mileage}
onInput={(e) => setCraneDataForm((p) => ({ ...p, current_mileage: e.currentTarget.value }))}
disabled={savingCraneData}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
placeholder="npr. 45000"
/>
</label>
{craneDataError && (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{craneDataError}</p>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setCraneDataModalOpen(false)}
disabled={savingCraneData}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm text-text-main hover:bg-canvas-base disabled:opacity-50"
>
Odustani
</button>
<button
type="button"
onClick={handleSaveCraneData}
disabled={savingCraneData}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{savingCraneData ? 'Spremam…' : 'Spremi podatke'}
</button>
</div>
</div>
</ModalShell>
)}
</>
);
}