From e8330473cd45cd76d8cd58da574fd4b887bffb71 Mon Sep 17 00:00:00 2001 From: mariomitte Date: Fri, 17 Jul 2026 16:42:02 +0200 Subject: [PATCH] feat: async PDF cache i API media putanje za putne naloge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uvodi background request tok za generiranje PDF-a s 24h cache-om i cleanup taskom preko Celery beat-a. Dodaje notification metadata download_url za ponovno preuzimanje bez regeneracije, API image endpointe za putne naloge i račune, te frontend integraciju za async download i produkcijski prikaz slika. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/core/settings/base.py | 7 + backend/core/settings/production.py | 5 +- backend/core/urls.py | 2 + backend/modules/fleet/admin.py | 10 + .../migrations/0023_generatedworkorderpdf.py | 39 ++ ...lter_workorderphoto_created_at_and_more.py | 35 ++ backend/modules/fleet/models.py | 51 +++ backend/modules/fleet/serializers.py | 14 + backend/modules/fleet/tasks.py | 107 +++++- .../tests/test_work_order_images_endpoint.py | 46 ++- backend/modules/fleet/views.py | 362 ++++++++++++++++-- docker-compose.dev.yml | 16 + docker-compose.prod.yml | 20 + .../dashboard/WorkOrderDetailModal.jsx | 181 +-------- .../dashboard/WorkOrderInvoicesPdfPage.jsx | 7 +- .../notifications/NotificationDetailModal.jsx | 13 +- frontend/src/stores/fleetDashboardStore.js | 42 +- 17 files changed, 726 insertions(+), 231 deletions(-) create mode 100644 backend/modules/fleet/migrations/0023_generatedworkorderpdf.py create mode 100644 backend/modules/fleet/migrations/0024_alter_workorderphoto_created_at_and_more.py diff --git a/backend/core/settings/base.py b/backend/core/settings/base.py index 09ea881..dbe7df4 100644 --- a/backend/core/settings/base.py +++ b/backend/core/settings/base.py @@ -1,5 +1,6 @@ import os from pathlib import Path +from celery.schedules import crontab from datetime import timedelta BASE_DIR = Path(__file__).resolve().parent.parent.parent @@ -169,6 +170,12 @@ CACHES = { CELERY_ACCEPT_CONTENT = ['json'] CELERY_TASK_SERIALIZER = 'json' +CELERY_BEAT_SCHEDULE = { + 'cleanup-expired-generated-pdfs-hourly': { + 'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task', + 'schedule': crontab(minute=0), + }, +} # URL kojim će preglednik dohvaćati statiku STATIC_URL = '/static/' diff --git a/backend/core/settings/production.py b/backend/core/settings/production.py index ee3ad16..c388a93 100644 --- a/backend/core/settings/production.py +++ b/backend/core/settings/production.py @@ -39,4 +39,7 @@ CSRF_TRUSTED_ORIGINS = [ STATIC_URL = '/static/' STATIC_ROOT = '/app/staticfiles' MEDIA_URL = '/media/' -MEDIA_ROOT = '/app/media' \ No newline at end of file +MEDIA_ROOT = '/app/media' + +# Privremeni fallback dok se media ne servira preko reverse proxy-ja. +SERVE_MEDIA_FILES = os.environ.get('SERVE_MEDIA_FILES', '1').strip().lower() in ('1', 'true', 'yes', 'on') \ No newline at end of file diff --git a/backend/core/urls.py b/backend/core/urls.py index 46b0cd9..0fd2475 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -30,4 +30,6 @@ if settings.DEBUG: urlpatterns += [ path('__debug__/', include(debug_toolbar.urls)), ] + +if settings.DEBUG or getattr(settings, 'SERVE_MEDIA_FILES', False): urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/backend/modules/fleet/admin.py b/backend/modules/fleet/admin.py index 7d5b4e1..b3f7773 100644 --- a/backend/modules/fleet/admin.py +++ b/backend/modules/fleet/admin.py @@ -7,6 +7,7 @@ from .models import ( WorkOrder, WorkOrderPhoto, WorkOrderInvoice, + GeneratedWorkOrderPdf, VehicleServiceRecord, VehicleServicePhoto, VehicleServiceAttachment, @@ -194,6 +195,15 @@ class WorkOrderInvoiceAdmin(admin.ModelAdmin): search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id') ordering = ('-datum', '-created_at') + +@admin.register(GeneratedWorkOrderPdf) +class GeneratedWorkOrderPdfAdmin(admin.ModelAdmin): + list_display = ('id', 'work_order', 'pdf_type', 'status', 'requested_by', 'expires_at', 'is_active', 'created_at') + list_filter = ('pdf_type', 'status', 'is_active', 'expires_at') + search_fields = ('work_order__id', 'requested_by__username', 'requested_by__email', 'filename') + readonly_fields = ('created_at', 'updated_at') + ordering = ('-created_at',) + @admin.register(VehicleServiceRecord) class VehicleServiceRecordAdmin(admin.ModelAdmin): list_display = ('id', 'service_title', 'task', 'vehicle', 'performed_by', 'service_date', 'mileage', 'next_service_due_at', 'cost', 'short_description') diff --git a/backend/modules/fleet/migrations/0023_generatedworkorderpdf.py b/backend/modules/fleet/migrations/0023_generatedworkorderpdf.py new file mode 100644 index 0000000..64c92c8 --- /dev/null +++ b/backend/modules/fleet/migrations/0023_generatedworkorderpdf.py @@ -0,0 +1,39 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import modules.fleet.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0022_workorderphoto'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='GeneratedWorkOrderPdf', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')), + ('pdf_type', models.CharField(choices=[('work_order', 'Putni nalog'), ('service_records', 'Servisni zapisi'), ('invoices', 'Računi')], db_index=True, max_length=32, verbose_name='Tip PDF-a')), + ('status', models.CharField(choices=[('pending', 'U obradi'), ('ready', 'Spremno'), ('failed', 'Neuspješno')], db_index=True, default='pending', max_length=16, verbose_name='Status')), + ('file', models.FileField(blank=True, null=True, upload_to=modules.fleet.models._generated_work_order_pdf_upload_to, verbose_name='PDF datoteka')), + ('filename', models.CharField(blank=True, max_length=255, verbose_name='Naziv datoteke')), + ('expires_at', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Vrijedi do')), + ('error_message', models.TextField(blank=True, verbose_name='Poruka greške')), + ('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_generated_work_order_pdfs', to=settings.AUTH_USER_MODEL, verbose_name='Zatražio')), + ('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='generated_pdfs', to='fleet.workorder', verbose_name='Putni nalog')), + ], + options={ + 'verbose_name': 'Generirani PDF putnog naloga', + 'verbose_name_plural': 'Generirani PDF-ovi putnih naloga', + 'ordering': ['-created_at'], + }, + ), + ] + diff --git a/backend/modules/fleet/migrations/0024_alter_workorderphoto_created_at_and_more.py b/backend/modules/fleet/migrations/0024_alter_workorderphoto_created_at_and_more.py new file mode 100644 index 0000000..d0fe602 --- /dev/null +++ b/backend/modules/fleet/migrations/0024_alter_workorderphoto_created_at_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.15 on 2026-07-17 14:32 + +import modules.fleet.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0023_generatedworkorderpdf'), + ] + + operations = [ + migrations.AlterField( + model_name='workorderphoto', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'), + ), + migrations.AlterField( + model_name='workorderphoto', + name='id', + field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='workorderphoto', + name='image', + field=models.ImageField(upload_to=modules.fleet.models._work_order_photo_upload_to, verbose_name='Fotografija putnog naloga'), + ), + migrations.AlterField( + model_name='workorderphoto', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'), + ), + ] diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py index 1541680..920ce0e 100644 --- a/backend/modules/fleet/models.py +++ b/backend/modules/fleet/models.py @@ -54,6 +54,15 @@ def _work_order_photo_upload_to(instance, filename): unique_name = f"{uuid.uuid4().hex}_{filename}" return f"fleet/{vehicle_reg}/work_order_photos/{unique_name}" +def _generated_work_order_pdf_upload_to(instance, filename): + vehicle_reg = 'unknown' + try: + vehicle_reg = instance.work_order.vehicle.registration_number + except Exception: + vehicle_reg = 'unknown' + unique_name = f"{uuid.uuid4().hex}_{filename}" + return f"fleet/{vehicle_reg}/generated_pdfs/{unique_name}" + class VehicleServicePhoto(BaseModel): """ @@ -384,6 +393,48 @@ class WorkOrderPhoto(BaseModel): return f"WorkOrderPhoto {self.pk} ({self.work_order_id})" +class GeneratedWorkOrderPdf(BaseModel): + PDF_TYPE_CHOICES = [ + ('work_order', _("Putni nalog")), + ('service_records', _("Servisni zapisi")), + ('invoices', _("Računi")), + ] + STATUS_CHOICES = [ + ('pending', _("U obradi")), + ('ready', _("Spremno")), + ('failed', _("Neuspješno")), + ] + + work_order = models.ForeignKey( + WorkOrder, + on_delete=models.CASCADE, + related_name='generated_pdfs', + verbose_name=_("Putni nalog"), + ) + requested_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='requested_generated_work_order_pdfs', + verbose_name=_("Zatražio"), + ) + pdf_type = models.CharField(max_length=32, choices=PDF_TYPE_CHOICES, db_index=True, verbose_name=_("Tip PDF-a")) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='pending', db_index=True, verbose_name=_("Status")) + file = models.FileField(upload_to=_generated_work_order_pdf_upload_to, null=True, blank=True, verbose_name=_("PDF datoteka")) + filename = models.CharField(max_length=255, blank=True, verbose_name=_("Naziv datoteke")) + expires_at = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name=_("Vrijedi do")) + error_message = models.TextField(blank=True, verbose_name=_("Poruka greške")) + + class Meta: + verbose_name = _("Generirani PDF putnog naloga") + verbose_name_plural = _("Generirani PDF-ovi putnih naloga") + ordering = ['-created_at'] + + def __str__(self): + return f"{self.pdf_type}:{self.work_order_id}:{self.status}" + + class VehicleServiceRecord(BaseModel): """ Zapisi o servisima/popravcima na vozilu. diff --git a/backend/modules/fleet/serializers.py b/backend/modules/fleet/serializers.py index 8915e11..fc53b90 100644 --- a/backend/modules/fleet/serializers.py +++ b/backend/modules/fleet/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers import re +from django.urls import reverse from .models import ( Crane, Vehicle, @@ -183,6 +184,8 @@ class WorkOrderSerializer(serializers.ModelSerializer): class WorkOrderInvoiceSerializer(serializers.ModelSerializer): + image_url = serializers.SerializerMethodField() + class Meta: model = WorkOrderInvoice fields = [ @@ -193,11 +196,22 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer): 'datum', 'opis', 'image', + 'image_url', 'created_by', 'created_at', ] read_only_fields = ['id', 'created_by', 'created_at'] + def get_image_url(self, obj): + request = self.context.get('request') + if not obj.image: + return None + if request is None: + return None + return request.build_absolute_uri( + reverse('work-order-invoice-image', kwargs={'pk': obj.pk}) + ) + class WorkOrderPhotoSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/modules/fleet/tasks.py b/backend/modules/fleet/tasks.py index ea13755..f4e21c7 100644 --- a/backend/modules/fleet/tasks.py +++ b/backend/modules/fleet/tasks.py @@ -3,6 +3,7 @@ from celery import shared_task from django.core.mail import send_mail from django.conf import settings from django.utils import timezone +from django.core.files.base import ContentFile import logging from io import BytesIO import base64 @@ -10,7 +11,7 @@ from PIL import Image, UnidentifiedImageError from reportlab.lib.pagesizes import A4 from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas -from .models import VehicleNotification +from .models import VehicleNotification, GeneratedWorkOrderPdf from .pdf_layout import register_unicode_fonts, draw_standard_header_footer logger = logging.getLogger(__name__) @@ -205,4 +206,106 @@ def build_work_order_invoices_pdf_task(work_order_id): return { "filename": filename, "pdf_base64": pdf_b64, - } \ No newline at end of file + } + + +@shared_task +def cleanup_expired_generated_pdfs_task(): + now = timezone.now() + expired = GeneratedWorkOrderPdf.objects.filter( + is_active=True, + expires_at__isnull=False, + expires_at__lte=now, + ) + deleted = 0 + for item in expired: + if item.file: + item.file.delete(save=False) + item.is_active = False + item.status = 'failed' + item.error_message = 'PDF cache istekao.' + item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) + deleted += 1 + return {"deleted": deleted} + + +@shared_task +def build_work_order_pdf_cached_task(generated_pdf_id): + from .views import _build_work_order_pdf, _build_work_order_service_records_pdf + from .services import NotificationService + + generated = ( + GeneratedWorkOrderPdf.objects + .select_related('work_order', 'work_order__vehicle', 'requested_by') + .filter(pk=generated_pdf_id, is_active=True) + .first() + ) + if generated is None: + return {"error": "Generated PDF zapis nije pronađen."} + + work_order = generated.work_order + + try: + if generated.pdf_type == 'invoices': + payload = build_work_order_invoices_pdf_task(str(work_order.pk)) + if not isinstance(payload, dict) or payload.get('error'): + raise ValueError(payload.get('error') if isinstance(payload, dict) else 'Neuspješno generiranje PDF računa.') + pdf_b64 = payload.get('pdf_base64') + if not pdf_b64: + raise ValueError('PDF sadržaj nije dostupan.') + pdf_bytes = base64.b64decode(pdf_b64) + elif generated.pdf_type == 'service_records': + pdf_bytes = _build_work_order_service_records_pdf(work_order) + else: + pdf_bytes = _build_work_order_pdf(work_order) + + filename = generated.filename or f"{work_order.pk}.{generated.pdf_type}.pdf" + generated.file.save(filename, ContentFile(pdf_bytes), save=False) + generated.status = 'ready' + generated.error_message = '' + generated.save(update_fields=['file', 'status', 'error_message', 'updated_at']) + + if generated.requested_by: + NotificationService.create_notification( + recipient=generated.requested_by, + title="PDF spreman", + message=f"PDF dokument je uspješno generiran za WO-{work_order.pk}.", + level="success", + send_email=False, + metadata={ + "entity_type": "work_order_pdf", + "work_order_id": str(work_order.pk), + "vehicle_id": str(work_order.vehicle_id), + "section": "work-orders", + "pdf_type": generated.pdf_type, + "stage": "completed", + "generated_pdf_id": str(generated.pk), + "download_url": f"/api/fleet/work-orders/{work_order.pk}/generated-pdfs/{generated.pk}/download/", + "filename": generated.filename or filename, + "expires_at": generated.expires_at.isoformat() if generated.expires_at else None, + }, + ) + return {"status": "ready", "generated_pdf_id": str(generated.pk)} + except Exception as exc: + generated.status = 'failed' + generated.error_message = str(exc) + generated.save(update_fields=['status', 'error_message', 'updated_at']) + if generated.requested_by: + NotificationService.create_notification( + recipient=generated.requested_by, + title="Greška kod PDF-a", + message=f"Generiranje PDF dokumenta nije uspjelo za WO-{work_order.pk}.", + level="warning", + send_email=False, + metadata={ + "entity_type": "work_order_pdf", + "work_order_id": str(work_order.pk), + "vehicle_id": str(work_order.vehicle_id), + "section": "work-orders", + "pdf_type": generated.pdf_type, + "stage": "failed", + "generated_pdf_id": str(generated.pk), + }, + ) + logger.exception("Greška kod build_work_order_pdf_cached_task: %s", exc) + return {"status": "failed", "error": str(exc)} \ No newline at end of file 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 9fadd85..90b9717 100644 --- a/backend/modules/fleet/tests/test_work_order_images_endpoint.py +++ b/backend/modules/fleet/tests/test_work_order_images_endpoint.py @@ -7,7 +7,7 @@ import uuid from io import BytesIO from PIL import Image -from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord, VehicleServicePhoto +from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto def create_test_image(filename='test.jpg', size=(40, 40), color='red'): @@ -94,3 +94,47 @@ class WorkOrderImagesEndpointTests(TestCase): response = self.client.get(service_photos_url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 0) + + def test_work_order_images_endpoint_returns_api_file_urls(self): + upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk}) + create_response = self.client.post( + upload_url, + data={ + 'image': create_test_image('workorder-url.jpg'), + 'description': 'URL test', + }, + format='multipart', + ) + self.assertEqual(create_response.status_code, 201, create_response.data) + + list_response = self.client.get(upload_url) + self.assertEqual(list_response.status_code, 200) + images = list_response.data.get('images', []) + self.assertEqual(len(images), 1) + + photo_id = images[0]['id'] + self.assertIn('/api/fleet/work-orders/', images[0]['original_url']) + self.assertIn(f'/images/{photo_id}/file', images[0]['original_url']) + + file_response = self.client.get(images[0]['original_url']) + self.assertEqual(file_response.status_code, 200) + self.assertTrue(file_response['Content-Type'].startswith('image/')) + + def test_work_order_invoice_serializer_exposes_image_api_url(self): + invoice = WorkOrderInvoice.objects.create( + work_order=self.work_order, + naziv_racuna='Test račun', + image=create_test_image('invoice.jpg'), + created_by=self.user, + ) + list_url = f"{reverse('work-order-invoice-list')}?work_order_id={self.work_order.pk}" + list_response = self.client.get(list_url) + self.assertEqual(list_response.status_code, 200) + self.assertEqual(len(list_response.data), 1) + image_url = list_response.data[0].get('image_url') + self.assertIsNotNone(image_url) + self.assertIn(f'/api/fleet/work-order-invoices/{invoice.pk}/image/', image_url) + + image_response = self.client.get(image_url) + self.assertEqual(image_response.status_code, 200) + self.assertTrue(image_response['Content-Type'].startswith('image/')) diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index ffe974c..d81c817 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -2,11 +2,12 @@ from io import BytesIO import base64 +from datetime import timedelta from PIL import Image, UnidentifiedImageError from django.conf import settings from django.core.mail import EmailMessage -from django.http import HttpResponse +from django.http import HttpResponse, FileResponse from django.urls import reverse from django.utils import timezone from django.utils.html import escape @@ -31,6 +32,7 @@ from .models import ( VehicleNotification, WorkOrder, WorkOrderPhoto, + GeneratedWorkOrderPdf, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, @@ -53,7 +55,11 @@ from .services import ( VehicleServicePhotoService, NotificationService, ) -from .tasks import build_work_order_invoices_pdf_task +from .tasks import ( + build_work_order_invoices_pdf_task, + build_work_order_pdf_cached_task, + cleanup_expired_generated_pdfs_task, +) register_unicode_fonts() @@ -132,6 +138,165 @@ def _parse_format(value): extension = 'jpg' if selected in ('jpeg', 'jpg') else selected return supported[selected], extension + +GENERATED_PDF_TTL_HOURS = 24 + + +def _pdf_filename(work_order, pdf_type): + if pdf_type == 'invoices': + return f"{work_order.pk}.work-order-invoices.pdf" + if pdf_type == 'service_records': + return f"{work_order.pk}.work-order-service-records.pdf" + return f"{work_order.pk}.work-order.pdf" + + +def _get_cached_pdf(work_order, pdf_type): + now = timezone.now() + return ( + GeneratedWorkOrderPdf.objects + .filter( + is_active=True, + work_order=work_order, + pdf_type=pdf_type, + status='ready', + expires_at__gt=now, + ) + .exclude(file='') + .exclude(file__isnull=True) + .order_by('-created_at') + .first() + ) + + +def _cleanup_expired_generated_pdfs(): + now = timezone.now() + expired = GeneratedWorkOrderPdf.objects.filter( + is_active=True, + expires_at__isnull=False, + expires_at__lte=now, + ) + for item in expired: + if item.file: + item.file.delete(save=False) + item.is_active = False + item.status = 'failed' + item.error_message = 'PDF cache istekao.' + item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) + + +def _cached_pdf_file_response(generated_pdf, *, default_filename): + generated_pdf.file.open('rb') + filename = generated_pdf.filename or default_filename + response = FileResponse(generated_pdf.file, content_type='application/pdf') + response['Content-Disposition'] = f'attachment; filename="{filename}"' + response['Cache-Control'] = 'private, max-age=3600' + return response + + +def _notify_pdf_request(*, user, work_order, doc_type, stage, generated_pdf=None): + if user is None: + return + if doc_type == 'work_order': + doc_label = "putnog naloga" + elif doc_type == 'service_records': + doc_label = "servisnih zapisa putnog naloga" + else: + doc_label = "računa putnog naloga" + + if stage == 'requested': + title = f"Zahtjev za PDF {doc_label}" + message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za WO-{work_order.pk}." + level = "info" + elif stage == 'failed': + title = f"Greška kod PDF-a ({doc_label})" + message = f"Generiranje PDF dokumenta nije uspjelo za WO-{work_order.pk}." + level = "warning" + else: + title = f"PDF spreman ({doc_label})" + message = f"PDF dokument je uspješno generiran za WO-{work_order.pk}." + level = "success" + + metadata = { + "entity_type": "work_order_pdf", + "work_order_id": str(work_order.pk), + "vehicle_id": str(work_order.vehicle_id), + "section": "work-orders", + "pdf_type": doc_type, + "stage": stage, + } + if generated_pdf and generated_pdf.pk: + metadata["generated_pdf_id"] = str(generated_pdf.pk) + metadata["download_url"] = ( + f"/api/fleet/work-orders/{work_order.pk}/generated-pdfs/{generated_pdf.pk}/download/" + ) + metadata["filename"] = generated_pdf.filename or _pdf_filename(work_order, doc_type) + if generated_pdf.expires_at: + metadata["expires_at"] = generated_pdf.expires_at.isoformat() + + NotificationService.create_notification( + recipient=user, + title=title, + message=message, + level=level, + send_email=False, + metadata=metadata, + ) + + +def _request_cached_pdf_generation(*, request, work_order, pdf_type): + _cleanup_expired_generated_pdfs() + cached = _get_cached_pdf(work_order, pdf_type) + if cached: + _notify_pdf_request( + user=request.user, + work_order=work_order, + doc_type=pdf_type, + stage='completed', + generated_pdf=cached, + ) + return { + "status": "ready", + "generated_pdf_id": str(cached.pk), + "download_url": f"/api/fleet/work-orders/{work_order.pk}/generated-pdfs/{cached.pk}/download/", + "filename": cached.filename or _pdf_filename(work_order, pdf_type), + "expires_at": cached.expires_at.isoformat() if cached.expires_at else None, + } + + existing_pending = GeneratedWorkOrderPdf.objects.filter( + is_active=True, + work_order=work_order, + pdf_type=pdf_type, + status='pending', + ).order_by('-created_at').first() + + if existing_pending: + return { + "status": "processing", + "generated_pdf_id": str(existing_pending.pk), + } + + generated_pdf = GeneratedWorkOrderPdf.objects.create( + work_order=work_order, + requested_by=request.user, + pdf_type=pdf_type, + status='pending', + filename=_pdf_filename(work_order, pdf_type), + expires_at=timezone.now() + timedelta(hours=GENERATED_PDF_TTL_HOURS), + ) + _notify_pdf_request(user=request.user, work_order=work_order, doc_type=pdf_type, stage='requested') + + try: + build_work_order_pdf_cached_task.delay(str(generated_pdf.pk)) + cleanup_expired_generated_pdfs_task.delay() + except KombuOperationalError: + build_work_order_pdf_cached_task.apply(args=[str(generated_pdf.pk)]).get() + cleanup_expired_generated_pdfs_task.apply().get() + + return { + "status": "processing", + "generated_pdf_id": str(generated_pdf.pk), + } + def _build_simple_pdf(title, rows): buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) @@ -1160,41 +1325,6 @@ def _send_document_email(*, recipient, subject, body, filename, pdf_bytes): message.send(fail_silently=False) -def _notify_pdf_request(*, user, work_order, doc_type, stage): - if user is None: - return - if doc_type == 'work_order': - doc_label = "putnog naloga" - elif doc_type == 'service_records': - doc_label = "servisnih zapisa putnog naloga" - else: - doc_label = "računa putnog naloga" - - if stage == 'requested': - title = f"Zahtjev za PDF {doc_label}" - message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za WO-{work_order.pk}." - level = "info" - else: - title = f"PDF spreman ({doc_label})" - message = f"PDF dokument je uspješno generiran za WO-{work_order.pk}." - level = "success" - - NotificationService.create_notification( - recipient=user, - title=title, - message=message, - level=level, - send_email=False, - metadata={ - "entity_type": "work_order_pdf", - "work_order_id": str(work_order.pk), - "vehicle_id": str(work_order.vehicle_id), - "section": "work-orders", - "pdf_type": doc_type, - "stage": stage, - }, - ) - @api_view(['POST']) @permission_classes([permissions.IsAuthenticated]) def pusher_auth(request): @@ -1273,13 +1403,15 @@ class WorkOrderViewSet(viewsets.ModelViewSet): images = [] for photo in photos_qs: - original_url = request.build_absolute_uri(photo.image.url) if photo.image else None + file_path = reverse('workorder-image-file', kwargs={'pk': work_order.pk, 'photo_id': photo.pk}) + original_url = request.build_absolute_uri(file_path) + optimized_url = request.build_absolute_uri(f"{file_path}?w={width}&q={quality}&fmt={image_format.lower()}") images.append({ "id": photo.pk, "work_order_id": photo.work_order_id, "description": photo.description, "original_url": original_url, - "optimized_url": original_url, + "optimized_url": optimized_url, "created_at": photo.created_at, }) @@ -1292,9 +1424,65 @@ class WorkOrderViewSet(viewsets.ModelViewSet): "images": images, }, status=status.HTTP_200_OK) + @action(detail=True, methods=['get'], url_path=r'images/(?P[^/.]+)/file') + def image_file(self, request, pk=None, photo_id=None): + work_order = self.get_object() + photo = WorkOrderPhoto.objects.filter( + is_active=True, + work_order_id=work_order.pk, + pk=photo_id, + ).first() + if photo is None or not photo.image: + raise DRFValidationError({"detail": "Fotografija putnog naloga nije dostupna."}) + + width = _parse_positive_int( + request.query_params.get('w'), + field_name='w', + default=1280, + min_value=160, + max_value=3840, + ) + quality = _parse_positive_int( + request.query_params.get('q'), + field_name='q', + default=75, + min_value=30, + max_value=95, + ) + image_format, extension = _parse_format(request.query_params.get('fmt')) + + try: + photo.image.open('rb') + with Image.open(photo.image) as source: + image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy() + if width and image.width > width: + ratio = width / float(image.width) + target_height = max(1, int(image.height * ratio)) + image = image.resize((width, target_height), Image.Resampling.LANCZOS) + + buffer = BytesIO() + save_kwargs = {"format": image_format, "optimize": True} + if image_format in ('WEBP', 'JPEG'): + save_kwargs["quality"] = quality + image.save(buffer, **save_kwargs) + except (UnidentifiedImageError, OSError): + raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."}) + finally: + photo.image.close() + + response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}") + response['Cache-Control'] = 'private, max-age=86400' + response['Content-Disposition'] = f'inline; filename="work-order-photo-{photo.pk}.{extension}"' + return response + @action(detail=True, methods=['get'], url_path='pdf') def pdf(self, request, pk=None): work_order = self.get_object() + _cleanup_expired_generated_pdfs() + cached = _get_cached_pdf(work_order, 'work_order') + if cached: + return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'work_order')) + _notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='requested') pdf_bytes = _build_work_order_pdf(work_order) _notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='completed') @@ -1302,9 +1490,20 @@ class WorkOrderViewSet(viewsets.ModelViewSet): response['Content-Disposition'] = f'attachment; filename="{work_order.pk}.work-order.pdf"' return response + @action(detail=True, methods=['post'], url_path='pdf-request') + def pdf_request(self, request, pk=None): + work_order = self.get_object() + payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='work_order') + return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED) + @action(detail=True, methods=['get'], url_path='service-records-pdf') def service_records_pdf(self, request, pk=None): work_order = self.get_object() + _cleanup_expired_generated_pdfs() + cached = _get_cached_pdf(work_order, 'service_records') + if cached: + return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'service_records')) + _notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='requested') pdf_bytes = _build_work_order_service_records_pdf(work_order) _notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed') @@ -1312,9 +1511,20 @@ class WorkOrderViewSet(viewsets.ModelViewSet): response['Content-Disposition'] = f'attachment; filename="{work_order.pk}.work-order-service-records.pdf"' 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() + payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='service_records') + return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED) + @action(detail=True, methods=['get'], url_path='invoices-pdf') def invoices_pdf(self, request, pk=None): work_order = self.get_object() + _cleanup_expired_generated_pdfs() + cached = _get_cached_pdf(work_order, 'invoices') + if cached: + return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'invoices')) + _notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='requested') try: task_result = build_work_order_invoices_pdf_task.delay(str(work_order.pk)) @@ -1341,13 +1551,34 @@ class WorkOrderViewSet(viewsets.ModelViewSet): response['Content-Disposition'] = f'attachment; filename="{filename}"' 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() + payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='invoices') + return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED) + + @action(detail=True, methods=['get'], url_path=r'generated-pdfs/(?P[^/.]+)/download') + def generated_pdf_download(self, request, pk=None, pdf_id=None): + work_order = self.get_object() + _cleanup_expired_generated_pdfs() + generated_pdf = GeneratedWorkOrderPdf.objects.filter( + is_active=True, + work_order=work_order, + pk=pdf_id, + status='ready', + expires_at__gt=timezone.now(), + ).exclude(file='').exclude(file__isnull=True).first() + if generated_pdf is None: + raise DRFValidationError({"detail": "PDF nije dostupan ili je istekao."}) + return _cached_pdf_file_response(generated_pdf, default_filename=_pdf_filename(work_order, generated_pdf.pdf_type)) + @action(detail=True, methods=['get'], url_path='pdf-preview') def pdf_preview(self, request, pk=None): work_order = self.get_object() invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at') invoice_rows = [] for invoice in invoices: - image_url = request.build_absolute_uri(invoice.image.url) if invoice.image else None + image_url = request.build_absolute_uri(reverse('work-order-invoice-image', kwargs={'pk': invoice.pk})) if invoice.image else None datum = invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-' invoice_rows.append( f""" @@ -1535,6 +1766,57 @@ class WorkOrderInvoiceViewSet(viewsets.ModelViewSet): qs = qs.filter(work_order_id=work_order_id) return qs + def get_serializer_context(self): + context = super().get_serializer_context() + context['request'] = self.request + return context + + @action(detail=True, methods=['get'], url_path='image') + def image(self, request, pk=None): + invoice = self.get_object() + if not invoice.image: + raise DRFValidationError({"detail": "Slika računa nije dostupna."}) + + width = _parse_positive_int( + request.query_params.get('w'), + field_name='w', + default=1280, + min_value=160, + max_value=3840, + ) + quality = _parse_positive_int( + request.query_params.get('q'), + field_name='q', + default=75, + min_value=30, + max_value=95, + ) + image_format, extension = _parse_format(request.query_params.get('fmt')) + + try: + invoice.image.open('rb') + with Image.open(invoice.image) as source: + image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy() + if width and image.width > width: + ratio = width / float(image.width) + target_height = max(1, int(image.height * ratio)) + image = image.resize((width, target_height), Image.Resampling.LANCZOS) + + buffer = BytesIO() + save_kwargs = {"format": image_format, "optimize": True} + if image_format in ('WEBP', 'JPEG'): + save_kwargs["quality"] = quality + image.save(buffer, **save_kwargs) + except (UnidentifiedImageError, OSError): + raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."}) + finally: + invoice.image.close() + + response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}") + response['Cache-Control'] = 'private, max-age=86400' + response['Content-Disposition'] = f'inline; filename="invoice-{invoice.pk}.{extension}"' + return response + def perform_create(self, serializer): work_order = serializer.validated_data.get('work_order') if work_order is None: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index aaba597..6998829 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -50,6 +50,22 @@ services: - redis - worker + beat: + container_name: 004erpbeat + build: + context: ./backend + dockerfile: Dockerfile + command: celery -A core beat --loglevel=info + user: "1000:1000" + volumes: + - ./backend:/app + - media_volume:/app/media + env_file: + - .env + depends_on: + - redis + - worker + volumes: media_volume: external: true # Ako je definiran u glavnom file-u, ovo ga povezuje diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 56f9fbd..03e110e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -46,6 +46,26 @@ services: redis: condition: service_started + beat: + build: + context: ./backend + dockerfile: Dockerfile + container_name: 004erpbeat + restart: unless-stopped + env_file: + - .env + environment: + DEBUG: "False" + DJANGO_SETTINGS_MODULE: core.settings.production + command: celery -A core beat --loglevel=info + volumes: + - media_volume:/app/media + depends_on: + redis: + condition: service_started + worker: + condition: service_started + flower: build: context: ./backend diff --git a/frontend/src/components/dashboard/WorkOrderDetailModal.jsx b/frontend/src/components/dashboard/WorkOrderDetailModal.jsx index 9e6e80f..99905c7 100644 --- a/frontend/src/components/dashboard/WorkOrderDetailModal.jsx +++ b/frontend/src/components/dashboard/WorkOrderDetailModal.jsx @@ -1,20 +1,16 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; -import { useStore } from '@nanostores/preact'; import WorkOrderImageCarousel from './WorkOrderImageCarousel'; import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload'; import ModalShell from '../ui/ModalShell'; import { createWorkOrderInvoice, - fetchVehicleById, fetchTeamMembers, - updateVehicle, fetchWorkOrderInvoices, fetchWorkOrderImages, openWorkOrderInvoicesPdfPage, sendWorkOrderEmail, } from '../../stores/fleetDashboardStore'; import { showToast } from '../../stores/toastStore'; -import { $craneSerialByVehicle, cacheCraneSerial, getCachedCraneSerial } from '../../stores/craneSerialStore'; import { formatEntityCode } from '../../lib/displayIds'; const PURPOSE_OPTIONS = [ @@ -24,21 +20,22 @@ const PURPOSE_OPTIONS = [ ]; export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmit }) { - const serialByVehicle = useStore($craneSerialByVehicle); const [isEditing, setIsEditing] = useState(false); const [form, setForm] = useState({ has_travel_order: false, status: 'open', start_mileage: '', end_mileage: '', + servicer_vehicle_registration: '', + servicer_vehicle_make_model: '', + servicer_vehicle_start_mileage: '', + servicer_vehicle_end_mileage: '', + servicer_vehicle_fuel_cost: '', location: '', travel_start_at: '', travel_end_at: '', purpose: '', notes: '', - crane_current_mileage: '', - crane_superstructure_working_hours: '', - crane_chassis_working_hours: '', }); const [imageFiles, setImageFiles] = useState([]); const [submitting, setSubmitting] = useState(false); @@ -65,7 +62,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi opis: '', imageFile: null, }); - const [resolvedCraneSerialNumber, setResolvedCraneSerialNumber] = useState(''); const [mapPickerOpen, setMapPickerOpen] = useState(false); const [locationQuery, setLocationQuery] = useState(''); const [locationResults, setLocationResults] = useState([]); @@ -73,15 +69,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi const [searchingLocation, setSearchingLocation] = useState(false); const [mapError, setMapError] = useState(''); - const targetCraneId = useMemo( - () => workOrder?.crane || workOrder?.vehicle || null, - [workOrder?.crane, workOrder?.vehicle] - ); - const cachedCraneSerial = useMemo( - () => getCachedCraneSerial(targetCraneId), - [serialByVehicle, targetCraneId] - ); - const craneLabel = useMemo(() => { if (!workOrder) return '-'; return workOrder.craneLabel || workOrder.craneInfo?.display_name || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`; @@ -103,9 +90,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi useEffect(() => { if (!open || !workOrder) return; setIsEditing(false); - const initialSerial = String(workOrder.craneInfo?.crane_serial_number || cachedCraneSerial || '').trim(); - setResolvedCraneSerialNumber(initialSerial); - cacheCraneSerial(targetCraneId, initialSerial); setForm({ has_travel_order: !!(workOrder.travel_start_at || workOrder.travel_end_at || workOrder.location || workOrder.start_mileage != null || workOrder.end_mileage != null), status: workOrder.status || 'open', @@ -121,9 +105,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi servicer_vehicle_start_mileage: workOrder.servicer_vehicle_start_mileage == null ? '' : String(workOrder.servicer_vehicle_start_mileage), servicer_vehicle_end_mileage: workOrder.servicer_vehicle_end_mileage == null ? '' : String(workOrder.servicer_vehicle_end_mileage), servicer_vehicle_fuel_cost: workOrder.servicer_vehicle_fuel_cost == null ? '' : String(workOrder.servicer_vehicle_fuel_cost), - crane_current_mileage: String(workOrder.craneInfo?.current_mileage ?? ''), - crane_superstructure_working_hours: String(workOrder.craneInfo?.superstructure_working_hours ?? ''), - crane_chassis_working_hours: String(workOrder.craneInfo?.chassis_working_hours ?? ''), }); setSubmitting(false); setError(''); @@ -155,38 +136,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi setSelectedLocation(null); setSearchingLocation(false); setMapError(''); - }, [open, workOrder, cachedCraneSerial, targetCraneId]); - - useEffect(() => { - if (!open || !targetCraneId) { - return; - } - let cancelled = false; - (async () => { - try { - const freshCrane = await fetchVehicleById(targetCraneId); - if (cancelled || !freshCrane) { - return; - } - const incomingSerial = String(freshCrane.crane_serial_number || '').trim(); - if (incomingSerial) { - cacheCraneSerial(targetCraneId, incomingSerial); - setResolvedCraneSerialNumber(incomingSerial); - } - setForm((prev) => ({ - ...prev, - crane_current_mileage: String(freshCrane.current_mileage ?? prev.crane_current_mileage ?? ''), - crane_superstructure_working_hours: String(freshCrane.superstructure_working_hours ?? prev.crane_superstructure_working_hours ?? ''), - crane_chassis_working_hours: String(freshCrane.chassis_working_hours ?? prev.crane_chassis_working_hours ?? ''), - })); - } catch { - // no-op: fallback remains workOrder.craneInfo values - } - })(); - return () => { - cancelled = true; - }; - }, [open, workOrder?.id, targetCraneId]); + }, [open, workOrder]); useEffect(() => { if (!emailModalOpen) return; @@ -318,13 +268,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi const endMileage = form.end_mileage === '' ? null : Number(form.end_mileage); let travelStart = null; let travelEnd = null; - const craneCurrentMileage = form.crane_current_mileage === '' ? null : Number(form.crane_current_mileage); - const craneSuperstructureHours = form.crane_superstructure_working_hours === '' ? null : Number(form.crane_superstructure_working_hours); - const craneChassisHours = form.crane_chassis_working_hours === '' ? null : Number(form.crane_chassis_working_hours); - const existingCraneCurrentMileage = Number(workOrder?.craneInfo?.current_mileage ?? 0); - const existingCraneSuperstructureHours = Number(workOrder?.craneInfo?.superstructure_working_hours ?? 0); - const existingCraneChassisHours = Number(workOrder?.craneInfo?.chassis_working_hours ?? 0); - if (startMileage != null && (Number.isNaN(startMileage) || startMileage < 0)) { setError('Početna kilometraža mora biti valjan nenegativan broj.'); return; @@ -341,18 +284,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi setError('Završna kilometraža mora biti veća ili jednaka početnoj.'); return; } - if (craneCurrentMileage != null && (Number.isNaN(craneCurrentMileage) || craneCurrentMileage < 0)) { - setError('Trenutna km dizalice mora biti valjan nenegativan broj.'); - return; - } - if (craneSuperstructureHours != null && (Number.isNaN(craneSuperstructureHours) || craneSuperstructureHours < 0)) { - setError('Radni sati nadogradnje moraju biti valjan nenegativan broj.'); - return; - } - if (craneChassisHours != null && (Number.isNaN(craneChassisHours) || craneChassisHours < 0)) { - setError('Radni sati podvozja moraju biti valjan nenegativan broj.'); - return; - } if ((form.travel_start_at && !form.travel_end_at) || (!form.travel_start_at && form.travel_end_at)) { setError('Unesite i početak i kraj puta, ili ostavite oba prazna.'); return; @@ -380,43 +311,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi setSubmitting(true); try { - const hasCraneChanges = ( - craneCurrentMileage != null - && craneSuperstructureHours != null - && craneChassisHours != null - && ( - craneCurrentMileage !== existingCraneCurrentMileage - || craneSuperstructureHours !== existingCraneSuperstructureHours - || craneChassisHours !== existingCraneChassisHours - ) - ); - - if (hasCraneChanges && targetCraneId) { - let craneSerialNumber = String(resolvedCraneSerialNumber || '').trim(); - if (!craneSerialNumber) { - craneSerialNumber = cachedCraneSerial; - } - if (!craneSerialNumber) { - const freshCrane = await fetchVehicleById(targetCraneId); - craneSerialNumber = String(freshCrane?.crane_serial_number || '').trim(); - if (craneSerialNumber) { - cacheCraneSerial(targetCraneId, craneSerialNumber); - setResolvedCraneSerialNumber(craneSerialNumber); - } - } - if (!craneSerialNumber) { - // nastavi sa spremanjem sati i kilometraže za legacy dizalice bez SN - } - const cranePatch = { - current_mileage: craneCurrentMileage, - superstructure_working_hours: craneSuperstructureHours, - chassis_working_hours: craneChassisHours, - }; - if (craneSerialNumber) { - cranePatch.crane_serial_number = craneSerialNumber; - } - await updateVehicle(targetCraneId, cranePatch); - } await onSubmit(workOrder, { status: form.status, start_mileage: startMileage, @@ -536,7 +430,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
-

Uredi nalog {formatEntityCode('PN', workOrder.id)}

+

Uredi putni nalog {formatEntityCode('PN', workOrder.id)}

Dizalica: {craneLabel}

-
-

Podaci dizalice

-
- - - - - -
-
-

Putni podaci

diff --git a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx index fe77bf2..67619be 100644 --- a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx +++ b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx @@ -143,8 +143,9 @@ export default function WorkOrderInvoicesPdfPage() { } if (Array.isArray(invoices)) { invoices.forEach((invoice) => { - if (invoice?.image) { - paths.push(invoice.image); + const invoiceImage = invoice?.image_url || invoice?.image; + if (invoiceImage) { + paths.push(invoiceImage); } }); } @@ -304,7 +305,7 @@ export default function WorkOrderInvoicesPdfPage() {
opis: {invoice.opis || '-'}
diff --git a/frontend/src/components/notifications/NotificationDetailModal.jsx b/frontend/src/components/notifications/NotificationDetailModal.jsx index 38e4120..3dedec8 100644 --- a/frontend/src/components/notifications/NotificationDetailModal.jsx +++ b/frontend/src/components/notifications/NotificationDetailModal.jsx @@ -1,6 +1,11 @@ import { useMemo } from 'preact/hooks'; import ModalShell from '../ui/ModalShell'; -import { downloadWorkOrderInvoicesPdf, downloadWorkOrderPdf, downloadWorkOrderServiceRecordsPdf } from '../../stores/fleetDashboardStore'; +import { + downloadGeneratedPdfByUrl, + downloadWorkOrderInvoicesPdf, + downloadWorkOrderPdf, + downloadWorkOrderServiceRecordsPdf, +} from '../../stores/fleetDashboardStore'; function formatTimestamp(value) { if (!value) return ''; @@ -57,6 +62,12 @@ function getEntityAction(notification, meta) { ? 'Preuzmi PDF servisnih zapisa' : 'Preuzmi PDF putnog naloga', run() { + if (metadata.download_url) { + return downloadGeneratedPdfByUrl( + metadata.download_url, + metadata.filename || `${metadata.work_order_id}.${metadata.pdf_type || 'work-order'}.pdf` + ); + } if (metadata.pdf_type === 'invoices') { return downloadWorkOrderInvoicesPdf(metadata.work_order_id); } diff --git a/frontend/src/stores/fleetDashboardStore.js b/frontend/src/stores/fleetDashboardStore.js index 75c3aa3..2786db5 100644 --- a/frontend/src/stores/fleetDashboardStore.js +++ b/frontend/src/stores/fleetDashboardStore.js @@ -742,9 +742,14 @@ export async function downloadWorkOrderPdf(workOrderId) { throw new Error('Work order ID je obavezan.'); } showToast('Kreiran je zahtjev za PDF putnog naloga.', 'info'); - const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/pdf/`, { responseType: 'blob' }); - saveBlobToFile(blob, `${workOrderId}.work-order.pdf`); - showToast('PDF putnog naloga je preuzet.', 'success'); + const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/pdf-request/`, {}); + if (payload?.status === 'ready' && payload?.download_url) { + await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order.pdf`); + showToast('PDF putnog naloga je preuzet iz cache-a.', 'success'); + return payload; + } + showToast('PDF putnog naloga se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info'); + return payload; } export async function downloadWorkOrderInvoicesPdf(workOrderId) { @@ -752,9 +757,14 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) { throw new Error('Work order ID je obavezan.'); } showToast('Kreiran je zahtjev za PDF računa putnog naloga.', 'info'); - const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-pdf/`, { responseType: 'blob' }); - saveBlobToFile(blob, `${workOrderId}.work-order-invoices.pdf`); - showToast('PDF računa putnog naloga je preuzet.', 'success'); + const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-pdf-request/`, {}); + if (payload?.status === 'ready' && payload?.download_url) { + await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order-invoices.pdf`); + showToast('PDF računa putnog naloga je preuzet iz cache-a.', 'success'); + return payload; + } + showToast('PDF računa se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info'); + return payload; } export async function downloadWorkOrderServiceRecordsPdf(workOrderId) { @@ -762,9 +772,23 @@ export async function downloadWorkOrderServiceRecordsPdf(workOrderId) { throw new Error('Work order ID je obavezan.'); } showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info'); - const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/`, { responseType: 'blob' }); - saveBlobToFile(blob, `${workOrderId}.work-order-service-records.pdf`); - showToast('PDF servisnih zapisa putnog naloga je preuzet.', 'success'); + const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {}); + if (payload?.status === 'ready' && payload?.download_url) { + await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order-service-records.pdf`); + showToast('PDF servisnih zapisa je preuzet iz cache-a.', 'success'); + return payload; + } + showToast('PDF servisnih zapisa se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info'); + return payload; +} + +export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') { + if (!downloadUrl) { + throw new Error('Nedostaje URL za preuzimanje PDF-a.'); + } + const blob = await api.get(downloadUrl, { responseType: 'blob' }); + saveBlobToFile(blob, filename); + return true; } export function openWorkOrderInvoicesPdfPage(workOrderId) {