From 4e3bf6dab049f1b6f18ffbf87e278b9e0de9e57a Mon Sep 17 00:00:00 2001 From: mariomitte Date: Sun, 12 Jul 2026 03:25:03 +0200 Subject: [PATCH] patch oko teksta PDFa i UI tablica --- .../users_import_template.csv | 6 + .../management/commands/import_users_csv.py | 202 +++++++++ backend/core/users/serializers.py | 16 +- backend/core/users/tests/test_models.py | 19 +- .../clients_import_template.csv | 11 + backend/modules/crm/management/__init__.py | 0 .../crm/management/commands/__init__.py | 0 .../management/commands/import_clients_csv.py | 130 ++++++ .../cranes_import_template.csv | 2 + .../cranes_import_template_v2.csv | 2 + .../cranes_import_template_v3.csv | 12 + .../servicer_vehicles_import_template.csv | 4 + backend/modules/fleet/management/__init__.py | 0 .../fleet/management/commands/__init__.py | 0 .../management/commands/import_cranes_csv.py | 283 ++++++++++++ .../commands/import_servicer_vehicles_csv.py | 267 ++++++++++++ ...r_vehicle_assigned_servicer_many_to_one.py | 28 ++ backend/modules/fleet/models.py | 2 +- backend/modules/fleet/pdf_layout.py | 128 ++++++ backend/modules/fleet/tasks.py | 93 ++-- .../modules/fleet/tests/test_integration.py | 12 +- .../modules/fleet/tests/test_notifications.py | 2 +- backend/modules/fleet/tests/test_services.py | 2 +- backend/modules/fleet/views.py | 408 +++++++++++++----- backend/modules/invoicing/tasks.py | 9 +- backend/modules/task_management/services.py | 5 + .../task_management/tests/test_serializers.py | 18 +- .../task_management/tests/test_views.py | 4 +- backend/pytest.ini | 3 +- frontend/public/service-worker.js | 222 +++++++++- .../src/components/crm/ClientsSection.jsx | 19 +- .../dashboard/FleetDashboardShell.jsx | 264 ++++++++---- .../dashboard/ServiceContextSelector.jsx | 12 +- .../dashboard/WorkOrderImageCarousel.jsx | 111 ++++- .../dashboard/WorkOrderInvoicesPdfPage.jsx | 38 +- .../fleet/ServiceRecordDetailModal.jsx | 2 +- .../src/components/ui/AnimatedDataTable.jsx | 18 +- frontend/src/components/ui/ThemeToggle.jsx | 2 +- frontend/src/layouts/Layout.astro | 12 +- frontend/src/stores/serviceContextStore.js | 112 +++-- 40 files changed, 2149 insertions(+), 331 deletions(-) create mode 100644 backend/core/users/import_templates/users_import_template.csv create mode 100644 backend/core/users/management/commands/import_users_csv.py create mode 100644 backend/modules/crm/import_templates/clients_import_template.csv create mode 100644 backend/modules/crm/management/__init__.py create mode 100644 backend/modules/crm/management/commands/__init__.py create mode 100644 backend/modules/crm/management/commands/import_clients_csv.py create mode 100644 backend/modules/fleet/import_templates/cranes_import_template.csv create mode 100644 backend/modules/fleet/import_templates/cranes_import_template_v2.csv create mode 100644 backend/modules/fleet/import_templates/cranes_import_template_v3.csv create mode 100644 backend/modules/fleet/import_templates/servicer_vehicles_import_template.csv create mode 100644 backend/modules/fleet/management/__init__.py create mode 100644 backend/modules/fleet/management/commands/__init__.py create mode 100644 backend/modules/fleet/management/commands/import_cranes_csv.py create mode 100644 backend/modules/fleet/management/commands/import_servicer_vehicles_csv.py create mode 100644 backend/modules/fleet/migrations/0021_alter_vehicle_assigned_servicer_many_to_one.py create mode 100644 backend/modules/fleet/pdf_layout.py diff --git a/backend/core/users/import_templates/users_import_template.csv b/backend/core/users/import_templates/users_import_template.csv new file mode 100644 index 0000000..4f803a2 --- /dev/null +++ b/backend/core/users/import_templates/users_import_template.csv @@ -0,0 +1,6 @@ +email,first_name,last_name,password,is_serviser,is_kupac,is_team_member,is_staff,is_superuser,is_active,telefon,oib,occupation,residence,work_position,licence_number,is_verified,client_id,client_tax_id +serviser1@example.com,Ime1,Prezime1,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,, +serviser2@example.com,Ime2,Prezime2,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,, +serviser3@example.com,Ime3,Prezime3,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,, +admin1@example.com,Admin1,Prezime1,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,, +admin2@example.com,Admin2,Prezime2,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,, diff --git a/backend/core/users/management/commands/import_users_csv.py b/backend/core/users/management/commands/import_users_csv.py new file mode 100644 index 0000000..7fb5be1 --- /dev/null +++ b/backend/core/users/management/commands/import_users_csv.py @@ -0,0 +1,202 @@ +import csv +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from modules.crm.models import Client + + +def _to_bool(value, field_name): + if value is None or value == "": + return None + normalized = str(value).strip().lower() + if normalized in {"1", "true", "t", "yes", "y", "da"}: + return True + if normalized in {"0", "false", "f", "no", "n", "ne"}: + return False + raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}") + + +def _to_int(value, field_name): + if value is None or value == "": + return None + try: + return int(str(value).strip()) + except (TypeError, ValueError) as exc: + raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc + + +class Command(BaseCommand): + help = "Uvozi korisnike (radnike) iz CSV datoteke u CustomUser model." + + expected_columns = [ + "email", + "first_name", + "last_name", + "password", + "is_serviser", + "is_kupac", + "is_team_member", + "is_staff", + "is_superuser", + "is_active", + "telefon", + "oib", + "occupation", + "residence", + "work_position", + "licence_number", + "is_verified", + "client_id", + "client_tax_id", + ] + + def add_arguments(self, parser): + parser.add_argument("csv_path", type=str, help="Putanja do CSV datoteke.") + parser.add_argument( + "--dry-run", + action="store_true", + help="Validira i simulira import bez spremanja u bazu.", + ) + parser.add_argument( + "--create-only", + action="store_true", + help="Kreira samo nove korisnike; postojeće preskače.", + ) + parser.add_argument( + "--default-password", + type=str, + default="", + help="Fallback lozinka za nove korisnike ako polje 'password' nije popunjeno.", + ) + + def handle(self, *args, **options): + csv_path = Path(options["csv_path"]).expanduser().resolve() + dry_run = options["dry_run"] + create_only = options["create_only"] + default_password = options["default_password"] + + if not csv_path.exists(): + raise CommandError(f"CSV datoteka ne postoji: {csv_path}") + + User = get_user_model() + created_count = 0 + updated_count = 0 + skipped_count = 0 + + with csv_path.open("r", encoding="utf-8-sig", newline="") as handle: + reader = csv.DictReader(handle) + if not reader.fieldnames: + raise CommandError("CSV nema zaglavlje (header).") + + normalized_headers = [str(h).strip() for h in reader.fieldnames] + missing = [column for column in self.expected_columns if column not in normalized_headers] + if missing: + raise CommandError("CSV nema obavezna polja: " + ", ".join(missing)) + + row_number = 1 + with transaction.atomic(): + for row in reader: + row_number += 1 + if not any((value or "").strip() for value in row.values()): + continue + + email = (row.get("email") or "").strip().lower() + if not email: + raise CommandError(f"Red {row_number}: email je obavezan.") + + user = User.objects.filter(email__iexact=email).first() + if user and create_only: + skipped_count += 1 + self.stdout.write( + self.style.WARNING( + f"Red {row_number}: preskočeno (korisnik već postoji) {email}" + ) + ) + continue + + is_create = user is None + if is_create: + password = (row.get("password") or "").strip() or default_password + if not password: + raise CommandError( + f"Red {row_number}: password je obavezan za nove korisnike " + "(ili koristite --default-password)." + ) + user = User(email=email) + user.set_password(password) + else: + password = (row.get("password") or "").strip() + if password: + user.set_password(password) + + client = self._resolve_client(row=row, row_number=row_number) + + user.first_name = (row.get("first_name") or "").strip() + user.last_name = (row.get("last_name") or "").strip() + user.telefon = (row.get("telefon") or "").strip() + user.oib = (row.get("oib") or "").strip() or None + user.occupation = (row.get("occupation") or "").strip() + user.residence = (row.get("residence") or "").strip() + user.work_position = (row.get("work_position") or "").strip() + user.licence_number = (row.get("licence_number") or "").strip() or None + + for bool_field in [ + "is_serviser", + "is_kupac", + "is_team_member", + "is_staff", + "is_superuser", + "is_active", + "is_verified", + ]: + parsed = _to_bool(row.get(bool_field), bool_field) + if parsed is not None: + setattr(user, bool_field, parsed) + + user.client_profile = client + user.username = None + + user.full_clean() + if not dry_run: + user.save() + + if is_create: + created_count += 1 + else: + updated_count += 1 + + self.stdout.write( + self.style.SUCCESS( + f"Red {row_number}: {'kreiran' if is_create else 'ažuriran'} korisnik {email}" + ) + ) + + if dry_run: + transaction.set_rollback(True) + + summary = ( + f"Import korisnika završen. created={created_count}, updated={updated_count}, " + f"skipped={skipped_count}, dry_run={dry_run}" + ) + self.stdout.write(self.style.SUCCESS(summary)) + + def _resolve_client(self, *, row, row_number): + client_id = _to_int(row.get("client_id"), "client_id") + client_tax_id = (row.get("client_tax_id") or "").strip() + + if client_id: + client = Client.objects.filter(id=client_id).first() + if not client: + raise CommandError(f"Red {row_number}: klijent s ID={client_id} ne postoji.") + return client + + if client_tax_id: + client = Client.objects.filter(tax_id=client_tax_id).first() + if not client: + raise CommandError(f"Red {row_number}: klijent s tax_id={client_tax_id} ne postoji.") + return client + + return None diff --git a/backend/core/users/serializers.py b/backend/core/users/serializers.py index 9106620..b187cbb 100644 --- a/backend/core/users/serializers.py +++ b/backend/core/users/serializers.py @@ -1,5 +1,4 @@ from rest_framework import serializers -from django.core.exceptions import ObjectDoesNotExist from .models import CustomUser class UserSerializer(serializers.ModelSerializer): @@ -14,11 +13,8 @@ class UserSerializer(serializers.ModelSerializer): 'first_name': {'required': True, 'allow_blank': False} } - def _get_assigned_asset(self, obj): - try: - return obj.assigned_vehicles - except ObjectDoesNotExist: - return None + def _get_assigned_assets(self, obj): + return obj.assigned_vehicles.all() def _serialize_asset(self, vehicle): if not vehicle: @@ -34,10 +30,14 @@ class UserSerializer(serializers.ModelSerializer): } def get_assigned_vehicle(self, obj): - return self._serialize_asset(self._get_assigned_asset(obj)) + assigned = self._get_assigned_assets(obj) + vehicle = assigned.filter(asset_type='vehicle').first() or assigned.first() + return self._serialize_asset(vehicle) def get_assigned_crane(self, obj): - return self._serialize_asset(self._get_assigned_asset(obj)) + assigned = self._get_assigned_assets(obj) + crane = assigned.filter(asset_type='crane').first() or assigned.first() + return self._serialize_asset(crane) def validate(self, data): """ diff --git a/backend/core/users/tests/test_models.py b/backend/core/users/tests/test_models.py index f511742..8b2e589 100644 --- a/backend/core/users/tests/test_models.py +++ b/backend/core/users/tests/test_models.py @@ -1,4 +1,5 @@ import pytest +from django.contrib.auth.models import Group from django.contrib.auth import get_user_model User = get_user_model() @@ -22,4 +23,20 @@ def test_create_superuser(): password="password123" ) assert admin.is_superuser is True - assert admin.is_staff is True \ No newline at end of file + assert admin.is_staff is True + + +@pytest.mark.django_db +def test_user_groups_many_to_many_relation(): + user = User.objects.create_user( + email="m2m@example.com", + password="password123", + ) + group_a = Group.objects.create(name="Serviseri") + group_b = Group.objects.create(name="Dispeceri") + + user.groups.add(group_a, group_b) + + assert user.groups.count() == 2 + assert group_a.user_set.filter(id=user.id).exists() + assert group_b.user_set.filter(id=user.id).exists() \ No newline at end of file diff --git a/backend/modules/crm/import_templates/clients_import_template.csv b/backend/modules/crm/import_templates/clients_import_template.csv new file mode 100644 index 0000000..97c1808 --- /dev/null +++ b/backend/modules/crm/import_templates/clients_import_template.csv @@ -0,0 +1,11 @@ +name,client_type,tax_id,email,phone,address,city,postal_code +Adriacink transport d.o.o.,legal,10000000001,adriacink.transport@example.com,+38591111001,Ulica 1,Zagreb,10000 +BOSMAN,legal,10000000002,bosman@example.com,+38591111002,Ulica 2,Zagreb,10000 +BOSSIL,legal,10000000003,bossil@example.com,+38591111003,Ulica 3,Zagreb,10000 +PAKLOG,legal,10000000004,paklog@example.com,+38591111004,Ulica 4,Zagreb,10000 +Palace,legal,10000000005,palace@example.com,+38591111005,Ulica 5,Zagreb,10000 +Petrokemija Kutina,legal,10000000006,petrokemija.kutina@example.com,+38591111006,Ulica 6,Kutina,44320 +Paron,legal,10000000007,paron@example.com,+38591111007,Ulica 7,Zagreb,10000 +PRANGL,legal,10000000008,prangl@example.com,+38591111008,Ulica 8,Zagreb,10000 +Salona MONT,legal,10000000009,salona.mont@example.com,+38591111009,Ulica 9,Split,21000 +STRABAG,legal,10000000010,strabag@example.com,+38591111010,Ulica 10,Zagreb,10000 diff --git a/backend/modules/crm/management/__init__.py b/backend/modules/crm/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/crm/management/commands/__init__.py b/backend/modules/crm/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/crm/management/commands/import_clients_csv.py b/backend/modules/crm/management/commands/import_clients_csv.py new file mode 100644 index 0000000..8212a29 --- /dev/null +++ b/backend/modules/crm/management/commands/import_clients_csv.py @@ -0,0 +1,130 @@ +import csv +from pathlib import Path + +from django.core.management.base import BaseCommand, CommandError +from django.core.exceptions import ValidationError +from django.db import transaction + +from modules.crm.models import Client + + +class Command(BaseCommand): + help = "Uvozi kupce (vlasnike dizalica) iz CSV datoteke u Client model." + + expected_columns = [ + "name", + "client_type", + "tax_id", + "email", + "phone", + "address", + "city", + "postal_code", + ] + + def add_arguments(self, parser): + parser.add_argument("csv_path", type=str, help="Putanja do CSV datoteke.") + parser.add_argument( + "--dry-run", + action="store_true", + help="Validira i simulira import bez spremanja u bazu.", + ) + parser.add_argument( + "--create-only", + action="store_true", + help="Kreira samo nove kupce; postojeće preskače.", + ) + + def handle(self, *args, **options): + csv_path = Path(options["csv_path"]).expanduser().resolve() + dry_run = options["dry_run"] + create_only = options["create_only"] + + if not csv_path.exists(): + raise CommandError(f"CSV datoteka ne postoji: {csv_path}") + + created_count = 0 + updated_count = 0 + skipped_count = 0 + + with csv_path.open("r", encoding="utf-8-sig", newline="") as handle: + reader = csv.DictReader(handle) + if not reader.fieldnames: + raise CommandError("CSV nema zaglavlje (header).") + + normalized_headers = [str(h).strip() for h in reader.fieldnames] + missing = [column for column in self.expected_columns if column not in normalized_headers] + if missing: + raise CommandError("CSV nema obavezna polja: " + ", ".join(missing)) + + row_number = 1 + with transaction.atomic(): + for row in reader: + row_number += 1 + if not any((value or "").strip() for value in row.values()): + continue + + tax_id = (row.get("tax_id") or "").strip() + email = (row.get("email") or "").strip().lower() + if not tax_id: + raise CommandError(f"Red {row_number}: tax_id je obavezan.") + if not email: + raise CommandError(f"Red {row_number}: email je obavezan.") + + client = Client.objects.filter(tax_id=tax_id).first() + if client and create_only: + skipped_count += 1 + self.stdout.write( + self.style.WARNING( + f"Red {row_number}: preskočeno (kupac već postoji) tax_id={tax_id}" + ) + ) + continue + + is_create = client is None + if is_create: + client = Client(tax_id=tax_id) + + client_type = (row.get("client_type") or "legal").strip() + if client_type not in {"legal", "individual"}: + raise CommandError( + f"Red {row_number}: client_type mora biti 'legal' ili 'individual'." + ) + + client.name = (row.get("name") or "").strip() + client.client_type = client_type + client.email = email + client.phone = (row.get("phone") or "").strip() or None + client.address = (row.get("address") or "").strip() + client.city = (row.get("city") or "").strip() + client.postal_code = (row.get("postal_code") or "").strip() + + try: + client.full_clean() + except ValidationError as exc: + raise CommandError( + f"Red {row_number}: validacija nije prošla -> {exc.message_dict}" + ) from exc + + if not dry_run: + client.save() + + if is_create: + created_count += 1 + else: + updated_count += 1 + + self.stdout.write( + self.style.SUCCESS( + f"Red {row_number}: {'kreiran' if is_create else 'ažuriran'} kupac {client.name}" + ) + ) + + if dry_run: + transaction.set_rollback(True) + + summary = ( + f"Import kupaca završen. created={created_count}, updated={updated_count}, " + f"skipped={skipped_count}, dry_run={dry_run}" + ) + self.stdout.write(self.style.SUCCESS(summary)) diff --git a/backend/modules/fleet/import_templates/cranes_import_template.csv b/backend/modules/fleet/import_templates/cranes_import_template.csv new file mode 100644 index 0000000..3696952 --- /dev/null +++ b/backend/modules/fleet/import_templates/cranes_import_template.csv @@ -0,0 +1,2 @@ +registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,assigned_servicer_email,is_active +ZG-CRN-001,123456,Liebherr,LTM 1050,2022,WLH1234567890ABCD,1200,800,45200,15000,true,,serviser1@example.com,true diff --git a/backend/modules/fleet/import_templates/cranes_import_template_v2.csv b/backend/modules/fleet/import_templates/cranes_import_template_v2.csv new file mode 100644 index 0000000..d74514d --- /dev/null +++ b/backend/modules/fleet/import_templates/cranes_import_template_v2.csv @@ -0,0 +1,2 @@ +registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,client_name,client_type,client_tax_id,client_email,client_phone,client_address,client_city,client_postal_code,assigned_servicer_email,is_active +ZG-CRN-001,123456,Liebherr,LTM 1050,2022,WLH1234567890ABCD,1200,800,45200,15000,false,,Kupac Primjer d.o.o.,legal,12345678901,kupac.primjer@example.com,+38591111222,Ulica 1,Zagreb,10000,serviser1@example.com,true diff --git a/backend/modules/fleet/import_templates/cranes_import_template_v3.csv b/backend/modules/fleet/import_templates/cranes_import_template_v3.csv new file mode 100644 index 0000000..f1cd4dd --- /dev/null +++ b/backend/modules/fleet/import_templates/cranes_import_template_v3.csv @@ -0,0 +1,12 @@ +registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,client_name,client_type,client_tax_id,client_email,client_phone,client_address,client_city,client_postal_code,assigned_servicer_email,is_active +ZG-LTM-001,210501,Liebherr,LTM 1350,2021,WLHZGLTM0012021AA,3400,2900,128450,15000,false,,Adriacink transport d.o.o.,legal,10000000001,adriacink.transport@example.com,+38591111001,Ulica 1,Zagreb,10000,serviser1@example.com,true +ZG-LTM-002,210502,Liebherr,LTM 1100,2022,WLHZGLTM0022022BB,2850,2410,96420,15000,false,,PAKLOG,legal,10000000004,paklog@example.com,+38591111004,Ulica 4,Zagreb,10000,serviser2@example.com,true +ZG-LTM-003,210503,Liebherr,LTM 1110,2023,WLHZGLTM0032023CC,1620,1395,54780,15000,false,,Palace,legal,10000000005,palace@example.com,+38591111005,Ulica 5,Zagreb,10000,serviser3@example.com,true +ZG-LTM-004,210504,Liebherr,LTM 1750,2021,WLHZGLTM0042021UA,3400,2900,128450,15000,false,,Adriacink transport d.o.o.,legal,10000000001,adriacink.transport@example.com,+38591111001,Ulica 1,Zagreb,10000,serviser1@example.com,true +ZG-LTM-005,210505,Liebherr,LTM 1500,2021,WLHZGLTM0052023AA,3400,2900,128450,15000,false,,BOSMAN,legal,10000000002,bosman@example.com,+38591111002,Ulica 2,Zagreb,10000,serviser1@example.com,true +ZG-LTM-006,210506,Liebherr,LTM 1060,2022,WLHZGLTM0062022BB,2850,2410,96420,15000,false,,BOSSIL,legal,10000000003,bossil@example.com,+38591111003,Ulica 3,Zagreb,10000,serviser2@example.com,true +ZG-LTM-007,210507,Liebherr,LTM 1060,2022,WLHZGLTM0072022BB,2850,2410,96420,15000,false,,Petrokemija Kutina,legal,10000000006,petrokemija.kutina@example.com,+38591111006,Ulica 6,Kutina,44320,serviser3@example.com,true +ZG-LTM-008,210508,Liebherr,LTM 1040,2022,WLHZGLTM0082022BB,2850,2410,96420,15000,false,,Paron,legal,10000000007,paron@example.com,+38591111007,Ulica 7,Zagreb,10000,serviser3@example.com,true +ZG-LTM-009,210509,Liebherr,LTM 1040,2022,WLHZGLTM0092022BB,2850,2410,96420,15000,false,,Petrokemija Kutina,legal,10000000006,petrokemija.kutina@example.com,+38591111006,Ulica 6,Kutina,44320,serviser3@example.com,true +ZG-LTM-010,210510,Liebherr,LTM 1250,2022,WLHZGLTM0102022BB,2850,2410,96420,15000,false,,PRANGL,legal,10000000008,prangl@example.com,+38591111008,Ulica 8,Zagreb,10000,serviser3@example.com,true +ZG-LTM-011,210511,Liebherr,LTM 1090,2022,WLHZGLTM0112022BB,2850,2410,96420,15000,false,,Salona MONT,legal,10000000009,salona.mont@example.com,+38591111009,Ulica 9,Split,21000,serviser3@example.com,true diff --git a/backend/modules/fleet/import_templates/servicer_vehicles_import_template.csv b/backend/modules/fleet/import_templates/servicer_vehicles_import_template.csv new file mode 100644 index 0000000..149f1d6 --- /dev/null +++ b/backend/modules/fleet/import_templates/servicer_vehicles_import_template.csv @@ -0,0 +1,4 @@ +registration_number,make,model,year,vin,current_mileage,service_interval_km,assigned_servicer_email,is_active +ZG-SRV-001,Volkswagen,Caddy,2021,WV1ZZZ2KZMX000001,128400,15000,serviser1@example.com,true +ZG-SRV-002,Renault,Trafic,2022,VF1FL0000NY000002,96420,15000,serviser2@example.com,true +ZG-SRV-003,Ford,Transit Custom,2023,WF0YXXTTGYPU00003,54780,15000,serviser3@example.com,true diff --git a/backend/modules/fleet/management/__init__.py b/backend/modules/fleet/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/fleet/management/commands/__init__.py b/backend/modules/fleet/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/fleet/management/commands/import_cranes_csv.py b/backend/modules/fleet/management/commands/import_cranes_csv.py new file mode 100644 index 0000000..490b9e5 --- /dev/null +++ b/backend/modules/fleet/management/commands/import_cranes_csv.py @@ -0,0 +1,283 @@ +import csv +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError +from django.core.exceptions import ValidationError +from django.db import transaction + +from modules.crm.models import Client +from modules.fleet.models import Vehicle + + +def _to_bool(value, field_name): + if value is None or value == "": + return None + normalized = str(value).strip().lower() + if normalized in {"1", "true", "t", "yes", "y", "da"}: + return True + if normalized in {"0", "false", "f", "no", "n", "ne"}: + return False + raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}") + + +def _to_int(value, field_name): + if value is None or value == "": + return None + try: + return int(str(value).strip()) + except (TypeError, ValueError) as exc: + raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc + + +class Command(BaseCommand): + help = "Uvozi dizalice iz CSV datoteke i sprema ih u Vehicle model (asset_type=crane)." + + expected_columns = [ + "registration_number", + "crane_serial_number", + "make", + "model", + "year", + "vin", + "superstructure_working_hours", + "chassis_working_hours", + "current_mileage", + "service_interval_km", + "is_company_vehicle", + "client_id", + "assigned_servicer_email", + "is_active", + ] + optional_client_columns = [ + "client_name", + "client_type", + "client_tax_id", + "client_email", + "client_phone", + "client_address", + "client_city", + "client_postal_code", + ] + + def add_arguments(self, parser): + parser.add_argument( + "csv_path", + type=str, + help="Apsolutna ili relativna putanja do CSV datoteke.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Validira CSV i ispisuje rezultat bez spremanja u bazu.", + ) + parser.add_argument( + "--create-only", + action="store_true", + help="Kreira samo nove zapise; postojeće registracije se preskaču.", + ) + + def handle(self, *args, **options): + csv_path = Path(options["csv_path"]).expanduser().resolve() + dry_run = options["dry_run"] + create_only = options["create_only"] + + if not csv_path.exists(): + raise CommandError(f"CSV datoteka ne postoji: {csv_path}") + + created_count = 0 + updated_count = 0 + skipped_count = 0 + + User = get_user_model() + + with csv_path.open("r", encoding="utf-8-sig", newline="") as handle: + reader = csv.DictReader(handle) + if not reader.fieldnames: + raise CommandError("CSV nema zaglavlje (header).") + + normalized_headers = [str(h).strip() for h in reader.fieldnames] + missing = [column for column in self.expected_columns if column not in normalized_headers] + if missing: + raise CommandError( + "CSV nema obavezna polja: " + ", ".join(missing) + ) + + row_number = 1 + with transaction.atomic(): + for row in reader: + row_number += 1 + if not any((value or "").strip() for value in row.values()): + continue + + registration_number = (row.get("registration_number") or "").strip() + crane_serial_number = (row.get("crane_serial_number") or "").strip() + + if not registration_number: + raise CommandError(f"Red {row_number}: registration_number je obavezan.") + if not crane_serial_number: + raise CommandError(f"Red {row_number}: crane_serial_number je obavezan.") + + vehicle = Vehicle.objects.filter(registration_number=registration_number).first() + if vehicle and create_only: + skipped_count += 1 + self.stdout.write( + self.style.WARNING( + f"Red {row_number}: preskočeno (već postoji) {registration_number}" + ) + ) + continue + + if vehicle is None: + vehicle = Vehicle(registration_number=registration_number) + was_created = True + else: + was_created = False + + assigned_servicer = None + assigned_servicer_email = (row.get("assigned_servicer_email") or "").strip() + if assigned_servicer_email: + assigned_servicer = User.objects.filter(email=assigned_servicer_email).first() + if not assigned_servicer: + raise CommandError( + f"Red {row_number}: korisnik s emailom '{assigned_servicer_email}' ne postoji." + ) + + is_company_vehicle = _to_bool(row.get("is_company_vehicle"), "is_company_vehicle") + is_active = _to_bool(row.get("is_active"), "is_active") + if is_company_vehicle is None: + is_company_vehicle = True + + client = self._resolve_or_create_client( + row=row, + row_number=row_number, + is_company_vehicle=is_company_vehicle, + ) + + vehicle.asset_type = "crane" + vehicle.crane_serial_number = crane_serial_number + vehicle.make = (row.get("make") or "").strip() + vehicle.model = (row.get("model") or "").strip() + vehicle.year = _to_int(row.get("year"), "year") + vehicle.vin = (row.get("vin") or "").strip() or None + vehicle.superstructure_working_hours = _to_int( + row.get("superstructure_working_hours"), + "superstructure_working_hours", + ) or 0 + vehicle.chassis_working_hours = _to_int( + row.get("chassis_working_hours"), + "chassis_working_hours", + ) or 0 + vehicle.current_mileage = _to_int(row.get("current_mileage"), "current_mileage") or 0 + vehicle.service_interval_km = _to_int( + row.get("service_interval_km"), + "service_interval_km", + ) or 15000 + if is_company_vehicle is not None: + vehicle.is_company_vehicle = is_company_vehicle + vehicle.client = client + vehicle.assigned_servicer = assigned_servicer + if is_active is not None: + vehicle.is_active = is_active + + try: + vehicle.full_clean() + except ValidationError as exc: + raise CommandError(f"Red {row_number}: validacija nije prošla -> {exc.message_dict}") from exc + + if not dry_run: + vehicle.save() + + if was_created: + created_count += 1 + else: + updated_count += 1 + + self.stdout.write( + self.style.SUCCESS( + f"Red {row_number}: {'kreirano' if was_created else 'ažurirano'} {registration_number}" + ) + ) + + if dry_run: + transaction.set_rollback(True) + + summary = ( + f"Import završen. created={created_count}, updated={updated_count}, " + f"skipped={skipped_count}, dry_run={dry_run}" + ) + self.stdout.write(self.style.SUCCESS(summary)) + + def _resolve_or_create_client(self, *, row, row_number, is_company_vehicle): + client_id = _to_int(row.get("client_id"), "client_id") + if client_id: + client = Client.objects.filter(id=client_id).first() + if not client: + raise CommandError( + f"Red {row_number}: klijent s ID={client_id} ne postoji." + ) + return client + + client_tax_id = (row.get("client_tax_id") or "").strip() + client_email = (row.get("client_email") or "").strip().lower() + client_name = (row.get("client_name") or "").strip() + + # Reuse pattern iz postojećih seed komandi: pokušaj pronaći postojeći zapis prije kreiranja. + if client_tax_id: + existing = Client.objects.filter(tax_id=client_tax_id).first() + if existing: + return existing + if client_email: + existing = Client.objects.filter(email__iexact=client_email).first() + if existing: + return existing + if client_name: + existing = Client.objects.filter(name=client_name).first() + if existing: + return existing + + if is_company_vehicle: + return None + + if not any([client_tax_id, client_email, client_name]): + raise CommandError( + f"Red {row_number}: za klijentsku dizalicu (is_company_vehicle=false) " + "morate navesti client_id ili client podatke za kreiranje kupca." + ) + + missing_for_create = [] + create_fields = { + "name": client_name, + "client_type": (row.get("client_type") or "legal").strip() or "legal", + "tax_id": client_tax_id, + "email": client_email, + "phone": (row.get("client_phone") or "").strip() or None, + "address": (row.get("client_address") or "").strip(), + "city": (row.get("client_city") or "").strip(), + "postal_code": (row.get("client_postal_code") or "").strip(), + } + for required_key in ["name", "tax_id", "email", "address", "city", "postal_code"]: + if not create_fields[required_key]: + missing_for_create.append(required_key) + + if missing_for_create: + raise CommandError( + f"Red {row_number}: nedostaju podaci za kreiranje kupca: {', '.join(missing_for_create)}" + ) + + if create_fields["client_type"] not in {"legal", "individual"}: + raise CommandError( + f"Red {row_number}: client_type mora biti 'legal' ili 'individual'." + ) + + client, was_created = Client.objects.get_or_create( + tax_id=create_fields["tax_id"], + defaults=create_fields, + ) + if was_created: + self.stdout.write( + self.style.SUCCESS( + f"Red {row_number}: kreiran kupac '{client.name}' (OIB/VAT: {client.tax_id})" + ) + ) + return client diff --git a/backend/modules/fleet/management/commands/import_servicer_vehicles_csv.py b/backend/modules/fleet/management/commands/import_servicer_vehicles_csv.py new file mode 100644 index 0000000..87bfbfe --- /dev/null +++ b/backend/modules/fleet/management/commands/import_servicer_vehicles_csv.py @@ -0,0 +1,267 @@ +import csv +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from modules.fleet.models import Vehicle + + +def _to_bool(value, field_name): + if value is None or value == "": + return None + normalized = str(value).strip().lower() + if normalized in {"1", "true", "t", "yes", "y", "da"}: + return True + if normalized in {"0", "false", "f", "no", "n", "ne"}: + return False + raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}") + + +def _to_int(value, field_name): + if value is None or value == "": + return None + try: + return int(str(value).strip()) + except (TypeError, ValueError) as exc: + raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc + + +class Command(BaseCommand): + help = ( + "Uvozi vozila servisera iz CSV datoteke i sprema ih u Vehicle model " + "(asset_type=vehicle, is_company_vehicle=true)." + ) + + expected_columns = [ + "registration_number", + "make", + "model", + "year", + "vin", + "current_mileage", + "service_interval_km", + "assigned_servicer_email", + "is_active", + ] + + users_template_columns = [ + "email", + "is_serviser", + ] + + def add_arguments(self, parser): + parser.add_argument( + "csv_path", + type=str, + help="Apsolutna ili relativna putanja do CSV datoteke vozila.", + ) + parser.add_argument( + "--users-csv-path", + type=str, + default="", + help=( + "Putanja do users_import_template.csv (fallback je " + "backend/core/users/import_templates/users_import_template.csv)." + ), + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Validira CSV i ispisuje rezultat bez spremanja u bazu.", + ) + parser.add_argument( + "--create-only", + action="store_true", + help="Kreira samo nove zapise; postojeće registracije se preskaču.", + ) + + def handle(self, *args, **options): + csv_path = self._resolve_input_csv_path(options["csv_path"]) + users_csv_path = self._resolve_users_csv_path(options.get("users_csv_path") or "") + dry_run = options["dry_run"] + create_only = options["create_only"] + + if not csv_path.exists(): + raise CommandError(f"CSV datoteka ne postoji: {csv_path}") + if not users_csv_path.exists(): + raise CommandError(f"users_import_template.csv ne postoji: {users_csv_path}") + + allowed_servicer_emails = self._load_servicer_emails_from_template(users_csv_path) + if not allowed_servicer_emails: + raise CommandError( + "users_import_template.csv ne sadrži nijednog servisera (is_serviser=true)." + ) + + User = get_user_model() + created_count = 0 + updated_count = 0 + skipped_count = 0 + + with csv_path.open("r", encoding="utf-8-sig", newline="") as handle: + reader = csv.DictReader(handle) + if not reader.fieldnames: + raise CommandError("CSV nema zaglavlje (header).") + + normalized_headers = [str(h).strip() for h in reader.fieldnames] + missing = [column for column in self.expected_columns if column not in normalized_headers] + if missing: + raise CommandError("CSV nema obavezna polja: " + ", ".join(missing)) + + row_number = 1 + with transaction.atomic(): + for row in reader: + row_number += 1 + if not any((value or "").strip() for value in row.values()): + continue + + registration_number = (row.get("registration_number") or "").strip() + if not registration_number: + raise CommandError(f"Red {row_number}: registration_number je obavezan.") + + assigned_servicer_email = (row.get("assigned_servicer_email") or "").strip().lower() + if not assigned_servicer_email: + raise CommandError(f"Red {row_number}: assigned_servicer_email je obavezan.") + if assigned_servicer_email not in allowed_servicer_emails: + raise CommandError( + f"Red {row_number}: '{assigned_servicer_email}' nije valjan serviser " + "u users_import_template.csv." + ) + + assigned_servicer = User.objects.filter(email__iexact=assigned_servicer_email).first() + if not assigned_servicer: + raise CommandError( + f"Red {row_number}: korisnik s emailom '{assigned_servicer_email}' ne postoji u bazi." + ) + if not assigned_servicer.is_serviser: + raise CommandError( + f"Red {row_number}: korisnik '{assigned_servicer_email}' nije označen kao serviser." + ) + + vehicle = Vehicle.objects.filter(registration_number=registration_number).first() + if vehicle and create_only: + skipped_count += 1 + self.stdout.write( + self.style.WARNING( + f"Red {row_number}: preskočeno (već postoji) {registration_number}" + ) + ) + continue + + if vehicle is None: + vehicle = Vehicle(registration_number=registration_number) + was_created = True + else: + was_created = False + if vehicle.asset_type and vehicle.asset_type != "vehicle": + raise CommandError( + f"Red {row_number}: zapis {registration_number} već postoji kao " + f"asset_type='{vehicle.asset_type}', ne može se prepisati u 'vehicle'." + ) + + is_active = _to_bool(row.get("is_active"), "is_active") + + vehicle.asset_type = "vehicle" + vehicle.make = (row.get("make") or "").strip() + vehicle.model = (row.get("model") or "").strip() + vehicle.year = _to_int(row.get("year"), "year") + vehicle.vin = (row.get("vin") or "").strip() or None + vehicle.current_mileage = _to_int(row.get("current_mileage"), "current_mileage") or 0 + vehicle.service_interval_km = _to_int( + row.get("service_interval_km"), + "service_interval_km", + ) or 15000 + vehicle.is_company_vehicle = True + vehicle.client = None + vehicle.assigned_servicer = assigned_servicer + if is_active is not None: + vehicle.is_active = is_active + + try: + vehicle.full_clean() + except ValidationError as exc: + raise CommandError(f"Red {row_number}: validacija nije prošla -> {exc.message_dict}") from exc + + if not dry_run: + vehicle.save() + + if was_created: + created_count += 1 + else: + updated_count += 1 + + self.stdout.write( + self.style.SUCCESS( + f"Red {row_number}: {'kreirano' if was_created else 'ažurirano'} " + f"{registration_number} -> {assigned_servicer_email}" + ) + ) + + if dry_run: + transaction.set_rollback(True) + + summary = ( + f"Import vozila servisera završen. created={created_count}, updated={updated_count}, " + f"skipped={skipped_count}, dry_run={dry_run}" + ) + self.stdout.write(self.style.SUCCESS(summary)) + + def _resolve_input_csv_path(self, csv_path): + resolved = Path(csv_path).expanduser().resolve() + if resolved.exists(): + return resolved + + raw = str(csv_path).strip() + candidates = [] + if "/backend/" in raw: + candidates.append(raw.replace("/backend/", "/", 1)) + if "\\backend\\" in raw: + candidates.append(raw.replace("\\backend\\", "\\", 1)) + if raw.startswith("backend/"): + candidates.append(raw.replace("backend/", "", 1)) + if raw.startswith("backend\\"): + candidates.append(raw.replace("backend\\", "", 1)) + + for candidate in candidates: + candidate_path = Path(candidate).expanduser().resolve() + if candidate_path.exists(): + self.stdout.write( + self.style.WARNING( + f"CSV nije pronađen na '{resolved}', koristim '{candidate_path}'." + ) + ) + return candidate_path + return resolved + + def _resolve_users_csv_path(self, users_csv_path): + if users_csv_path: + return Path(users_csv_path).expanduser().resolve() + backend_root = Path(__file__).resolve().parents[4] + return backend_root / "core" / "users" / "import_templates" / "users_import_template.csv" + + def _load_servicer_emails_from_template(self, users_csv_path): + with users_csv_path.open("r", encoding="utf-8-sig", newline="") as handle: + reader = csv.DictReader(handle) + if not reader.fieldnames: + raise CommandError("users_import_template.csv nema zaglavlje (header).") + + normalized_headers = [str(h).strip() for h in reader.fieldnames] + missing = [column for column in self.users_template_columns if column not in normalized_headers] + if missing: + raise CommandError( + "users_import_template.csv nema obavezna polja: " + ", ".join(missing) + ) + + emails = set() + row_number = 1 + for row in reader: + row_number += 1 + if not any((value or "").strip() for value in row.values()): + continue + is_serviser = _to_bool(row.get("is_serviser"), f"is_serviser (users CSV, red {row_number})") + email = (row.get("email") or "").strip().lower() + if is_serviser and email: + emails.add(email) + return emails diff --git a/backend/modules/fleet/migrations/0021_alter_vehicle_assigned_servicer_many_to_one.py b/backend/modules/fleet/migrations/0021_alter_vehicle_assigned_servicer_many_to_one.py new file mode 100644 index 0000000..37f2cfd --- /dev/null +++ b/backend/modules/fleet/migrations/0021_alter_vehicle_assigned_servicer_many_to_one.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.15 on 2026-07-11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("fleet", "0020_alter_workorderinvoice_created_at_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="vehicle", + name="assigned_servicer", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="assigned_vehicles", + to=settings.AUTH_USER_MODEL, + verbose_name="Serviser", + ), + ), + ] diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py index 875c0a4..0bae765 100644 --- a/backend/modules/fleet/models.py +++ b/backend/modules/fleet/models.py @@ -149,7 +149,7 @@ class Vehicle(BaseModel): ) current_mileage = models.PositiveIntegerField(default=0, verbose_name=_("Trenutna kilometraža (km)")) service_interval_km = models.PositiveIntegerField(default=15000, verbose_name=_("Interval servisa (km)")) - assigned_servicer = models.OneToOneField( + assigned_servicer = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='assigned_vehicles', diff --git a/backend/modules/fleet/pdf_layout.py b/backend/modules/fleet/pdf_layout.py new file mode 100644 index 0000000..808166e --- /dev/null +++ b/backend/modules/fleet/pdf_layout.py @@ -0,0 +1,128 @@ +import os + +from reportlab.lib.pagesizes import A4 +from reportlab.lib import colors +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont + +DEFAULT_MARGIN = 28 +DEFAULT_HEADER_H = 86 +DEFAULT_FOOTER_H = 72 + + +def register_unicode_fonts(): + if "Vera" in pdfmetrics.getRegisteredFontNames(): + return + fonts_dir = os.path.join(os.path.dirname(__import__("reportlab").__file__), "fonts") + pdfmetrics.registerFont(TTFont("Vera", os.path.join(fonts_dir, "Vera.ttf"))) + pdfmetrics.registerFont(TTFont("Vera-Bold", os.path.join(fonts_dir, "VeraBd.ttf"))) + pdfmetrics.registerFont(TTFont("Vera-Italic", os.path.join(fonts_dir, "VeraIt.ttf"))) + + +def draw_standard_header_footer( + pdf, + *, + page_num, + client_name, + manufacturer, + model, + serial, + upgrade_hours, + chassis_hours, + mileage, + work_order_number, + generated_date, + report_title="Izvještaj servisera", + page_size=A4, + margin=DEFAULT_MARGIN, + header_h=DEFAULT_HEADER_H, + footer_h=DEFAULT_FOOTER_H, +): + w, h = page_size + + pdf.setStrokeColor(colors.black) + pdf.setLineWidth(0.7) + pdf.rect(margin, h - header_h, w - 2 * margin, header_h - 4, stroke=1, fill=0) + + pdf.setFont("Vera-Bold", 11) + pdf.drawString(margin + 6, h - 22, str(client_name or "-")[:44]) + pdf.drawCentredString(w / 2, h - 22, report_title) + pdf.setFont("Vera", 9) + pdf.drawRightString(w - margin - 6, h - 22, f"Stranica {page_num}") + + pdf.setLineWidth(0.5) + pdf.line(margin, h - 36, w - margin, h - 36) + + labels = [ + "Model:", + "Serijski broj:", + "Radni sati nadogradnje:", + "Radni sati podvozja:", + "Km:", + "Broj naloga:", + ] + values = [ + (manufacturer, model), + serial, + upgrade_hours, + chassis_hours, + mileage, + work_order_number, + ] + col_w = (w - 2 * margin) / len(labels) + pdf.setFont("Vera", 7.2) + for i, (lbl, val) in enumerate(zip(labels, values)): + x = margin + i * col_w + 3 + pdf.drawString(x, h - 49, lbl) + value_text = str(val or "-") + value_font_size = 9 + if i == 0 and isinstance(val, tuple): + manufacturer_text = str(val[0] or "-")[:24] + model_text = str(val[1] or "-")[:24] + pdf.setFont("Vera-Bold", 8.2) + pdf.drawString(x, h - 59, manufacturer_text) + pdf.setFont("Vera", 7.6) + pdf.drawString(x, h - 68, model_text) + elif i == len(labels) - 1: + value_font_size = 7 + max_value_width = col_w - 8 + while ( + pdf.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width + and value_font_size > 5.5 + ): + value_font_size -= 0.3 + if pdf.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width: + trimmed = value_text + while ( + len(trimmed) > 3 + and pdf.stringWidth(f"{trimmed}...", "Vera-Bold", value_font_size) > max_value_width + ): + trimmed = trimmed[:-1] + value_text = f"{trimmed}..." + else: + value_text = value_text[:24] + if not (i == 0 and isinstance(val, tuple)): + pdf.setFont("Vera-Bold", value_font_size) + pdf.drawString(x, h - 61, value_text) + pdf.setFont("Vera", 7.2) + if i > 0: + pdf.line(margin + i * col_w, h - 36, margin + i * col_w, h - header_h + 4) + + footer_y = footer_h + pdf.setLineWidth(0.5) + pdf.rect(margin, footer_y + 42, w - 2 * margin, 18, stroke=1, fill=0) + sig_labels = [f"Datum: {generated_date}", "Potpis servisera", "Pečat i potpis klijenta *1", "Pregledao"] + sig_w = (w - 2 * margin) / 4 + pdf.setFont("Vera", 7.2) + for i, lbl in enumerate(sig_labels): + x = margin + i * sig_w + pdf.drawString(x + 4, footer_y + 45, lbl) + if i > 0: + pdf.line(x, footer_y + 42, x, footer_y + 60) + pdf.setFont("Vera", 6.5) + pdf.drawString(margin, footer_y + 26, "*1- potpisom klijent potvrđuje da je suglasan s podacima u radnom nalogu") + pdf.drawCentredString( + w / 2, + footer_y + 14, + "Ovlašteni servis LIEBHERR Werk-Ehingen GmbH, LIEBHERR-Werk Nenzing GmbH, Liebherr-MCCTech Rostock GmbH", + ) diff --git a/backend/modules/fleet/tasks.py b/backend/modules/fleet/tasks.py index 3cd4a5a..ea13755 100644 --- a/backend/modules/fleet/tasks.py +++ b/backend/modules/fleet/tasks.py @@ -2,6 +2,7 @@ from celery import shared_task from django.core.mail import send_mail from django.conf import settings +from django.utils import timezone import logging from io import BytesIO import base64 @@ -10,8 +11,10 @@ from reportlab.lib.pagesizes import A4 from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas from .models import VehicleNotification +from .pdf_layout import register_unicode_fonts, draw_standard_header_footer logger = logging.getLogger(__name__) +register_unicode_fonts() @shared_task(bind=True, max_retries=3, default_retry_delay=60) @@ -78,53 +81,93 @@ def _build_work_order_invoices_pdf(work_order): buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) width, height = A4 - y = height - 50 + margin = 28 + header_h = 86 + footer_h = 72 + content_top = height - header_h - 10 + content_bottom = footer_h + 52 + client_name = getattr(getattr(work_order.vehicle, "client", None), "name", None) or "-" + manufacturer = str(work_order.vehicle.make or "-") + model_line = str(work_order.vehicle.model or "-") + serial_line = str(getattr(work_order.vehicle, "crane_serial_number", None) or "-") + upgrade_hours = str(getattr(work_order.vehicle, "superstructure_working_hours", "-") or "-") + chassis_hours = str(getattr(work_order.vehicle, "chassis_working_hours", "-") or "-") + mileage = str(getattr(work_order.vehicle, "current_mileage", "-") or "-") + generated_date = timezone.localtime(timezone.now()).strftime("%d.%m.%Y") - pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(40, y, f"Računi putnog naloga WO-{work_order.pk}") - y -= 22 - pdf.setFont("Helvetica", 10) - pdf.drawString(40, y, f"Dizalica: {work_order.vehicle.registration_number}") - y -= 16 - pdf.drawString(40, y, f"Datum naloga: {work_order.date}") + def draw_header_footer(page_num): + draw_standard_header_footer( + pdf, + page_num=page_num, + client_name=client_name, + manufacturer=manufacturer, + model=model_line, + serial=serial_line, + upgrade_hours=upgrade_hours, + chassis_hours=chassis_hours, + mileage=mileage, + work_order_number=str(work_order.pk), + generated_date=generated_date, + report_title="Računi putnog naloga", + page_size=A4, + margin=margin, + header_h=header_h, + footer_h=footer_h, + ) + + page_num = 1 + draw_header_footer(page_num) + y = content_top + y -= 8 + + pdf.setFont("Vera-Bold", 13) + pdf.drawString(margin, y, f"Računi putnog naloga WO-{work_order.pk}") + y -= 20 + pdf.setFont("Vera", 10) + pdf.drawString(margin, y, f"Datum naloga: {work_order.date}") invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at') if not invoices.exists(): - y -= 24 - pdf.drawString(40, y, "Nema računa za ovaj putni nalog.") + y -= 20 + pdf.drawString(margin, y, "Nema računa za ovaj putni nalog.") pdf.save() return buffer.getvalue() for index, invoice in enumerate(invoices, start=1): pdf.showPage() - y = height - 50 - pdf.setFont("Helvetica-Bold", 13) - pdf.drawString(40, y, f"Račun #{index}") + page_num += 1 + draw_header_footer(page_num) + y = content_top + y -= 8 + pdf.setFont("Vera-Bold", 13) + pdf.drawString(margin, y, f"Račun #{index}") y -= 22 - pdf.setFont("Helvetica", 10) - pdf.drawString(40, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}") + pdf.setFont("Vera", 10) + pdf.drawString(margin, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}") y -= 16 - pdf.drawString(40, y, f"lokacija: {invoice.lokacija or '-'}") + pdf.drawString(margin, y, f"lokacija: {invoice.lokacija or '-'}") y -= 16 - pdf.drawString(40, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}") + pdf.drawString(margin, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}") y -= 16 - pdf.drawString(40, y, f"opis: {(invoice.opis or '-')[:140]}") + pdf.drawString(margin, y, f"opis: {(invoice.opis or '-')[:140]}") y -= 20 if not invoice.image: - pdf.drawString(40, y, "slika: Nema slike.") + pdf.drawString(margin, y, "slika: Nema slike.") continue try: invoice.image.open('rb') with Image.open(invoice.image) as source: image = source.convert('RGB') - max_width = width - 80 - max_height = y - 60 + max_width = width - (2 * margin) + max_height = y - content_bottom if max_height < 120: pdf.showPage() - y = height - 60 - max_height = y - 60 + page_num += 1 + draw_header_footer(page_num) + y = content_top + max_height = y - content_bottom ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0) draw_width = max(1, int(image.width * ratio)) @@ -132,7 +175,7 @@ def _build_work_order_invoices_pdf(work_order): image_reader = ImageReader(image) pdf.drawImage( image_reader, - 40, + margin, y - draw_height, width=draw_width, height=draw_height, @@ -140,7 +183,7 @@ def _build_work_order_invoices_pdf(work_order): mask='auto', ) except (UnidentifiedImageError, OSError): - pdf.drawString(40, y, "slika: Slika nije dostupna ili je oštećena.") + pdf.drawString(margin, y, "slika: Slika nije dostupna ili je oštećena.") finally: invoice.image.close() diff --git a/backend/modules/fleet/tests/test_integration.py b/backend/modules/fleet/tests/test_integration.py index 76d20b5..2dac94a 100644 --- a/backend/modules/fleet/tests/test_integration.py +++ b/backend/modules/fleet/tests/test_integration.py @@ -4,14 +4,12 @@ from modules.fleet.models import Vehicle from django.contrib.auth import get_user_model @pytest.mark.django_db -def test_vehicle_servicer_one_to_one_constraint(test_user): - - # Prvo vozilo prolazi +def test_vehicle_servicer_many_to_one_allows_multiple_vehicles(test_user): Vehicle.objects.create(registration_number="ZG-111-AA", assigned_servicer=test_user) - - # Drugo vozilo za istog servisera mora baciti IntegrityError - with pytest.raises(Exception): # Django IntegrityError - Vehicle.objects.create(registration_number="ZG-222-BB", assigned_servicer=test_user) + + Vehicle.objects.create(registration_number="ZG-222-BB", assigned_servicer=test_user) + + assert Vehicle.objects.filter(assigned_servicer=test_user).count() == 2 @pytest.mark.django_db def test_vehicle_next_service_logic(): diff --git a/backend/modules/fleet/tests/test_notifications.py b/backend/modules/fleet/tests/test_notifications.py index a1a7a0a..5658060 100644 --- a/backend/modules/fleet/tests/test_notifications.py +++ b/backend/modules/fleet/tests/test_notifications.py @@ -116,7 +116,7 @@ class WorkOrderAndServiceRecordNotificationTests(TestCase): "creator": self.creator, "start_mileage": 10000, "end_mileage": 10120, - "purpose": "Routine visit" + "purpose": "kontrola" } wo = VehicleService.create_work_order(data) diff --git a/backend/modules/fleet/tests/test_services.py b/backend/modules/fleet/tests/test_services.py index 30b4756..90ffe9d 100644 --- a/backend/modules/fleet/tests/test_services.py +++ b/backend/modules/fleet/tests/test_services.py @@ -19,7 +19,7 @@ class FleetServiceTests(TestCase): ) def test_create_work_order_via_service(self): - data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "Delivery"} + data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "kontrola"} wo = VehicleService.create_work_order(data) self.vehicle.refresh_from_db() self.assertEqual(self.vehicle.current_mileage, 5200) diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index 3e22aba..710e63d 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -11,21 +11,13 @@ from django.urls import reverse from django.utils import timezone from django.utils.html import escape from django.db.models import Q -import os from reportlab.lib.pagesizes import A4 from reportlab.lib import colors from reportlab.lib.utils import ImageReader from reportlab.lib.styles import getSampleStyleSheet from reportlab.pdfgen import canvas from reportlab.platypus import Table, TableStyle, Paragraph -from reportlab.pdfbase import pdfmetrics -from reportlab.pdfbase.ttfonts import TTFont - -# Register Unicode-capable fonts (Vera supports HR characters: č, ć, đ, š, ž) -_RL_FONTS_DIR = os.path.join(os.path.dirname(__import__('reportlab').__file__), 'fonts') -pdfmetrics.registerFont(TTFont('Vera', os.path.join(_RL_FONTS_DIR, 'Vera.ttf'))) -pdfmetrics.registerFont(TTFont('Vera-Bold', os.path.join(_RL_FONTS_DIR, 'VeraBd.ttf'))) -pdfmetrics.registerFont(TTFont('Vera-Italic', os.path.join(_RL_FONTS_DIR, 'VeraIt.ttf'))) +from .pdf_layout import register_unicode_fonts, draw_standard_header_footer from rest_framework import viewsets, permissions, status from rest_framework.decorators import action, api_view, permission_classes from rest_framework.response import Response @@ -61,6 +53,8 @@ from .services import ( ) from .tasks import build_work_order_invoices_pdf_task +register_unicode_fonts() + def _fleet_assets_queryset_for_user(user, model, *, asset_type=None): queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True) if asset_type: @@ -226,6 +220,30 @@ def _build_work_order_pdf(work_order): place_label = (work_order.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 = ( + creator.assigned_vehicles + .filter(asset_type='vehicle', is_active=True) + .order_by('registration_number') + .first() + ) + assigned_servicer_vehicle_label = "-" + assigned_servicer_vehicle_registration = "-" + if assigned_servicer_vehicle: + assigned_servicer_vehicle_label = " ".join( + part for part in [assigned_servicer_vehicle.make, assigned_servicer_vehicle.model] if part + ).strip() or assigned_servicer_vehicle.registration_number + assigned_servicer_vehicle_registration = assigned_servicer_vehicle.registration_number or "-" + + servicer_vehicle_label = ( + assigned_servicer_vehicle_label + if assigned_servicer_vehicle + else (work_order.servicer_vehicle_make_model or '-') + ) + servicer_vehicle_registration = ( + assigned_servicer_vehicle_registration + if assigned_servicer_vehicle + else (work_order.servicer_vehicle_registration or '-') + ) buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) @@ -331,7 +349,7 @@ def _build_work_order_pdf(work_order): [ "1 dana" if travel_start else "-", _fmt_date(travel_start.date() if travel_start else work_order.date), - f"{work_order.servicer_vehicle_make_model or '-'}, {work_order.servicer_vehicle_registration or '-'}", + f"{servicer_vehicle_label}, {servicer_vehicle_registration}", ], ["Troškovi putovanja terete:", "Posebni dodaci (predujam):", "Početno stanje kilometara:"], [ @@ -397,7 +415,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 '-', work_order.servicer_vehicle_make_model or '-', str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)], + [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)], ["", "", "", "", "", ""], ["", "", "", "", "", ""], ], @@ -493,7 +511,8 @@ def _build_work_order_pdf(work_order): def _build_work_order_service_records_pdf(work_order): vehicle = work_order.vehicle client_name = getattr(vehicle.client, 'name', None) or '-' - model_str = ' '.join(filter(None, [vehicle.make, vehicle.model])) or '-' + manufacturer_str = str(vehicle.make or '-') + model_str = str(vehicle.model or '-') serial_str = str(vehicle.crane_serial_number or '-') upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-') chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-') @@ -563,86 +582,24 @@ def _build_work_order_service_records_pdf(work_order): super().save() def _draw_header_footer(self): - w, h = A4 page_num = self._current_page - - self.setStrokeColor(colors.black) - self.setLineWidth(0.7) - self.rect(MARGIN, h - HEADER_H, w - 2 * MARGIN, HEADER_H - 4, stroke=1, fill=0) - - self.setFont("Vera-Bold", 11) - self.drawString(MARGIN + 6, h - 22, client_name[:44]) - self.drawCentredString(w / 2, h - 22, "Izvještaj servisera") - self.setFont("Vera", 9) - self.drawRightString(w - MARGIN - 6, h - 22, f"Stranica {page_num}") - - self.setLineWidth(0.5) - self.line(MARGIN, h - 36, w - MARGIN, h - 36) - - labels = [ - "Model:", - "Serijski broj:", - "Radni sati nadogradnje:", - "Radni sati podvozja:", - "Km:", - "Broj naloga:", - ] - values = [ - model_str, - serial_str, - upgrade_hours_str, - chassis_hours_str, - mileage_str, - nalog_str, - ] - col_w = (w - 2 * MARGIN) / len(labels) - self.setFont("Vera", 7.2) - for i, (lbl, val) in enumerate(zip(labels, values)): - x = MARGIN + i * col_w + 3 - self.drawString(x, h - 49, lbl) - value_text = str(val or '-') - value_font_size = 9 - if i == len(labels) - 1: - value_font_size = 7 - max_value_width = col_w - 8 - while ( - self.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width - and value_font_size > 5.5 - ): - value_font_size -= 0.3 - if self.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width: - trimmed = value_text - while ( - len(trimmed) > 3 - and self.stringWidth(f"{trimmed}...", "Vera-Bold", value_font_size) > max_value_width - ): - trimmed = trimmed[:-1] - value_text = f"{trimmed}..." - else: - value_text = value_text[:24] - self.setFont("Vera-Bold", value_font_size) - self.drawString(x, h - 61, value_text) - self.setFont("Vera", 7.2) - if i > 0: - self.line(MARGIN + i * col_w, h - 36, MARGIN + i * col_w, h - HEADER_H + 4) - - footer_y = FOOTER_H - self.setLineWidth(0.5) - self.rect(MARGIN, footer_y + 42, w - 2 * MARGIN, 18, stroke=1, fill=0) - sig_labels = [f"Datum: {generated_date_str}", "Potpis servisera", "Pečat i potpis klijenta *1", "Pregledao"] - sig_w = (w - 2 * MARGIN) / 4 - self.setFont("Vera", 7.2) - for i, lbl in enumerate(sig_labels): - x = MARGIN + i * sig_w - self.drawString(x + 4, footer_y + 45, lbl) - if i > 0: - self.line(x, footer_y + 42, x, footer_y + 60) - self.setFont("Vera", 6.5) - self.drawString(MARGIN, footer_y + 26, "*1- potpisom klijent potvrđuje da je suglasan s podacima u radnom nalogu") - self.drawCentredString( - w / 2, - footer_y + 14, - "Ovlašteni servis LIEBHERR Werk-Ehingen GmbH, LIEBHERR-Werk Nenzing GmbH, Liebherr-MCCTech Rostock GmbH", + draw_standard_header_footer( + self, + page_num=page_num, + client_name=client_name, + manufacturer=manufacturer_str, + model=model_str, + serial=serial_str, + upgrade_hours=upgrade_hours_str, + chassis_hours=chassis_hours_str, + mileage=mileage_str, + work_order_number=nalog_str, + generated_date=generated_date_str, + report_title="Izvještaj servisera", + page_size=A4, + margin=MARGIN, + header_h=HEADER_H, + footer_h=FOOTER_H, ) pdf = PageCanvas(buffer, pagesize=A4) @@ -799,7 +756,7 @@ def _build_work_order_service_records_pdf(work_order): if not related_tasks: pdf.setFont("Vera", 9) - pdf.drawString(MARGIN, y, "Nema povezanih taskova za ovaj putni nalog.") + pdf.drawString(MARGIN, y, "Nema povezanih servisnih zadataka za ovaj putni nalog.") else: for task in related_tasks: task_records = [ @@ -812,7 +769,7 @@ def _build_work_order_service_records_pdf(work_order): if y < content_bottom + 60: y = new_page() pdf.setFont("Vera-Bold", 10) - pdf.drawString(MARGIN, y, (task.title or f"Task #{task.id}")[:95]) + pdf.drawString(MARGIN, y, (task.title or f"Servisni zadatak #{task.id}")[:95]) y -= 14 for record in task_records: @@ -912,6 +869,247 @@ def _build_work_order_service_records_pdf(work_order): pdf.save() return buffer.getvalue() + +def _build_service_record_pdf(service_record): + vehicle = service_record.vehicle + task = getattr(service_record, 'task', None) + work_order = getattr(task, 'work_order', None) if task else None + client_name = getattr(getattr(vehicle, 'client', None), 'name', None) or '-' + manufacturer_str = str(vehicle.make or '-') + model_str = str(vehicle.model or '-') + serial_str = str(vehicle.crane_serial_number or '-') + upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-') + chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-') + mileage_str = str(vehicle.current_mileage or '-') + nalog_str = str(getattr(work_order, 'pk', '-') or '-') + generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y') + + HEADER_H = 86 + FOOTER_H = 72 + MARGIN = 28 + + def _fmt_date(value): + if not value: + return '-' + return value.strftime('%d.%m.%Y') if hasattr(value, 'strftime') else str(value) + + buffer = BytesIO() + + class PageCanvas(canvas.Canvas): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._current_page = 0 + + def showPage(self): + self._current_page += 1 + self._draw_header_footer() + super().showPage() + + def save(self): + self._current_page += 1 + self._draw_header_footer() + super().save() + + def _draw_header_footer(self): + page_num = self._current_page + draw_standard_header_footer( + self, + page_num=page_num, + client_name=client_name, + manufacturer=manufacturer_str, + model=model_str, + serial=serial_str, + upgrade_hours=upgrade_hours_str, + chassis_hours=chassis_hours_str, + mileage=mileage_str, + work_order_number=nalog_str, + generated_date=generated_date_str, + report_title="Izvještaj servisera", + page_size=A4, + margin=MARGIN, + header_h=HEADER_H, + footer_h=FOOTER_H, + ) + + pdf = PageCanvas(buffer, pagesize=A4) + page_w, page_h = A4 + content_top = page_h - HEADER_H - 10 + content_bottom = FOOTER_H + 52 + content_width = page_w - 2 * MARGIN + + def new_page(): + pdf.showPage() + return content_top + + def draw_table(y, data, col_widths, style, row_heights=None): + table = Table(data, colWidths=col_widths, rowHeights=row_heights) + table.setStyle(style) + _, h = table.wrap(content_width, page_h) + if y - h < content_bottom: + y = new_page() + table.drawOn(pdf, MARGIN, y - h) + return y - h - 10 + + paragraph_styles = getSampleStyleSheet() + description_style = paragraph_styles['BodyText'].clone('service-record-description') + description_style.fontName = 'Vera' + description_style.fontSize = 8.2 + description_style.leading = 10.4 + + y = content_top - 6 + y -= 6 + + info_rows = [ + ["ID", str(service_record.pk), "Datum", _fmt_date(service_record.service_date)], + ["Naziv", service_record.service_title or '-', "Servisni zadatak", getattr(task, 'title', '-') or '-'], + ["Dizalica", getattr(vehicle, 'registration_number', '-') or '-', "SN", getattr(vehicle, 'crane_serial_number', '-') or '-'], + ["Serviser", _user_display_name(service_record.performed_by) or '-', "KM", str(service_record.mileage or '-')], + ["Trošak", str(service_record.cost), "Sljedeći servis", str(service_record.next_service_due_at or '-')], + ] + y = draw_table( + y, + info_rows, + [78, content_width * 0.37, 88, content_width - 166 - (content_width * 0.37)], + TableStyle([ + ('FONTNAME', (0, 0), (-1, -1), 'Vera'), + ('FONTNAME', (0, 0), (0, -1), 'Vera-Bold'), + ('FONTNAME', (2, 0), (2, -1), 'Vera-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 8.5), + ('GRID', (0, 0), (-1, -1), 0.5, colors.black), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ]), + ) + + y = draw_table( + y, + [["Servisni opis"], [Paragraph(service_record.description or '-', description_style)]], + [content_width], + TableStyle([ + ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), + ('FONTNAME', (0, 1), (-1, -1), 'Vera'), + ('FONTSIZE', (0, 0), (-1, -1), 8.2), + ('GRID', (0, 0), (-1, -1), 0.5, colors.black), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ]), + ) + + y = draw_table( + y, + [["Korišteni dijelovi"], [Paragraph(service_record.parts or '-', description_style)]], + [content_width], + TableStyle([ + ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), + ('FONTNAME', (0, 1), (-1, -1), 'Vera'), + ('FONTSIZE', (0, 0), (-1, -1), 8.2), + ('GRID', (0, 0), (-1, -1), 0.5, colors.black), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ]), + ) + + attachments = list(service_record.attachments.filter(is_active=True).all()) + attachment_names = [os.path.basename(item.file.name) for item in attachments if item.file] + y = draw_table( + y, + [["Prilozi"], [Paragraph(', '.join(attachment_names) if attachment_names else '-', description_style)]], + [content_width], + TableStyle([ + ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), + ('FONTNAME', (0, 1), (-1, -1), 'Vera'), + ('FONTSIZE', (0, 0), (-1, -1), 8.2), + ('GRID', (0, 0), (-1, -1), 0.5, colors.black), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ]), + ) + + photos = [photo for photo in service_record.photos.filter(is_active=True).all() if photo.image] + if photos: + if y < content_bottom + 30: + y = new_page() + y -= 8 + pdf.setFont("Vera", 8) + pdf.drawString(MARGIN, y, "Fotografije:") + y -= 10 + + image_gap = 10 + max_image_height = 110 + image_width = (content_width - image_gap) / 2 + for index in range(0, len(photos), 2): + row_photos = photos[index:index + 2] + prepared = [] + for photo in row_photos: + try: + image_reader = ImageReader(photo.image) + source_w, source_h = image_reader.getSize() + if not source_w or not source_h: + continue + scaled_h = min(max_image_height, image_width * (float(source_h) / float(source_w))) + prepared.append((photo, image_reader, scaled_h)) + except (OSError, ValueError, TypeError, UnidentifiedImageError): + continue + if not prepared: + continue + + caption_height = 12 + row_height = max(item[2] for item in prepared) + caption_height + if y - row_height < content_bottom: + y = new_page() + + base_y = y - caption_height + for photo_index, (photo, image_reader, scaled_h) in enumerate(prepared): + image_x = MARGIN + photo_index * (image_width + image_gap) + image_y = base_y - scaled_h + pdf.drawImage( + image_reader, + image_x, + image_y, + width=image_width, + height=scaled_h, + preserveAspectRatio=True, + anchor='c', + mask='auto', + ) + pdf.setFont("Vera", 7) + caption = (photo.description or '').strip() or f"Slika {index + photo_index + 1}" + pdf.drawString(image_x, image_y - 10, caption[:52]) + + y = base_y - max(item[2] for item in prepared) - 14 + else: + y = draw_table( + y, + [["Fotografije"], ["-"]], + [content_width], + TableStyle([ + ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), + ('FONTNAME', (0, 1), (-1, -1), 'Vera'), + ('FONTSIZE', (0, 0), (-1, -1), 8.2), + ('GRID', (0, 0), (-1, -1), 0.5, colors.black), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ]), + ) + + pdf.save() + return buffer.getvalue() + def _first_non_empty(*values): for value in values: if isinstance(value, str) and value.strip(): @@ -1294,18 +1492,7 @@ class VehicleServiceRecordViewSet(viewsets.ModelViewSet): @action(detail=True, methods=['get'], url_path='pdf') def pdf(self, request, pk=None): service_record = self.get_object() - rows = [ - ("ID", str(service_record.pk)), - ("Datum", str(service_record.service_date)), - ("Dizalica", service_record.vehicle.registration_number), - ("Serviser", service_record.performed_by.get_full_name() or service_record.performed_by.email), - ("Kilometraza", str(service_record.mileage)), - ("Sljedeci servis", str(service_record.next_service_due_at or '-')), - ("Trosak", str(service_record.cost)), - ("Opis", service_record.description or '-'), - ("Dijelovi", service_record.parts or '-'), - ] - pdf_bytes = _build_simple_pdf(f"Servisni zapis {service_record.pk}", rows) + pdf_bytes = _build_service_record_pdf(service_record) response = HttpResponse(pdf_bytes, content_type='application/pdf') response['Content-Disposition'] = f'attachment; filename="{service_record.pk}.service-record.pdf"' return response @@ -1321,16 +1508,7 @@ class VehicleServiceRecordViewSet(viewsets.ModelViewSet): if not recipient: raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."}) - rows = [ - ("ID", str(service_record.pk)), - ("Datum", str(service_record.service_date)), - ("Dizalica", service_record.vehicle.registration_number), - ("Kilometraza", str(service_record.mileage)), - ("Trosak", str(service_record.cost)), - ("Opis", service_record.description or '-'), - ("Dijelovi", service_record.parts or '-'), - ] - pdf_bytes = _build_simple_pdf(f"Servisni zapis {service_record.pk}", rows) + pdf_bytes = _build_service_record_pdf(service_record) subject = _first_non_empty(request.data.get('subject')) or f"Servisni zapis {service_record.pk}" body = _first_non_empty(request.data.get('message')) or ( f"U prilogu je PDF servisnog zapisa. Poslano {timezone.now().strftime('%d.%m.%Y %H:%M')}." diff --git a/backend/modules/invoicing/tasks.py b/backend/modules/invoicing/tasks.py index dac2b62..7f2598a 100644 --- a/backend/modules/invoicing/tasks.py +++ b/backend/modules/invoicing/tasks.py @@ -10,6 +10,13 @@ from modules.crm.models import Client logger = logging.getLogger(__name__) + +class InvoiceService: + @staticmethod + def create_full_invoice(*args, **kwargs): + from modules.invoicing.services import InvoiceService as _InvoiceService + return _InvoiceService.create_full_invoice(*args, **kwargs) + @shared_task(bind=True, max_retries=3) def process_paperless_document_task(self, document_id): """ @@ -57,8 +64,6 @@ def process_paperless_document_task(self, document_id): "created_at": raw_created, } - from modules.invoicing.services import InvoiceService - lookup_field = User.USERNAME_FIELD system_user = User.objects.filter(**{lookup_field: "bot@erp.hr"}).first() diff --git a/backend/modules/task_management/services.py b/backend/modules/task_management/services.py index 4c7dd91..89d34cf 100644 --- a/backend/modules/task_management/services.py +++ b/backend/modules/task_management/services.py @@ -18,6 +18,9 @@ class TaskService: raise ValidationError({"status": "Samo admini mogu odmah označiti zadatak kao gotov."}) payload = dict(data) + title = (payload.get('title') or '').strip() + if title and len(title) < 3: + raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."}) if user and 'assigned_to' not in payload: payload['assigned_to'] = user @@ -40,6 +43,8 @@ class TaskService: # Spremanje for attr, value in data.items(): setattr(instance, attr, value) + if 'title' in data and len(str(data.get('title') or '').strip()) < 3: + raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."}) try: instance.full_clean() except DjangoValidationError as exc: diff --git a/backend/modules/task_management/tests/test_serializers.py b/backend/modules/task_management/tests/test_serializers.py index 444120f..546aacb 100644 --- a/backend/modules/task_management/tests/test_serializers.py +++ b/backend/modules/task_management/tests/test_serializers.py @@ -7,26 +7,28 @@ from modules.fleet.models import Vehicle, WorkOrder class TaskSerializerTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username="task-user-base", email="task-user-base@example.test", password="pass") + self.vehicle = Vehicle.objects.create(registration_number="ZG-TSK-01", asset_type='crane') + def test_validate_title_too_short_raises(self): - serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan"}) + serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)}) with self.assertRaises(ValidationError): serializer.is_valid(raise_exception=True) def test_validate_title_ok(self): - serializer = TaskSerializer(data={"title": "Valid title", "description": "", "status": "aktivan"}) + serializer = TaskSerializer(data={"title": "Valid title", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)}) assert serializer.is_valid(), serializer.errors def test_cannot_close_without_work_order(self): - serializer = TaskSerializer(data={"title": "Task 1", "description": "", "status": "neaktivan"}) + serializer = TaskSerializer(data={"title": "Task 1", "description": "", "status": "neaktivan", "vehicle": str(self.vehicle.id)}) with self.assertRaises(ValidationError): serializer.is_valid(raise_exception=True) def test_can_close_with_work_order(self): - User = get_user_model() - user = User.objects.create_user(username="task-user", email="task-user@example.test", password="pass") - vehicle = Vehicle.objects.create(registration_number="ZG-TSK-02", asset_type='crane') - work_order = WorkOrder.objects.create(vehicle=vehicle, creator=user, purpose='defektaza') - task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=user) + work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='defektaza') + task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle) serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True) assert serializer.is_valid(), serializer.errors \ No newline at end of file diff --git a/backend/modules/task_management/tests/test_views.py b/backend/modules/task_management/tests/test_views.py index d8dd4f6..8035c5e 100644 --- a/backend/modules/task_management/tests/test_views.py +++ b/backend/modules/task_management/tests/test_views.py @@ -5,6 +5,7 @@ from unittest.mock import patch from modules.task_management.views import TaskViewSet from modules.task_management.serializers import TaskSerializer from modules.task_management.models import Task +from modules.fleet.models import Vehicle class TaskViewSetTests(TestCase): @@ -15,10 +16,11 @@ class TaskViewSetTests(TestCase): self.view = TaskViewSet() self.view.request = self.factory.post("/") self.view.request.user = self.user + self.vehicle = Vehicle.objects.create(registration_number="ZG-TV-01", asset_type='crane') @patch("modules.task_management.views.TaskService.create_task_entry") def test_perform_create_calls_service(self, mock_create): - data = {"title": "Task from view", "description": "", "status": "aktivan"} + data = {"title": "Task from view", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)} serializer = TaskSerializer(data=data, context={"request": self.view.request}) assert serializer.is_valid(), serializer.errors diff --git a/backend/pytest.ini b/backend/pytest.ini index cce1585..9559838 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,3 +1,4 @@ [pytest] DJANGO_SETTINGS_MODULE = core.settings -python_files = tests.py test_*.py *_tests.py \ No newline at end of file +python_files = tests.py test_*.py *_tests.py +addopts = --import-mode=importlib \ No newline at end of file diff --git a/frontend/public/service-worker.js b/frontend/public/service-worker.js index 7a1fd8d..86ea725 100644 --- a/frontend/public/service-worker.js +++ b/frontend/public/service-worker.js @@ -1,7 +1,147 @@ -const CACHE_NAME = 'erp-shell-v1'; +const CACHE_NAME = 'erp-shell-v2'; +const API_CACHE_NAME = 'erp-api-v1'; +const WRITE_QUEUE_DB_NAME = 'erp-write-queue-db'; +const WRITE_QUEUE_STORE = 'requests'; +const WRITE_QUEUE_SYNC_TAG = 'erp-write-queue-sync'; const OFFLINE_URL = '/offline.html'; const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL]; +const API_CACHE_BLOCKLIST = [ + '/api/token/', + '/api/token/refresh/', + '/api/fleet/pusher-auth/', +]; + +function isCacheableApiGet(request, url) { + if (request.method !== 'GET') return false; + if (url.origin !== self.location.origin) return false; + if (!url.pathname.startsWith('/api/')) return false; + return !API_CACHE_BLOCKLIST.some((blocked) => url.pathname.startsWith(blocked)); +} + +function isQueueableApiWrite(request, url) { + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) return false; + if (url.origin !== self.location.origin) return false; + if (!url.pathname.startsWith('/api/')) return false; + if (API_CACHE_BLOCKLIST.some((blocked) => url.pathname.startsWith(blocked))) return false; + const contentType = (request.headers.get('content-type') || '').toLowerCase(); + if (contentType.includes('multipart/form-data')) return false; + return true; +} + +function openWriteQueueDb() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(WRITE_QUEUE_DB_NAME, 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(WRITE_QUEUE_STORE)) { + db.createObjectStore(WRITE_QUEUE_STORE, { keyPath: 'id', autoIncrement: true }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function serializeHeaders(headers) { + const serialized = {}; + headers.forEach((value, key) => { + serialized[key] = value; + }); + return serialized; +} + +function getAllQueuedRequests(db) { + return new Promise((resolve, reject) => { + const tx = db.transaction(WRITE_QUEUE_STORE, 'readonly'); + const store = tx.objectStore(WRITE_QUEUE_STORE); + const request = store.getAll(); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); +} + +function addQueuedRequest(db, queuedRequest) { + return new Promise((resolve, reject) => { + const tx = db.transaction(WRITE_QUEUE_STORE, 'readwrite'); + const store = tx.objectStore(WRITE_QUEUE_STORE); + const request = store.add(queuedRequest); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function deleteQueuedRequest(db, id) { + return new Promise((resolve, reject) => { + const tx = db.transaction(WRITE_QUEUE_STORE, 'readwrite'); + const store = tx.objectStore(WRITE_QUEUE_STORE); + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +async function queueWriteRequest(request) { + const body = request.method === 'DELETE' ? null : await request.clone().text(); + const db = await openWriteQueueDb(); + const payload = { + url: request.url, + method: request.method, + headers: serializeHeaders(request.headers), + body, + createdAt: Date.now(), + }; + await addQueuedRequest(db, payload); +} + +function createQueuedResponse() { + return new Response( + JSON.stringify({ + queued: true, + offline: true, + detail: 'Zahtjev je spremljen i bit će sinkroniziran kad veza bude dostupna.', + }), + { + status: 202, + headers: { 'Content-Type': 'application/json' }, + } + ); +} + +async function scheduleWriteQueueSync() { + if (!self.registration || !self.registration.sync) return; + await self.registration.sync.register(WRITE_QUEUE_SYNC_TAG); +} + +async function replayQueuedRequests() { + const db = await openWriteQueueDb(); + const queuedRequests = await getAllQueuedRequests(db); + for (const queued of queuedRequests) { + const headers = new Headers(queued.headers || {}); + const init = { + method: queued.method, + headers, + credentials: 'same-origin', + }; + if (queued.body != null && queued.method !== 'DELETE') { + init.body = queued.body; + } + try { + const response = await fetch(queued.url, init); + if (response.ok) { + await deleteQueuedRequest(db, queued.id); + continue; + } + if (response.status >= 500) { + throw new Error(`Replay failed with status ${response.status}`); + } + await deleteQueuedRequest(db, queued.id); + } catch (error) { + throw error; + } + } +} + self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)) @@ -11,22 +151,76 @@ self.addEventListener('install', (event) => { self.addEventListener('activate', (event) => { event.waitUntil( - caches.keys().then((cacheNames) => - Promise.all( - cacheNames - .filter((name) => name !== CACHE_NAME) - .map((name) => caches.delete(name)) - ) - ) + Promise.all([ + caches.keys().then((cacheNames) => + Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME && name !== API_CACHE_NAME) + .map((name) => caches.delete(name)) + ) + ), + replayQueuedRequests().catch((error) => { + console.error('Initial write-queue replay failed.', error); + }), + ]) ); self.clients.claim(); }); self.addEventListener('fetch', (event) => { const { request } = event; + const url = new URL(request.url); + if (isQueueableApiWrite(request, url)) { + event.respondWith( + fetch(request.clone()).catch(async () => { + try { + await queueWriteRequest(request); + } catch (error) { + console.error('Failed to queue offline write request.', error); + return new Response( + JSON.stringify({ + queued: false, + offline: true, + detail: 'Zahtjev nije moguće spremiti offline.', + }), + { + status: 503, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + try { + await scheduleWriteQueueSync(); + } catch (error) { + console.warn('Background sync registration failed.', error); + } + return createQueuedResponse(); + }) + ); + return; + } + if (request.method !== 'GET') return; - const url = new URL(request.url); + if (isCacheableApiGet(request, url)) { + event.respondWith( + caches.open(API_CACHE_NAME).then(async (cache) => { + const cached = await cache.match(request); + const networkFetch = fetch(request) + .then((response) => { + if (response && response.ok) { + cache.put(request, response.clone()); + } + return response; + }) + .catch(() => cached); + + return cached || networkFetch; + }) + ); + return; + } + if (url.origin !== self.location.origin) return; if (request.mode === 'navigate') { @@ -60,3 +254,13 @@ self.addEventListener('fetch', (event) => { ); } }); + +self.addEventListener('sync', (event) => { + if (event.tag !== WRITE_QUEUE_SYNC_TAG) return; + event.waitUntil(replayQueuedRequests()); +}); + +self.addEventListener('message', (event) => { + if (event.data !== 'FLUSH_WRITE_QUEUE') return; + event.waitUntil(replayQueuedRequests()); +}); diff --git a/frontend/src/components/crm/ClientsSection.jsx b/frontend/src/components/crm/ClientsSection.jsx index 07fe446..4d09bf5 100644 --- a/frontend/src/components/crm/ClientsSection.jsx +++ b/frontend/src/components/crm/ClientsSection.jsx @@ -131,23 +131,20 @@ export default function ClientsSection({ onSetServiceContext = null }) { headClassName="text-xs uppercase tracking-wide text-text-muted" bodyClassName="divide-y divide-border-hairline" rowClassName="hover:bg-canvas-deep" + isRowSelected={(item) => String(item.id) === String(selectedClientCraneId)} tableClassName="min-w-full text-sm" wrapperClassName="overflow-x-auto" - renderRow={(item) => { - const isSelected = String(item.id) === String(selectedClientCraneId); - const cellClassName = isSelected ? 'bg-emerald-50' : ''; - const firstCellClassName = isSelected ? 'bg-emerald-50 text-emerald-800' : ''; - return ( + renderRow={(item) => ( <> - setSelectedClientCraneId(item.id)}>{item.registration_number} - setSelectedClientCraneId(item.id)}>{item.make} {item.model} {item.year ? `(${item.year})` : ''} - setSelectedClientCraneId(item.id)}>{item.current_mileage?.toLocaleString('hr-HR')} km - setSelectedClientCraneId(item.id)}> + setSelectedClientCraneId(item.id)}>{item.registration_number} + setSelectedClientCraneId(item.id)}>{item.make} {item.model} {item.year ? `(${item.year})` : ''} + setSelectedClientCraneId(item.id)}>{item.current_mileage?.toLocaleString('hr-HR')} km + setSelectedClientCraneId(item.id)}> {item.service_interval_km ? `${(Math.floor(item.current_mileage / item.service_interval_km) + 1) * item.service_interval_km} km` : '—'} - setSelectedClientCraneId(item.id)}> + setSelectedClientCraneId(item.id)}> - );}} + )} /> )} diff --git a/frontend/src/components/dashboard/FleetDashboardShell.jsx b/frontend/src/components/dashboard/FleetDashboardShell.jsx index aae82ef..e0c6373 100644 --- a/frontend/src/components/dashboard/FleetDashboardShell.jsx +++ b/frontend/src/components/dashboard/FleetDashboardShell.jsx @@ -1,6 +1,6 @@ import { useStore } from '@nanostores/preact'; import { useEffect, useMemo, useState } from 'preact/hooks'; -import { animated, useSpring } from '@react-spring/web'; +import { animated, useSpring, useTransition } from '@react-spring/web'; import DashboardTopbar from './DashboardTopbar'; import WorkOrderModal from './WorkOrderModal'; import TaskCreateModal from './TaskCreateModal'; @@ -112,6 +112,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page const [taskScope, setTaskScope] = useState('all'); const [workOrderScope, setWorkOrderScope] = useState('context'); const [selectedCraneRowId, setSelectedCraneRowId] = useState(null); + const [expandedServiceTaskId, setExpandedServiceTaskId] = useState(null); const [hasMounted, setHasMounted] = useState(false); useEffect(() => { @@ -270,12 +271,50 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page || contextCrane?.client_name || null ), [clients, selectedClientId, contextCrane]); + const contextCraneLabel = useMemo(() => { + if (!hydratedContextCrane) return null; + const serial = String(hydratedContextCrane.crane_serial_number || '').trim(); + if (serial) return `SN ${serial}`; + return hydratedContextCrane.registration_number || '-'; + }, [hydratedContextCrane]); const serviceRecordsForContext = useMemo(() => { if (!selectedVehicleId) return []; return [...serviceRecords] .filter((record) => String(record.vehicle) === String(selectedVehicleId)) .sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || ''))); }, [serviceRecords, selectedVehicleId]); + const serviceTaskGroups = useMemo(() => { + if (!selectedVehicleId) return []; + const contextTasks = [...tasks] + .filter((task) => String(task.vehicle || '') === String(selectedVehicleId)) + .sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))); + const groups = contextTasks.map((task) => ({ + id: String(task.id), + title: task.title || 'Radni zadatak', + status: task.status || '-', + task, + records: serviceRecordsForContext.filter((record) => String(record.task || '') === String(task.id)), + })); + const unassignedRecords = serviceRecordsForContext.filter((record) => !record.task); + if (unassignedRecords.length > 0) { + groups.push({ + id: 'unassigned-task', + title: 'Bez radnog zadatka', + status: '-', + task: null, + records: unassignedRecords, + }); + } + return groups; + }, [selectedVehicleId, tasks, serviceRecordsForContext]); + const serviceTaskTransitions = useTransition(serviceTaskGroups, { + keys: (group) => group.id, + from: { opacity: 0, transform: 'translate3d(0,8px,0)' }, + enter: { opacity: 1, transform: 'translate3d(0,0,0)' }, + leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' }, + trail: 30, + config: { tension: 230, friction: 26 }, + }); const scopedWorkOrders = useMemo(() => { const sorted = [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))); if (workOrderScope === 'all') { @@ -363,6 +402,14 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page setCurrentPage(1); }, [workOrderScope, hydratedSelectedVehicleId]); + useEffect(() => { + if (!expandedServiceTaskId) return; + const exists = serviceTaskGroups.some((group) => String(group.id) === String(expandedServiceTaskId)); + if (!exists) { + setExpandedServiceTaskId(null); + } + }, [expandedServiceTaskId, serviceTaskGroups]); + useEffect(() => { if (!selectedCraneRowId) return; const exists = cranes.some((item) => String(item.id) === String(selectedCraneRowId)); @@ -421,6 +468,27 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page showToast('Servisni kontekst je postavljen.', 'success'); } + async function openTaskModalFromContext() { + if (!selectedClientId || !selectedVehicleId) { + showToast('Odaberite kupca i dizalicu u Servisnom kontekstu prije kreiranja radnog zadatka.', 'warning'); + return; + } + try { + const availableCranes = await ensureVehiclesCatalog(); + const selectedFromContext = availableCranes.find( + (item) => String(item.id) === String(selectedVehicleId) + ); + if (!selectedFromContext) { + showToast('Odabrana dizalica nije dostupna u trenutnom kontekstu.', 'warning'); + return; + } + } catch { + showToast('Ne mogu dohvatiti dizalice. Provjerite mrežu ili prijavu.', 'error'); + return; + } + setIsTaskModalOpen(true); + } + const workOrderColumns = [ { key: 'code', label: 'Putni nalog', className: 'px-4 py-3' }, { key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' }, @@ -474,26 +542,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page } setIsWorkOrderModalOpen(true); }} - onOpenTaskModal={async () => { - if (!selectedClientId || !selectedVehicleId) { - showToast('Odaberite kupca i dizalicu u Servisnom kontekstu prije kreiranja radnog zadatka.', 'warning'); - return; - } - try { - const availableCranes = await ensureVehiclesCatalog(); - const selectedFromContext = availableCranes.find( - (item) => String(item.id) === String(selectedVehicleId) - ); - if (!selectedFromContext) { - showToast('Odabrana dizalica nije dostupna u trenutnom kontekstu.', 'warning'); - return; - } - } catch { - showToast('Ne mogu dohvatiti dizalice. Provjerite mrežu ili prijavu.', 'error'); - return; - } - setIsTaskModalOpen(true); - }} + onOpenTaskModal={openTaskModalFromContext} onNewServiceRecord={({ craneId }) => { const crane = cranes.find((item) => String(item.id) === String(craneId)); openServiceRecordForCrane(crane ?? null); @@ -640,8 +689,8 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page

Putni nalozi dizalica

{workOrderScope === 'context' - ? (selectedContextClientName && hydratedContextCrane?.registration_number - ? `Kontekst: ${selectedContextClientName} / ${hydratedContextCrane.registration_number}` + ? (selectedContextClientName && contextCraneLabel + ? `Kontekst: ${selectedContextClientName} / ${contextCraneLabel}` : 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.') : 'Prikaz svih putnih naloga.'}

@@ -777,12 +826,19 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page

Servisni zapisi

- {selectedContextClientName && hydratedContextCrane?.registration_number - ? `Kontekst: ${selectedContextClientName} / ${hydratedContextCrane.registration_number}` + {selectedContextClientName && contextCraneLabel + ? `Kontekst: ${selectedContextClientName} / ${contextCraneLabel}` : 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.'}

+ {hydratedSelectedVehicleId && hydratedContextCrane && ( openServiceRecordForCrane(hydratedContextCrane)} /> )} @@ -795,55 +851,101 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
- record.id} - loading={loading} - loadingMessage="Učitavanje..." - emptyMessage={!hydratedSelectedVehicleId - ? 'Odaberite dizalicu za prikaz servisnih zapisa.' - : 'Nema aktivnih servisnih zapisa za odabranu dizalicu.'} - renderRow={(record) => ( - <> - {formatDate(record.service_date)} - {record.crane_registration || record.vehicle_registration || '-'} - {record.task_label || '-'} - - {record.description || '-'} - - {formatCost(record.cost)} - {record.files_count ?? (record.photos_count ?? 0)} - - - - - - )} - /> + {!hydratedSelectedVehicleId && ( +

+ Odaberite dizalicu za prikaz servisnih zapisa. +

+ )} + {hydratedSelectedVehicleId && loading && ( +

+ Učitavanje... +

+ )} + {hydratedSelectedVehicleId && !loading && serviceTaskGroups.length === 0 && ( +

+ Nema grupiranih radnih zadataka za odabranu dizalicu. +

+ )} + {hydratedSelectedVehicleId && !loading && serviceTaskGroups.length > 0 && ( +
+ {serviceTaskTransitions((style, group) => { + const isOpen = String(expandedServiceTaskId || '') === String(group.id); + return ( + + + {isOpen && ( +
+ {group.records.length === 0 ? ( +

+ Nema servisnih zapisa za odabrani radni zadatak. +

+ ) : ( + record.id} + rowClassName="hover:bg-canvas-deep" + renderRow={(record) => ( + <> + {formatDate(record.service_date)} + {record.crane_registration || record.vehicle_registration || '-'} + + {record.description || '-'} + + {formatCost(record.cost)} + {record.files_count ?? (record.photos_count ?? 0)} + + + + + + )} + /> + )} +
+ )} +
+ ); + })} +
+ )} )} @@ -884,43 +986,45 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page columns={vehicleColumns} rows={cranes} rowKey={(vehicle) => vehicle.id} + rowClassName="hover:bg-canvas-deep" + isRowSelected={(vehicle) => String(vehicle.id) === String(selectedCraneRowId)} loading={loading} loadingMessage="Učitavanje vozila..." emptyMessage="Nema dostupnih vozila." renderRow={(vehicle) => ( <> setSelectedCraneRowId(vehicle.id)} > {vehicle.registration_number || '-'} setSelectedCraneRowId(vehicle.id)} > {vehicle.crane_serial_number || '-'} setSelectedCraneRowId(vehicle.id)} > {vehicle.make || '-'} {vehicle.model || ''} setSelectedCraneRowId(vehicle.id)} > {vehicle.client_name || '-'} setSelectedCraneRowId(vehicle.id)} > {vehicle.current_mileage ?? '-'} setSelectedCraneRowId(vehicle.id)} > diff --git a/frontend/src/components/dashboard/ServiceContextSelector.jsx b/frontend/src/components/dashboard/ServiceContextSelector.jsx index ccf4b43..1555b96 100644 --- a/frontend/src/components/dashboard/ServiceContextSelector.jsx +++ b/frontend/src/components/dashboard/ServiceContextSelector.jsx @@ -51,6 +51,14 @@ export default function ServiceContextSelector({ onNewServiceRecord }) { const selectedClient = availableClients.find((c) => String(c.id) === String(selectedClientId)); const selectedCrane = cranes.find((item) => String(item.id) === String(selectedVehicleId)); const showSelector = editMode || !selectedClientId; + const selectedCraneLabel = (() => { + if (!selectedCrane) return ''; + const serial = String(selectedCrane.crane_serial_number || '').trim(); + if (serial) { + return `SN ${serial} ${selectedCrane.make || ''} ${selectedCrane.model || ''}`.trim(); + } + return `${selectedCrane.registration_number || '-'} ${selectedCrane.make || ''} ${selectedCrane.model || ''}`.trim(); + })(); function handleClientChange(e) { setSelectedClient(e.target.value); @@ -97,7 +105,7 @@ export default function ServiceContextSelector({ onNewServiceRecord }) { {clientCranes.length > 0 ? clientCranes.map((item) => ( )) : @@ -137,7 +145,7 @@ export default function ServiceContextSelector({ onNewServiceRecord }) { <> / - {selectedCrane.registration_number} {selectedCrane.make} {selectedCrane.model} + {selectedCraneLabel} )} diff --git a/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx b/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx index db206ae..269d3e3 100644 --- a/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx +++ b/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo, useState } from 'preact/hooks'; +import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { animated, useTransition } from '@react-spring/web'; +import { api } from '../../services/apiClient'; function getBrowserApiBase() { const apiUrl = import.meta.env.PUBLIC_API_URL || ''; @@ -44,6 +45,109 @@ export function resolveMediaUrl(pathOrUrl) { return new URL(finalSrc, apiUrl).toString(); } +function isProtectedMediaUrl(url) { + return typeof url === 'string' && /\/api\/fleet\//i.test(url); +} + +function revokeObjectUrls(entries = {}) { + Object.values(entries).forEach((url) => { + if (typeof url === 'string' && url.startsWith('blob:')) { + window.URL.revokeObjectURL(url); + } + }); +} + +export function useAuthenticatedMediaSources(mediaPaths = []) { + const [authenticatedSources, setAuthenticatedSources] = useState({}); + const authenticatedSourcesRef = useRef({}); + + const resolvedMediaPaths = useMemo(() => { + const unique = new Set(); + if (!Array.isArray(mediaPaths)) { + return []; + } + mediaPaths.forEach((item) => { + const resolved = resolveMediaUrl(item); + if (resolved) { + unique.add(resolved); + } + }); + return Array.from(unique); + }, [mediaPaths]); + const resolvedMediaPathsKey = useMemo(() => resolvedMediaPaths.join('|'), [resolvedMediaPaths]); + + useEffect(() => { + authenticatedSourcesRef.current = authenticatedSources; + }, [authenticatedSources]); + + useEffect(() => { + let cancelled = false; + const protectedPaths = resolvedMediaPaths.filter((url) => isProtectedMediaUrl(url)); + + if (protectedPaths.length === 0) { + setAuthenticatedSources((previous) => { + revokeObjectUrls(previous); + return {}; + }); + return undefined; + } + + (async () => { + const nextSources = {}; + const createdObjectUrls = []; + + const fetched = await Promise.all( + protectedPaths.map(async (url) => { + try { + const blob = await api.get(url, { responseType: 'blob' }); + const blobUrl = window.URL.createObjectURL(blob); + createdObjectUrls.push(blobUrl); + return [url, blobUrl]; + } catch { + return null; + } + }) + ); + + fetched.forEach((entry) => { + if (!entry) return; + const [url, blobUrl] = entry; + nextSources[url] = blobUrl; + }); + + if (cancelled) { + createdObjectUrls.forEach((blobUrl) => window.URL.revokeObjectURL(blobUrl)); + return; + } + + setAuthenticatedSources((previous) => { + revokeObjectUrls(previous); + return nextSources; + }); + })(); + + return () => { + cancelled = true; + }; + }, [resolvedMediaPathsKey]); + + useEffect(() => () => { + revokeObjectUrls(authenticatedSourcesRef.current); + }, []); + + const getAuthenticatedMediaSrc = (pathOrUrl) => { + const resolved = resolveMediaUrl(pathOrUrl); + if (!resolved) { + return ''; + } + return authenticatedSources[resolved] || resolved; + }; + + return { + getAuthenticatedMediaSrc, + }; +} + export default function WorkOrderImageCarousel({ images = [], editMode = false, emptyLabel = 'Nema priložene tehničke dokumentacije' }) { const normalizedImages = useMemo(() => ( Array.isArray(images) @@ -51,6 +155,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false, : (typeof images === 'string' && images ? [images] : []) ), [images]); const [currentImgIdx, setCurrentImgIdx] = useState(0); + const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(normalizedImages); useEffect(() => { setCurrentImgIdx(0); @@ -87,7 +192,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false, setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length); }; - const finalSrc = resolveMediaUrl(normalizedImages[currentImgIdx] || ''); + const finalSrc = getAuthenticatedMediaSrc(normalizedImages[currentImgIdx] || ''); const imageTransitions = useTransition(currentImgIdx, { from: { opacity: 0, transform: 'translate3d(8%,0,0) scale(1.01)' }, enter: { opacity: 1, transform: 'translate3d(0%,0,0) scale(1)' }, @@ -101,7 +206,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false, {imageTransitions((style, idx) => ( { + const paths = []; + if (Array.isArray(taskContext.tasks)) { + taskContext.tasks.forEach((task) => { + if (!Array.isArray(task?.service_records)) return; + task.service_records.forEach((record) => { + if (!Array.isArray(record?.photos)) return; + record.photos.forEach((photo) => { + const candidate = photo?.optimized_url || photo?.image_url; + if (candidate) { + paths.push(candidate); + } + }); + }); + }); + } + if (Array.isArray(invoices)) { + invoices.forEach((invoice) => { + if (invoice?.image) { + paths.push(invoice.image); + } + }); + } + return paths; + }, [taskContext.tasks, invoices]); + + const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(mediaPaths); + return (
@@ -144,7 +172,7 @@ export default function WorkOrderInvoicesPdfPage() {
-

Task i povezani servisni zapisi

+

Servisni zapisi

diff --git a/frontend/src/components/fleet/ServiceRecordDetailModal.jsx b/frontend/src/components/fleet/ServiceRecordDetailModal.jsx index a1090af..077d451 100644 --- a/frontend/src/components/fleet/ServiceRecordDetailModal.jsx +++ b/frontend/src/components/fleet/ServiceRecordDetailModal.jsx @@ -276,7 +276,7 @@ export default function ServiceRecordDetailModal({

Trošak / KM / Dizalica

- {formatCost(serviceRecord.cost)} • {serviceRecord.mileage ?? '-'} km • {serviceRecord.crane_registration || serviceRecord.vehicle_registration || '-'} + {formatCost(serviceRecord.cost)} • {serviceRecord.mileage ?? '-'} km • {serviceRecord.crane_serial_number || serviceRecord.crane_registration || serviceRecord.vehicle_registration || '-'}

diff --git a/frontend/src/components/ui/AnimatedDataTable.jsx b/frontend/src/components/ui/AnimatedDataTable.jsx index 9f3d011..1c60225 100644 --- a/frontend/src/components/ui/AnimatedDataTable.jsx +++ b/frontend/src/components/ui/AnimatedDataTable.jsx @@ -13,6 +13,7 @@ export default function AnimatedDataTable({ headClassName = 'bg-canvas-deep text-left text-xs uppercase tracking-wide text-text-muted', bodyClassName = 'divide-y divide-border-hairline', rowClassName = 'hover:bg-canvas-deep', + isRowSelected = () => false, wrapperClassName = 'overflow-x-auto', trail = 35, }) { @@ -23,9 +24,20 @@ export default function AnimatedDataTable({ const transitions = useTransition(rows, { keys: (row) => rowKey(row), - from: { opacity: 0, transform: 'translate3d(0,8px,0)' }, - enter: { opacity: 1, transform: 'translate3d(0,0,0)' }, - leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' }, + from: { opacity: 0, transform: 'translate3d(0,8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' }, + enter: (row) => ({ + opacity: 1, + transform: 'translate3d(0,0,0)', + boxShadow: isRowSelected(row) + ? 'inset 0 0 0 2px rgba(16,185,129,0.75)' + : 'inset 0 0 0 0px rgba(16,185,129,0)', + }), + update: (row) => ({ + boxShadow: isRowSelected(row) + ? 'inset 0 0 0 2px rgba(16,185,129,0.75)' + : 'inset 0 0 0 0px rgba(16,185,129,0)', + }), + leave: { opacity: 0, transform: 'translate3d(0,-8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' }, trail, config: { tension: 230, friction: 26 }, }); diff --git a/frontend/src/components/ui/ThemeToggle.jsx b/frontend/src/components/ui/ThemeToggle.jsx index 3033b0b..76a40c9 100644 --- a/frontend/src/components/ui/ThemeToggle.jsx +++ b/frontend/src/components/ui/ThemeToggle.jsx @@ -24,7 +24,7 @@ export default function ThemeToggle() {