feat: vise taskova po putnom nalogu i izmjena podataka dizalice

- task_management/views.py: dodaj ?work_order= filter u get_queryset
  (jedini razlog zasto putni nalog nije mogao imati vise taskova bio je
  nedostatak backend filtera; model uvijek podrzavao vise taskova)
- fleet/serializers.py: ukloni current_mileage iz read_only_fields
  kako bi se moglo azurirati putem PATCH /fleet/cranes/{id}/
- fleetDashboardStore.js: dodaj fetchTasksByWorkOrder(workOrderId)
  koji dohvaca GET tasks/tasks/?work_order={id}
- WorkOrderDetailModal.jsx:
  * import TaskCreateModal, fetchTasksByWorkOrder, updateVehicle, getStatusLabel
  * nova sekcija 'Zadaci putnog naloga' s popisom i gumbom '+ Dodaj zadatak'
  * gumb '🔧 Izmijeni podatke dizalice' otvara CraneDataModal
  * CraneDataModal: unos superstructure_working_hours, chassis_working_hours,
    current_mileage; poziva updateVehicle; prikazuje trenutne vrijednosti
    kao placeholder

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-31 12:28:48 +02:00
parent 525152b817
commit 8b82481bb4
4 changed files with 226 additions and 1 deletions

View File

@@ -38,7 +38,7 @@ class VehicleSerializer(serializers.ModelSerializer):
'is_company_vehicle', 'client', 'client_name',
'assigned_servicer', 'is_active',
]
read_only_fields = ['id', 'current_mileage', 'client_name']
read_only_fields = ['id', 'client_name']
def get_client_name(self, obj):
if obj.client_id:

View File

@@ -30,6 +30,9 @@ class TaskViewSet(viewsets.ModelViewSet):
scheduled_date = self.request.query_params.get('scheduled_date')
if scheduled_date:
qs = qs.filter(scheduled_date=scheduled_date)
work_order_id = self.request.query_params.get('work_order')
if work_order_id:
qs = qs.filter(work_order_id=work_order_id)
return qs
def perform_create(self, serializer):

View File

