From 9a7c7e3163453d3bdf445fa40dad1ac5c4aa41a6 Mon Sep 17 00:00:00 2001 From: mariomitte Date: Mon, 3 Aug 2026 00:44:15 +0200 Subject: [PATCH] feat: add per-task PDF/DOCX export for service records Enable generating service-record documents for a single task on /putni-nalozi/racuni while preserving existing full work-order export. - Backend service-records PDF/DOCX endpoints now accept optional task_id and validate the task belongs to the selected work order. - Document builders support scoped task lists, and per-task downloads use task-specific filenames. - Frontend task cards now include per-task PDF/DOCX actions, while top-level buttons are clarified as exporting all service records. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/modules/fleet/views.py | 71 +++++++++++++++---- .../dashboard/WorkOrderInvoicesPdfPage.jsx | 20 +++++- frontend/src/stores/fleetDashboardStore.js | 34 +++++++-- 3 files changed, 104 insertions(+), 21 deletions(-) diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index 77e6881..06ab767 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -210,6 +210,32 @@ def _docx_filename(work_order, doc_type): return f"{display_code}.work-order.docx" +def _service_records_pdf_filename(work_order, task=None): + if not task: + return _pdf_filename(work_order, 'service_records') + display_code = _work_order_display_code(work_order) + return f"{display_code}.task-{task.pk}.work-order-service-records.pdf" + + +def _service_records_docx_filename(work_order, task=None): + if not task: + return _docx_filename(work_order, 'service_records') + display_code = _work_order_display_code(work_order) + return f"{display_code}.task-{task.pk}.work-order-service-records.docx" + + +def _resolve_service_report_tasks(work_order, task_id): + queryset = _work_order_related_tasks_queryset(work_order) + if not task_id: + return list(queryset), None + selected_task = queryset.filter(pk=task_id).first() + if not selected_task: + raise DRFValidationError({ + 'task_id': 'Odabrani task nije povezan s ovim putnim nalogom.' + }) + return [selected_task], selected_task + + def _compress_image_for_pdf(image_field, max_width=1280, quality=75): """ Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji, @@ -884,7 +910,7 @@ def _build_work_order_pdf(work_order): return buffer.getvalue() -def _build_work_order_service_records_pdf(work_order): +def _build_work_order_service_records_pdf(work_order, related_tasks=None): vehicle = work_order.vehicle client_name = getattr(vehicle.client, 'name', None) or '-' manufacturer_str = str(vehicle.make or '-') @@ -900,7 +926,10 @@ def _build_work_order_service_records_pdf(work_order): FOOTER_H = 72 MARGIN = 28 - related_tasks = list(_work_order_related_tasks_queryset(work_order)) + if related_tasks is None: + related_tasks = list(_work_order_related_tasks_queryset(work_order)) + else: + related_tasks = list(related_tasks) # 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( @@ -1812,7 +1841,7 @@ def _build_work_order_docx_bytes(work_order): return buffer.getvalue() -def _build_work_order_service_records_docx_bytes(work_order): +def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None): from docx.shared import Cm doc = _create_docx_document(SERVICE_REPORT_DOCX_TEMPLATE_NAME) @@ -1821,7 +1850,10 @@ def _build_work_order_service_records_docx_bytes(work_order): servicer_name = _user_display_name(work_order.creator) or '-' completion_label = "Da ☒ Ne ☐" if work_order.status == 'closed' else "Da ☐ Ne ☒" - related_tasks = list(_work_order_related_tasks_queryset(work_order).select_related('work_hours_table')) + if related_tasks is None: + related_tasks = list(_work_order_related_tasks_queryset(work_order).select_related('work_hours_table')) + else: + related_tasks = list(related_tasks) normalized_rows = [] for task in related_tasks: table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None) @@ -2792,27 +2824,38 @@ class WorkOrderViewSet(viewsets.ModelViewSet): @action(detail=True, methods=['get'], url_path='service-records-pdf') def service_records_pdf(self, request, pk=None): work_order = self.get_object() - _cleanup_expired_generated_pdfs() - cached = _get_cached_pdf(work_order, 'service_records') - if cached: - return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'service_records')) + task_id = request.query_params.get('task_id') + related_tasks, selected_task = _resolve_service_report_tasks(work_order, task_id) - _notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='requested') - pdf_bytes = _build_work_order_service_records_pdf(work_order) - _notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed') + if not selected_task: + _cleanup_expired_generated_pdfs() + cached = _get_cached_pdf(work_order, 'service_records') + if cached: + return _cached_pdf_file_response( + cached, + default_filename=_service_records_pdf_filename(work_order), + ) + _notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='requested') + + pdf_bytes = _build_work_order_service_records_pdf(work_order, related_tasks=related_tasks) + + if not selected_task: + _notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed') response = HttpResponse(pdf_bytes, content_type='application/pdf') - response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "service_records")}"' + response['Content-Disposition'] = f'attachment; filename="{_service_records_pdf_filename(work_order, selected_task)}"' return response @action(detail=True, methods=['get'], url_path='service-records-docx') def service_records_docx(self, request, pk=None): work_order = self.get_object() - docx_bytes = _build_work_order_service_records_docx_bytes(work_order) + task_id = request.query_params.get('task_id') + related_tasks, selected_task = _resolve_service_report_tasks(work_order, task_id) + docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=related_tasks) response = HttpResponse( docx_bytes, content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document', ) - response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "service_records")}"' + response['Content-Disposition'] = f'attachment; filename="{_service_records_docx_filename(work_order, selected_task)}"' return response @action(detail=True, methods=['post'], url_path='service-records-pdf-request') diff --git a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx index fbf02b5..b6c12e6 100644 --- a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx +++ b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx @@ -350,7 +350,7 @@ export default function WorkOrderInvoicesPdfPage() { disabled={!workOrderId} className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60" > - Preuzmi PDF servisnih zapisa + Preuzmi PDF svih servisnih zapisa @@ -397,6 +397,22 @@ export default function WorkOrderInvoicesPdfPage() { > Uredi tekst napomene + +
diff --git a/frontend/src/stores/fleetDashboardStore.js b/frontend/src/stores/fleetDashboardStore.js index 60a5aa1..b3b8185 100644 --- a/frontend/src/stores/fleetDashboardStore.js +++ b/frontend/src/stores/fleetDashboardStore.js @@ -865,10 +865,25 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) { return payload; } -export async function downloadWorkOrderServiceRecordsPdf(workOrderId) { +export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = null) { if (!workOrderId) { throw new Error('Work order ID je obavezan.'); } + if (taskId) { + try { + const blob = await api.get( + `fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/?task_id=${encodeURIComponent(taskId)}`, + { responseType: 'blob' }, + ); + saveBlobToFile(blob, `${workOrderId}.task-${taskId}.work-order-service-records.pdf`); + showToast('PDF servisnih zapisa za odabrani task je preuzet.', 'success'); + return; + } catch (err) { + const msg = err?.message || 'Preuzimanje PDF-a servisnih zapisa za task nije uspjelo.'; + showToast(msg, 'error'); + throw err; + } + } showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info'); const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {}); if (payload?.status === 'ready' && payload?.download_url) { @@ -897,14 +912,23 @@ export async function downloadWorkOrderDocx(workOrderId) { } } -export async function downloadWorkOrderServiceRecordsDocx(workOrderId) { +export async function downloadWorkOrderServiceRecordsDocx(workOrderId, taskId = null) { if (!workOrderId) { throw new Error('Work order ID je obavezan.'); } + const query = taskId ? `?task_id=${encodeURIComponent(taskId)}` : ''; try { - const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/`, { responseType: 'blob' }); - saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`); - showToast('DOCX servisnih zapisa je preuzet.', 'success'); + const blob = await api.get( + `fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/${query}`, + { responseType: 'blob' }, + ); + if (taskId) { + saveBlobToFile(blob, `${workOrderId}.task-${taskId}.work-order-service-records.docx`); + showToast('DOCX servisnih zapisa za odabrani task je preuzet.', 'success'); + } else { + saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`); + showToast('DOCX servisnih zapisa je preuzet.', 'success'); + } } catch (err) { const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.'; showToast(msg, 'error');