diff --git a/backend/modules/fleet/tests/test_work_order_images_endpoint.py b/backend/modules/fleet/tests/test_work_order_images_endpoint.py index f881b8a..67c79aa 100644 --- a/backend/modules/fleet/tests/test_work_order_images_endpoint.py +++ b/backend/modules/fleet/tests/test_work_order_images_endpoint.py @@ -6,8 +6,10 @@ from django.utils import timezone from rest_framework.test import APIClient import uuid from io import BytesIO -from datetime import timedelta +from datetime import date, timedelta import zipfile +from pathlib import Path +from django.conf import settings from PIL import Image from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, GeneratedWorkOrderPdf @@ -55,13 +57,17 @@ class WorkOrderImagesEndpointTests(TestCase): self.work_order = WorkOrder.objects.create( vehicle=self.vehicle, creator=self.user, + display_code='MT150726', purpose='kontrola', + notes='Napomena iz putnog naloga', ) self.task = Task.objects.create( title='Test servisni zadatak', assigned_to=self.user, vehicle=self.vehicle, work_order=self.work_order, + scheduled_date=date(2033, 12, 24), + service_report_note='Napomena iz taska', ) self.service_record = VehicleServiceRecord.objects.create( @@ -271,3 +277,51 @@ class WorkOrderImagesEndpointTests(TestCase): settings_xml = archive.read('word/settings.xml').decode('utf-8') self.assertNotIn('documentProtection', settings_xml) self.assertNotIn('writeProtection', settings_xml) + + def test_task_specific_service_records_docx_uses_task_note_and_scheduled_date(self): + response = self.client.get( + f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/?task_id={self.task.pk}" + ) + self.assertEqual(response.status_code, 200, response.content) + content_disposition = response.get('Content-Disposition', '') + self.assertIn('MT150726.SN-Test_servisni_zadatak.docx', content_disposition) + + archive = zipfile.ZipFile(BytesIO(response.content)) + document_xml = archive.read('word/document.xml').decode('utf-8') + self.assertIn('Napomena iz taska', document_xml) + self.assertNotIn('Napomena iz putnog naloga', document_xml) + self.assertIn('24.12.2033', document_xml) + self.assertEqual(document_xml.count('w:type="page"'), 1) + + def test_monthly_service_tasks_archive_returns_zip_with_task_docx(self): + response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12') + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response['Content-Type'], 'application/zip') + + archive = zipfile.ZipFile(BytesIO(response.content)) + names = archive.namelist() + self.assertIn('MT150726.SN-Test_servisni_zadatak.docx', names) + + def test_monthly_work_orders_archive_contains_work_orders_and_invoices_folder(self): + WorkOrderInvoice.objects.create( + work_order=self.work_order, + naziv_racuna='Prosinac račun', + datum='2033-12-10', + image=create_test_pdf('racun-prosinac.pdf'), + created_by=self.user, + ) + + before_files = set((Path(settings.MEDIA_ROOT) / 'fleet' / 'generated_archives').glob('*.zip')) + response = self.client.get('/api/fleet/reports/monthly-work-orders-archive/?year=2033&month=12') + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response['Content-Type'], 'application/zip') + + archive = zipfile.ZipFile(BytesIO(response.content)) + names = archive.namelist() + self.assertIn('MT150726.work-order.pdf', names) + self.assertTrue(any(name.startswith('Racuni/MT150726/') for name in names), names) + + generated_archives_dir = Path(settings.MEDIA_ROOT) / 'fleet' / 'generated_archives' + self.assertTrue(generated_archives_dir.exists()) + after_files = set(generated_archives_dir.glob('*.zip')) + self.assertTrue(after_files - before_files) diff --git a/backend/modules/fleet/urls.py b/backend/modules/fleet/urls.py index 50671e2..6f55b02 100644 --- a/backend/modules/fleet/urls.py +++ b/backend/modules/fleet/urls.py @@ -3,7 +3,7 @@ from django.urls import path from .views import ( CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet, WorkOrderInvoiceViewSet, ServiceContextNoteViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth, - monthly_servicer_report_docx, monthly_costs_report_docx, MonthlyServicerDayEntryViewSet, + monthly_servicer_report_docx, monthly_costs_report_docx, monthly_service_tasks_archive, monthly_work_orders_archive, MonthlyServicerDayEntryViewSet, ) router = DefaultRouter() @@ -24,4 +24,6 @@ urlpatterns += [ path('pusher-auth/', pusher_auth, name='pusher-auth'), path('reports/monthly-servicer/', monthly_servicer_report_docx, name='monthly-servicer-report'), path('reports/monthly-costs/', monthly_costs_report_docx, name='monthly-costs-report'), + path('reports/monthly-service-tasks-archive/', monthly_service_tasks_archive, name='monthly-service-tasks-archive'), + path('reports/monthly-work-orders-archive/', monthly_work_orders_archive, name='monthly-work-orders-archive'), ] \ No newline at end of file diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index 06ab767..c9f428a 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 zipfile import re from collections import OrderedDict from datetime import timedelta @@ -183,6 +184,7 @@ def _parse_format(value): GENERATED_PDF_TTL_HOURS = 24 +GENERATED_ARCHIVE_TTL_DAYS = 7 def _work_order_display_code(work_order): @@ -192,6 +194,17 @@ def _work_order_display_code(work_order): return 'NALOG' +def _sanitize_task_title_for_filename(value): + normalized = re.sub(r'\s+', '_', str(value or '').strip()) + normalized = re.sub(r'[^A-Za-z0-9_-]+', '', normalized) + normalized = normalized.strip('._-') + return normalized or 'servisni_zapis' + + +def _service_task_filename_label(task): + return f"SN-{_sanitize_task_title_for_filename(getattr(task, 'title', ''))}" + + def _pdf_filename(work_order, pdf_type): display_code = _work_order_display_code(work_order) if pdf_type == 'invoices': @@ -214,14 +227,14 @@ 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" + return f"{display_code}.{_service_task_filename_label(task)}.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" + return f"{display_code}.{_service_task_filename_label(task)}.docx" def _resolve_service_report_tasks(work_order, task_id): @@ -941,13 +954,13 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None): .prefetch_related('photos') .order_by('service_date', 'created_at') ) - # Per-task notes; fall back to work_order.notes + # Per-task notes from Task.service_report_note (edited via "Uredi tekst napomene") 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() + notes_text = "\n".join(task_note_parts) if task_note_parts else '-' buffer = BytesIO() @@ -1387,8 +1400,9 @@ def _build_service_record_pdf(service_record): y = content_top - 6 y -= 6 + task_scheduled_date = getattr(task, 'scheduled_date', None) if task else None info_rows = [ - ["ID", str(service_record.pk), "Datum", _fmt_date(service_record.service_date)], + ["ID", str(service_record.pk), "Datum", _fmt_date(task_scheduled_date)], ["Naziv", service_record.service_title or '-', "Servisni zadatak", getattr(task, 'title', '-') or '-'], ["Dizalica", getattr(vehicle, 'registration_number', '-') or '-', "SN", getattr(vehicle, 'crane_serial_number', '-') or '-'], ["Serviser", _user_display_name(service_record.performed_by) or '-', "KM", str(service_record.mileage or '-')], @@ -1704,6 +1718,12 @@ def _docx_remove_rows_after(table, keep_rows=1): table._tbl.remove(row._tr) +def _docx_remove_table(table): + parent = table._tbl.getparent() + if parent is not None: + parent.remove(table._tbl) + + def _docx_move_table_after_paragraph_text(document, table, marker_text): if not marker_text: return False @@ -1745,11 +1765,15 @@ def _extract_unique_parts_entries(records): if match: serial = _normalize_whitespace(match.group(1)) or '-' description = _normalize_whitespace(match.group(2)) or '-' + related_task = getattr(record, 'task', None) + changed_at_value = '-' + if related_task and getattr(related_task, 'scheduled_date', None): + changed_at_value = related_task.scheduled_date.strftime('%d.%m.%Y') unique[key] = { 'serial': serial, 'description': description, 'note': '-', - 'changed_at': record.service_date.strftime('%d.%m.%Y') if record.service_date else '-', + 'changed_at': changed_at_value, } return list(unique.values()) @@ -1791,6 +1815,27 @@ def _docx_cleanup_service_report_template(document): body.remove(element) +def _docx_remove_empty_page_break_paragraphs(document): + body = document._body._element + paragraph_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p' + break_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}br' + text_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t' + wordprocessing_namespace = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' + + for element in list(body): + if element.tag != paragraph_namespace: + continue + text = ''.join(node.text or '' for node in element.iter(text_namespace)).strip() + if text: + continue + has_page_break = any( + br.tag == break_namespace and br.get(f'{{{wordprocessing_namespace}}}type') == 'page' + for br in element.iter(break_namespace) + ) + if has_page_break: + body.remove(element) + + def _build_work_order_docx_bytes(work_order): vehicle = work_order.vehicle creator = work_order.creator @@ -1903,15 +1948,15 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None) for task in related_tasks } - # Build notes: use per-task service_report_note; fall back to work_order.notes + # Build notes only from per-task service_report_note (edited via "Uredi tekst napomene") 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 '-') + notes_text = "\n".join(task_note_parts) if task_note_parts else '-' - if len(doc.tables) >= 4: + if len(doc.tables) >= 3: 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 '-') @@ -1941,20 +1986,8 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None) 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] - 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: {notes_text}") - _set_docx_cell_text(details_table, 1, 0, f"Popravak: {repair_text}") + if len(doc.tables) > 3: + _docx_remove_table(doc.tables[3]) else: raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu (nedostaju tablice)."}) @@ -1997,6 +2030,7 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None) cells[index].text = '-' _docx_move_table_after_paragraph_text(doc, hours_table, 'Tablica radnih sati') _docx_cleanup_service_report_template(doc) + _docx_remove_empty_page_break_paragraphs(doc) doc.add_page_break() _docx_add_heading(doc, 'Servisni zapisi', level=2) @@ -2006,9 +2040,10 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None) if not task_records: continue has_records = True + task_date_label = task.scheduled_date.strftime('%d.%m.%Y') if task.scheduled_date else '-' _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"Datum: {task_date_label}") 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") @@ -2635,6 +2670,185 @@ def monthly_costs_report_docx(request): return resp +def _monthly_archive_prefix_for_user(user): + first_name = str(getattr(user, 'first_name', '') or '').strip() + last_name = str(getattr(user, 'last_name', '') or '').strip() + if first_name and last_name: + return f"{first_name[0].upper()}{last_name[0].upper()}" + return 'MT' + + +def _generated_archives_dir(): + root = Path(settings.MEDIA_ROOT) / 'fleet' / 'generated_archives' + root.mkdir(parents=True, exist_ok=True) + return root + + +def _cleanup_expired_generated_archives(): + root = _generated_archives_dir() + threshold = timezone.now() - timedelta(days=GENERATED_ARCHIVE_TTL_DAYS) + threshold_ts = threshold.timestamp() + for candidate in root.glob('*.zip'): + try: + if candidate.stat().st_mtime <= threshold_ts: + candidate.unlink(missing_ok=True) + except OSError: + continue + + +def _persist_generated_archive(filename, content): + root = _generated_archives_dir() + timestamp = timezone.now().strftime('%Y%m%d%H%M%S') + stored_name = f"{timestamp}-{Path(filename).name}" + target = root / stored_name + with target.open('wb') as handle: + handle.write(content) + return target + + +def _unique_zip_entry_name(entry_name, used_names): + candidate = entry_name + entry_path = Path(entry_name) + stem = entry_path.stem + suffix = entry_path.suffix + parent = str(entry_path.parent) + counter = 2 + while candidate in used_names: + filename = f"{stem}-{counter}{suffix}" + candidate = f"{parent}/{filename}" if parent not in ('', '.') else filename + counter += 1 + used_names.add(candidate) + return candidate + + +@api_view(['GET']) +@permission_classes([permissions.IsAuthenticated]) +def monthly_service_tasks_archive(request): + from datetime import date as _dt_date + from modules.task_management.models import Task + + try: + year = int(request.query_params.get('year', _dt_date.today().year)) + month = int(request.query_params.get('month', _dt_date.today().month)) + if not (1 <= month <= 12): + raise ValueError() + except (ValueError, TypeError): + return Response({'detail': 'Nevažeći year/month parametar.'}, status=400) + + tasks = list( + Task.objects + .filter( + assigned_to=request.user, + is_active=True, + scheduled_date__year=year, + scheduled_date__month=month, + work_order__isnull=False, + work_order__is_active=True, + ) + .select_related('work_order', 'vehicle', 'work_hours_table') + .order_by('scheduled_date', 'created_at') + ) + if not tasks: + return Response({'detail': 'Nema servisnih taskova za odabrani mjesec.'}, status=404) + + used_names = set() + archive_buffer = BytesIO() + with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive: + for task in tasks: + work_order = task.work_order + if work_order is None: + continue + docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=[task]) + base_name = _service_records_docx_filename(work_order, task) + entry_name = _unique_zip_entry_name(base_name, used_names) + archive.writestr(entry_name, docx_bytes) + + archive_content = archive_buffer.getvalue() + if not archive_content: + return Response({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}, status=400) + + prefix = _monthly_archive_prefix_for_user(request.user) + archive_filename = f"{prefix}-{month:02d}-{year}-SN.zip" + _cleanup_expired_generated_archives() + _persist_generated_archive(archive_filename, archive_content) + + response = HttpResponse(archive_content, content_type='application/zip') + response['Content-Disposition'] = f'attachment; filename="{archive_filename}"' + return response + + +@api_view(['GET']) +@permission_classes([permissions.IsAuthenticated]) +def monthly_work_orders_archive(request): + from datetime import date as _dt_date + from modules.task_management.models import Task + + try: + year = int(request.query_params.get('year', _dt_date.today().year)) + month = int(request.query_params.get('month', _dt_date.today().month)) + if not (1 <= month <= 12): + raise ValueError() + except (ValueError, TypeError): + return Response({'detail': 'Nevažeći year/month parametar.'}, status=400) + + monthly_tasks = ( + Task.objects + .filter( + assigned_to=request.user, + is_active=True, + scheduled_date__year=year, + scheduled_date__month=month, + work_order__isnull=False, + work_order__is_active=True, + ) + .select_related('work_order') + .order_by('scheduled_date', 'created_at') + ) + work_order_ids = [task.work_order_id for task in monthly_tasks if task.work_order_id] + if not work_order_ids: + return Response({'detail': 'Nema putnih naloga za odabrani mjesec.'}, status=404) + + work_orders = list( + WorkOrder.objects + .filter(id__in=work_order_ids, is_active=True) + .select_related('vehicle', 'vehicle__client', 'creator') + .distinct() + ) + if not work_orders: + return Response({'detail': 'Nema putnih naloga za odabrani mjesec.'}, status=404) + + used_names = set() + archive_buffer = BytesIO() + with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive: + for work_order in work_orders: + pdf_bytes = _build_work_order_pdf(work_order) + work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names) + archive.writestr(work_order_pdf_name, pdf_bytes) + + display_code = _work_order_display_code(work_order) + invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at') + for index, invoice in enumerate(invoices, start=1): + attachment = _file_attachment(invoice.image, fallback_name=f"invoice-{index}.bin") + if not attachment: + continue + invoice_filename, content, _content_type = attachment + archive_path = f"Racuni/{display_code}/{invoice_filename}" + archive.writestr(_unique_zip_entry_name(archive_path, used_names), content) + + archive_content = archive_buffer.getvalue() + if not archive_content: + return Response({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}, status=400) + + prefix = _monthly_archive_prefix_for_user(request.user) + archive_filename = f"{prefix}-{month:02d}-{year}-PN.zip" + _cleanup_expired_generated_archives() + _persist_generated_archive(archive_filename, archive_content) + + response = HttpResponse(archive_content, content_type='application/zip') + response['Content-Disposition'] = f'attachment; filename="{archive_filename}"' + return response + + @api_view(['POST']) @permission_classes([permissions.IsAuthenticated]) def pusher_auth(request): @@ -3013,6 +3227,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet): 'title': task.title, 'status': task.status, 'description': task.description, + 'scheduled_date': task.scheduled_date, '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), diff --git a/frontend/src/components/dashboard/TaskCalendarWidget.jsx b/frontend/src/components/dashboard/TaskCalendarWidget.jsx index 58d7a1c..9c2b63b 100644 --- a/frontend/src/components/dashboard/TaskCalendarWidget.jsx +++ b/frontend/src/components/dashboard/TaskCalendarWidget.jsx @@ -1,8 +1,10 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; import { getStatusLabel } from '../../stores/taskStore'; import { + downloadMonthlyServiceTasksArchive, downloadMonthlyCostsReport, downloadMonthlyServiserReport, + downloadMonthlyWorkOrdersArchive, fetchMonthlyCostInvoices, fetchMonthlyServicerEntries, upsertMonthlyServicerEntry, @@ -89,6 +91,9 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders const [manualEntryDraft, setManualEntryDraft] = useState(null); const [manualEntryError, setManualEntryError] = useState(''); const [savingManualEntry, setSavingManualEntry] = useState(false); + const [bulkDownloadOpen, setBulkDownloadOpen] = useState(false); + const [downloadingAllTasks, setDownloadingAllTasks] = useState(false); + const [downloadingAllWorkOrders, setDownloadingAllWorkOrders] = useState(false); const reportOpen = reportType !== null; const panelWidth = reportOpen ? 920 : 300; @@ -341,6 +346,9 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders function toggleReport(type) { setReportType((prev) => (prev === type ? null : type)); setManualEntryTarget(null); + if (type !== 'servicer') { + setBulkDownloadOpen(false); + } } async function handleDownload() { @@ -357,6 +365,28 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders } } + async function handleDownloadAllTasksArchive() { + if (downloadingAllTasks) return; + setDownloadingAllTasks(true); + try { + await downloadMonthlyServiceTasksArchive(viewYear, viewMonth + 1); + setBulkDownloadOpen(false); + } finally { + setDownloadingAllTasks(false); + } + } + + async function handleDownloadAllWorkOrdersArchive() { + if (downloadingAllWorkOrders) return; + setDownloadingAllWorkOrders(true); + try { + await downloadMonthlyWorkOrdersArchive(viewYear, viewMonth + 1); + setBulkDownloadOpen(false); + } finally { + setDownloadingAllWorkOrders(false); + } + } + function handleServicerRowClick(row) { if (!row?.clickable) return; setManualEntryTarget(row); @@ -631,8 +661,17 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders disabled={downloading} className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-60" > - {downloading ? 'Preuzimanje...' : 'Preuzmi DOCX'} + {downloading ? 'Preuzimanje...' : reportType === 'servicer' ? 'Preuzmi mjesečni izvještaj' : 'Preuzmi DOCX'} + {reportType === 'servicer' && ( + + )}