@@ -2,15 +2,19 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
import TaskCreateModal from './TaskCreateModal';
import ModalShell from '../ui/ModalShell';
import {
createWorkOrderInvoice,
fetchTasksByWorkOrder,
fetchTeamMembers,
fetchWorkOrderInvoices,
fetchWorkOrderImages,
openWorkOrderInvoicesPdfPage,
sendWorkOrderEmail,
updateVehicle,
} from '../../stores/fleetDashboardStore';
import { getStatusLabel } from '../../stores/taskStore';
import { showToast } from '../../stores/toastStore';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { $serviceNotes, $serviceNotesLoading, fetchServiceNotes } from '../../stores/serviceNotesStore';
@@ -81,6 +85,12 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
const [selectedLocation, setSelectedLocation] = useState(null);
const [searchingLocation, setSearchingLocation] = useState(false);
const [mapError, setMapError] = useState('');
const [workOrderTasks, setWorkOrderTasks] = useState([]);
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false);
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('');
const craneLabel = useMemo(() => {
if (!workOrder) return '-';
@@ -168,6 +178,12 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setSelectedLocation(null);
setSearchingLocation(false);
setMapError('');
setWorkOrderTasks([]);
setAddTaskModalOpen(false);
setCraneDataModalOpen(false);
setCraneDataForm({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
setSavingCraneData(false);
setCraneDataError('');
}, [open, workOrder]);
useEffect(() => {
@@ -251,6 +267,23 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
};
}, [open, workOrder?.id]);
useEffect(() => {
if (!open || !workOrder?.id) {
setWorkOrderTasks([]);
return;
}
let cancelled = false;
(async () => {
try {
const tasks = await fetchTasksByWorkOrder(workOrder.id);
if (!cancelled) setWorkOrderTasks(tasks);
} catch {
if (!cancelled) setWorkOrderTasks([]);
}
})();
return () => { cancelled = true; };
}, [open, workOrder?.id]);
const selectedMapEmbedUrl = (() => {
if (!selectedLocation) return null;
const lat = Number(selectedLocation.lat);
@@ -265,6 +298,60 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
return null;
}
const craneVehicleId = workOrder.vehicle || workOrder.crane;
const craneVehicle = workOrder.craneInfo || null;
const openCraneDataModal = () => {
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 () => {
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);
showToast('Podaci dizalice su uspješno ažurirani.', 'success');
setCraneDataModalOpen(false);
} catch (err) {
setCraneDataError(err?.message || 'Ažuriranje podataka dizalice nije uspjelo.');
} finally {
setSavingCraneData(false);
}
};
const searchLocation = async () => {
const query = locationQuery.trim();
if (!query) {
@@ -522,6 +609,13 @@ 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={() => {
@@ -540,6 +634,33 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
)}
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-base p-3">
<div className="mb-2 flex items-center justify-between">
<h4 className="text-sm font-semibold text-text-main">Zadaci putnog naloga ({workOrderTasks.length})</h4>
<button
type="button"
onClick={() => setAddTaskModalOpen(true)}
className="rounded-md border border-border-hairline px-2 py-1 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
+ Dodaj zadatak
</button>
</div>
{workOrderTasks.length === 0 ? (
<p className="text-xs text-text-muted">Nema zadataka za ovaj putni nalog.</p>
) : (
<ul className="space-y-1">
{workOrderTasks.map((task) => (
<li key={task.id} className="flex items-center justify-between rounded-lg bg-canvas-deep px-3 py-2 text-sm">
<span className="font-medium text-text-main">{task.title}</span>
<span className="rounded-full bg-canvas-base px-2 py-0.5 text-[11px] text-text-muted border border-border-hairline">
{getStatusLabel(task.status)}
</span>
</li>
))}
</ul>
)}
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Status</h4>
<label className="mb-3 flex flex-col gap-1 text-sm">
@@ -1165,6 +1286,99 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
}
}}
/>
<TaskCreateModal
open={addTaskModalOpen}
workOrders={[workOrder]}
contextCrane={craneVehicle || { id: craneVehicleId, registration_number: craneLabel }}
onClose={() => setAddTaskModalOpen(false)}
onSuccess={async () => {
const tasks = await fetchTasksByWorkOrder(workOrder.id).catch(() => []);
setWorkOrderTasks(tasks);
}}
/>
{craneDataModalOpen && (
<ModalShell
onClose={() => setCraneDataModalOpen(false)}
overlayClassName="z-[60] overflow-y-auto p-4 pt-16"
contentClassName="flex items-start justify-center min-h-full"
panelClassName="w-full max-w-md rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
>
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
<div>
<h4 className="text-sm font-semibold text-text-main">Izmijeni podatke dizalice</h4>
<p className="text-xs text-text-muted">{craneLabel}</p>
</div>
<button
type="button"
onClick={() => setCraneDataModalOpen(false)}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
</button>
</div>
<div className="space-y-4 px-4 py-4">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Radni sati superstrukture</span>
<input
type="number"
min="0"
value={craneDataForm.superstructure_working_hours}
onInput={(e) => setCraneDataForm((p) => ({ ...p, superstructure_working_hours: e.currentTarget.value }))}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
placeholder={String(craneVehicle?.superstructure_working_hours ?? '0')}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Radni sati podvozja</span>
<input
type="number"
min="0"
value={craneDataForm.chassis_working_hours}
onInput={(e) => setCraneDataForm((p) => ({ ...p, chassis_working_hours: e.currentTarget.value }))}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
placeholder={String(craneVehicle?.chassis_working_hours ?? '0')}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Prijeđeni kilometri dizalice</span>
<input
type="number"
min="0"
value={craneDataForm.current_mileage}
onInput={(e) => setCraneDataForm((p) => ({ ...p, current_mileage: e.currentTarget.value }))}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
placeholder={String(craneVehicle?.current_mileage ?? '0')}
/>
</label>
{craneDataError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{craneDataError}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-border-hairline px-4 py-3">
<button
type="button"
onClick={() => setCraneDataModalOpen(false)}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
disabled={savingCraneData}
>
Odustani
</button>
<button
type="button"
onClick={handleSaveCraneData}
disabled={savingCraneData}
className="rounded-md bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{savingCraneData ? 'Spremam…' : 'Spremi podatke'}
</button>
</div>
</div>
</ModalShell>
)}
</>
);
}

View File

@@ -721,6 +721,14 @@ export async function fetchWorkOrderById(workOrderId) {
return await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/`);
}
export async function fetchTasksByWorkOrder(workOrderId) {
if (!workOrderId) {
return [];
}
const data = await api.get(`tasks/tasks/?work_order=${encodeURIComponent(workOrderId)}`);
return Array.isArray(data) ? data : (data?.results ?? []);
}
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
if (!workOrderId) {
return { tasks: [], additional_costs_table: null };