feat: per-task work hours table, notes, and read-only work order data in PDF page
Backend: - Added service_report_note TextField to Task model (migration 0012) - Exposed service_report_note in TaskSerializer - task_service_context: fixed service record filter from work_order.vehicle_id -> task.vehicle_id (bug: cross-crane work orders returned wrong records for non-primary crane) - task_service_context: include service_report_note in each task payload - _build_work_order_service_records_docx_bytes: use per-task service_report_note (fallback: work_order.notes); fix service records query to use task vehicle ids for cross-crane support - PDF generator: same per-task notes and cross-crane vehicle fix Frontend: - WorkOrderInvoicesPdfPage: removed global 'Uredi tablicu radnih sati' / 'Uredi tekst napomene' buttons that always opened tasks[0]; replaced with per-task buttons inside each task article - WorkOrderInvoicesPdfPage: separate editingTaskNote state / handler that calls updateTaskServiceReportNote (PATCH task.service_report_note) - WorkOrderServiceNotesModal: now receives task prop instead of workOrder; reads/writes task.service_report_note; shows task title in header - TaskWorkHoursTableModal: added workOrder prop; renders read-only amber info block with travel dates, mileage, and servicer vehicle data - fleetDashboardStore: added updateTaskServiceReportNote() function - WorkOrderInvoicesPdfPage: show saved service_report_note inline on each task card for quick reference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -137,6 +137,7 @@ const FB_MUTED = [
|
||||
export default function TaskWorkHoursTableModal({
|
||||
open,
|
||||
task,
|
||||
workOrder = null,
|
||||
defaultLocation = '',
|
||||
saving = false,
|
||||
onClose,
|
||||
@@ -337,6 +338,39 @@ export default function TaskWorkHoursTableModal({
|
||||
|
||||
<div className="space-y-4 overflow-y-auto px-5 py-4">
|
||||
|
||||
{/* ── read-only work order travel data ── */}
|
||||
{workOrder && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-xs dark:border-amber-800 dark:bg-amber-900/20">
|
||||
<p className="mb-1.5 font-semibold text-amber-800 dark:text-amber-300">Podaci iz putnog naloga (samo za prikaz)</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-amber-900 dark:text-amber-200 sm:grid-cols-3">
|
||||
{workOrder.origin_location && (
|
||||
<div><span className="font-medium">Ishodište:</span> {workOrder.origin_location}</div>
|
||||
)}
|
||||
{workOrder.location && (
|
||||
<div><span className="font-medium">Odredište:</span> {workOrder.location}</div>
|
||||
)}
|
||||
{workOrder.travel_start_at && (
|
||||
<div><span className="font-medium">Polazak:</span> {new Date(workOrder.travel_start_at).toLocaleString('hr-HR', { dateStyle: 'short', timeStyle: 'short' })}</div>
|
||||
)}
|
||||
{workOrder.travel_end_at && (
|
||||
<div><span className="font-medium">Povratak:</span> {new Date(workOrder.travel_end_at).toLocaleString('hr-HR', { dateStyle: 'short', timeStyle: 'short' })}</div>
|
||||
)}
|
||||
{workOrder.start_mileage != null && (
|
||||
<div><span className="font-medium">KM početak:</span> {workOrder.start_mileage}</div>
|
||||
)}
|
||||
{workOrder.end_mileage != null && (
|
||||
<div><span className="font-medium">KM kraj:</span> {workOrder.end_mileage}</div>
|
||||
)}
|
||||
{workOrder.distance != null && (
|
||||
<div><span className="font-medium">Prijeđeno km:</span> {workOrder.distance}</div>
|
||||
)}
|
||||
{workOrder.servicer_vehicle_registration && (
|
||||
<div><span className="font-medium">Vozilo servisera:</span> {workOrder.servicer_vehicle_registration} {workOrder.servicer_vehicle_make_model || ''}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── info banner ── */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-300">
|
||||
<span><span className="font-semibold">Put (od–do):</span> unesi Put od/do → Sati puta se računaju automatski; Km vozila se popunjava ručno; polja Vr. rada su blokirana.</span>
|
||||
|
||||
@@ -12,13 +12,12 @@ import {
|
||||
fetchWorkOrderInvoices,
|
||||
fetchWorkOrderTaskServiceContext,
|
||||
updateTaskWorkHoursTable,
|
||||
updateWorkOrder,
|
||||
updateTaskServiceReportNote,
|
||||
updateWorkOrderAdditionalCostsTable,
|
||||
} from '../../stores/fleetDashboardStore';
|
||||
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
|
||||
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
||||
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||
import { showToast } from '../../stores/toastStore';
|
||||
import { useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
||||
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
|
||||
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
|
||||
@@ -155,9 +154,9 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' });
|
||||
const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false);
|
||||
const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false);
|
||||
const [editingServiceReportNote, setEditingServiceReportNote] = useState(false);
|
||||
const [savingServiceReportNote, setSavingServiceReportNote] = useState(false);
|
||||
const [serviceReportNoteError, setServiceReportNoteError] = useState('');
|
||||
const [editingTaskNote, setEditingTaskNote] = useState(null);
|
||||
const [savingTaskNote, setSavingTaskNote] = useState(false);
|
||||
const [taskNoteError, setTaskNoteError] = useState('');
|
||||
const [invoiceDetailModal, setInvoiceDetailModal] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -277,6 +276,29 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTaskServiceReportNote = async (note) => {
|
||||
if (!editingTaskNote?.id) return;
|
||||
setSavingTaskNote(true);
|
||||
setTaskNoteError('');
|
||||
try {
|
||||
const updated = await updateTaskServiceReportNote(editingTaskNote.id, note);
|
||||
setTaskContext((prev) => ({
|
||||
...prev,
|
||||
tasks: (Array.isArray(prev.tasks) ? prev.tasks : []).map((task) => (
|
||||
String(task.id) === String(editingTaskNote.id)
|
||||
? { ...task, service_report_note: updated?.service_report_note ?? note }
|
||||
: task
|
||||
)),
|
||||
}));
|
||||
setEditingTaskNote(null);
|
||||
} catch (err) {
|
||||
const message = err?.message || 'Spremanje napomene nije uspjelo.';
|
||||
setTaskNoteError(message);
|
||||
} finally {
|
||||
setSavingTaskNote(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAdditionalCostsTable = async (tableData) => {
|
||||
if (!workOrderId) return;
|
||||
setSavingAdditionalCosts(true);
|
||||
@@ -289,24 +311,6 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveServiceReportNote = async (notes) => {
|
||||
if (!workOrderId) return;
|
||||
setSavingServiceReportNote(true);
|
||||
setServiceReportNoteError('');
|
||||
try {
|
||||
const updated = await updateWorkOrder(workOrderId, { notes: String(notes || '') });
|
||||
setWorkOrder(updated);
|
||||
setEditingServiceReportNote(false);
|
||||
showToast('Napomena za izvještaj servisera je spremljena.', 'success');
|
||||
} catch (err) {
|
||||
const message = err?.message || 'Spremanje napomene nije uspjelo.';
|
||||
setServiceReportNoteError(message);
|
||||
showToast(message, 'error');
|
||||
} finally {
|
||||
setSavingServiceReportNote(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -340,26 +344,6 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-main">Servisni zapisi</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const firstTask = Array.isArray(taskContext.tasks) ? taskContext.tasks[0] : null;
|
||||
if (!firstTask) return;
|
||||
setEditingTask(firstTask);
|
||||
}}
|
||||
disabled={!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0}
|
||||
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||
>
|
||||
Uredi tablicu radnih sati
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingServiceReportNote(true)}
|
||||
disabled={!workOrderId}
|
||||
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||
>
|
||||
Uredi tekst napomene
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
|
||||
@@ -386,13 +370,36 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
<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 className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||
<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>
|
||||
{task.service_report_note && (
|
||||
<div className="mt-1 rounded bg-canvas-deep px-2 py-1 text-xs text-text-muted">
|
||||
<span className="font-semibold">Napomena:</span> {task.service_report_note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTask(task)}
|
||||
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
Uredi tablicu radnih sati
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setTaskNoteError(''); setEditingTaskNote(task); }}
|
||||
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
Uredi tekst napomene
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="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>
|
||||
) : (
|
||||
@@ -517,6 +524,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
<TaskWorkHoursTableModal
|
||||
open={!!editingTask}
|
||||
task={editingTask}
|
||||
workOrder={workOrder}
|
||||
defaultLocation={workOrder?.location || ''}
|
||||
saving={savingWorkHours}
|
||||
onClose={() => setEditingTask(null)}
|
||||
@@ -531,15 +539,15 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
onSave={handleSaveAdditionalCostsTable}
|
||||
/>
|
||||
<WorkOrderServiceNotesModal
|
||||
open={editingServiceReportNote}
|
||||
workOrder={workOrder}
|
||||
saving={savingServiceReportNote}
|
||||
error={serviceReportNoteError}
|
||||
open={!!editingTaskNote}
|
||||
task={editingTaskNote}
|
||||
saving={savingTaskNote}
|
||||
error={taskNoteError}
|
||||
onClose={() => {
|
||||
setEditingServiceReportNote(false);
|
||||
setServiceReportNoteError('');
|
||||
setEditingTaskNote(null);
|
||||
setTaskNoteError('');
|
||||
}}
|
||||
onSave={handleSaveServiceReportNote}
|
||||
onSave={handleSaveTaskServiceReportNote}
|
||||
/>
|
||||
{invoiceDetailModal && (
|
||||
<InvoiceDetailModal
|
||||
|
||||
@@ -3,7 +3,7 @@ import ModalShell from '../ui/ModalShell';
|
||||
|
||||
export default function WorkOrderServiceNotesModal({
|
||||
open,
|
||||
workOrder,
|
||||
task,
|
||||
saving = false,
|
||||
error = '',
|
||||
onClose,
|
||||
@@ -16,8 +16,8 @@ export default function WorkOrderServiceNotesModal({
|
||||
setNotes('');
|
||||
return;
|
||||
}
|
||||
setNotes(String(workOrder?.notes || ''));
|
||||
}, [open, workOrder?.id, workOrder?.notes]);
|
||||
setNotes(String(task?.service_report_note || ''));
|
||||
}, [open, task?.id, task?.service_report_note]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -37,7 +37,9 @@ export default function WorkOrderServiceNotesModal({
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-text-main">UREDI TEKST NAPOMENE</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
Tekst se prikazuje u PDF-u "Izvještaj servisera" na prvoj stranici.
|
||||
{task?.title
|
||||
? `Zadatak: ${task.title}`
|
||||
: 'Tekst se prikazuje u PDF-u "Izvještaj servisera".'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -56,11 +58,11 @@ export default function WorkOrderServiceNotesModal({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<label htmlFor="work-order-notes" className="text-sm font-medium text-text-main">
|
||||
<label htmlFor="task-service-report-note" className="text-sm font-medium text-text-main">
|
||||
Napomena
|
||||
</label>
|
||||
<textarea
|
||||
id="work-order-notes"
|
||||
id="task-service-report-note"
|
||||
value={notes}
|
||||
onInput={(event) => setNotes(event.currentTarget.value)}
|
||||
rows={8}
|
||||
|
||||
@@ -771,6 +771,17 @@ export async function updateTaskWorkHoursTable(taskId, data) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function updateTaskServiceReportNote(taskId, note) {
|
||||
if (!taskId) {
|
||||
throw new Error('Task ID je obavezan.');
|
||||
}
|
||||
const payload = await api.patch(`tasks/tasks/${encodeURIComponent(taskId)}/`, {
|
||||
service_report_note: String(note ?? ''),
|
||||
});
|
||||
showToast('Napomena servisnog izvještaja je uspješno spremljena.', 'success');
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function createWorkOrderInvoice(workOrderId, data = {}, signal = null) {
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
|
||||
Reference in New Issue
Block a user