2 Commits

Author SHA1 Message Date
mariomitte
4e3bf6dab0 patch oko teksta PDFa i UI tablica
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-07-12 03:25:03 +02:00
mariomitte
3b54499b17 patch astro frontend config file 2026-07-10 23:34:29 +02:00
41 changed files with 2153 additions and 331 deletions

View File

@@ -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,,
1 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
2 serviser1@example.com Ime1 Prezime1 password123 true false true false false true +38591111222 12345678901 Serviser Zagreb Serviser dizalice LIC-001 true
3 serviser2@example.com Ime2 Prezime2 password123 true false true false false true +38591111222 12345678901 Serviser Zagreb Serviser dizalice LIC-001 true
4 serviser3@example.com Ime3 Prezime3 password123 true false true false false true +38591111222 12345678901 Serviser Zagreb Serviser dizalice LIC-001 true
5 admin1@example.com Admin1 Prezime1 password123 false false true true true true +38591111222 12345678901 Admin Zagreb Direktor LIC-001 true
6 admin2@example.com Admin2 Prezime2 password123 false false true true true true +38591111222 12345678901 Admin Zagreb Direktor LIC-001 true

View File

@@ -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

View File

@@ -1,5 +1,4 @@
from rest_framework import serializers from rest_framework import serializers
from django.core.exceptions import ObjectDoesNotExist
from .models import CustomUser from .models import CustomUser
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
@@ -14,11 +13,8 @@ class UserSerializer(serializers.ModelSerializer):
'first_name': {'required': True, 'allow_blank': False} 'first_name': {'required': True, 'allow_blank': False}
} }
def _get_assigned_asset(self, obj): def _get_assigned_assets(self, obj):
try: return obj.assigned_vehicles.all()
return obj.assigned_vehicles
except ObjectDoesNotExist:
return None
def _serialize_asset(self, vehicle): def _serialize_asset(self, vehicle):
if not vehicle: if not vehicle:
@@ -34,10 +30,14 @@ class UserSerializer(serializers.ModelSerializer):
} }
def get_assigned_vehicle(self, obj): 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): 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): def validate(self, data):
""" """

View File

@@ -1,4 +1,5 @@
import pytest import pytest
from django.contrib.auth.models import Group
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
User = get_user_model() User = get_user_model()
@@ -23,3 +24,19 @@ def test_create_superuser():
) )
assert admin.is_superuser is True assert admin.is_superuser is True
assert admin.is_staff is True 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()

View File

@@ -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
1 name client_type tax_id email phone address city postal_code
2 Adriacink transport d.o.o. legal 10000000001 adriacink.transport@example.com +38591111001 Ulica 1 Zagreb 10000
3 BOSMAN legal 10000000002 bosman@example.com +38591111002 Ulica 2 Zagreb 10000
4 BOSSIL legal 10000000003 bossil@example.com +38591111003 Ulica 3 Zagreb 10000
5 PAKLOG legal 10000000004 paklog@example.com +38591111004 Ulica 4 Zagreb 10000
6 Palace legal 10000000005 palace@example.com +38591111005 Ulica 5 Zagreb 10000
7 Petrokemija Kutina legal 10000000006 petrokemija.kutina@example.com +38591111006 Ulica 6 Kutina 44320
8 Paron legal 10000000007 paron@example.com +38591111007 Ulica 7 Zagreb 10000
9 PRANGL legal 10000000008 prangl@example.com +38591111008 Ulica 8 Zagreb 10000
10 Salona MONT legal 10000000009 salona.mont@example.com +38591111009 Ulica 9 Split 21000
11 STRABAG legal 10000000010 strabag@example.com +38591111010 Ulica 10 Zagreb 10000

View File

@@ -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))

View File

@@ -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
1 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
2 ZG-CRN-001 123456 Liebherr LTM 1050 2022 WLH1234567890ABCD 1200 800 45200 15000 true serviser1@example.com true

View File

@@ -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
1 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
2 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

View File

@@ -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
1 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
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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

View File

@@ -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
1 registration_number make model year vin current_mileage service_interval_km assigned_servicer_email is_active
2 ZG-SRV-001 Volkswagen Caddy 2021 WV1ZZZ2KZMX000001 128400 15000 serviser1@example.com true
3 ZG-SRV-002 Renault Trafic 2022 VF1FL0000NY000002 96420 15000 serviser2@example.com true
4 ZG-SRV-003 Ford Transit Custom 2023 WF0YXXTTGYPU00003 54780 15000 serviser3@example.com true

View File

@@ -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

View File

@@ -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

View File

@@ -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",
),
),
]

View File

