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>
This commit is contained in:
@@ -210,6 +210,32 @@ def _docx_filename(work_order, doc_type):
|
|||||||
return f"{display_code}.work-order.docx"
|
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):
|
def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
|
||||||
"""
|
"""
|
||||||
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
|
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()
|
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
|
vehicle = work_order.vehicle
|
||||||
client_name = getattr(vehicle.client, 'name', None) or '-'
|
client_name = getattr(vehicle.client, 'name', None) or '-'
|
||||||
manufacturer_str = str(vehicle.make or '-')
|
manufacturer_str = str(vehicle.make or '-')
|
||||||
@@ -900,7 +926,10 @@ def _build_work_order_service_records_pdf(work_order):
|
|||||||
FOOTER_H = 72
|
FOOTER_H = 72
|
||||||
MARGIN = 28
|
MARGIN = 28
|
||||||
|
|
||||||
|
if related_tasks is None:
|
||||||
related_tasks = list(_work_order_related_tasks_queryset(work_order))
|
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)
|
# 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})
|
task_vehicle_ids = list({task.vehicle_id for task in related_tasks if task.vehicle_id})
|
||||||
service_rows = list(
|
service_rows = list(
|
||||||
@@ -1812,7 +1841,7 @@ def _build_work_order_docx_bytes(work_order):
|
|||||||
return buffer.getvalue()
|
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
|
from docx.shared import Cm
|
||||||
|
|
||||||
doc = _create_docx_document(SERVICE_REPORT_DOCX_TEMPLATE_NAME)
|
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 '-'
|
servicer_name = _user_display_name(work_order.creator) or '-'
|
||||||
completion_label = "Da ☒ Ne ☐" if work_order.status == 'closed' else "Da ☐ Ne ☒"
|
completion_label = "Da ☒ Ne ☐" if work_order.status == 'closed' else "Da ☐ Ne ☒"
|
||||||
|
|
||||||
|
if related_tasks is None:
|
||||||
related_tasks = list(_work_order_related_tasks_queryset(work_order).select_related('work_hours_table'))
|
related_tasks = list(_work_order_related_tasks_queryset(work_order).select_related('work_hours_table'))
|
||||||
|
else:
|
||||||
|
related_tasks = list(related_tasks)
|
||||||
normalized_rows = []
|
normalized_rows = []
|
||||||
for task in related_tasks:
|
for task in related_tasks:
|
||||||
table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None)
|
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')
|
@action(detail=True, methods=['get'], url_path='service-records-pdf')
|
||||||
def service_records_pdf(self, request, pk=None):
|
def service_records_pdf(self, request, pk=None):
|
||||||
work_order = self.get_object()
|
work_order = self.get_object()
|
||||||
|
task_id = request.query_params.get('task_id')
|
||||||
|
related_tasks, selected_task = _resolve_service_report_tasks(work_order, task_id)
|
||||||
|
|
||||||
|
if not selected_task:
|
||||||
_cleanup_expired_generated_pdfs()
|
_cleanup_expired_generated_pdfs()
|
||||||
cached = _get_cached_pdf(work_order, 'service_records')
|
cached = _get_cached_pdf(work_order, 'service_records')
|
||||||
if cached:
|
if cached:
|
||||||
return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'service_records'))
|
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')
|
_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)
|
|
||||||
|
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')
|
_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 = 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
|
return response
|
||||||
|
|
||||||
@action(detail=True, methods=['get'], url_path='service-records-docx')
|
@action(detail=True, methods=['get'], url_path='service-records-docx')
|
||||||
def service_records_docx(self, request, pk=None):
|
def service_records_docx(self, request, pk=None):
|
||||||
work_order = self.get_object()
|
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(
|
response = HttpResponse(
|
||||||
docx_bytes,
|
docx_bytes,
|
||||||
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
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
|
return response
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], url_path='service-records-pdf-request')
|
@action(detail=True, methods=['post'], url_path='service-records-pdf-request')
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
disabled={!workOrderId}
|
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"
|
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
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -358,7 +358,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
disabled={!workOrderId}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
Preuzmi DOCX servisnih zapisa
|
Preuzmi DOCX svih servisnih zapisa
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,6 +397,22 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
>
|
>
|
||||||
Uredi tekst napomene
|
Uredi tekst napomene
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId, task.id)}
|
||||||
|
disabled={!workOrderId || !task.id}
|
||||||
|
className="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
PDF za ovaj task
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadWorkOrderServiceRecordsDocx(workOrderId, task.id)}
|
||||||
|
disabled={!workOrderId || !task.id}
|
||||||
|
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
DOCX za ovaj task
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -865,10 +865,25 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
|
export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = null) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
throw new Error('Work order ID je obavezan.');
|
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');
|
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/`, {});
|
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {});
|
||||||
if (payload?.status === 'ready' && payload?.download_url) {
|
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) {
|
if (!workOrderId) {
|
||||||
throw new Error('Work order ID je obavezan.');
|
throw new Error('Work order ID je obavezan.');
|
||||||
}
|
}
|
||||||
|
const query = taskId ? `?task_id=${encodeURIComponent(taskId)}` : '';
|
||||||
try {
|
try {
|
||||||
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/`, { responseType: 'blob' });
|
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`);
|
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
|
||||||
showToast('DOCX servisnih zapisa je preuzet.', 'success');
|
showToast('DOCX servisnih zapisa je preuzet.', 'success');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.';
|
const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.';
|
||||||
showToast(msg, 'error');
|
showToast(msg, 'error');
|
||||||
|
|||||||
Reference in New Issue
Block a user