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:
@@ -38,7 +38,7 @@ class VehicleSerializer(serializers.ModelSerializer):
|
|||||||
'is_company_vehicle', 'client', 'client_name',
|
'is_company_vehicle', 'client', 'client_name',
|
||||||
'assigned_servicer', 'is_active',
|
'assigned_servicer', 'is_active',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'current_mileage', 'client_name']
|
read_only_fields = ['id', 'client_name']
|
||||||
|
|
||||||
def get_client_name(self, obj):
|
def get_client_name(self, obj):
|
||||||
if obj.client_id:
|
if obj.client_id:
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ class TaskViewSet(viewsets.ModelViewSet):
|
|||||||
scheduled_date = self.request.query_params.get('scheduled_date')
|
scheduled_date = self.request.query_params.get('scheduled_date')
|
||||||
if scheduled_date:
|
if scheduled_date:
|
||||||
qs = qs.filter(scheduled_date=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
|
return qs
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
|
|||||||
@@ -2,15 +2,19 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
|
|||||||
import { useStore } from '@nanostores/preact';
|
import { useStore } from '@nanostores/preact';
|
||||||
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
|
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
|
||||||
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
|
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
|
||||||
|
import TaskCreateModal from './TaskCreateModal';
|
||||||
import ModalShell from '../ui/ModalShell';
|
import ModalShell from '../ui/ModalShell';
|
||||||
import {
|
import {
|
||||||
createWorkOrderInvoice,
|
createWorkOrderInvoice,
|
||||||
|
fetchTasksByWorkOrder,
|
||||||
fetchTeamMembers,
|
fetchTeamMembers,
|
||||||
fetchWorkOrderInvoices,
|
fetchWorkOrderInvoices,
|
||||||
fetchWorkOrderImages,
|
fetchWorkOrderImages,
|
||||||
openWorkOrderInvoicesPdfPage,
|
openWorkOrderInvoicesPdfPage,
|
||||||
sendWorkOrderEmail,
|
sendWorkOrderEmail,
|
||||||
|
updateVehicle,
|
||||||
} from '../../stores/fleetDashboardStore';
|
} from '../../stores/fleetDashboardStore';
|
||||||
|
import { getStatusLabel } from '../../stores/taskStore';
|
||||||
import { showToast } from '../../stores/toastStore';
|
import { showToast } from '../../stores/toastStore';
|
||||||
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||||
import { $serviceNotes, $serviceNotesLoading, fetchServiceNotes } from '../../stores/serviceNotesStore';
|
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 [selectedLocation, setSelectedLocation] = useState(null);
|
||||||
const [searchingLocation, setSearchingLocation] = useState(false);
|
const [searchingLocation, setSearchingLocation] = useState(false);
|
||||||
const [mapError, setMapError] = useState('');
|
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(() => {
|
const craneLabel = useMemo(() => {
|
||||||
if (!workOrder) return '-';
|
if (!workOrder) return '-';
|
||||||
@@ -168,6 +178,12 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
setSelectedLocation(null);
|
setSelectedLocation(null);
|
||||||
setSearchingLocation(false);
|
setSearchingLocation(false);
|
||||||
setMapError('');
|
setMapError('');
|
||||||
|
setWorkOrderTasks([]);
|
||||||
|
setAddTaskModalOpen(false);
|
||||||
|
setCraneDataModalOpen(false);
|
||||||
|
setCraneDataForm({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
|
||||||
|
setSavingCraneData(false);
|
||||||
|
setCraneDataError('');
|
||||||
}, [open, workOrder]);
|
}, [open, workOrder]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -251,6 +267,23 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
};
|
};
|
||||||
}, [open, workOrder?.id]);
|
}, [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 = (() => {
|
const selectedMapEmbedUrl = (() => {
|
||||||
if (!selectedLocation) return null;
|
if (!selectedLocation) return null;
|
||||||
const lat = Number(selectedLocation.lat);
|
const lat = Number(selectedLocation.lat);
|
||||||
@@ -265,6 +298,60 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
return null;
|
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 searchLocation = async () => {
|
||||||
const query = locationQuery.trim();
|
const query = locationQuery.trim();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
@@ -522,6 +609,13 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
>
|
>
|
||||||
Dodaj račun
|
Dodaj račun
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -540,6 +634,33 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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>
|
<h4 className="mb-2 text-sm font-semibold text-text-main">Status</h4>
|
||||||
<label className="mb-3 flex flex-col gap-1 text-sm">
|
<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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -721,6 +721,14 @@ export async function fetchWorkOrderById(workOrderId) {
|
|||||||
return await api.get(`fleet/work-orders/${encodeURIComponent(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) {
|
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
return { tasks: [], additional_costs_table: null };
|
return { tasks: [], additional_costs_table: null };
|
||||||
|
|||||||
Reference in New Issue
Block a user