@@ -149,7 +149,7 @@ class Vehicle(BaseModel):
) )
current_mileage = models.PositiveIntegerField(default=0, verbose_name=_("Trenutna kilometraža (km)")) current_mileage = models.PositiveIntegerField(default=0, verbose_name=_("Trenutna kilometraža (km)"))
service_interval_km = models.PositiveIntegerField(default=15000, verbose_name=_("Interval servisa (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, settings.AUTH_USER_MODEL,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='assigned_vehicles', related_name='assigned_vehicles',

View File

@@ -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",
)

View File

@@ -2,6 +2,7 @@
from celery import shared_task from celery import shared_task
from django.core.mail import send_mail from django.core.mail import send_mail
from django.conf import settings from django.conf import settings
from django.utils import timezone
import logging import logging
from io import BytesIO from io import BytesIO
import base64 import base64
@@ -10,8 +11,10 @@ from reportlab.lib.pagesizes import A4
from reportlab.lib.utils import ImageReader from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
from .models import VehicleNotification from .models import VehicleNotification
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
register_unicode_fonts()
@shared_task(bind=True, max_retries=3, default_retry_delay=60) @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() buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4) pdf = canvas.Canvas(buffer, pagesize=A4)
width, height = 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) def draw_header_footer(page_num):
pdf.drawString(40, y, f"Računi putnog naloga WO-{work_order.pk}") draw_standard_header_footer(
y -= 22 pdf,
pdf.setFont("Helvetica", 10) page_num=page_num,
pdf.drawString(40, y, f"Dizalica: {work_order.vehicle.registration_number}") client_name=client_name,
y -= 16 manufacturer=manufacturer,
pdf.drawString(40, y, f"Datum naloga: {work_order.date}") 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') invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')
if not invoices.exists(): if not invoices.exists():
y -= 24 y -= 20
pdf.drawString(40, y, "Nema računa za ovaj putni nalog.") pdf.drawString(margin, y, "Nema računa za ovaj putni nalog.")
pdf.save() pdf.save()
return buffer.getvalue() return buffer.getvalue()
for index, invoice in enumerate(invoices, start=1): for index, invoice in enumerate(invoices, start=1):
pdf.showPage() pdf.showPage()
y = height - 50 page_num += 1
pdf.setFont("Helvetica-Bold", 13) draw_header_footer(page_num)
pdf.drawString(40, y, f"Račun #{index}") y = content_top
y -= 8
pdf.setFont("Vera-Bold", 13)
pdf.drawString(margin, y, f"Račun #{index}")
y -= 22 y -= 22
pdf.setFont("Helvetica", 10) pdf.setFont("Vera", 10)
pdf.drawString(40, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}") pdf.drawString(margin, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}")
y -= 16 y -= 16
pdf.drawString(40, y, f"lokacija: {invoice.lokacija or '-'}") pdf.drawString(margin, y, f"lokacija: {invoice.lokacija or '-'}")
y -= 16 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 y -= 16
pdf.drawString(40, y, f"opis: {(invoice.opis or '-')[:140]}") pdf.drawString(margin, y, f"opis: {(invoice.opis or '-')[:140]}")
y -= 20 y -= 20
if not invoice.image: if not invoice.image:
pdf.drawString(40, y, "slika: Nema slike.") pdf.drawString(margin, y, "slika: Nema slike.")
continue continue
try: try:
invoice.image.open('rb') invoice.image.open('rb')
with Image.open(invoice.image) as source: with Image.open(invoice.image) as source:
image = source.convert('RGB') image = source.convert('RGB')
max_width = width - 80 max_width = width - (2 * margin)
max_height = y - 60 max_height = y - content_bottom
if max_height < 120: if max_height < 120:
pdf.showPage() pdf.showPage()
y = height - 60 page_num += 1
max_height = y - 60 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) ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0)
draw_width = max(1, int(image.width * ratio)) draw_width = max(1, int(image.width * ratio))
@@ -132,7 +175,7 @@ def _build_work_order_invoices_pdf(work_order):
image_reader = ImageReader(image) image_reader = ImageReader(image)
pdf.drawImage( pdf.drawImage(
image_reader, image_reader,
40, margin,
y - draw_height, y - draw_height,
width=draw_width, width=draw_width,
height=draw_height, height=draw_height,
@@ -140,7 +183,7 @@ def _build_work_order_invoices_pdf(work_order):
mask='auto', mask='auto',
) )
except (UnidentifiedImageError, OSError): 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: finally:
invoice.image.close() invoice.image.close()

View File

@@ -4,15 +4,13 @@ from modules.fleet.models import Vehicle
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@pytest.mark.django_db @pytest.mark.django_db
def test_vehicle_servicer_one_to_one_constraint(test_user): def test_vehicle_servicer_many_to_one_allows_multiple_vehicles(test_user):
# Prvo vozilo prolazi
Vehicle.objects.create(registration_number="ZG-111-AA", assigned_servicer=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 @pytest.mark.django_db
def test_vehicle_next_service_logic(): def test_vehicle_next_service_logic():
# current=12000, interval=15000 -> next_service_at = 15000 # current=12000, interval=15000 -> next_service_at = 15000

View File

@@ -116,7 +116,7 @@ class WorkOrderAndServiceRecordNotificationTests(TestCase):
"creator": self.creator, "creator": self.creator,
"start_mileage": 10000, "start_mileage": 10000,
"end_mileage": 10120, "end_mileage": 10120,
"purpose": "Routine visit" "purpose": "kontrola"
} }
wo = VehicleService.create_work_order(data) wo = VehicleService.create_work_order(data)

View File

@@ -19,7 +19,7 @@ class FleetServiceTests(TestCase):
) )
def test_create_work_order_via_service(self): 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) wo = VehicleService.create_work_order(data)
self.vehicle.refresh_from_db() self.vehicle.refresh_from_db()
self.assertEqual(self.vehicle.current_mileage, 5200) self.assertEqual(self.vehicle.current_mileage, 5200)

View File

