diff --git a/backend/modules/fleet/docx_templates/Servisni N-R.docx b/backend/modules/fleet/docx_templates/Servisni N-R.docx new file mode 100644 index 0000000..134fffb Binary files /dev/null and b/backend/modules/fleet/docx_templates/Servisni N-R.docx differ diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index edb9413..4321d6a 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -6,6 +6,7 @@ import base64 import csv import mimetypes import threading +import re from collections import OrderedDict from datetime import timedelta from pathlib import Path @@ -83,6 +84,8 @@ from .tasks import ( register_unicode_fonts() User = get_user_model() +DOCX_TEMPLATE_DIR = Path(__file__).resolve().parent / 'docx_templates' +SERVICE_REPORT_DOCX_TEMPLATE_NAME = 'Servisni N-R.docx' def _fleet_assets_queryset_for_user(user, model, *, asset_type=None): queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True) @@ -196,6 +199,15 @@ def _pdf_filename(work_order, pdf_type): return f"{display_code}.work-order.pdf" +def _docx_filename(work_order, doc_type): + display_code = _work_order_display_code(work_order) + if doc_type == 'invoices': + return f"{display_code}.work-order-invoices.docx" + if doc_type == 'service_records': + return f"{display_code}.work-order-service-records.docx" + return f"{display_code}.work-order.docx" + + def _get_cached_pdf(work_order, pdf_type): now = timezone.now() expected_filename = _pdf_filename(work_order, pdf_type) @@ -1532,6 +1544,405 @@ def _build_work_order_invoices_pdf_bytes(work_order): raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."}) +def _create_docx_document(template_name=None): + try: + from docx import Document + except ModuleNotFoundError: + raise DRFValidationError({"detail": "DOCX generiranje nije dostupno: nedostaje python-docx paket."}) + if not template_name: + return Document() + template_path = DOCX_TEMPLATE_DIR / template_name + if not template_path.exists(): + raise DRFValidationError({"detail": f"DOCX template nije pronađen: {template_name}."}) + return Document(str(template_path)) + + +def _set_docx_cell_text(table, row_index, col_index, value): + row_count = len(getattr(table, 'rows', [])) + if row_count <= row_index: + raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu tablice."}) + col_count = len(getattr(table.rows[row_index], 'cells', [])) + if col_count <= col_index: + raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu tablice."}) + table.rows[row_index].cells[col_index].text = str(value or '-') + + +def _docx_add_heading(document, text, level=1): + style_name = f'Heading {int(level)}' + try: + document.styles[style_name] + return document.add_paragraph(str(text or ''), style=style_name) + except KeyError: + paragraph = document.add_paragraph() + run = paragraph.add_run(str(text or '')) + run.bold = True + return paragraph + + +def _normalize_whitespace(value): + return " ".join(str(value or '').split()).strip() + + +def _docx_remove_rows_after(table, keep_rows=1): + rows = list(getattr(table, 'rows', [])) + for row in rows[keep_rows:]: + table._tbl.remove(row._tr) + + +def _docx_move_table_after_paragraph_text(document, table, marker_text): + if not marker_text: + return False + marker_norm = _normalize_whitespace(marker_text).lower() + if not marker_norm: + return False + body = document._body._element + children = list(body) + table_element = table._tbl + paragraph_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p' + text_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t' + for index, element in enumerate(children): + if element.tag != paragraph_namespace: + continue + paragraph_text = ''.join(node.text or '' for node in element.iter(text_namespace)) + if marker_norm not in _normalize_whitespace(paragraph_text).lower(): + continue + if table_element in children: + body.remove(table_element) + children = list(body) + index = children.index(element) + body.insert(index + 1, table_element) + return True + return False + + +def _extract_unique_parts_entries(records): + unique = OrderedDict() + for record in records: + raw_parts = _normalize_whitespace(getattr(record, 'parts', None)) + if not raw_parts or raw_parts == '-': + continue + key = raw_parts.lower() + if key in unique: + continue + serial = '-' + description = raw_parts + match = re.match(r'^\[([^\]]+)\]\s*(.*)$', raw_parts) + if match: + serial = _normalize_whitespace(match.group(1)) or '-' + description = _normalize_whitespace(match.group(2)) or '-' + unique[key] = { + 'serial': serial, + 'description': description, + 'note': '-', + 'changed_at': record.service_date.strftime('%d.%m.%Y') if record.service_date else '-', + } + return list(unique.values()) + + +def _docx_cleanup_service_report_template(document): + body = document._body._element + children = list(body) + paragraph_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p' + text_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t' + seen_hours_heading = False + seen_note = False + remove_tail = False + + for element in children: + tag = element.tag + if tag == paragraph_namespace: + text = ''.join(node.text or '' for node in element.iter(text_namespace)) + normalized = _normalize_whitespace(text) + normalized_lower = normalized.lower() + + if 'servisni zapisi' in normalized_lower: + remove_tail = True + if remove_tail: + body.remove(element) + continue + + if normalized_lower == 'tablica radnih sati': + if seen_hours_heading: + body.remove(element) + continue + seen_hours_heading = True + + if normalized_lower.startswith('napomene:'): + if seen_note: + body.remove(element) + continue + seen_note = True + elif remove_tail and not tag.endswith('sectPr'): + body.remove(element) + + +def _build_work_order_docx_bytes(work_order): + vehicle = work_order.vehicle + creator = work_order.creator + doc = _create_docx_document() + _docx_add_heading(doc, f'Putni nalog {_work_order_display_code(work_order)}', level=1) + + rows = [ + ('Datum naloga', str(work_order.date or '-')), + ('Serviser', _user_display_name(creator) or getattr(creator, 'email', '-') or '-'), + ('Klijent', getattr(getattr(vehicle, 'client', None), 'name', None) or '-'), + ('Dizalica', " ".join(part for part in [vehicle.registration_number, vehicle.make, vehicle.model] if part) or '-'), + ('Lokacija', work_order.location or '-'), + ('Ishodište', work_order.origin_location or '-'), + ('Svrha', work_order.purpose or '-'), + ('Status', work_order.status or '-'), + ('Napomene', work_order.notes or '-'), + ] + table = doc.add_table(rows=1, cols=2) + table.rows[0].cells[0].text = 'Polje' + table.rows[0].cells[1].text = 'Vrijednost' + for label, value in rows: + row_cells = table.add_row().cells + row_cells[0].text = str(label) + row_cells[1].text = str(value) + + additional_costs_table = getattr(work_order, 'additional_costs_table', None) + if additional_costs_table and isinstance(additional_costs_table.data, dict): + additional_rows = additional_costs_table.data.get('rows', []) + if isinstance(additional_rows, list) and additional_rows: + doc.add_paragraph() + _docx_add_heading(doc, 'Dodatni troškovi', level=2) + costs_table = doc.add_table(rows=1, cols=4) + costs_table.rows[0].cells[0].text = 'Naziv' + costs_table.rows[0].cells[1].text = 'Broj računa' + costs_table.rows[0].cells[2].text = 'Ukupan iznos' + costs_table.rows[0].cells[3].text = 'Prilog' + for entry in additional_rows: + if not isinstance(entry, dict): + continue + row_cells = costs_table.add_row().cells + row_cells[0].text = str(entry.get('naziv') or '-') + row_cells[1].text = str(entry.get('broj_racuna') or '-') + row_cells[2].text = str(entry.get('ukupan_iznos') or '-') + row_cells[3].text = str(entry.get('prilog') or '-') + + buffer = BytesIO() + doc.save(buffer) + return buffer.getvalue() + + +def _build_work_order_service_records_docx_bytes(work_order): + doc = _create_docx_document(SERVICE_REPORT_DOCX_TEMPLATE_NAME) + vehicle = work_order.vehicle + 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)) + normalized_rows = [] + for task in related_tasks: + table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None) + if not isinstance(table_data, dict): + continue + rows = table_data.get('rows', []) + if not isinstance(rows, list): + continue + for row in rows: + if not isinstance(row, dict): + continue + normalized_rows.append({ + 'day': str(row.get('day', '') or '').strip() or '-', + 'date': str(row.get('date', '') or '').strip() or '-', + 'work_time': " - ".join([ + str(row.get('work_time_from', '') or '').strip() or '-', + str(row.get('work_time_to', '') or '').strip() or '-', + ]), + 'travel_time': " - ".join([ + str(row.get('travel_time_from', '') or '').strip() or '-', + str(row.get('travel_time_to', '') or '').strip() or '-', + ]), + 'break_hours': str(row.get('break_hours', '') or '').strip() or '-', + 'work_hours': str(row.get('work_hours', '') or '').strip() or '-', + 'travel_hours': str(row.get('travel_hours', '') or '').strip() or '-', + 'places': "\n".join([ + f"Polazak: {str(row.get('departure_place', '') or '').strip() or '-'}", + f"Dolazak: {str(row.get('arrival_place', '') or '').strip() or '-'}", + ]), + 'vehicle_km': str(row.get('vehicle_km', '') or '').strip() or '-', + }) + + service_rows = list( + VehicleServiceRecord.objects.filter( + is_active=True, + vehicle_id=work_order.vehicle_id, + ) + .select_related('performed_by', 'task') + .order_by('service_date', 'created_at') + ) + records_by_task = { + task.id: [row for row in service_rows if getattr(row, 'task_id', None) == task.id] + for task in related_tasks + } + + if len(doc.tables) >= 4: + info_table = doc.tables[0] + _set_docx_cell_text(info_table, 1, 0, client_name) + _set_docx_cell_text(info_table, 1, 1, work_order.location or '-') + _set_docx_cell_text(info_table, 3, 0, servicer_name) + if len(info_table.rows[3].cells) > 1: + _set_docx_cell_text(info_table, 3, 1, '-') + + transport_table = doc.tables[1] + _set_docx_cell_text(transport_table, 1, 0, work_order.servicer_vehicle_make_model or '-') + _set_docx_cell_text(transport_table, 1, 1, work_order.servicer_vehicle_registration or '-') + _set_docx_cell_text(transport_table, 1, 2, '-') + _set_docx_cell_text(transport_table, 1, 4, completion_label) + + all_task_records = [record for task in related_tasks for record in records_by_task.get(task.id, [])] + summary_table = doc.tables[2] + _docx_remove_rows_after(summary_table, keep_rows=1) + unique_parts_entries = _extract_unique_parts_entries(all_task_records) + summary_rows = unique_parts_entries or [{ + 'serial': '-', + 'description': '-', + 'note': '-', + 'changed_at': '-', + }] + for index, entry in enumerate(summary_rows): + target_row = summary_table.rows[1] if index == 0 and len(summary_table.rows) > 1 else summary_table.add_row() + target_row.cells[0].text = entry['serial'] + target_row.cells[1].text = entry['description'] + target_row.cells[2].text = entry['note'] + 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, []) + if not task_records: + continue + repair_lines.append(f"Zadatak: {task.title or f'Servisni zadatak #{task.id}'}") + for record in task_records: + 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, 1, 0, f"Popravak: {repair_text}") + else: + raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu (nedostaju tablice)."}) + + for paragraph in doc.paragraphs: + paragraph_text = str(getattr(paragraph, 'text', '') or '').strip() + if paragraph_text.lower().startswith('napomene:'): + paragraph.text = f"Napomene: {notes_text}" + break + + hours_table = None + for table in doc.tables: + if not table.rows: + continue + headers = [_normalize_whitespace(cell.text).lower() for cell in table.rows[0].cells] + if len(headers) >= 9 and headers[0] == 'dan' and headers[1] == 'datum': + hours_table = table + break + if hours_table is None: + _docx_add_heading(doc, 'Tablica radnih sati', level=2) + hours_table = doc.add_table(rows=1, cols=9) + headers = ['Dan', 'Datum', 'Vrijeme rada', 'Vrijeme putovanja', 'Pauza h', 'Sati rada', 'Sati puta', 'Polazak / dolazak', 'Km vozila'] + for index, header in enumerate(headers): + hours_table.rows[0].cells[index].text = header + _docx_remove_rows_after(hours_table, keep_rows=1) + if normalized_rows: + for row in normalized_rows: + cells = hours_table.add_row().cells + cells[0].text = row['day'] + cells[1].text = row['date'] + cells[2].text = row['work_time'] + cells[3].text = row['travel_time'] + cells[4].text = row['break_hours'] + cells[5].text = row['work_hours'] + cells[6].text = row['travel_hours'] + cells[7].text = row['places'] + cells[8].text = row['vehicle_km'] + else: + cells = hours_table.add_row().cells + for index in range(9): + cells[index].text = '-' + _docx_move_table_after_paragraph_text(doc, hours_table, 'Tablica radnih sati') + _docx_cleanup_service_report_template(doc) + doc.add_page_break() + _docx_add_heading(doc, 'Servisni zapisi', level=2) + + has_records = False + for task in related_tasks: + task_records = records_by_task.get(task.id, []) + if not task_records: + continue + has_records = True + _docx_add_heading(doc, task.title or f"Servisni zadatak #{task.id}", level=3) + for record in task_records: + doc.add_paragraph(f"Datum: {record.service_date or '-'}") + doc.add_paragraph(f"Opis: {record.description or '-'}") + doc.add_paragraph(f"Korišteni dijelovi: {record.parts or '-'}") + doc.add_paragraph(f"Trošak: {record.cost or '-'} EUR") + doc.add_paragraph(f"Kilometraža: {record.mileage if record.mileage is not None else '-'}") + doc.add_paragraph('') + if not has_records: + doc.add_paragraph('Nema povezanih servisnih zapisa za ovaj putni nalog.') + + buffer = BytesIO() + doc.save(buffer) + return buffer.getvalue() + + +def _build_work_order_invoices_docx_bytes(work_order): + doc = _create_docx_document() + _docx_add_heading(doc, f'Računi putnog naloga {_work_order_display_code(work_order)}', level=1) + doc.add_paragraph(f'Datum generiranja: {timezone.localtime(timezone.now()).strftime("%d.%m.%Y %H:%M")}') + doc.add_paragraph(f'Klijent: {getattr(getattr(work_order.vehicle, "client", None), "name", None) or "-"}') + doc.add_paragraph(f'Dizalica: {work_order.vehicle.registration_number or "-"}') + + invoices = list(work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')) + table = doc.add_table(rows=1, cols=5) + table.rows[0].cells[0].text = 'Naziv računa' + table.rows[0].cells[1].text = 'Lokacija' + table.rows[0].cells[2].text = 'Datum' + table.rows[0].cells[3].text = 'Opis' + table.rows[0].cells[4].text = 'Prilog' + + if invoices: + for invoice in invoices: + cells = table.add_row().cells + cells[0].text = str(invoice.naziv_racuna or '-') + cells[1].text = str(invoice.lokacija or '-') + cells[2].text = invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-' + cells[3].text = str(invoice.opis or '-') + cells[4].text = Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else '-' + else: + cells = table.add_row().cells + for index in range(5): + cells[index].text = '-' + + additional_costs_table = getattr(work_order, 'additional_costs_table', None) + if additional_costs_table and isinstance(additional_costs_table.data, dict): + rows = additional_costs_table.data.get('rows', []) + if isinstance(rows, list) and rows: + doc.add_paragraph() + _docx_add_heading(doc, 'Dodatni troškovi', level=2) + costs_table = doc.add_table(rows=1, cols=3) + costs_table.rows[0].cells[0].text = 'Naziv' + costs_table.rows[0].cells[1].text = 'Broj računa' + costs_table.rows[0].cells[2].text = 'Ukupan iznos' + for row in rows: + if not isinstance(row, dict): + continue + cells = costs_table.add_row().cells + cells[0].text = str(row.get('naziv') or '-') + cells[1].text = str(row.get('broj_racuna') or '-') + cells[2].text = str(row.get('ukupan_iznos') or '-') + + buffer = BytesIO() + doc.save(buffer) + return buffer.getvalue() + + def _guess_content_type(filename): mime, _ = mimetypes.guess_type(str(filename or '')) return mime or 'application/octet-stream' @@ -1836,6 +2247,17 @@ class WorkOrderViewSet(viewsets.ModelViewSet): response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "work_order")}"' return response + @action(detail=True, methods=['get'], url_path='docx') + def docx(self, request, pk=None): + work_order = self.get_object() + docx_bytes = _build_work_order_docx_bytes(work_order) + response = HttpResponse( + docx_bytes, + content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ) + response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "work_order")}"' + return response + @action(detail=True, methods=['post'], url_path='pdf-request') def pdf_request(self, request, pk=None): work_order = self.get_object() @@ -1857,6 +2279,17 @@ class WorkOrderViewSet(viewsets.ModelViewSet): response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "service_records")}"' 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) + response = HttpResponse( + docx_bytes, + content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ) + response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "service_records")}"' + return response + @action(detail=True, methods=['post'], url_path='service-records-pdf-request') def service_records_pdf_request(self, request, pk=None): work_order = self.get_object() @@ -1879,6 +2312,17 @@ class WorkOrderViewSet(viewsets.ModelViewSet): response['Content-Disposition'] = f'attachment; filename="{filename}"' return response + @action(detail=True, methods=['get'], url_path='invoices-docx') + def invoices_docx(self, request, pk=None): + work_order = self.get_object() + docx_bytes = _build_work_order_invoices_docx_bytes(work_order) + response = HttpResponse( + docx_bytes, + content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ) + response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "invoices")}"' + return response + @action(detail=True, methods=['post'], url_path='invoices-pdf-request') def invoices_pdf_request(self, request, pk=None): work_order = self.get_object() diff --git a/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx b/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx index 269d3e3..32f8bc2 100644 --- a/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx +++ b/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx @@ -140,6 +140,9 @@ export function useAuthenticatedMediaSources(mediaPaths = []) { if (!resolved) { return ''; } + if (isProtectedMediaUrl(resolved)) { + return authenticatedSources[resolved] || ''; + } return authenticatedSources[resolved] || resolved; };