@@ -825,6 +864,48 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders )} + + {bulkDownloadOpen && reportType === 'servicer' && ( +

+
+
+
+

Preuzmi sve

+

+ {MONTH_NAMES[viewMonth]} {viewYear} +

+
+ +
+ +
+ + +
+
+
+ )} )} diff --git a/frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx b/frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx index b0a0627..f2d31d9 100644 --- a/frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx +++ b/frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx @@ -116,6 +116,7 @@ const FIELD_CONFIGS = [ ]; const DAY_OPTIONS = ['', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB', 'NED']; +const DAY_LABELS_BY_INDEX = ['NED', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB']; const FB_INPUT = [ 'block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs', @@ -145,15 +146,45 @@ export default function TaskWorkHoursTableModal({ }) { const [rows, setRows] = useState([]); + const getDefaultDayDate = (fallbackToToday = false) => { + const raw = String(task?.scheduled_date || '').trim(); + const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (match) { + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(year, month - 1, day); + if (!Number.isNaN(date.getTime())) { + return { + day: DAY_LABELS_BY_INDEX[date.getDay()] || '', + date: `${String(day).padStart(2, '0')}.${String(month).padStart(2, '0')}.${year}`, + }; + } + } + if (!fallbackToToday) { + return { day: '', date: '' }; + } + const today = new Date(); + return { + day: DAY_LABELS_BY_INDEX[today.getDay()] || '', + date: `${String(today.getDate()).padStart(2, '0')}.${String(today.getMonth() + 1).padStart(2, '0')}.${today.getFullYear()}`, + }; + }; + useEffect(() => { if (!open) { setRows([]); return; } const incoming = Array.isArray(task?.work_hours_table?.rows) ? task.work_hours_table.rows : []; + const defaultDayDate = getDefaultDayDate(false); setRows( incoming.length > 0 ? incoming.map((r) => ({ ...EMPTY_ROW, ...(r || {}) })) - : [{ ...EMPTY_ROW }, { ...EMPTY_ROW }, { ...EMPTY_ROW }], + : [ + { ...EMPTY_ROW, ...defaultDayDate }, + { ...EMPTY_ROW, ...defaultDayDate }, + { ...EMPTY_ROW, ...defaultDayDate }, + ], ); - }, [open, task?.id, task?.work_hours_table]); + }, [open, task?.id, task?.scheduled_date, task?.work_hours_table]); if (!open || !task) return null; @@ -252,20 +283,15 @@ export default function TaskWorkHoursTableModal({ const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]); const addThreeRows = () => { - const today = new Date(); - const dd = String(today.getDate()).padStart(2, '0'); - const mm = String(today.getMonth() + 1).padStart(2, '0'); - const dateStr = `${dd}.${mm}.${today.getFullYear()}`; - const dayLabels = ['NED', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB']; - const dayLabel = dayLabels[today.getDay()]; + const defaults = getDefaultDayDate(true); const gid = newGroupId(); const loc = defaultLocation || ''; setRows((prev) => [ ...prev, - { ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: 'Zagreb', arrival_place: loc, _groupId: gid, _role: 'departure' }, - { ...EMPTY_ROW, day: dayLabel, date: dateStr, _groupId: gid, _role: 'work' }, - { ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: loc, arrival_place: 'Zagreb', _groupId: gid, _role: 'return' }, + { ...EMPTY_ROW, day: defaults.day, date: defaults.date, departure_place: 'Zagreb', arrival_place: loc, _groupId: gid, _role: 'departure' }, + { ...EMPTY_ROW, day: defaults.day, date: defaults.date, _groupId: gid, _role: 'work' }, + { ...EMPTY_ROW, day: defaults.day, date: defaults.date, departure_place: loc, arrival_place: 'Zagreb', _groupId: gid, _role: 'return' }, ]); }; diff --git a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx index b6c12e6..c9b5c11 100644 --- a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx +++ b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx @@ -399,7 +399,14 @@ export default function WorkOrderInvoicesPdfPage() {