@@ -11,21 +11,13 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.html import escape from django.utils.html import escape
from django.db.models import Q from django.db.models import Q
import os
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib import colors from reportlab.lib import colors
from reportlab.lib.utils import ImageReader from reportlab.lib.utils import ImageReader
from reportlab.lib.styles import getSampleStyleSheet from reportlab.lib.styles import getSampleStyleSheet
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
from reportlab.platypus import Table, TableStyle, Paragraph from reportlab.platypus import Table, TableStyle, Paragraph
from reportlab.pdfbase import pdfmetrics from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
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 rest_framework import viewsets, permissions, status from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action, api_view, permission_classes from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response from rest_framework.response import Response
@@ -61,6 +53,8 @@ from .services import (
) )
from .tasks import build_work_order_invoices_pdf_task from .tasks import build_work_order_invoices_pdf_task
register_unicode_fonts()
def _fleet_assets_queryset_for_user(user, model, *, asset_type=None): def _fleet_assets_queryset_for_user(user, model, *, asset_type=None):
queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True) queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True)
if asset_type: 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' 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] invoice_names = [inv.naziv_racuna for inv in invoices[:5] if inv.naziv_racuna]
attachments_text = ', '.join(invoice_names) if invoice_names else '-' 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() buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4) pdf = canvas.Canvas(buffer, pagesize=A4)
@@ -331,7 +349,7 @@ def _build_work_order_pdf(work_order):
[ [
"1 dana" if travel_start else "-", "1 dana" if travel_start else "-",
_fmt_date(travel_start.date() if travel_start else work_order.date), _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:"], ["Troškovi putovanja terete:", "Posebni dodaci (predujam):", "Početno stanje kilometara:"],
[ [
@@ -397,7 +415,7 @@ def _build_work_order_pdf(work_order):
y, y,
[ [
["RELACIJA od", "RELACIJA do", "Vrsta prijevoznog sredstva", "Razred [km]", "Iznos za prijevoz", "Ukupan iznos"], ["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): def _build_work_order_service_records_pdf(work_order):
vehicle = work_order.vehicle vehicle = work_order.vehicle
client_name = getattr(vehicle.client, 'name', None) or '-' 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 '-') serial_str = str(vehicle.crane_serial_number or '-')
upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-') upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-')
chassis_hours_str = str(getattr(vehicle, 'chassis_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() super().save()
def _draw_header_footer(self): def _draw_header_footer(self):
w, h = A4
page_num = self._current_page page_num = self._current_page
draw_standard_header_footer(
self.setStrokeColor(colors.black) self,
self.setLineWidth(0.7) page_num=page_num,
self.rect(MARGIN, h - HEADER_H, w - 2 * MARGIN, HEADER_H - 4, stroke=1, fill=0) client_name=client_name,
manufacturer=manufacturer_str,
self.setFont("Vera-Bold", 11) model=model_str,
self.drawString(MARGIN + 6, h - 22, client_name[:44]) serial=serial_str,
self.drawCentredString(w / 2, h - 22, "Izvještaj servisera") upgrade_hours=upgrade_hours_str,
self.setFont("Vera", 9) chassis_hours=chassis_hours_str,
self.drawRightString(w - MARGIN - 6, h - 22, f"Stranica {page_num}") mileage=mileage_str,
work_order_number=nalog_str,
self.setLineWidth(0.5) generated_date=generated_date_str,
self.line(MARGIN, h - 36, w - MARGIN, h - 36) report_title="Izvještaj servisera",
page_size=A4,
labels = [ margin=MARGIN,
"Model:", header_h=HEADER_H,
"Serijski broj:", footer_h=FOOTER_H,
"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",
) )
pdf = PageCanvas(buffer, pagesize=A4) pdf = PageCanvas(buffer, pagesize=A4)
@@ -799,7 +756,7 @@ def _build_work_order_service_records_pdf(work_order):
if not related_tasks: if not related_tasks:
pdf.setFont("Vera", 9) 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: else:
for task in related_tasks: for task in related_tasks:
task_records = [ task_records = [
@@ -812,7 +769,7 @@ def _build_work_order_service_records_pdf(work_order):
if y < content_bottom + 60: if y < content_bottom + 60:
y = new_page() y = new_page()
pdf.setFont("Vera-Bold", 10) 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 y -= 14
for record in task_records: for record in task_records:
@@ -912,6 +869,247 @@ def _build_work_order_service_records_pdf(work_order):
pdf.save() pdf.save()
return buffer.getvalue() 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): def _first_non_empty(*values):
for value in values: for value in values:
if isinstance(value, str) and value.strip(): if isinstance(value, str) and value.strip():
@@ -1294,18 +1492,7 @@ class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['get'], url_path='pdf') @action(detail=True, methods=['get'], url_path='pdf')
def pdf(self, request, pk=None): def pdf(self, request, pk=None):
service_record = self.get_object() service_record = self.get_object()
rows = [ pdf_bytes = _build_service_record_pdf(service_record)
("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)
response = HttpResponse(pdf_bytes, content_type='application/pdf') response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{service_record.pk}.service-record.pdf"' response['Content-Disposition'] = f'attachment; filename="{service_record.pk}.service-record.pdf"'
return response return response
@@ -1321,16 +1508,7 @@ class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
if not recipient: if not recipient:
raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."}) raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."})
rows = [ pdf_bytes = _build_service_record_pdf(service_record)
("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)
subject = _first_non_empty(request.data.get('subject')) or f"Servisni zapis {service_record.pk}" subject = _first_non_empty(request.data.get('subject')) or f"Servisni zapis {service_record.pk}"
body = _first_non_empty(request.data.get('message')) or ( 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')}." f"U prilogu je PDF servisnog zapisa. Poslano {timezone.now().strftime('%d.%m.%Y %H:%M')}."

View File

@@ -10,6 +10,13 @@ from modules.crm.models import Client
logger = logging.getLogger(__name__) 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) @shared_task(bind=True, max_retries=3)
def process_paperless_document_task(self, document_id): def process_paperless_document_task(self, document_id):
""" """
@@ -57,8 +64,6 @@ def process_paperless_document_task(self, document_id):
"created_at": raw_created, "created_at": raw_created,
} }
from modules.invoicing.services import InvoiceService
lookup_field = User.USERNAME_FIELD lookup_field = User.USERNAME_FIELD
system_user = User.objects.filter(**{lookup_field: "bot@erp.hr"}).first() system_user = User.objects.filter(**{lookup_field: "bot@erp.hr"}).first()

View File

@@ -18,6 +18,9 @@ class TaskService:
raise ValidationError({"status": "Samo admini mogu odmah označiti zadatak kao gotov."}) raise ValidationError({"status": "Samo admini mogu odmah označiti zadatak kao gotov."})
payload = dict(data) 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: if user and 'assigned_to' not in payload:
payload['assigned_to'] = user payload['assigned_to'] = user
@@ -40,6 +43,8 @@ class TaskService:
# Spremanje # Spremanje
for attr, value in data.items(): for attr, value in data.items():
setattr(instance, attr, value) 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: try:
instance.full_clean() instance.full_clean()
except DjangoValidationError as exc: except DjangoValidationError as exc:

View File

@@ -7,26 +7,28 @@ from modules.fleet.models import Vehicle, WorkOrder
class TaskSerializerTests(TestCase): 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): 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): with self.assertRaises(ValidationError):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
def test_validate_title_ok(self): 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 assert serializer.is_valid(), serializer.errors
def test_cannot_close_without_work_order(self): 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): with self.assertRaises(ValidationError):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
def test_can_close_with_work_order(self): def test_can_close_with_work_order(self):
User = get_user_model() work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='defektaza')
user = User.objects.create_user(username="task-user", email="task-user@example.test", password="pass") task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle)
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)
serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True) serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True)
assert serializer.is_valid(), serializer.errors assert serializer.is_valid(), serializer.errors

View File

