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:
mariomitte
2026-08-03 00:28:15 +02:00
parent e8272a00bb
commit 7da1c84139
8 changed files with 176 additions and 71 deletions

View File

@@ -900,17 +900,25 @@ def _build_work_order_service_records_pdf(work_order):
FOOTER_H = 72
MARGIN = 28
related_tasks = list(_work_order_related_tasks_queryset(work_order))
# Collect service records for all task vehicles (supports cross-crane work orders)
task_vehicle_ids = list({task.vehicle_id for task in related_tasks if task.vehicle_id})
service_rows = list(
VehicleServiceRecord.objects.filter(
is_active=True,
vehicle_id=work_order.vehicle_id,
vehicle_id__in=task_vehicle_ids if task_vehicle_ids else [work_order.vehicle_id],
)
.select_related('performed_by', 'task')
.prefetch_related('photos')
.order_by('service_date', 'created_at')
)
related_tasks = list(_work_order_related_tasks_queryset(work_order))
notes_text = str(work_order.notes or '').strip()
# Per-task notes; fall back to work_order.notes
task_note_parts = [
str(task.service_report_note or '').strip()
for task in related_tasks
if str(task.service_report_note or '').strip()
]
notes_text = "\n".join(task_note_parts) if task_note_parts else str(work_order.notes or '').strip()
buffer = BytesIO()
@@ -1812,9 +1820,8 @@ def _build_work_order_service_records_docx_bytes(work_order):
client_name = getattr(getattr(vehicle, 'client', None), 'name', None) or '-'
servicer_name = _user_display_name(work_order.creator) or '-'
completion_label = "Da ☒ Ne ☐" if work_order.status == 'closed' else "Da ☐ Ne ☒"
notes_text = str(work_order.notes or '').strip() or '-'
related_tasks = list(_work_order_related_tasks_queryset(work_order))
related_tasks = list(_work_order_related_tasks_queryset(work_order).select_related('work_hours_table'))
normalized_rows = []
for task in related_tasks:
table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None)
@@ -1847,10 +1854,13 @@ def _build_work_order_service_records_docx_bytes(work_order):
'vehicle_km': str(row.get('vehicle_km', '') or '').strip() or '-',
})
# Collect service records per task, filtering by each task's own vehicle
# (supports cross-crane work orders where each task may have a different crane).
task_vehicle_ids = list({task.vehicle_id for task in related_tasks if task.vehicle_id})
service_rows = list(
VehicleServiceRecord.objects.filter(
is_active=True,
vehicle_id=work_order.vehicle_id,
vehicle_id__in=task_vehicle_ids,
)
.select_related('performed_by', 'task')
.prefetch_related('photos')
@@ -1861,6 +1871,14 @@ def _build_work_order_service_records_docx_bytes(work_order):
for task in related_tasks
}
# Build notes: use per-task service_report_note; fall back to work_order.notes
task_note_parts = [
str(task.service_report_note or '').strip()
for task in related_tasks
if str(task.service_report_note or '').strip()
]
notes_text = "\n".join(task_note_parts) if task_note_parts else (str(work_order.notes or '').strip() or '-')
if len(doc.tables) >= 4:
info_table = doc.tables[0]
_set_docx_cell_text(info_table, 1, 0, client_name)
@@ -1893,7 +1911,6 @@ def _build_work_order_service_records_docx_bytes(work_order):
target_row.cells[3].text = entry['changed_at']
details_table = doc.tables[3]
issue_text = str(work_order.notes or '-').strip() or '-'
repair_lines = []
for task in related_tasks:
task_records = records_by_task.get(task.id, [])
@@ -1904,7 +1921,7 @@ def _build_work_order_service_records_docx_bytes(work_order):
description = str(record.description or '-').strip() or '-'
repair_lines.append(f"- {description}")
repair_text = "\n".join(repair_lines) if repair_lines else '-'
_set_docx_cell_text(details_table, 0, 0, f"Kvar: {issue_text}")
_set_docx_cell_text(details_table, 0, 0, f"Kvar: {notes_text}")
_set_docx_cell_text(details_table, 1, 0, f"Popravak: {repair_text}")
else:
raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu (nedostaju tablice)."})
@@ -2922,7 +2939,10 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
payload = []
for task in tasks:
records_payload = []
for record in task.service_records.filter(is_active=True, vehicle_id=work_order.vehicle_id).all():
# Filter records by the task's own vehicle (not work_order.vehicle) to support
# cross-crane work orders where each task may belong to a different crane.
task_vehicle_id = task.vehicle_id
for record in task.service_records.filter(is_active=True, vehicle_id=task_vehicle_id).all():
photos_payload = []
for photo in record.photos.filter(is_active=True).all():
if not photo.image:
@@ -2950,6 +2970,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
'title': task.title,
'status': task.status,
'description': task.description,
'service_report_note': task.service_report_note or '',
'assigned_to_name': _user_display_name(task.assigned_to),
'vehicle_registration': getattr(task.vehicle, 'registration_number', None),
'work_hours_table': getattr(getattr(task, 'work_hours_table', None), 'data', None),

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0011_taskcalendar'),
]
operations = [
migrations.AddField(
model_name='task',
name='service_report_note',
field=models.TextField(
blank=True,
default='',
verbose_name='Napomena izvještaja servisera',
help_text='Tekst napomene za PDF izvještaj servisera za ovaj zadatak.',
),
),
]

View File

@@ -54,6 +54,12 @@ class Task(BaseModel):
verbose_name=_("Planirani datum izvršenja"),
help_text=_("Datum kada je planirano izvršenje radnog zadatka."),
)
service_report_note = models.TextField(
blank=True,
default='',
verbose_name=_("Napomena izvještaja servisera"),
help_text=_("Tekst napomene za PDF izvještaj servisera za ovaj zadatak."),
)
class Meta:
ordering = ['-created_at']

View File

@@ -103,6 +103,7 @@ class TaskSerializer(serializers.ModelSerializer):
'vehicle_owner_name', 'vehicle_asset_type', 'crane_serial_number',
'work_order', 'work_order_label',
'scheduled_date',
'service_report_note',
'template_id', 'auto_close_work_order',
'created_at',
]
@@ -114,6 +115,7 @@ class TaskSerializer(serializers.ModelSerializer):
'work_order': {'required': False, 'allow_null': True},
'vehicle': {'required': False, 'allow_null': True},
'scheduled_date': {'required': False, 'allow_null': True},
'service_report_note': {'required': False, 'allow_blank': True},
}
def validate_title(self, value):

View File

@@ -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 (oddo):</span> unesi Put od/do Sati puta se računaju automatski; Km vozila se popunjava ručno; polja Vr. rada su blokirana.</span>

View File

@@ -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

View File

@@ -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}

View File

@@ -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.');