From d35eaeccf45c326bf67ac3c4e5b421c6ac1c579a Mon Sep 17 00:00:00 2001 From: mariomitte Date: Sun, 19 Jul 2026 19:54:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20dodaj=20ishodi=C5=A1te=20i=20PDF=20ra?= =?UTF-8?q?=C4=8Dune=20za=20putni=20nalog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dodano je origin_location polje kroz backend model, serializer, admin i UI modal za kreiranje/uređivanje putnog naloga (default: Zagreb). Računi putnog naloga sada prihvaćaju slike i PDF datoteke, uz backend validaciju formata, podršku za serving PDF-a i prilagodbu preview prikaza. Za greške uploada računa dodan je error toast kako bi poruke validacije bile vidljive korisniku odmah u sučelju. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/modules/fleet/admin.py | 4 +-- .../0027_workorder_origin_location.py | 16 ++++++++++ .../0028_alter_workorderinvoice_image.py | 18 +++++++++++ backend/modules/fleet/models.py | 5 +-- backend/modules/fleet/serializers.py | 31 ++++++++++++++++++- backend/modules/fleet/tasks.py | 6 ++++ .../tests/test_work_order_images_endpoint.py | 29 +++++++++++++++++ backend/modules/fleet/views.py | 17 ++++++++-- .../dashboard/WorkOrderDetailModal.jsx | 20 ++++++++++-- .../dashboard/WorkOrderInvoicesPdfPage.jsx | 17 +++++++++- .../components/dashboard/WorkOrderModal.jsx | 15 +++++++++ frontend/src/stores/fleetDashboardStore.js | 11 +++++-- 12 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 backend/modules/fleet/migrations/0027_workorder_origin_location.py create mode 100644 backend/modules/fleet/migrations/0028_alter_workorderinvoice_image.py diff --git a/backend/modules/fleet/admin.py b/backend/modules/fleet/admin.py index 7dfa64d..003365b 100644 --- a/backend/modules/fleet/admin.py +++ b/backend/modules/fleet/admin.py @@ -44,7 +44,7 @@ class ServiceAttachmentInline(admin.TabularInline): class WorkOrderInline(admin.TabularInline): model = WorkOrder extra = 0 - fields = ('display_code', 'creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status') + fields = ('display_code', 'creator', 'date', 'start_mileage', 'end_mileage', 'origin_location', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status') readonly_fields = ('date',) @@ -176,7 +176,7 @@ class CraneAdmin(VehicleAdmin): @admin.register(WorkOrder) class WorkOrderAdmin(admin.ModelAdmin): - list_display = ('display_code', 'id', 'vehicle', 'creator', 'date', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status') + list_display = ('display_code', 'id', 'vehicle', 'creator', 'date', 'origin_location', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status') list_filter = ('status', 'date', 'vehicle') search_fields = ('display_code', 'vehicle__registration_number', 'creator__username', 'purpose') readonly_fields = ('date',) diff --git a/backend/modules/fleet/migrations/0027_workorder_origin_location.py b/backend/modules/fleet/migrations/0027_workorder_origin_location.py new file mode 100644 index 0000000..64eb579 --- /dev/null +++ b/backend/modules/fleet/migrations/0027_workorder_origin_location.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0026_backfill_workorder_display_code'), + ] + + operations = [ + migrations.AddField( + model_name='workorder', + name='origin_location', + field=models.CharField(blank=True, max_length=255, verbose_name='Lokacija ishodišta'), + ), + ] diff --git a/backend/modules/fleet/migrations/0028_alter_workorderinvoice_image.py b/backend/modules/fleet/migrations/0028_alter_workorderinvoice_image.py new file mode 100644 index 0000000..58d4d7e --- /dev/null +++ b/backend/modules/fleet/migrations/0028_alter_workorderinvoice_image.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + +import modules.fleet.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0027_workorder_origin_location'), + ] + + operations = [ + migrations.AlterField( + model_name='workorderinvoice', + name='image', + field=models.FileField(blank=True, null=True, upload_to=modules.fleet.models._work_order_invoice_upload_to, verbose_name='Datoteka računa'), + ), + ] diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py index fa5755e..9b1dbb1 100644 --- a/backend/modules/fleet/models.py +++ b/backend/modules/fleet/models.py @@ -285,6 +285,7 @@ class WorkOrder(BaseModel): blank=True, verbose_name=_("Vozilo servisera - trošak goriva (EUR)"), ) + origin_location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija ishodišta")) location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija odredišta")) travel_start_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Početak puta")) travel_end_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Kraj puta")) @@ -351,11 +352,11 @@ class WorkOrderInvoice(BaseModel): lokacija = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija")) datum = models.DateField(default=timezone.now, verbose_name=_("Datum")) opis = models.TextField(blank=True, verbose_name=_("Opis")) - image = models.ImageField( + image = models.FileField( upload_to=_work_order_invoice_upload_to, null=True, blank=True, - verbose_name=_("Slika računa"), + verbose_name=_("Datoteka računa"), ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, diff --git a/backend/modules/fleet/serializers.py b/backend/modules/fleet/serializers.py index a6dc986..14bf8c4 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 pathlib import Path from django.urls import reverse from django.utils import timezone from .models import ( @@ -104,7 +105,7 @@ class WorkOrderSerializer(serializers.ModelSerializer): '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', + 'origin_location', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'notes', 'status' ] read_only_fields = ['id', 'date'] @@ -119,6 +120,7 @@ class WorkOrderSerializer(serializers.ModelSerializer): 'servicer_vehicle_start_mileage': {'required': False, 'allow_null': True}, 'servicer_vehicle_end_mileage': {'required': False, 'allow_null': True}, 'servicer_vehicle_fuel_cost': {'required': False, 'allow_null': True}, + 'origin_location': {'required': False, 'allow_blank': True}, 'location': {'required': False, 'allow_blank': True}, 'travel_start_at': {'required': False, 'allow_null': True}, 'travel_end_at': {'required': False, 'allow_null': True}, @@ -211,6 +213,7 @@ class WorkOrderSerializer(serializers.ModelSerializer): class WorkOrderInvoiceSerializer(serializers.ModelSerializer): image_url = serializers.SerializerMethodField() + image_content_type = serializers.SerializerMethodField() class Meta: model = WorkOrderInvoice @@ -223,6 +226,7 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer): 'opis', 'image', 'image_url', + 'image_content_type', 'created_by', 'created_at', ] @@ -238,6 +242,31 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer): reverse('work-order-invoice-image', kwargs={'pk': obj.pk}) ) + def get_image_content_type(self, obj): + if not obj.image: + return None + suffix = Path(obj.image.name or '').suffix.lower() + if suffix == '.pdf': + return 'application/pdf' + if suffix in {'.jpg', '.jpeg'}: + return 'image/jpeg' + if suffix == '.png': + return 'image/png' + if suffix == '.webp': + return 'image/webp' + if suffix == '.gif': + return 'image/gif' + return 'application/octet-stream' + + def validate_image(self, value): + if not value: + return value + suffix = Path(getattr(value, 'name', '')).suffix.lower() + allowed_suffixes = {'.jpg', '.jpeg', '.png', '.webp', '.gif', '.pdf'} + if suffix not in allowed_suffixes: + raise serializers.ValidationError('Datoteka računa mora biti slika (.jpg, .jpeg, .png, .webp, .gif) ili PDF (.pdf).') + return value + class WorkOrderPhotoSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/modules/fleet/tasks.py b/backend/modules/fleet/tasks.py index 235b86c..c3d7701 100644 --- a/backend/modules/fleet/tasks.py +++ b/backend/modules/fleet/tasks.py @@ -6,6 +6,7 @@ from django.utils import timezone from django.core.files.base import ContentFile import logging from io import BytesIO +from pathlib import Path import base64 from PIL import Image, UnidentifiedImageError from reportlab.lib.pagesizes import A4 @@ -165,6 +166,11 @@ def _build_work_order_invoices_pdf(work_order): pdf.drawString(margin, y, "slika: Nema slike.") continue + suffix = Path(invoice.image.name or '').suffix.lower() + if suffix == '.pdf': + pdf.drawString(margin, y, "prilog: PDF račun (otvorite datoteku računa za pregled).") + continue + try: invoice.image.open('rb') with Image.open(invoice.image) as source: 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 90b9717..71f4e0e 100644 --- a/backend/modules/fleet/tests/test_work_order_images_endpoint.py +++ b/backend/modules/fleet/tests/test_work_order_images_endpoint.py @@ -22,6 +22,15 @@ def create_test_image(filename='test.jpg', size=(40, 40), color='red'): ) +def create_test_pdf(filename='test.pdf'): + content = b'%PDF-1.4\n1 0 obj<>endobj\ntrailer<>\n%%EOF' + return SimpleUploadedFile( + name=filename, + content=content, + content_type='application/pdf', + ) + + class WorkOrderImagesEndpointTests(TestCase): def setUp(self): user_model = get_user_model() @@ -138,3 +147,23 @@ class WorkOrderImagesEndpointTests(TestCase): image_response = self.client.get(image_url) self.assertEqual(image_response.status_code, 200) self.assertTrue(image_response['Content-Type'].startswith('image/')) + + def test_work_order_invoice_serializer_supports_pdf_file(self): + invoice = WorkOrderInvoice.objects.create( + work_order=self.work_order, + naziv_racuna='PDF račun', + image=create_test_pdf('invoice.pdf'), + 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) + self.assertEqual(list_response.data[0].get('image_content_type'), 'application/pdf') + + file_response = self.client.get(image_url) + self.assertEqual(file_response.status_code, 200) + self.assertEqual(file_response['Content-Type'], 'application/pdf') diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index e706837..c5df354 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -3,6 +3,7 @@ from io import BytesIO import base64 from datetime import timedelta +from pathlib import Path from PIL import Image, UnidentifiedImageError from django.conf import settings @@ -401,6 +402,7 @@ def _build_work_order_pdf(work_order): transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0) grand_total = daily_total + transport_total place_label = (work_order.location or 'Zagreb').split(',')[0].strip() or 'Zagreb' + origin_label = (work_order.origin_location or 'Zagreb').split(',')[0].strip() or 'Zagreb' invoice_names = [inv.naziv_racuna for inv in invoices[:5] if inv.naziv_racuna] attachments_text = ', '.join(invoice_names) if invoice_names else '-' assigned_servicer_vehicle = ( @@ -598,7 +600,7 @@ def _build_work_order_pdf(work_order): y, [ ["RELACIJA od", "RELACIJA do", "Vrsta prijevoznog sredstva", "Razred [km]", "Iznos za prijevoz", "Ukupan iznos"], - [place_label or '-', work_order.location or '-', f"{servicer_vehicle_label}, {servicer_vehicle_registration}", str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)], + [origin_label or '-', work_order.location or '-', f"{servicer_vehicle_label}, {servicer_vehicle_registration}", str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)], ["", "", "", "", "", ""], ["", "", "", "", "", ""], ], @@ -1601,7 +1603,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet): {escape(invoice.lokacija or '-')} {escape(datum)} {escape(invoice.opis or '-')} - {f'Otvori sliku' if image_url else '-'} + {f'Otvori dokument' if image_url else '-'} """ ) @@ -1791,6 +1793,17 @@ class WorkOrderInvoiceViewSet(viewsets.ModelViewSet): if not invoice.image: raise DRFValidationError({"detail": "Slika računa nije dostupna."}) + suffix = Path(invoice.image.name or '').suffix.lower() + if suffix == '.pdf': + invoice.image.open('rb') + try: + response = HttpResponse(invoice.image.read(), content_type='application/pdf') + finally: + invoice.image.close() + response['Cache-Control'] = 'private, max-age=86400' + response['Content-Disposition'] = f'inline; filename="invoice-{invoice.pk}.pdf"' + return response + width = _parse_positive_int( request.query_params.get('w'), field_name='w', diff --git a/frontend/src/components/dashboard/WorkOrderDetailModal.jsx b/frontend/src/components/dashboard/WorkOrderDetailModal.jsx index bf1e2ca..1e776f3 100644 --- a/frontend/src/components/dashboard/WorkOrderDetailModal.jsx +++ b/frontend/src/components/dashboard/WorkOrderDetailModal.jsx @@ -32,6 +32,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi servicer_vehicle_start_mileage: '', servicer_vehicle_end_mileage: '', servicer_vehicle_fuel_cost: '', + origin_location: 'Zagreb', location: '', travel_start_at: '', travel_end_at: '', @@ -92,11 +93,12 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi if (!open || !workOrder) return; setIsEditing(false); setForm({ - has_travel_order: !!(workOrder.travel_start_at || workOrder.travel_end_at || workOrder.location || workOrder.start_mileage != null || workOrder.end_mileage != null), + has_travel_order: !!(workOrder.travel_start_at || workOrder.travel_end_at || workOrder.origin_location || workOrder.location || workOrder.start_mileage != null || workOrder.end_mileage != null), display_code: workOrder.display_code || '', status: workOrder.status || 'open', start_mileage: String(workOrder.start_mileage ?? ''), end_mileage: workOrder.end_mileage == null ? '' : String(workOrder.end_mileage), + origin_location: workOrder.origin_location || 'Zagreb', location: workOrder.location || '', travel_start_at: workOrder.travel_start_at ? String(workOrder.travel_start_at).slice(0, 16) : '', travel_end_at: workOrder.travel_end_at ? String(workOrder.travel_end_at).slice(0, 16) : '', @@ -332,6 +334,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi servicer_vehicle_start_mileage: form.servicer_vehicle_start_mileage === '' ? null : Number(form.servicer_vehicle_start_mileage), servicer_vehicle_end_mileage: form.servicer_vehicle_end_mileage === '' ? null : Number(form.servicer_vehicle_end_mileage), servicer_vehicle_fuel_cost: form.servicer_vehicle_fuel_cost === '' ? null : Number(form.servicer_vehicle_fuel_cost), + origin_location: form.origin_location.trim() || 'Zagreb', location: form.location.trim(), travel_start_at: travelStart ? travelStart.toISOString() : null, travel_end_at: travelEnd ? travelEnd.toISOString() : null, @@ -599,6 +602,16 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi

Putni podaci

+