@@ -5,6 +5,7 @@ from unittest.mock import patch
from modules.task_management.views import TaskViewSet from modules.task_management.views import TaskViewSet
from modules.task_management.serializers import TaskSerializer from modules.task_management.serializers import TaskSerializer
from modules.task_management.models import Task from modules.task_management.models import Task
from modules.fleet.models import Vehicle
class TaskViewSetTests(TestCase): class TaskViewSetTests(TestCase):
@@ -15,10 +16,11 @@ class TaskViewSetTests(TestCase):
self.view = TaskViewSet() self.view = TaskViewSet()
self.view.request = self.factory.post("/") self.view.request = self.factory.post("/")
self.view.request.user = self.user 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") @patch("modules.task_management.views.TaskService.create_task_entry")
def test_perform_create_calls_service(self, mock_create): 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}) serializer = TaskSerializer(data=data, context={"request": self.view.request})
assert serializer.is_valid(), serializer.errors assert serializer.is_valid(), serializer.errors

View File

@@ -1,3 +1,4 @@
[pytest] [pytest]
DJANGO_SETTINGS_MODULE = core.settings DJANGO_SETTINGS_MODULE = core.settings
python_files = tests.py test_*.py *_tests.py python_files = tests.py test_*.py *_tests.py
addopts = --import-mode=importlib

View File

@@ -31,6 +31,10 @@ services:
environment: environment:
- PUBLIC_API_URL=${PUBLIC_API_URL} - PUBLIC_API_URL=${PUBLIC_API_URL}
- NODE_ENV=development - NODE_ENV=development
- PUBLIC_ALLOWED_HOSTS=serviseri-004.captain.mitteworkspace.cloud
- PUBLIC_HMR_HOST=serviseri-004.captain.mitteworkspace.cloud
- PUBLIC_HMR_PROTOCOL=ws
- PUBLIC_HMR_CLIENT_PORT=4321
flower: flower:
container_name: 004erpflower container_name: 004erpflower

View File

@@ -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 OFFLINE_URL = '/offline.html';
const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL]; 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) => { self.addEventListener('install', (event) => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)) caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
@@ -11,22 +151,76 @@ self.addEventListener('install', (event) => {
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
event.waitUntil( event.waitUntil(
Promise.all([
caches.keys().then((cacheNames) => caches.keys().then((cacheNames) =>
Promise.all( Promise.all(
cacheNames cacheNames
.filter((name) => name !== CACHE_NAME) .filter((name) => name !== CACHE_NAME && name !== API_CACHE_NAME)
.map((name) => caches.delete(name)) .map((name) => caches.delete(name))
) )
) ),
replayQueuedRequests().catch((error) => {
console.error('Initial write-queue replay failed.', error);
}),
])
); );
self.clients.claim(); self.clients.claim();
}); });
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
const { request } = 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; 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 (url.origin !== self.location.origin) return;
if (request.mode === 'navigate') { 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());
});

View File

@@ -131,23 +131,20 @@ export default function ClientsSection({ onSetServiceContext = null }) {
headClassName="text-xs uppercase tracking-wide text-text-muted" headClassName="text-xs uppercase tracking-wide text-text-muted"
bodyClassName="divide-y divide-border-hairline" bodyClassName="divide-y divide-border-hairline"
rowClassName="hover:bg-canvas-deep" rowClassName="hover:bg-canvas-deep"
isRowSelected={(item) => String(item.id) === String(selectedClientCraneId)}
tableClassName="min-w-full text-sm" tableClassName="min-w-full text-sm"
wrapperClassName="overflow-x-auto" wrapperClassName="overflow-x-auto"
renderRow={(item) => { 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 (
<> <>
<td className={`py-2 pr-4 font-medium cursor-pointer ${firstCellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.registration_number}</td> <td className="py-2 pr-4 font-medium cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>{item.registration_number}</td>
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.make} {item.model} {item.year ? `(${item.year})` : ''}</td> <td className="py-2 pr-4 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>{item.make} {item.model} {item.year ? `(${item.year})` : ''}</td>
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.current_mileage?.toLocaleString('hr-HR')} km</td> <td className="py-2 pr-4 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>{item.current_mileage?.toLocaleString('hr-HR')} km</td>
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}> <td className="py-2 pr-4 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>
{item.service_interval_km {item.service_interval_km
? `${(Math.floor(item.current_mileage / item.service_interval_km) + 1) * item.service_interval_km} km` ? `${(Math.floor(item.current_mileage / item.service_interval_km) + 1) * item.service_interval_km} km`
: '—'} : '—'}
</td> </td>
<td className={`py-2 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}> <td className="py-2 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>
<span className={item.is_company_vehicle <span className={item.is_company_vehicle
? 'rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700' ? 'rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700'
: 'rounded-full bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700' : 'rounded-full bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700'
@@ -156,7 +153,7 @@ export default function ClientsSection({ onSetServiceContext = null }) {
</span> </span>
</td> </td>
</> </>
);}} )}
/> />
)} )}
</div> </div>

View File

