feat: update service report exports and calendar bulk downloads
Align service-record PDF/DOCX generation with task-level data and naming. - Use Task.service_report_note as report note source - Use Task.scheduled_date for service-record dates - Rename per-task exports to MT...SN... format - Add monthly SN/PN ZIP download endpoints with 7-day retention - Add calendar 'Preuzmi sve' modal with both bulk download actions - Prefill work-hours day/date from scheduled_date for empty tables - Remove duplicate note table and clean extra DOCX page breaks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -6,8 +6,10 @@ from django.utils import timezone
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
import uuid
|
import uuid
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from datetime import timedelta
|
from datetime import date, timedelta
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from django.conf import settings
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, GeneratedWorkOrderPdf
|
from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, GeneratedWorkOrderPdf
|
||||||
@@ -55,13 +57,17 @@ class WorkOrderImagesEndpointTests(TestCase):
|
|||||||
self.work_order = WorkOrder.objects.create(
|
self.work_order = WorkOrder.objects.create(
|
||||||
vehicle=self.vehicle,
|
vehicle=self.vehicle,
|
||||||
creator=self.user,
|
creator=self.user,
|
||||||
|
display_code='MT150726',
|
||||||
purpose='kontrola',
|
purpose='kontrola',
|
||||||
|
notes='Napomena iz putnog naloga',
|
||||||
)
|
)
|
||||||
self.task = Task.objects.create(
|
self.task = Task.objects.create(
|
||||||
title='Test servisni zadatak',
|
title='Test servisni zadatak',
|
||||||
assigned_to=self.user,
|
assigned_to=self.user,
|
||||||
vehicle=self.vehicle,
|
vehicle=self.vehicle,
|
||||||
work_order=self.work_order,
|
work_order=self.work_order,
|
||||||
|
scheduled_date=date(2033, 12, 24),
|
||||||
|
service_report_note='Napomena iz taska',
|
||||||
)
|
)
|
||||||
|
|
||||||
self.service_record = VehicleServiceRecord.objects.create(
|
self.service_record = VehicleServiceRecord.objects.create(
|
||||||
@@ -271,3 +277,51 @@ class WorkOrderImagesEndpointTests(TestCase):
|
|||||||
settings_xml = archive.read('word/settings.xml').decode('utf-8')
|
settings_xml = archive.read('word/settings.xml').decode('utf-8')
|
||||||
self.assertNotIn('documentProtection', settings_xml)
|
self.assertNotIn('documentProtection', settings_xml)
|
||||||
self.assertNotIn('writeProtection', 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)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from django.urls import path
|
|||||||
from .views import (
|
from .views import (
|
||||||
CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet,
|
CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet,
|
||||||
WorkOrderInvoiceViewSet, ServiceContextNoteViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth,
|
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()
|
router = DefaultRouter()
|
||||||
@@ -24,4 +24,6 @@ urlpatterns += [
|
|||||||
path('pusher-auth/', pusher_auth, name='pusher-auth'),
|
path('pusher-auth/', pusher_auth, name='pusher-auth'),
|
||||||
path('reports/monthly-servicer/', monthly_servicer_report_docx, name='monthly-servicer-report'),
|
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-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'),
|
||||||
]
|
]
|
||||||
@@ -6,6 +6,7 @@ import base64
|
|||||||
import csv
|
import csv
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import threading
|
import threading
|
||||||
|
import zipfile
|
||||||
import re
|
import re
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -183,6 +184,7 @@ def _parse_format(value):
|
|||||||
|
|
||||||
|
|
||||||
GENERATED_PDF_TTL_HOURS = 24
|
GENERATED_PDF_TTL_HOURS = 24
|
||||||
|
GENERATED_ARCHIVE_TTL_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
def _work_order_display_code(work_order):
|
def _work_order_display_code(work_order):
|
||||||
@@ -192,6 +194,17 @@ def _work_order_display_code(work_order):
|
|||||||
return 'NALOG'
|
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):
|
def _pdf_filename(work_order, pdf_type):
|
||||||
display_code = _work_order_display_code(work_order)
|
display_code = _work_order_display_code(work_order)
|
||||||
if pdf_type == 'invoices':
|
if pdf_type == 'invoices':
|
||||||
@@ -214,14 +227,14 @@ def _service_records_pdf_filename(work_order, task=None):
|
|||||||
if not task:
|
if not task:
|
||||||
return _pdf_filename(work_order, 'service_records')
|
return _pdf_filename(work_order, 'service_records')
|
||||||
display_code = _work_order_display_code(work_order)
|
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):
|
def _service_records_docx_filename(work_order, task=None):
|
||||||
if not task:
|
if not task:
|
||||||
return _docx_filename(work_order, 'service_records')
|
return _docx_filename(work_order, 'service_records')
|
||||||
display_code = _work_order_display_code(work_order)
|
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):
|
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')
|
.prefetch_related('photos')
|
||||||
.order_by('service_date', 'created_at')
|
.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 = [
|
task_note_parts = [
|
||||||
str(task.service_report_note or '').strip()
|
str(task.service_report_note or '').strip()
|
||||||
for task in related_tasks
|
for task in related_tasks
|
||||||
if str(task.service_report_note or '').strip()
|
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()
|
buffer = BytesIO()
|
||||||
|
|
||||||
@@ -1387,8 +1400,9 @@ def _build_service_record_pdf(service_record):
|
|||||||
y = content_top - 6
|
y = content_top - 6
|
||||||
y -= 6
|
y -= 6
|
||||||
|
|
||||||
|
task_scheduled_date = getattr(task, 'scheduled_date', None) if task else None
|
||||||
info_rows = [
|
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 '-'],
|
["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 '-'],
|
["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 '-')],
|
["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)
|
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):
|
def _docx_move_table_after_paragraph_text(document, table, marker_text):
|
||||||
if not marker_text:
|
if not marker_text:
|
||||||
return False
|
return False
|
||||||
@@ -1745,11 +1765,15 @@ def _extract_unique_parts_entries(records):
|
|||||||
if match:
|
if match:
|
||||||
serial = _normalize_whitespace(match.group(1)) or '-'
|
serial = _normalize_whitespace(match.group(1)) or '-'
|
||||||
description = _normalize_whitespace(match.group(2)) 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] = {
|
unique[key] = {
|
||||||
'serial': serial,
|
'serial': serial,
|
||||||
'description': description,
|
'description': description,
|
||||||
'note': '-',
|
'note': '-',
|
||||||
'changed_at': record.service_date.strftime('%d.%m.%Y') if record.service_date else '-',
|
'changed_at': changed_at_value,
|
||||||
}
|
}
|
||||||
return list(unique.values())
|
return list(unique.values())
|
||||||
|
|
||||||
@@ -1791,6 +1815,27 @@ def _docx_cleanup_service_report_template(document):
|
|||||||
body.remove(element)
|
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):
|
def _build_work_order_docx_bytes(work_order):
|
||||||
vehicle = work_order.vehicle
|
vehicle = work_order.vehicle
|
||||||
creator = work_order.creator
|
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
|
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 = [
|
task_note_parts = [
|
||||||
str(task.service_report_note or '').strip()
|
str(task.service_report_note or '').strip()
|
||||||
for task in related_tasks
|
for task in related_tasks
|
||||||
if str(task.service_report_note or '').strip()
|
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]
|
info_table = doc.tables[0]
|
||||||
_set_docx_cell_text(info_table, 1, 0, client_name)
|
_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, 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[1].text = entry['description']
|
||||||
target_row.cells[2].text = entry['note']
|
target_row.cells[2].text = entry['note']
|
||||||
target_row.cells[3].text = entry['changed_at']
|
target_row.cells[3].text = entry['changed_at']
|
||||||
|
if len(doc.tables) > 3:
|
||||||
details_table = doc.tables[3]
|
_docx_remove_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}")
|
|
||||||
else:
|
else:
|
||||||
raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu (nedostaju tablice)."})
|
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 = '-'
|
cells[index].text = '-'
|
||||||
_docx_move_table_after_paragraph_text(doc, hours_table, 'Tablica radnih sati')
|
_docx_move_table_after_paragraph_text(doc, hours_table, 'Tablica radnih sati')
|
||||||
_docx_cleanup_service_report_template(doc)
|
_docx_cleanup_service_report_template(doc)
|
||||||
|
_docx_remove_empty_page_break_paragraphs(doc)
|
||||||
doc.add_page_break()
|
doc.add_page_break()
|
||||||
_docx_add_heading(doc, 'Servisni zapisi', level=2)
|
_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:
|
if not task_records:
|
||||||
continue
|
continue
|
||||||
has_records = True
|
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)
|
_docx_add_heading(doc, task.title or f"Servisni zadatak #{task.id}", level=3)
|
||||||
for record in task_records:
|
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"Opis: {record.description or '-'}")
|
||||||
doc.add_paragraph(f"Korišteni dijelovi: {record.parts 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"Trošak: {record.cost or '-'} EUR")
|
||||||
@@ -2635,6 +2670,185 @@ def monthly_costs_report_docx(request):
|
|||||||
return resp
|
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'])
|
@api_view(['POST'])
|
||||||
@permission_classes([permissions.IsAuthenticated])
|
@permission_classes([permissions.IsAuthenticated])
|
||||||
def pusher_auth(request):
|
def pusher_auth(request):
|
||||||
@@ -3013,6 +3227,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
|||||||
'title': task.title,
|
'title': task.title,
|
||||||
'status': task.status,
|
'status': task.status,
|
||||||
'description': task.description,
|
'description': task.description,
|
||||||
|
'scheduled_date': task.scheduled_date,
|
||||||
'service_report_note': task.service_report_note or '',
|
'service_report_note': task.service_report_note or '',
|
||||||
'assigned_to_name': _user_display_name(task.assigned_to),
|
'assigned_to_name': _user_display_name(task.assigned_to),
|
||||||
'vehicle_registration': getattr(task.vehicle, 'registration_number', None),
|
'vehicle_registration': getattr(task.vehicle, 'registration_number', None),
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { getStatusLabel } from '../../stores/taskStore';
|
import { getStatusLabel } from '../../stores/taskStore';
|
||||||
import {
|
import {
|
||||||
|
downloadMonthlyServiceTasksArchive,
|
||||||
downloadMonthlyCostsReport,
|
downloadMonthlyCostsReport,
|
||||||
downloadMonthlyServiserReport,
|
downloadMonthlyServiserReport,
|
||||||
|
downloadMonthlyWorkOrdersArchive,
|
||||||
fetchMonthlyCostInvoices,
|
fetchMonthlyCostInvoices,
|
||||||
fetchMonthlyServicerEntries,
|
fetchMonthlyServicerEntries,
|
||||||
upsertMonthlyServicerEntry,
|
upsertMonthlyServicerEntry,
|
||||||
@@ -89,6 +91,9 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
const [manualEntryDraft, setManualEntryDraft] = useState(null);
|
const [manualEntryDraft, setManualEntryDraft] = useState(null);
|
||||||
const [manualEntryError, setManualEntryError] = useState('');
|
const [manualEntryError, setManualEntryError] = useState('');
|
||||||
const [savingManualEntry, setSavingManualEntry] = useState(false);
|
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 reportOpen = reportType !== null;
|
||||||
const panelWidth = reportOpen ? 920 : 300;
|
const panelWidth = reportOpen ? 920 : 300;
|
||||||
@@ -341,6 +346,9 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
function toggleReport(type) {
|
function toggleReport(type) {
|
||||||
setReportType((prev) => (prev === type ? null : type));
|
setReportType((prev) => (prev === type ? null : type));
|
||||||
setManualEntryTarget(null);
|
setManualEntryTarget(null);
|
||||||
|
if (type !== 'servicer') {
|
||||||
|
setBulkDownloadOpen(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownload() {
|
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) {
|
function handleServicerRowClick(row) {
|
||||||
if (!row?.clickable) return;
|
if (!row?.clickable) return;
|
||||||
setManualEntryTarget(row);
|
setManualEntryTarget(row);
|
||||||
@@ -631,8 +661,17 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
disabled={downloading}
|
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"
|
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'}
|
||||||
</button>
|
</button>
|
||||||
|
{reportType === 'servicer' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBulkDownloadOpen(true)}
|
||||||
|
className="ml-2 rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep"
|
||||||
|
>
|
||||||
|
Preuzmi sve
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="border-b border-border-hairline bg-canvas-deep px-4 py-1.5 text-[11px] text-text-muted">
|
<p className="border-b border-border-hairline bg-canvas-deep px-4 py-1.5 text-[11px] text-text-muted">
|
||||||
@@ -825,6 +864,48 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{bulkDownloadOpen && reportType === 'servicer' && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/30 px-4">
|
||||||
|
<div className="w-full max-w-md rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-text-main">Preuzmi sve</h3>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">
|
||||||
|
{MONTH_NAMES[viewMonth]} {viewYear}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBulkDownloadOpen(false)}
|
||||||
|
className="rounded p-1 text-text-muted hover:bg-canvas-deep"
|
||||||
|
aria-label="Zatvori"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={downloadingAllTasks || downloadingAllWorkOrders}
|
||||||
|
onClick={handleDownloadAllTasksArchive}
|
||||||
|
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{downloadingAllTasks ? 'Generiranje...' : 'Preuzmi sve pojedinačne servisne taskove (SN)'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={downloadingAllTasks || downloadingAllWorkOrders}
|
||||||
|
onClick={handleDownloadAllWorkOrdersArchive}
|
||||||
|
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{downloadingAllWorkOrders ? 'Generiranje...' : 'Preuzmi sve putne naloge + račune (PN)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</LeftSlideDrawer>
|
</LeftSlideDrawer>
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ const FIELD_CONFIGS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const DAY_OPTIONS = ['', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB', 'NED'];
|
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 = [
|
const FB_INPUT = [
|
||||||
'block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs',
|
'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 [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(() => {
|
useEffect(() => {
|
||||||
if (!open) { setRows([]); return; }
|
if (!open) { setRows([]); return; }
|
||||||
const incoming = Array.isArray(task?.work_hours_table?.rows) ? task.work_hours_table.rows : [];
|
const incoming = Array.isArray(task?.work_hours_table?.rows) ? task.work_hours_table.rows : [];
|
||||||
|
const defaultDayDate = getDefaultDayDate(false);
|
||||||
setRows(
|
setRows(
|
||||||
incoming.length > 0
|
incoming.length > 0
|
||||||
? incoming.map((r) => ({ ...EMPTY_ROW, ...(r || {}) }))
|
? 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;
|
if (!open || !task) return null;
|
||||||
|
|
||||||
@@ -252,20 +283,15 @@ export default function TaskWorkHoursTableModal({
|
|||||||
const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]);
|
const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]);
|
||||||
|
|
||||||
const addThreeRows = () => {
|
const addThreeRows = () => {
|
||||||
const today = new Date();
|
const defaults = getDefaultDayDate(true);
|
||||||
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 gid = newGroupId();
|
const gid = newGroupId();
|
||||||
const loc = defaultLocation || '';
|
const loc = defaultLocation || '';
|
||||||
|
|
||||||
setRows((prev) => [
|
setRows((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: 'Zagreb', arrival_place: loc, _groupId: gid, _role: 'departure' },
|
{ ...EMPTY_ROW, day: defaults.day, date: defaults.date, departure_place: 'Zagreb', arrival_place: loc, _groupId: gid, _role: 'departure' },
|
||||||
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, _groupId: gid, _role: 'work' },
|
{ ...EMPTY_ROW, day: defaults.day, date: defaults.date, _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: loc, arrival_place: 'Zagreb', _groupId: gid, _role: 'return' },
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -399,7 +399,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId, task.id)}
|
onClick={() => downloadWorkOrderServiceRecordsPdf(
|
||||||
|
workOrderId,
|
||||||
|
task.id,
|
||||||
|
{
|
||||||
|
workOrderDisplayCode: workOrder?.display_code,
|
||||||
|
taskTitle: task.title,
|
||||||
|
},
|
||||||
|
)}
|
||||||
disabled={!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"
|
className="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
@@ -407,7 +414,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => downloadWorkOrderServiceRecordsDocx(workOrderId, task.id)}
|
onClick={() => downloadWorkOrderServiceRecordsDocx(
|
||||||
|
workOrderId,
|
||||||
|
task.id,
|
||||||
|
{
|
||||||
|
workOrderDisplayCode: workOrder?.display_code,
|
||||||
|
taskTitle: task.title,
|
||||||
|
},
|
||||||
|
)}
|
||||||
disabled={!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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -865,7 +865,7 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = null) {
|
export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = null, taskOptions = {}) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
throw new Error('Work order ID je obavezan.');
|
throw new Error('Work order ID je obavezan.');
|
||||||
}
|
}
|
||||||
@@ -875,7 +875,12 @@ export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = n
|
|||||||
`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/?task_id=${encodeURIComponent(taskId)}`,
|
`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/?task_id=${encodeURIComponent(taskId)}`,
|
||||||
{ responseType: 'blob' },
|
{ responseType: 'blob' },
|
||||||
);
|
);
|
||||||
saveBlobToFile(blob, `${workOrderId}.task-${taskId}.work-order-service-records.pdf`);
|
const workOrderDisplayCode = String(taskOptions?.workOrderDisplayCode || '').trim().toUpperCase() || String(workOrderId);
|
||||||
|
const taskTitle = String(taskOptions?.taskTitle || '').trim()
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.replace(/[^A-Za-z0-9_-]+/g, '')
|
||||||
|
.replace(/^[_\-.]+|[_\-.]+$/g, '') || `task-${taskId}`;
|
||||||
|
saveBlobToFile(blob, `${workOrderDisplayCode}.SN-${taskTitle}.pdf`);
|
||||||
showToast('PDF servisnih zapisa za odabrani task je preuzet.', 'success');
|
showToast('PDF servisnih zapisa za odabrani task je preuzet.', 'success');
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -912,7 +917,7 @@ export async function downloadWorkOrderDocx(workOrderId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadWorkOrderServiceRecordsDocx(workOrderId, taskId = null) {
|
export async function downloadWorkOrderServiceRecordsDocx(workOrderId, taskId = null, taskOptions = {}) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
throw new Error('Work order ID je obavezan.');
|
throw new Error('Work order ID je obavezan.');
|
||||||
}
|
}
|
||||||
@@ -923,7 +928,12 @@ export async function downloadWorkOrderServiceRecordsDocx(workOrderId, taskId =
|
|||||||
{ responseType: 'blob' },
|
{ responseType: 'blob' },
|
||||||
);
|
);
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
saveBlobToFile(blob, `${workOrderId}.task-${taskId}.work-order-service-records.docx`);
|
const workOrderDisplayCode = String(taskOptions?.workOrderDisplayCode || '').trim().toUpperCase() || String(workOrderId);
|
||||||
|
const taskTitle = String(taskOptions?.taskTitle || '').trim()
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.replace(/[^A-Za-z0-9_-]+/g, '')
|
||||||
|
.replace(/^[_\-.]+|[_\-.]+$/g, '') || `task-${taskId}`;
|
||||||
|
saveBlobToFile(blob, `${workOrderDisplayCode}.SN-${taskTitle}.docx`);
|
||||||
showToast('DOCX servisnih zapisa za odabrani task je preuzet.', 'success');
|
showToast('DOCX servisnih zapisa za odabrani task je preuzet.', 'success');
|
||||||
} else {
|
} else {
|
||||||
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
|
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
|
||||||
@@ -1010,6 +1020,34 @@ export async function downloadMonthlyCostsReport(year, month) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadMonthlyServiceTasksArchive(year, month) {
|
||||||
|
try {
|
||||||
|
const blob = await api.get(
|
||||||
|
`fleet/reports/monthly-service-tasks-archive/?year=${year}&month=${month}`,
|
||||||
|
{ responseType: 'blob' },
|
||||||
|
);
|
||||||
|
saveBlobToFile(blob, `MT-${String(month).padStart(2, '0')}-${year}-SN.zip`);
|
||||||
|
showToast('ZIP pojedinačnih servisnih taskova je preuzet.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err?.message || 'Preuzimanje ZIP-a servisnih taskova nije uspjelo.', 'error');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadMonthlyWorkOrdersArchive(year, month) {
|
||||||
|
try {
|
||||||
|
const blob = await api.get(
|
||||||
|
`fleet/reports/monthly-work-orders-archive/?year=${year}&month=${month}`,
|
||||||
|
{ responseType: 'blob' },
|
||||||
|
);
|
||||||
|
saveBlobToFile(blob, `MT-${String(month).padStart(2, '0')}-${year}-PN.zip`);
|
||||||
|
showToast('ZIP putnih naloga i računa je preuzet.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err?.message || 'Preuzimanje ZIP-a putnih naloga nije uspjelo.', 'error');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') {
|
export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') {
|
||||||
if (!downloadUrl) {
|
if (!downloadUrl) {
|
||||||
showToast('Nedostaje URL za preuzimanje PDF-a.', 'error');
|
showToast('Nedostaje URL za preuzimanje PDF-a.', 'error');
|
||||||
|
|||||||
Reference in New Issue
Block a user