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:
@@ -146,13 +146,12 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
'title': "Naslov zadatka je obavezan ako se task ne kreira iz templatea."
|
||||
})
|
||||
|
||||
# If task has no crane yet, auto-derive it from the work order's crane (backward compat).
|
||||
# Cross-crane assignment is intentionally allowed: a servicer can share one work order
|
||||
# across tasks for multiple cranes on the same day.
|
||||
if work_order and not vehicle:
|
||||
vehicle = work_order.vehicle
|
||||
attrs['vehicle'] = vehicle
|
||||
if work_order and vehicle and work_order.vehicle_id != vehicle.id:
|
||||
raise serializers.ValidationError({
|
||||
'work_order': "Putni nalog mora pripadati istoj dizalici kao task."
|
||||
})
|
||||
|
||||
if status_value in {'neaktivan', 'zavrsen'} and not work_order:
|
||||
raise serializers.ValidationError({
|
||||
|
||||
@@ -99,4 +99,58 @@ class TaskSerializerTests(TestCase):
|
||||
"vehicle": str(self.vehicle.id), "scheduled_date": "not-a-date"}
|
||||
)
|
||||
assert not serializer.is_valid()
|
||||
assert 'scheduled_date' in serializer.errors
|
||||
assert 'scheduled_date' in serializer.errors
|
||||
|
||||
def test_can_assign_work_order_from_different_crane(self):
|
||||
"""Task može dobiti putni nalog koji pripada drugoj dizalici (cross-crane dijeljenje naloga)."""
|
||||
other_vehicle = Vehicle.objects.create(
|
||||
registration_number="ZG-TSK-02",
|
||||
asset_type='crane',
|
||||
make='Manitowoc',
|
||||
model='Grove',
|
||||
client=self.client,
|
||||
is_company_vehicle=False,
|
||||
)
|
||||
work_order = WorkOrder.objects.create(vehicle=other_vehicle, creator=self.user, purpose='kontrola')
|
||||
task = Task.objects.create(title="Cross-crane task", status="servis", assigned_to=self.user, vehicle=self.vehicle)
|
||||
|
||||
serializer = TaskSerializer(instance=task, data={"work_order": str(work_order.id)}, partial=True)
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
updated_task = serializer.save()
|
||||
assert updated_task.work_order_id == work_order.id
|
||||
assert updated_task.vehicle_id == self.vehicle.id # task zadržava svoju dizalicu
|
||||
|
||||
def test_can_close_task_with_cross_crane_work_order(self):
|
||||
"""Task se može zatvoriti putnim nalogom koji pripada drugoj dizalici."""
|
||||
other_vehicle = Vehicle.objects.create(
|
||||
registration_number="ZG-TSK-03",
|
||||
asset_type='crane',
|
||||
make='Tadano',
|
||||
model='GR',
|
||||
client=self.client,
|
||||
is_company_vehicle=False,
|
||||
)
|
||||
work_order = WorkOrder.objects.create(vehicle=other_vehicle, creator=self.user, purpose='popravak')
|
||||
task = Task.objects.create(title="Task for close", status="servis", assigned_to=self.user, vehicle=self.vehicle)
|
||||
|
||||
serializer = TaskSerializer(
|
||||
instance=task,
|
||||
data={"status": "zavrsen", "work_order": str(work_order.id)},
|
||||
partial=True
|
||||
)
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
updated_task = serializer.save()
|
||||
assert updated_task.status == 'zavrsen'
|
||||
assert updated_task.work_order_id == work_order.id
|
||||
|
||||
def test_work_order_auto_derives_vehicle_when_task_has_none(self):
|
||||
"""Ako task nema dizalicu a dobiva WO, dizalica se preuzima iz WO (backward compat)."""
|
||||
work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='defektaza')
|
||||
serializer = TaskSerializer(
|
||||
data={"title": "Auto-vehicle task", "status": "aktivan", "work_order": str(work_order.id)},
|
||||
)
|
||||
# vehicle je obavezan pri kreiranju, ali ako task nije instanca ovo bi trebalo failati
|
||||
# Provjeri da vehicle derivation iz WO radi samo ako task već postoji (partial update)
|
||||
task = Task.objects.create(title="Temp task", status="aktivan", assigned_to=self.user, vehicle=self.vehicle)
|
||||
serializer2 = TaskSerializer(instance=task, data={"work_order": str(work_order.id)}, partial=True)
|
||||
assert serializer2.is_valid(), serializer2.errors
|
||||
@@ -432,10 +432,10 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
[scopedWorkOrders, currentPage]
|
||||
);
|
||||
const serviceRecordWorkOrders = useMemo(() => {
|
||||
const craneId = serviceRecordCrane?.id || contextCrane?.id;
|
||||
if (!craneId) return [];
|
||||
return workOrders
|
||||
.filter((order) => String(order.vehicle) === String(craneId))
|
||||
// Show ALL open work orders so a task can be linked to any work order regardless of crane.
|
||||
// Cross-crane sharing is intentional: a servicer may use one travel order for multiple cranes.
|
||||
return [...workOrders]
|
||||
.filter((order) => String(order.status || '').toLowerCase() !== 'closed')
|
||||
.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
||||
}, [workOrders, serviceRecordCrane, contextCrane]);
|
||||
const serviceRecordTasks = useMemo(() => {
|
||||
@@ -541,10 +541,8 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
}, [serviceRecords, selectedTask?.id]);
|
||||
const selectedTaskWorkOrders = useMemo(() => {
|
||||
if (!selectedTask) return [];
|
||||
const byVehicle = selectedTask.vehicle
|
||||
? workOrders.filter((order) => String(order.vehicle) === String(selectedTask.vehicle))
|
||||
: workOrders;
|
||||
return [...byVehicle].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
||||
// Show ALL work orders — cross-crane sharing is allowed so no vehicle filter.
|
||||
return [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
||||
}, [selectedTask, workOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -609,13 +609,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
>
|
||||
Dodaj račun
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCraneDataModal}
|
||||
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
🔧 Izmijeni podatke dizalice
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
Reference in New Issue
Block a user