@@ -1,6 +1,6 @@
import { useStore } from '@nanostores/preact'; import { useStore } from '@nanostores/preact';
import { useEffect, useMemo, useState } from 'preact/hooks'; 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 DashboardTopbar from './DashboardTopbar';
import WorkOrderModal from './WorkOrderModal'; import WorkOrderModal from './WorkOrderModal';
import TaskCreateModal from './TaskCreateModal'; import TaskCreateModal from './TaskCreateModal';
@@ -112,6 +112,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const [taskScope, setTaskScope] = useState('all'); const [taskScope, setTaskScope] = useState('all');
const [workOrderScope, setWorkOrderScope] = useState('context'); const [workOrderScope, setWorkOrderScope] = useState('context');
const [selectedCraneRowId, setSelectedCraneRowId] = useState(null); const [selectedCraneRowId, setSelectedCraneRowId] = useState(null);
const [expandedServiceTaskId, setExpandedServiceTaskId] = useState(null);
const [hasMounted, setHasMounted] = useState(false); const [hasMounted, setHasMounted] = useState(false);
useEffect(() => { useEffect(() => {
@@ -270,12 +271,50 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|| contextCrane?.client_name || contextCrane?.client_name
|| null || null
), [clients, selectedClientId, contextCrane]); ), [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(() => { const serviceRecordsForContext = useMemo(() => {
if (!selectedVehicleId) return []; if (!selectedVehicleId) return [];
return [...serviceRecords] return [...serviceRecords]
.filter((record) => String(record.vehicle) === String(selectedVehicleId)) .filter((record) => String(record.vehicle) === String(selectedVehicleId))
.sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || ''))); .sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || '')));
}, [serviceRecords, selectedVehicleId]); }, [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 scopedWorkOrders = useMemo(() => {
const sorted = [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))); const sorted = [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
if (workOrderScope === 'all') { if (workOrderScope === 'all') {
@@ -363,6 +402,14 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
setCurrentPage(1); setCurrentPage(1);
}, [workOrderScope, hydratedSelectedVehicleId]); }, [workOrderScope, hydratedSelectedVehicleId]);
useEffect(() => {
if (!expandedServiceTaskId) return;
const exists = serviceTaskGroups.some((group) => String(group.id) === String(expandedServiceTaskId));
if (!exists) {
setExpandedServiceTaskId(null);
}
}, [expandedServiceTaskId, serviceTaskGroups]);
useEffect(() => { useEffect(() => {
if (!selectedCraneRowId) return; if (!selectedCraneRowId) return;
const exists = cranes.some((item) => String(item.id) === String(selectedCraneRowId)); 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'); 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 = [ const workOrderColumns = [
{ key: 'code', label: 'Putni nalog', className: 'px-4 py-3' }, { key: 'code', label: 'Putni nalog', className: 'px-4 py-3' },
{ key: 'vehicle', label: 'Dizalica', 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); setIsWorkOrderModalOpen(true);
}} }}
onOpenTaskModal={async () => { onOpenTaskModal={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);
}}
onNewServiceRecord={({ craneId }) => { onNewServiceRecord={({ craneId }) => {
const crane = cranes.find((item) => String(item.id) === String(craneId)); const crane = cranes.find((item) => String(item.id) === String(craneId));
openServiceRecordForCrane(crane ?? null); openServiceRecordForCrane(crane ?? null);
@@ -640,8 +689,8 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
<h3 className="font-semibold text-text-main">Putni nalozi dizalica</h3> <h3 className="font-semibold text-text-main">Putni nalozi dizalica</h3>
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
{workOrderScope === 'context' {workOrderScope === 'context'
? (selectedContextClientName && hydratedContextCrane?.registration_number ? (selectedContextClientName && contextCraneLabel
? `Kontekst: ${selectedContextClientName} / ${hydratedContextCrane.registration_number}` ? `Kontekst: ${selectedContextClientName} / ${contextCraneLabel}`
: 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.') : 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.')
: 'Prikaz svih putnih naloga.'} : 'Prikaz svih putnih naloga.'}
</p> </p>
@@ -777,12 +826,19 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
<div> <div>
<h3 className="font-semibold text-text-main">Servisni zapisi</h3> <h3 className="font-semibold text-text-main">Servisni zapisi</h3>
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
{selectedContextClientName && hydratedContextCrane?.registration_number {selectedContextClientName && contextCraneLabel
? `Kontekst: ${selectedContextClientName} / ${hydratedContextCrane.registration_number}` ? `Kontekst: ${selectedContextClientName} / ${contextCraneLabel}`
: 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.'} : 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.'}
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button
type="button"
onClick={openTaskModalFromContext}
className="rounded-md bg-indigo-600 px-3 py-1 text-xs font-semibold text-white hover:bg-indigo-700"
>
+ Novi radni zadatak
</button>
{hydratedSelectedVehicleId && hydratedContextCrane && ( {hydratedSelectedVehicleId && hydratedContextCrane && (
<ServiceRecordCreateButton onClick={() => openServiceRecordForCrane(hydratedContextCrane)} /> <ServiceRecordCreateButton onClick={() => openServiceRecordForCrane(hydratedContextCrane)} />
)} )}
@@ -795,28 +851,66 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
</button> </button>
</div> </div>
</div> </div>
{!hydratedSelectedVehicleId && (
<p className="px-4 py-6 text-center text-sm text-text-muted">
Odaberite dizalicu za prikaz servisnih zapisa.
</p>
)}
{hydratedSelectedVehicleId && loading && (
<p className="px-4 py-6 text-center text-sm text-text-muted">
Učitavanje...
</p>
)}
{hydratedSelectedVehicleId && !loading && serviceTaskGroups.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-text-muted">
Nema grupiranih radnih zadataka za odabranu dizalicu.
</p>
)}
{hydratedSelectedVehicleId && !loading && serviceTaskGroups.length > 0 && (
<div className="divide-y divide-border-hairline">
{serviceTaskTransitions((style, group) => {
const isOpen = String(expandedServiceTaskId || '') === String(group.id);
return (
<animated.div key={group.id} style={style}>
<button
type="button"
onClick={() => setExpandedServiceTaskId(isOpen ? null : group.id)}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-canvas-deep"
>
<div>
<span className="font-medium text-text-main">{group.title}</span>
<span className="ml-2 text-xs text-text-muted">{group.status}</span>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700">
{group.records.length} servisnih zapisa
</span>
<span className="text-text-muted">{isOpen ? '▲' : '▼'}</span>
</div>
</button>
{isOpen && (
<div className="border-t border-border-hairline bg-canvas-base/50 px-4 py-3">
{group.records.length === 0 ? (
<p className="text-sm text-text-muted">
Nema servisnih zapisa za odabrani radni zadatak.
</p>
) : (
<AnimatedDataTable <AnimatedDataTable
columns={[ columns={[
{ key: 'date', label: 'Datum', className: 'px-4 py-3' }, { key: 'date', label: 'Datum', className: 'px-4 py-3' },
{ key: 'crane', label: 'Dizalica', className: 'px-4 py-3' }, { key: 'crane', label: 'Dizalica', className: 'px-4 py-3' },
{ key: 'task', label: 'Radni zadatak', className: 'px-4 py-3' },
{ key: 'description', label: 'Opis', className: 'px-4 py-3' }, { key: 'description', label: 'Opis', className: 'px-4 py-3' },
{ key: 'cost', label: 'Trošak', className: 'px-4 py-3' }, { key: 'cost', label: 'Trošak', className: 'px-4 py-3' },
{ key: 'files', label: 'Datoteke', className: 'px-4 py-3' }, { key: 'files', label: 'Datoteke', className: 'px-4 py-3' },
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' }, { key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
]} ]}
rows={serviceRecordsForContext} rows={group.records}
rowKey={(record) => record.id} rowKey={(record) => record.id}
loading={loading} rowClassName="hover:bg-canvas-deep"
loadingMessage="Učitavanje..."
emptyMessage={!hydratedSelectedVehicleId
? 'Odaberite dizalicu za prikaz servisnih zapisa.'
: 'Nema aktivnih servisnih zapisa za odabranu dizalicu.'}
renderRow={(record) => ( renderRow={(record) => (
<> <>
<td className="px-4 py-3">{formatDate(record.service_date)}</td> <td className="px-4 py-3">{formatDate(record.service_date)}</td>
<td className="px-4 py-3">{record.crane_registration || record.vehicle_registration || '-'}</td> <td className="px-4 py-3">{record.crane_registration || record.vehicle_registration || '-'}</td>
<td className="px-4 py-3">{record.task_label || '-'}</td>
<td className="max-w-md truncate px-4 py-3" title={record.description || ''}> <td className="max-w-md truncate px-4 py-3" title={record.description || ''}>
{record.description || '-'} {record.description || '-'}
</td> </td>
@@ -826,7 +920,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setServiceRecordBackTask(null); setServiceRecordBackTask(group.task);
setSelectedServiceRecord(record); setSelectedServiceRecord(record);
}} }}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base" className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
@@ -844,6 +938,14 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
</> </>
)} )}
/> />
)}
</div>
)}
</animated.div>
);
})}
</div>
)}
</article> </article>
)} )}
@@ -884,43 +986,45 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
columns={vehicleColumns} columns={vehicleColumns}
rows={cranes} rows={cranes}
rowKey={(vehicle) => vehicle.id} rowKey={(vehicle) => vehicle.id}
rowClassName="hover:bg-canvas-deep"
isRowSelected={(vehicle) => String(vehicle.id) === String(selectedCraneRowId)}
loading={loading} loading={loading}
loadingMessage="Učitavanje vozila..." loadingMessage="Učitavanje vozila..."
emptyMessage="Nema dostupnih vozila." emptyMessage="Nema dostupnih vozila."
renderRow={(vehicle) => ( renderRow={(vehicle) => (
<> <>
<td <td
className={`px-4 py-3 font-medium ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50 text-emerald-800' : 'text-text-main'}`} className="px-4 py-3 font-medium text-text-main"
onClick={() => setSelectedCraneRowId(vehicle.id)} onClick={() => setSelectedCraneRowId(vehicle.id)}
> >
{vehicle.registration_number || '-'} {vehicle.registration_number || '-'}
</td> </td>
<td <td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`} className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)} onClick={() => setSelectedCraneRowId(vehicle.id)}
> >
{vehicle.crane_serial_number || '-'} {vehicle.crane_serial_number || '-'}
</td> </td>
<td <td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`} className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)} onClick={() => setSelectedCraneRowId(vehicle.id)}
> >
{vehicle.make || '-'} {vehicle.model || ''} {vehicle.make || '-'} {vehicle.model || ''}
</td> </td>
<td <td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`} className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)} onClick={() => setSelectedCraneRowId(vehicle.id)}
> >
{vehicle.client_name || '-'} {vehicle.client_name || '-'}
</td> </td>
<td <td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`} className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)} onClick={() => setSelectedCraneRowId(vehicle.id)}
> >
{vehicle.current_mileage ?? '-'} {vehicle.current_mileage ?? '-'}
</td> </td>
<td <td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`} className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)} onClick={() => setSelectedCraneRowId(vehicle.id)}
> >
<span className={vehicle.is_active ? 'text-emerald-700' : 'text-red-700'}> <span className={vehicle.is_active ? 'text-emerald-700' : 'text-red-700'}>

View File

@@ -51,6 +51,14 @@ export default function ServiceContextSelector({ onNewServiceRecord }) {
const selectedClient = availableClients.find((c) => String(c.id) === String(selectedClientId)); const selectedClient = availableClients.find((c) => String(c.id) === String(selectedClientId));
const selectedCrane = cranes.find((item) => String(item.id) === String(selectedVehicleId)); const selectedCrane = cranes.find((item) => String(item.id) === String(selectedVehicleId));
const showSelector = editMode || !selectedClientId; 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) { function handleClientChange(e) {
setSelectedClient(e.target.value); setSelectedClient(e.target.value);
@@ -97,7 +105,7 @@ export default function ServiceContextSelector({ onNewServiceRecord }) {
{clientCranes.length > 0 {clientCranes.length > 0
? clientCranes.map((item) => ( ? clientCranes.map((item) => (
<option key={item.id} value={item.id}> <option key={item.id} value={item.id}>
{item.registration_number} {item.make} {item.model} {item.crane_serial_number ? `SN ${item.crane_serial_number}` : (item.registration_number || '-')} {item.make} {item.model}
</option> </option>
)) ))
: <option disabled>Nema dizalica za ovog kupca</option> : <option disabled>Nema dizalica za ovog kupca</option>
@@ -137,7 +145,7 @@ export default function ServiceContextSelector({ onNewServiceRecord }) {
<> <>
<span className="text-xs text-text-muted">/</span> <span className="text-xs text-text-muted">/</span>
<span className="rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-700"> <span className="rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-700">
{selectedCrane.registration_number} {selectedCrane.make} {selectedCrane.model} {selectedCraneLabel}
</span> </span>
</> </>
)} )}

View File

@@ -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 { animated, useTransition } from '@react-spring/web';
import { api } from '../../services/apiClient';
function getBrowserApiBase() { function getBrowserApiBase() {
const apiUrl = import.meta.env.PUBLIC_API_URL || ''; const apiUrl = import.meta.env.PUBLIC_API_URL || '';
@@ -44,6 +45,109 @@ export function resolveMediaUrl(pathOrUrl) {
return new URL(finalSrc, apiUrl).toString(); 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' }) { export default function WorkOrderImageCarousel({ images = [], editMode = false, emptyLabel = 'Nema priložene tehničke dokumentacije' }) {
const normalizedImages = useMemo(() => ( const normalizedImages = useMemo(() => (
Array.isArray(images) Array.isArray(images)
@@ -51,6 +155,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
: (typeof images === 'string' && images ? [images] : []) : (typeof images === 'string' && images ? [images] : [])
), [images]); ), [images]);
const [currentImgIdx, setCurrentImgIdx] = useState(0); const [currentImgIdx, setCurrentImgIdx] = useState(0);
const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(normalizedImages);
useEffect(() => { useEffect(() => {
setCurrentImgIdx(0); setCurrentImgIdx(0);
@@ -87,7 +192,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length); setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
}; };
const finalSrc = resolveMediaUrl(normalizedImages[currentImgIdx] || ''); const finalSrc = getAuthenticatedMediaSrc(normalizedImages[currentImgIdx] || '');
const imageTransitions = useTransition(currentImgIdx, { const imageTransitions = useTransition(currentImgIdx, {
from: { opacity: 0, transform: 'translate3d(8%,0,0) scale(1.01)' }, from: { opacity: 0, transform: 'translate3d(8%,0,0) scale(1.01)' },
enter: { opacity: 1, transform: 'translate3d(0%,0,0) scale(1)' }, enter: { opacity: 1, transform: 'translate3d(0%,0,0) scale(1)' },
@@ -101,7 +206,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
{imageTransitions((style, idx) => ( {imageTransitions((style, idx) => (
<animated.img <animated.img
key={idx} key={idx}
src={resolveMediaUrl(normalizedImages[idx] || finalSrc)} src={getAuthenticatedMediaSrc(normalizedImages[idx] || finalSrc)}
alt={`Dokumentacija s terena ${idx + 1}`} alt={`Dokumentacija s terena ${idx + 1}`}
style={style} style={style}
className="absolute inset-0 h-full w-full object-cover will-change-transform" className="absolute inset-0 h-full w-full object-cover will-change-transform"

View File

@@ -11,7 +11,7 @@ import {
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore'; import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore'; import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
import { formatEntityCode } from '../../lib/displayIds'; import { formatEntityCode } from '../../lib/displayIds';
import { resolveMediaUrl } from './WorkOrderImageCarousel'; import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
function readWorkOrderIdFromQuery() { function readWorkOrderIdFromQuery() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -121,6 +121,34 @@ export default function WorkOrderInvoicesPdfPage() {
return `${formatEntityCode('PN', workOrder.id)}`; return `${formatEntityCode('PN', workOrder.id)}`;
}, [workOrder]); }, [workOrder]);
const mediaPaths = useMemo(() => {
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 ( return (
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6"> <section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4"> <div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
@@ -144,7 +172,7 @@ export default function WorkOrderInvoicesPdfPage() {
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4"> <div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-text-main">Task i povezani servisni zapisi</h2> <h2 className="text-lg font-semibold text-text-main">Servisni zapisi</h2>
<button <button
type="button" type="button"
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)} onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
@@ -185,13 +213,13 @@ export default function WorkOrderInvoicesPdfPage() {
record.photos.map((photo) => ( record.photos.map((photo) => (
<a <a
key={photo.id} key={photo.id}
href={resolveMediaUrl(photo.optimized_url || photo.image_url)} href={getAuthenticatedMediaSrc(photo.optimized_url || photo.image_url)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex" className="inline-flex"
> >
<img <img
src={resolveMediaUrl(photo.optimized_url || photo.image_url)} src={getAuthenticatedMediaSrc(photo.optimized_url || photo.image_url)}
alt={`Servisna slika ${photo.id}`} alt={`Servisna slika ${photo.id}`}
className="h-20 w-20 rounded border border-border-hairline object-cover" className="h-20 w-20 rounded border border-border-hairline object-cover"
/> />
@@ -244,7 +272,7 @@ export default function WorkOrderInvoicesPdfPage() {
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div> <div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
</div> </div>
<InvoiceImagePreview <InvoiceImagePreview
imageUrl={resolveMediaUrl(invoice.image)} imageUrl={getAuthenticatedMediaSrc(invoice.image) || resolveMediaUrl(invoice.image)}
alt={`Račun ${invoice.naziv_racuna}`} alt={`Račun ${invoice.naziv_racuna}`}
/> />
</div> </div>

View File

@@ -276,7 +276,7 @@ export default function ServiceRecordDetailModal({
<div className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"> <div className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main">
<p className="text-xs text-text-muted">Trošak / KM / Dizalica</p> <p className="text-xs text-text-muted">Trošak / KM / Dizalica</p>
<p> <p>
{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 || '-'}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -13,6 +13,7 @@ export default function AnimatedDataTable({
headClassName = 'bg-canvas-deep text-left text-xs uppercase tracking-wide text-text-muted', headClassName = 'bg-canvas-deep text-left text-xs uppercase tracking-wide text-text-muted',
bodyClassName = 'divide-y divide-border-hairline', bodyClassName = 'divide-y divide-border-hairline',
rowClassName = 'hover:bg-canvas-deep', rowClassName = 'hover:bg-canvas-deep',
isRowSelected = () => false,
wrapperClassName = 'overflow-x-auto', wrapperClassName = 'overflow-x-auto',
trail = 35, trail = 35,
}) { }) {
@@ -23,9 +24,20 @@ export default function AnimatedDataTable({
const transitions = useTransition(rows, { const transitions = useTransition(rows, {
keys: (row) => rowKey(row), keys: (row) => rowKey(row),
from: { 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: { opacity: 1, transform: 'translate3d(0,0,0)' }, enter: (row) => ({
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' }, 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, trail,
config: { tension: 230, friction: 26 }, config: { tension: 230, friction: 26 },
}); });

View File

@@ -24,7 +24,7 @@ export default function ThemeToggle() {
<button <button
onClick={toggleTheme} onClick={toggleTheme}
type="button" type="button"
className="p-2.5 rounded-s-xl bg-canvas-elevated border-y border-s border-border-hairline text-text-muted hover:text-text-main transition-all duration-200 focus:outline-none cursor-pointer flex items-center justify-center" className="relative inline-flex h-10 w-10 items-center justify-center rounded-lg border border-border-hairline bg-canvas-elevated text-text-muted hover:bg-canvas-base"
aria-label="Prebaci temu" aria-label="Prebaci temu"
> >
{isDark ? ( {isDark ? (

View File

@@ -85,6 +85,16 @@ const isDev = import.meta.env.DEV;
navigator.serviceWorker.register('/service-worker.js').catch(function (error) { navigator.serviceWorker.register('/service-worker.js').catch(function (error) {
console.error('Service worker registration failed:', error); console.error('Service worker registration failed:', error);
}); });
window.addEventListener('online', function () {
navigator.serviceWorker.ready
.then(function (registration) {
if (!registration.active) return;
registration.active.postMessage('FLUSH_WRITE_QUEUE');
})
.catch(function () {
// no-op, SW may not be ready yet
});
});
return; return;
} }
@@ -94,7 +104,7 @@ const isDev = import.meta.env.DEV;
const keys = await caches.keys(); const keys = await caches.keys();
await Promise.all( await Promise.all(
keys keys
.filter((key) => key.startsWith('erp-shell-')) .filter((key) => key.startsWith('erp-shell-') || key.startsWith('erp-api-'))
.map((key) => caches.delete(key)) .map((key) => caches.delete(key))
); );
} }

View File

@@ -12,74 +12,98 @@ const LS_VEHICLE = 'topbar_selected_vehicle';
export const $selectedClientId = atom(null); export const $selectedClientId = atom(null);
export const $selectedVehicleId = atom(null); export const $selectedVehicleId = atom(null);
function normalizeId(value) {
return value ? String(value) : null;
}
function emitServiceContextChanged(clientId, vehicleId) { function emitServiceContextChanged(clientId, vehicleId) {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent('service-context:changed', { window.dispatchEvent(new CustomEvent('service-context:changed', {
detail: { detail: {
clientId: clientId || null, clientId: normalizeId(clientId),
vehicleId: vehicleId || null, vehicleId: normalizeId(vehicleId),
}, },
})); }));
} }
/** Čita localStorage i puni atome. Idempotentno — sigurno pozvati više puta. */ function persistContext(clientId, vehicleId) {
export function hydrateServiceContext() {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const client = localStorage.getItem(LS_CLIENT);
const vehicle = localStorage.getItem(LS_VEHICLE);
$selectedClientId.set(client || null);
$selectedVehicleId.set(vehicle || null);
emitServiceContextChanged(client || null, vehicle || null);
}
/** Postavi novog kupca i resetiraj odabrano vozilo. */
export function setSelectedClient(id) {
$selectedClientId.set(id || null);
$selectedVehicleId.set(null);
if (id) {
localStorage.setItem(LS_CLIENT, id);
} else {
localStorage.removeItem(LS_CLIENT);
}
localStorage.removeItem(LS_VEHICLE);
emitServiceContextChanged(id || null, null);
}
/** Postavi odabrano vozilo. */
export function setSelectedVehicle(id) {
$selectedVehicleId.set(id || null);
if (id) {
localStorage.setItem(LS_VEHICLE, id);
} else {
localStorage.removeItem(LS_VEHICLE);
}
emitServiceContextChanged($selectedClientId.get(), id || null);
}
/** Postavi cijeli servisni kontekst odjednom (kupac + vozilo). */
export function setServiceContext(clientId, vehicleId) {
$selectedClientId.set(clientId || null);
$selectedVehicleId.set(vehicleId || null);
if (clientId) { if (clientId) {
localStorage.setItem(LS_CLIENT, clientId); localStorage.setItem(LS_CLIENT, clientId);
} else { } else {
localStorage.removeItem(LS_CLIENT); localStorage.removeItem(LS_CLIENT);
} }
if (vehicleId) { if (vehicleId) {
localStorage.setItem(LS_VEHICLE, vehicleId); localStorage.setItem(LS_VEHICLE, vehicleId);
} else { } else {
localStorage.removeItem(LS_VEHICLE); localStorage.removeItem(LS_VEHICLE);
} }
emitServiceContextChanged(clientId || null, vehicleId || null); }
/** Čita localStorage i puni atome. Idempotentno — sigurno pozvati više puta. */
export function hydrateServiceContext() {
if (typeof window === 'undefined') return;
const client = normalizeId(localStorage.getItem(LS_CLIENT));
const vehicle = normalizeId(localStorage.getItem(LS_VEHICLE));
const currentClient = normalizeId($selectedClientId.get());
const currentVehicle = normalizeId($selectedVehicleId.get());
if (currentClient === client && currentVehicle === vehicle) {
return;
}
$selectedClientId.set(client);
$selectedVehicleId.set(vehicle);
emitServiceContextChanged(client, vehicle);
}
/** Postavi novog kupca i resetiraj odabrano vozilo. */
export function setSelectedClient(id) {
const nextClient = normalizeId(id);
const currentClient = normalizeId($selectedClientId.get());
const currentVehicle = normalizeId($selectedVehicleId.get());
if (currentClient === nextClient && currentVehicle === null) {
return;
}
$selectedClientId.set(nextClient);
$selectedVehicleId.set(null);
persistContext(nextClient, null);
emitServiceContextChanged(nextClient, null);
}
/** Postavi odabrano vozilo. */
export function setSelectedVehicle(id) {
const nextVehicle = normalizeId(id);
const currentVehicle = normalizeId($selectedVehicleId.get());
const currentClient = normalizeId($selectedClientId.get());
if (currentVehicle === nextVehicle) {
return;
}
$selectedVehicleId.set(nextVehicle);
persistContext(currentClient, nextVehicle);
emitServiceContextChanged(currentClient, nextVehicle);
}
/** Postavi cijeli servisni kontekst odjednom (kupac + vozilo). */
export function setServiceContext(clientId, vehicleId) {
const nextClient = normalizeId(clientId);
const nextVehicle = normalizeId(vehicleId);
const currentClient = normalizeId($selectedClientId.get());
const currentVehicle = normalizeId($selectedVehicleId.get());
if (currentClient === nextClient && currentVehicle === nextVehicle) {
return;
}
$selectedClientId.set(nextClient);
$selectedVehicleId.set(nextVehicle);
persistContext(nextClient, nextVehicle);
emitServiceContextChanged(nextClient, nextVehicle);
} }
/** Poništi cijeli kontekst. */ /** Poništi cijeli kontekst. */
export function clearServiceContext() { export function clearServiceContext() {
if ($selectedClientId.get() === null && $selectedVehicleId.get() === null) {
return;
}
$selectedClientId.set(null); $selectedClientId.set(null);
$selectedVehicleId.set(null); $selectedVehicleId.set(null);
localStorage.removeItem(LS_CLIENT); persistContext(null, null);
localStorage.removeItem(LS_VEHICLE);
emitServiceContextChanged(null, null); emitServiceContextChanged(null, null);
} }