Compare commits
2 Commits
environmen
...
4e3bf6dab0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e3bf6dab0 | ||
|
|
3b54499b17 |
@@ -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,,
|
||||
|
202
backend/core/users/management/commands/import_users_csv.py
Normal file
202
backend/core/users/management/commands/import_users_csv.py
Normal 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
|
||||
@@ -1,5 +1,4 @@
|
||||
from rest_framework import serializers
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from .models import CustomUser
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
@@ -14,11 +13,8 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
'first_name': {'required': True, 'allow_blank': False}
|
||||
}
|
||||
|
||||
def _get_assigned_asset(self, obj):
|
||||
try:
|
||||
return obj.assigned_vehicles
|
||||
except ObjectDoesNotExist:
|
||||
return None
|
||||
def _get_assigned_assets(self, obj):
|
||||
return obj.assigned_vehicles.all()
|
||||
|
||||
def _serialize_asset(self, vehicle):
|
||||
if not vehicle:
|
||||
@@ -34,10 +30,14 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
}
|
||||
|
||||
def get_assigned_vehicle(self, obj):
|
||||
return self._serialize_asset(self._get_assigned_asset(obj))
|
||||
assigned = self._get_assigned_assets(obj)
|
||||
vehicle = assigned.filter(asset_type='vehicle').first() or assigned.first()
|
||||
return self._serialize_asset(vehicle)
|
||||
|
||||
def get_assigned_crane(self, obj):
|
||||
return self._serialize_asset(self._get_assigned_asset(obj))
|
||||
assigned = self._get_assigned_assets(obj)
|
||||
crane = assigned.filter(asset_type='crane').first() or assigned.first()
|
||||
return self._serialize_asset(crane)
|
||||
|
||||
def validate(self, data):
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
@@ -23,3 +24,19 @@ def test_create_superuser():
|
||||
)
|
||||
assert admin.is_superuser 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()
|
||||
@@ -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
|
||||
|
0
backend/modules/crm/management/__init__.py
Normal file
0
backend/modules/crm/management/__init__.py
Normal file
0
backend/modules/crm/management/commands/__init__.py
Normal file
0
backend/modules/crm/management/commands/__init__.py
Normal file
130
backend/modules/crm/management/commands/import_clients_csv.py
Normal file
130
backend/modules/crm/management/commands/import_clients_csv.py
Normal 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))
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
0
backend/modules/fleet/management/__init__.py
Normal file
0
backend/modules/fleet/management/__init__.py
Normal file
283
backend/modules/fleet/management/commands/import_cranes_csv.py
Normal file
283
backend/modules/fleet/management/commands/import_cranes_csv.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -149,7 +149,7 @@ class Vehicle(BaseModel):
|
||||
)
|
||||
current_mileage = models.PositiveIntegerField(default=0, verbose_name=_("Trenutna kilometraža (km)"))
|
||||
service_interval_km = models.PositiveIntegerField(default=15000, verbose_name=_("Interval servisa (km)"))
|
||||
assigned_servicer = models.OneToOneField(
|
||||
assigned_servicer = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='assigned_vehicles',
|
||||
|
||||
128
backend/modules/fleet/pdf_layout.py
Normal file
128
backend/modules/fleet/pdf_layout.py
Normal 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",
|
||||
)
|
||||
@@ -2,6 +2,7 @@
|
||||
from celery import shared_task
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
import logging
|
||||
from io import BytesIO
|
||||
import base64
|
||||
@@ -10,8 +11,10 @@ from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfgen import canvas
|
||||
from .models import VehicleNotification
|
||||
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
register_unicode_fonts()
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
@@ -78,53 +81,93 @@ def _build_work_order_invoices_pdf(work_order):
|
||||
buffer = BytesIO()
|
||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||
width, height = A4
|
||||
y = height - 50
|
||||
margin = 28
|
||||
header_h = 86
|
||||
footer_h = 72
|
||||
content_top = height - header_h - 10
|
||||
content_bottom = footer_h + 52
|
||||
client_name = getattr(getattr(work_order.vehicle, "client", None), "name", None) or "-"
|
||||
manufacturer = str(work_order.vehicle.make or "-")
|
||||
model_line = str(work_order.vehicle.model or "-")
|
||||
serial_line = str(getattr(work_order.vehicle, "crane_serial_number", None) or "-")
|
||||
upgrade_hours = str(getattr(work_order.vehicle, "superstructure_working_hours", "-") or "-")
|
||||
chassis_hours = str(getattr(work_order.vehicle, "chassis_working_hours", "-") or "-")
|
||||
mileage = str(getattr(work_order.vehicle, "current_mileage", "-") or "-")
|
||||
generated_date = timezone.localtime(timezone.now()).strftime("%d.%m.%Y")
|
||||
|
||||
pdf.setFont("Helvetica-Bold", 14)
|
||||
pdf.drawString(40, y, f"Računi putnog naloga WO-{work_order.pk}")
|
||||
y -= 22
|
||||
pdf.setFont("Helvetica", 10)
|
||||
pdf.drawString(40, y, f"Dizalica: {work_order.vehicle.registration_number}")
|
||||
y -= 16
|
||||
pdf.drawString(40, y, f"Datum naloga: {work_order.date}")
|
||||
def draw_header_footer(page_num):
|
||||
draw_standard_header_footer(
|
||||
pdf,
|
||||
page_num=page_num,
|
||||
client_name=client_name,
|
||||
manufacturer=manufacturer,
|
||||
model=model_line,
|
||||
serial=serial_line,
|
||||
upgrade_hours=upgrade_hours,
|
||||
chassis_hours=chassis_hours,
|
||||
mileage=mileage,
|
||||
work_order_number=str(work_order.pk),
|
||||
generated_date=generated_date,
|
||||
report_title="Računi putnog naloga",
|
||||
page_size=A4,
|
||||
margin=margin,
|
||||
header_h=header_h,
|
||||
footer_h=footer_h,
|
||||
)
|
||||
|
||||
page_num = 1
|
||||
draw_header_footer(page_num)
|
||||
y = content_top
|
||||
y -= 8
|
||||
|
||||
pdf.setFont("Vera-Bold", 13)
|
||||
pdf.drawString(margin, y, f"Računi putnog naloga WO-{work_order.pk}")
|
||||
y -= 20
|
||||
pdf.setFont("Vera", 10)
|
||||
pdf.drawString(margin, y, f"Datum naloga: {work_order.date}")
|
||||
|
||||
invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')
|
||||
if not invoices.exists():
|
||||
y -= 24
|
||||
pdf.drawString(40, y, "Nema računa za ovaj putni nalog.")
|
||||
y -= 20
|
||||
pdf.drawString(margin, y, "Nema računa za ovaj putni nalog.")
|
||||
pdf.save()
|
||||
return buffer.getvalue()
|
||||
|
||||
for index, invoice in enumerate(invoices, start=1):
|
||||
pdf.showPage()
|
||||
y = height - 50
|
||||
pdf.setFont("Helvetica-Bold", 13)
|
||||
pdf.drawString(40, y, f"Račun #{index}")
|
||||
page_num += 1
|
||||
draw_header_footer(page_num)
|
||||
y = content_top
|
||||
y -= 8
|
||||
pdf.setFont("Vera-Bold", 13)
|
||||
pdf.drawString(margin, y, f"Račun #{index}")
|
||||
y -= 22
|
||||
pdf.setFont("Helvetica", 10)
|
||||
pdf.drawString(40, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}")
|
||||
pdf.setFont("Vera", 10)
|
||||
pdf.drawString(margin, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}")
|
||||
y -= 16
|
||||
pdf.drawString(40, y, f"lokacija: {invoice.lokacija or '-'}")
|
||||
pdf.drawString(margin, y, f"lokacija: {invoice.lokacija or '-'}")
|
||||
y -= 16
|
||||
pdf.drawString(40, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}")
|
||||
pdf.drawString(margin, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}")
|
||||
y -= 16
|
||||
pdf.drawString(40, y, f"opis: {(invoice.opis or '-')[:140]}")
|
||||
pdf.drawString(margin, y, f"opis: {(invoice.opis or '-')[:140]}")
|
||||
y -= 20
|
||||
|
||||
if not invoice.image:
|
||||
pdf.drawString(40, y, "slika: Nema slike.")
|
||||
pdf.drawString(margin, y, "slika: Nema slike.")
|
||||
continue
|
||||
|
||||
try:
|
||||
invoice.image.open('rb')
|
||||
with Image.open(invoice.image) as source:
|
||||
image = source.convert('RGB')
|
||||
max_width = width - 80
|
||||
max_height = y - 60
|
||||
max_width = width - (2 * margin)
|
||||
max_height = y - content_bottom
|
||||
if max_height < 120:
|
||||
pdf.showPage()
|
||||
y = height - 60
|
||||
max_height = y - 60
|
||||
page_num += 1
|
||||
draw_header_footer(page_num)
|
||||
y = content_top
|
||||
max_height = y - content_bottom
|
||||
|
||||
ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0)
|
||||
draw_width = max(1, int(image.width * ratio))
|
||||
@@ -132,7 +175,7 @@ def _build_work_order_invoices_pdf(work_order):
|
||||
image_reader = ImageReader(image)
|
||||
pdf.drawImage(
|
||||
image_reader,
|
||||
40,
|
||||
margin,
|
||||
y - draw_height,
|
||||
width=draw_width,
|
||||
height=draw_height,
|
||||
@@ -140,7 +183,7 @@ def _build_work_order_invoices_pdf(work_order):
|
||||
mask='auto',
|
||||
)
|
||||
except (UnidentifiedImageError, OSError):
|
||||
pdf.drawString(40, y, "slika: Slika nije dostupna ili je oštećena.")
|
||||
pdf.drawString(margin, y, "slika: Slika nije dostupna ili je oštećena.")
|
||||
finally:
|
||||
invoice.image.close()
|
||||
|
||||
|
||||
@@ -4,14 +4,12 @@ from modules.fleet.models import Vehicle
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_vehicle_servicer_one_to_one_constraint(test_user):
|
||||
|
||||
# Prvo vozilo prolazi
|
||||
def test_vehicle_servicer_many_to_one_allows_multiple_vehicles(test_user):
|
||||
Vehicle.objects.create(registration_number="ZG-111-AA", assigned_servicer=test_user)
|
||||
|
||||
# Drugo vozilo za istog servisera mora baciti IntegrityError
|
||||
with pytest.raises(Exception): # Django IntegrityError
|
||||
Vehicle.objects.create(registration_number="ZG-222-BB", assigned_servicer=test_user)
|
||||
Vehicle.objects.create(registration_number="ZG-222-BB", assigned_servicer=test_user)
|
||||
|
||||
assert Vehicle.objects.filter(assigned_servicer=test_user).count() == 2
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_vehicle_next_service_logic():
|
||||
|
||||
@@ -116,7 +116,7 @@ class WorkOrderAndServiceRecordNotificationTests(TestCase):
|
||||
"creator": self.creator,
|
||||
"start_mileage": 10000,
|
||||
"end_mileage": 10120,
|
||||
"purpose": "Routine visit"
|
||||
"purpose": "kontrola"
|
||||
}
|
||||
|
||||
wo = VehicleService.create_work_order(data)
|
||||
|
||||
@@ -19,7 +19,7 @@ class FleetServiceTests(TestCase):
|
||||
)
|
||||
|
||||
def test_create_work_order_via_service(self):
|
||||
data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "Delivery"}
|
||||
data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "kontrola"}
|
||||
wo = VehicleService.create_work_order(data)
|
||||
self.vehicle.refresh_from_db()
|
||||
self.assertEqual(self.vehicle.current_mileage, 5200)
|
||||
|
||||
@@ -11,21 +11,13 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.html import escape
|
||||
from django.db.models import Q
|
||||
import os
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.platypus import Table, TableStyle, Paragraph
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
|
||||
# Register Unicode-capable fonts (Vera supports HR characters: č, ć, đ, š, ž)
|
||||
_RL_FONTS_DIR = os.path.join(os.path.dirname(__import__('reportlab').__file__), 'fonts')
|
||||
pdfmetrics.registerFont(TTFont('Vera', os.path.join(_RL_FONTS_DIR, 'Vera.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('Vera-Bold', os.path.join(_RL_FONTS_DIR, 'VeraBd.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('Vera-Italic', os.path.join(_RL_FONTS_DIR, 'VeraIt.ttf')))
|
||||
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
|
||||
from rest_framework import viewsets, permissions, status
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
@@ -61,6 +53,8 @@ from .services import (
|
||||
)
|
||||
from .tasks import build_work_order_invoices_pdf_task
|
||||
|
||||
register_unicode_fonts()
|
||||
|
||||
def _fleet_assets_queryset_for_user(user, model, *, asset_type=None):
|
||||
queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True)
|
||||
if asset_type:
|
||||
@@ -226,6 +220,30 @@ def _build_work_order_pdf(work_order):
|
||||
place_label = (work_order.location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
|
||||
invoice_names = [inv.naziv_racuna for inv in invoices[:5] if inv.naziv_racuna]
|
||||
attachments_text = ', '.join(invoice_names) if invoice_names else '-'
|
||||
assigned_servicer_vehicle = (
|
||||
creator.assigned_vehicles
|
||||
.filter(asset_type='vehicle', is_active=True)
|
||||
.order_by('registration_number')
|
||||
.first()
|
||||
)
|
||||
assigned_servicer_vehicle_label = "-"
|
||||
assigned_servicer_vehicle_registration = "-"
|
||||
if assigned_servicer_vehicle:
|
||||
assigned_servicer_vehicle_label = " ".join(
|
||||
part for part in [assigned_servicer_vehicle.make, assigned_servicer_vehicle.model] if part
|
||||
).strip() or assigned_servicer_vehicle.registration_number
|
||||
assigned_servicer_vehicle_registration = assigned_servicer_vehicle.registration_number or "-"
|
||||
|
||||
servicer_vehicle_label = (
|
||||
assigned_servicer_vehicle_label
|
||||
if assigned_servicer_vehicle
|
||||
else (work_order.servicer_vehicle_make_model or '-')
|
||||
)
|
||||
servicer_vehicle_registration = (
|
||||
assigned_servicer_vehicle_registration
|
||||
if assigned_servicer_vehicle
|
||||
else (work_order.servicer_vehicle_registration or '-')
|
||||
)
|
||||
|
||||
buffer = BytesIO()
|
||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||
@@ -331,7 +349,7 @@ def _build_work_order_pdf(work_order):
|
||||
[
|
||||
"1 dana" if travel_start else "-",
|
||||
_fmt_date(travel_start.date() if travel_start else work_order.date),
|
||||
f"{work_order.servicer_vehicle_make_model or '-'}, {work_order.servicer_vehicle_registration or '-'}",
|
||||
f"{servicer_vehicle_label}, {servicer_vehicle_registration}",
|
||||
],
|
||||
["Troškovi putovanja terete:", "Posebni dodaci (predujam):", "Početno stanje kilometara:"],
|
||||
[
|
||||
@@ -397,7 +415,7 @@ def _build_work_order_pdf(work_order):
|
||||
y,
|
||||
[
|
||||
["RELACIJA od", "RELACIJA do", "Vrsta prijevoznog sredstva", "Razred [km]", "Iznos za prijevoz", "Ukupan iznos"],
|
||||
[place_label or '-', work_order.location or '-', work_order.servicer_vehicle_make_model or '-', str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)],
|
||||
[place_label or '-', work_order.location or '-', f"{servicer_vehicle_label}, {servicer_vehicle_registration}", str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)],
|
||||
["", "", "", "", "", ""],
|
||||
["", "", "", "", "", ""],
|
||||
],
|
||||
@@ -493,7 +511,8 @@ def _build_work_order_pdf(work_order):
|
||||
def _build_work_order_service_records_pdf(work_order):
|
||||
vehicle = work_order.vehicle
|
||||
client_name = getattr(vehicle.client, 'name', None) or '-'
|
||||
model_str = ' '.join(filter(None, [vehicle.make, vehicle.model])) or '-'
|
||||
manufacturer_str = str(vehicle.make or '-')
|
||||
model_str = str(vehicle.model or '-')
|
||||
serial_str = str(vehicle.crane_serial_number or '-')
|
||||
upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-')
|
||||
chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-')
|
||||
@@ -563,86 +582,24 @@ def _build_work_order_service_records_pdf(work_order):
|
||||
super().save()
|
||||
|
||||
def _draw_header_footer(self):
|
||||
w, h = A4
|
||||
page_num = self._current_page
|
||||
|
||||
self.setStrokeColor(colors.black)
|
||||
self.setLineWidth(0.7)
|
||||
self.rect(MARGIN, h - HEADER_H, w - 2 * MARGIN, HEADER_H - 4, stroke=1, fill=0)
|
||||
|
||||
self.setFont("Vera-Bold", 11)
|
||||
self.drawString(MARGIN + 6, h - 22, client_name[:44])
|
||||
self.drawCentredString(w / 2, h - 22, "Izvještaj servisera")
|
||||
self.setFont("Vera", 9)
|
||||
self.drawRightString(w - MARGIN - 6, h - 22, f"Stranica {page_num}")
|
||||
|
||||
self.setLineWidth(0.5)
|
||||
self.line(MARGIN, h - 36, w - MARGIN, h - 36)
|
||||
|
||||
labels = [
|
||||
"Model:",
|
||||
"Serijski broj:",
|
||||
"Radni sati nadogradnje:",
|
||||
"Radni sati podvozja:",
|
||||
"Km:",
|
||||
"Broj naloga:",
|
||||
]
|
||||
values = [
|
||||
model_str,
|
||||
serial_str,
|
||||
upgrade_hours_str,
|
||||
chassis_hours_str,
|
||||
mileage_str,
|
||||
nalog_str,
|
||||
]
|
||||
col_w = (w - 2 * MARGIN) / len(labels)
|
||||
self.setFont("Vera", 7.2)
|
||||
for i, (lbl, val) in enumerate(zip(labels, values)):
|
||||
x = MARGIN + i * col_w + 3
|
||||
self.drawString(x, h - 49, lbl)
|
||||
value_text = str(val or '-')
|
||||
value_font_size = 9
|
||||
if i == len(labels) - 1:
|
||||
value_font_size = 7
|
||||
max_value_width = col_w - 8
|
||||
while (
|
||||
self.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width
|
||||
and value_font_size > 5.5
|
||||
):
|
||||
value_font_size -= 0.3
|
||||
if self.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width:
|
||||
trimmed = value_text
|
||||
while (
|
||||
len(trimmed) > 3
|
||||
and self.stringWidth(f"{trimmed}...", "Vera-Bold", value_font_size) > max_value_width
|
||||
):
|
||||
trimmed = trimmed[:-1]
|
||||
value_text = f"{trimmed}..."
|
||||
else:
|
||||
value_text = value_text[:24]
|
||||
self.setFont("Vera-Bold", value_font_size)
|
||||
self.drawString(x, h - 61, value_text)
|
||||
self.setFont("Vera", 7.2)
|
||||
if i > 0:
|
||||
self.line(MARGIN + i * col_w, h - 36, MARGIN + i * col_w, h - HEADER_H + 4)
|
||||
|
||||
footer_y = FOOTER_H
|
||||
self.setLineWidth(0.5)
|
||||
self.rect(MARGIN, footer_y + 42, w - 2 * MARGIN, 18, stroke=1, fill=0)
|
||||
sig_labels = [f"Datum: {generated_date_str}", "Potpis servisera", "Pečat i potpis klijenta *1", "Pregledao"]
|
||||
sig_w = (w - 2 * MARGIN) / 4
|
||||
self.setFont("Vera", 7.2)
|
||||
for i, lbl in enumerate(sig_labels):
|
||||
x = MARGIN + i * sig_w
|
||||
self.drawString(x + 4, footer_y + 45, lbl)
|
||||
if i > 0:
|
||||
self.line(x, footer_y + 42, x, footer_y + 60)
|
||||
self.setFont("Vera", 6.5)
|
||||
self.drawString(MARGIN, footer_y + 26, "*1- potpisom klijent potvrđuje da je suglasan s podacima u radnom nalogu")
|
||||
self.drawCentredString(
|
||||
w / 2,
|
||||
footer_y + 14,
|
||||
"Ovlašteni servis LIEBHERR Werk-Ehingen GmbH, LIEBHERR-Werk Nenzing GmbH, Liebherr-MCCTech Rostock GmbH",
|
||||
draw_standard_header_footer(
|
||||
self,
|
||||
page_num=page_num,
|
||||
client_name=client_name,
|
||||
manufacturer=manufacturer_str,
|
||||
model=model_str,
|
||||
serial=serial_str,
|
||||
upgrade_hours=upgrade_hours_str,
|
||||
chassis_hours=chassis_hours_str,
|
||||
mileage=mileage_str,
|
||||
work_order_number=nalog_str,
|
||||
generated_date=generated_date_str,
|
||||
report_title="Izvještaj servisera",
|
||||
page_size=A4,
|
||||
margin=MARGIN,
|
||||
header_h=HEADER_H,
|
||||
footer_h=FOOTER_H,
|
||||
)
|
||||
|
||||
pdf = PageCanvas(buffer, pagesize=A4)
|
||||
@@ -799,7 +756,7 @@ def _build_work_order_service_records_pdf(work_order):
|
||||
|
||||
if not related_tasks:
|
||||
pdf.setFont("Vera", 9)
|
||||
pdf.drawString(MARGIN, y, "Nema povezanih taskova za ovaj putni nalog.")
|
||||
pdf.drawString(MARGIN, y, "Nema povezanih servisnih zadataka za ovaj putni nalog.")
|
||||
else:
|
||||
for task in related_tasks:
|
||||
task_records = [
|
||||
@@ -812,7 +769,7 @@ def _build_work_order_service_records_pdf(work_order):
|
||||
if y < content_bottom + 60:
|
||||
y = new_page()
|
||||
pdf.setFont("Vera-Bold", 10)
|
||||
pdf.drawString(MARGIN, y, (task.title or f"Task #{task.id}")[:95])
|
||||
pdf.drawString(MARGIN, y, (task.title or f"Servisni zadatak #{task.id}")[:95])
|
||||
y -= 14
|
||||
|
||||
for record in task_records:
|
||||
@@ -912,6 +869,247 @@ def _build_work_order_service_records_pdf(work_order):
|
||||
pdf.save()
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _build_service_record_pdf(service_record):
|
||||
vehicle = service_record.vehicle
|
||||
task = getattr(service_record, 'task', None)
|
||||
work_order = getattr(task, 'work_order', None) if task else None
|
||||
client_name = getattr(getattr(vehicle, 'client', None), 'name', None) or '-'
|
||||
manufacturer_str = str(vehicle.make or '-')
|
||||
model_str = str(vehicle.model or '-')
|
||||
serial_str = str(vehicle.crane_serial_number or '-')
|
||||
upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-')
|
||||
chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-')
|
||||
mileage_str = str(vehicle.current_mileage or '-')
|
||||
nalog_str = str(getattr(work_order, 'pk', '-') or '-')
|
||||
generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y')
|
||||
|
||||
HEADER_H = 86
|
||||
FOOTER_H = 72
|
||||
MARGIN = 28
|
||||
|
||||
def _fmt_date(value):
|
||||
if not value:
|
||||
return '-'
|
||||
return value.strftime('%d.%m.%Y') if hasattr(value, 'strftime') else str(value)
|
||||
|
||||
buffer = BytesIO()
|
||||
|
||||
class PageCanvas(canvas.Canvas):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._current_page = 0
|
||||
|
||||
def showPage(self):
|
||||
self._current_page += 1
|
||||
self._draw_header_footer()
|
||||
super().showPage()
|
||||
|
||||
def save(self):
|
||||
self._current_page += 1
|
||||
self._draw_header_footer()
|
||||
super().save()
|
||||
|
||||
def _draw_header_footer(self):
|
||||
page_num = self._current_page
|
||||
draw_standard_header_footer(
|
||||
self,
|
||||
page_num=page_num,
|
||||
client_name=client_name,
|
||||
manufacturer=manufacturer_str,
|
||||
model=model_str,
|
||||
serial=serial_str,
|
||||
upgrade_hours=upgrade_hours_str,
|
||||
chassis_hours=chassis_hours_str,
|
||||
mileage=mileage_str,
|
||||
work_order_number=nalog_str,
|
||||
generated_date=generated_date_str,
|
||||
report_title="Izvještaj servisera",
|
||||
page_size=A4,
|
||||
margin=MARGIN,
|
||||
header_h=HEADER_H,
|
||||
footer_h=FOOTER_H,
|
||||
)
|
||||
|
||||
pdf = PageCanvas(buffer, pagesize=A4)
|
||||
page_w, page_h = A4
|
||||
content_top = page_h - HEADER_H - 10
|
||||
content_bottom = FOOTER_H + 52
|
||||
content_width = page_w - 2 * MARGIN
|
||||
|
||||
def new_page():
|
||||
pdf.showPage()
|
||||
return content_top
|
||||
|
||||
def draw_table(y, data, col_widths, style, row_heights=None):
|
||||
table = Table(data, colWidths=col_widths, rowHeights=row_heights)
|
||||
table.setStyle(style)
|
||||
_, h = table.wrap(content_width, page_h)
|
||||
if y - h < content_bottom:
|
||||
y = new_page()
|
||||
table.drawOn(pdf, MARGIN, y - h)
|
||||
return y - h - 10
|
||||
|
||||
paragraph_styles = getSampleStyleSheet()
|
||||
description_style = paragraph_styles['BodyText'].clone('service-record-description')
|
||||
description_style.fontName = 'Vera'
|
||||
description_style.fontSize = 8.2
|
||||
description_style.leading = 10.4
|
||||
|
||||
y = content_top - 6
|
||||
y -= 6
|
||||
|
||||
info_rows = [
|
||||
["ID", str(service_record.pk), "Datum", _fmt_date(service_record.service_date)],
|
||||
["Naziv", service_record.service_title or '-', "Servisni zadatak", getattr(task, 'title', '-') or '-'],
|
||||
["Dizalica", getattr(vehicle, 'registration_number', '-') or '-', "SN", getattr(vehicle, 'crane_serial_number', '-') or '-'],
|
||||
["Serviser", _user_display_name(service_record.performed_by) or '-', "KM", str(service_record.mileage or '-')],
|
||||
["Trošak", str(service_record.cost), "Sljedeći servis", str(service_record.next_service_due_at or '-')],
|
||||
]
|
||||
y = draw_table(
|
||||
y,
|
||||
info_rows,
|
||||
[78, content_width * 0.37, 88, content_width - 166 - (content_width * 0.37)],
|
||||
TableStyle([
|
||||
('FONTNAME', (0, 0), (-1, -1), 'Vera'),
|
||||
('FONTNAME', (0, 0), (0, -1), 'Vera-Bold'),
|
||||
('FONTNAME', (2, 0), (2, -1), 'Vera-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8.5),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||
]),
|
||||
)
|
||||
|
||||
y = draw_table(
|
||||
y,
|
||||
[["Servisni opis"], [Paragraph(service_record.description or '-', description_style)]],
|
||||
[content_width],
|
||||
TableStyle([
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
||||
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||
]),
|
||||
)
|
||||
|
||||
y = draw_table(
|
||||
y,
|
||||
[["Korišteni dijelovi"], [Paragraph(service_record.parts or '-', description_style)]],
|
||||
[content_width],
|
||||
TableStyle([
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
||||
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||
]),
|
||||
)
|
||||
|
||||
attachments = list(service_record.attachments.filter(is_active=True).all())
|
||||
attachment_names = [os.path.basename(item.file.name) for item in attachments if item.file]
|
||||
y = draw_table(
|
||||
y,
|
||||
[["Prilozi"], [Paragraph(', '.join(attachment_names) if attachment_names else '-', description_style)]],
|
||||
[content_width],
|
||||
TableStyle([
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
||||
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||
]),
|
||||
)
|
||||
|
||||
photos = [photo for photo in service_record.photos.filter(is_active=True).all() if photo.image]
|
||||
if photos:
|
||||
if y < content_bottom + 30:
|
||||
y = new_page()
|
||||
y -= 8
|
||||
pdf.setFont("Vera", 8)
|
||||
pdf.drawString(MARGIN, y, "Fotografije:")
|
||||
y -= 10
|
||||
|
||||
image_gap = 10
|
||||
max_image_height = 110
|
||||
image_width = (content_width - image_gap) / 2
|
||||
for index in range(0, len(photos), 2):
|
||||
row_photos = photos[index:index + 2]
|
||||
prepared = []
|
||||
for photo in row_photos:
|
||||
try:
|
||||
image_reader = ImageReader(photo.image)
|
||||
source_w, source_h = image_reader.getSize()
|
||||
if not source_w or not source_h:
|
||||
continue
|
||||
scaled_h = min(max_image_height, image_width * (float(source_h) / float(source_w)))
|
||||
prepared.append((photo, image_reader, scaled_h))
|
||||
except (OSError, ValueError, TypeError, UnidentifiedImageError):
|
||||
continue
|
||||
if not prepared:
|
||||
continue
|
||||
|
||||
caption_height = 12
|
||||
row_height = max(item[2] for item in prepared) + caption_height
|
||||
if y - row_height < content_bottom:
|
||||
y = new_page()
|
||||
|
||||
base_y = y - caption_height
|
||||
for photo_index, (photo, image_reader, scaled_h) in enumerate(prepared):
|
||||
image_x = MARGIN + photo_index * (image_width + image_gap)
|
||||
image_y = base_y - scaled_h
|
||||
pdf.drawImage(
|
||||
image_reader,
|
||||
image_x,
|
||||
image_y,
|
||||
width=image_width,
|
||||
height=scaled_h,
|
||||
preserveAspectRatio=True,
|
||||
anchor='c',
|
||||
mask='auto',
|
||||
)
|
||||
pdf.setFont("Vera", 7)
|
||||
caption = (photo.description or '').strip() or f"Slika {index + photo_index + 1}"
|
||||
pdf.drawString(image_x, image_y - 10, caption[:52])
|
||||
|
||||
y = base_y - max(item[2] for item in prepared) - 14
|
||||
else:
|
||||
y = draw_table(
|
||||
y,
|
||||
[["Fotografije"], ["-"]],
|
||||
[content_width],
|
||||
TableStyle([
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
|
||||
('FONTNAME', (0, 1), (-1, -1), 'Vera'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8.2),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||
]),
|
||||
)
|
||||
|
||||
pdf.save()
|
||||
return buffer.getvalue()
|
||||
|
||||
def _first_non_empty(*values):
|
||||
for value in values:
|
||||
if isinstance(value, str) and value.strip():
|
||||
@@ -1294,18 +1492,7 @@ class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
|
||||
@action(detail=True, methods=['get'], url_path='pdf')
|
||||
def pdf(self, request, pk=None):
|
||||
service_record = self.get_object()
|
||||
rows = [
|
||||
("ID", str(service_record.pk)),
|
||||
("Datum", str(service_record.service_date)),
|
||||
("Dizalica", service_record.vehicle.registration_number),
|
||||
("Serviser", service_record.performed_by.get_full_name() or service_record.performed_by.email),
|
||||
("Kilometraza", str(service_record.mileage)),
|
||||
("Sljedeci servis", str(service_record.next_service_due_at or '-')),
|
||||
("Trosak", str(service_record.cost)),
|
||||
("Opis", service_record.description or '-'),
|
||||
("Dijelovi", service_record.parts or '-'),
|
||||
]
|
||||
pdf_bytes = _build_simple_pdf(f"Servisni zapis {service_record.pk}", rows)
|
||||
pdf_bytes = _build_service_record_pdf(service_record)
|
||||
response = HttpResponse(pdf_bytes, content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="{service_record.pk}.service-record.pdf"'
|
||||
return response
|
||||
@@ -1321,16 +1508,7 @@ class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
|
||||
if not recipient:
|
||||
raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."})
|
||||
|
||||
rows = [
|
||||
("ID", str(service_record.pk)),
|
||||
("Datum", str(service_record.service_date)),
|
||||
("Dizalica", service_record.vehicle.registration_number),
|
||||
("Kilometraza", str(service_record.mileage)),
|
||||
("Trosak", str(service_record.cost)),
|
||||
("Opis", service_record.description or '-'),
|
||||
("Dijelovi", service_record.parts or '-'),
|
||||
]
|
||||
pdf_bytes = _build_simple_pdf(f"Servisni zapis {service_record.pk}", rows)
|
||||
pdf_bytes = _build_service_record_pdf(service_record)
|
||||
subject = _first_non_empty(request.data.get('subject')) or f"Servisni zapis {service_record.pk}"
|
||||
body = _first_non_empty(request.data.get('message')) or (
|
||||
f"U prilogu je PDF servisnog zapisa. Poslano {timezone.now().strftime('%d.%m.%Y %H:%M')}."
|
||||
|
||||
@@ -10,6 +10,13 @@ from modules.crm.models import Client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InvoiceService:
|
||||
@staticmethod
|
||||
def create_full_invoice(*args, **kwargs):
|
||||
from modules.invoicing.services import InvoiceService as _InvoiceService
|
||||
return _InvoiceService.create_full_invoice(*args, **kwargs)
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def process_paperless_document_task(self, document_id):
|
||||
"""
|
||||
@@ -57,8 +64,6 @@ def process_paperless_document_task(self, document_id):
|
||||
"created_at": raw_created,
|
||||
}
|
||||
|
||||
from modules.invoicing.services import InvoiceService
|
||||
|
||||
lookup_field = User.USERNAME_FIELD
|
||||
system_user = User.objects.filter(**{lookup_field: "bot@erp.hr"}).first()
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ class TaskService:
|
||||
raise ValidationError({"status": "Samo admini mogu odmah označiti zadatak kao gotov."})
|
||||
|
||||
payload = dict(data)
|
||||
title = (payload.get('title') or '').strip()
|
||||
if title and len(title) < 3:
|
||||
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
|
||||
if user and 'assigned_to' not in payload:
|
||||
payload['assigned_to'] = user
|
||||
|
||||
@@ -40,6 +43,8 @@ class TaskService:
|
||||
# Spremanje
|
||||
for attr, value in data.items():
|
||||
setattr(instance, attr, value)
|
||||
if 'title' in data and len(str(data.get('title') or '').strip()) < 3:
|
||||
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
|
||||
try:
|
||||
instance.full_clean()
|
||||
except DjangoValidationError as exc:
|
||||
|
||||
@@ -7,26 +7,28 @@ from modules.fleet.models import Vehicle, WorkOrder
|
||||
|
||||
|
||||
class TaskSerializerTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(username="task-user-base", email="task-user-base@example.test", password="pass")
|
||||
self.vehicle = Vehicle.objects.create(registration_number="ZG-TSK-01", asset_type='crane')
|
||||
|
||||
def test_validate_title_too_short_raises(self):
|
||||
serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan"})
|
||||
serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)})
|
||||
with self.assertRaises(ValidationError):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
def test_validate_title_ok(self):
|
||||
serializer = TaskSerializer(data={"title": "Valid title", "description": "", "status": "aktivan"})
|
||||
serializer = TaskSerializer(data={"title": "Valid title", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)})
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
def test_cannot_close_without_work_order(self):
|
||||
serializer = TaskSerializer(data={"title": "Task 1", "description": "", "status": "neaktivan"})
|
||||
serializer = TaskSerializer(data={"title": "Task 1", "description": "", "status": "neaktivan", "vehicle": str(self.vehicle.id)})
|
||||
with self.assertRaises(ValidationError):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
def test_can_close_with_work_order(self):
|
||||
User = get_user_model()
|
||||
user = User.objects.create_user(username="task-user", email="task-user@example.test", password="pass")
|
||||
vehicle = Vehicle.objects.create(registration_number="ZG-TSK-02", asset_type='crane')
|
||||
work_order = WorkOrder.objects.create(vehicle=vehicle, creator=user, purpose='defektaza')
|
||||
task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=user)
|
||||
work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='defektaza')
|
||||
task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle)
|
||||
|
||||
serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True)
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import patch
|
||||
from modules.task_management.views import TaskViewSet
|
||||
from modules.task_management.serializers import TaskSerializer
|
||||
from modules.task_management.models import Task
|
||||
from modules.fleet.models import Vehicle
|
||||
|
||||
|
||||
class TaskViewSetTests(TestCase):
|
||||
@@ -15,10 +16,11 @@ class TaskViewSetTests(TestCase):
|
||||
self.view = TaskViewSet()
|
||||
self.view.request = self.factory.post("/")
|
||||
self.view.request.user = self.user
|
||||
self.vehicle = Vehicle.objects.create(registration_number="ZG-TV-01", asset_type='crane')
|
||||
|
||||
@patch("modules.task_management.views.TaskService.create_task_entry")
|
||||
def test_perform_create_calls_service(self, mock_create):
|
||||
data = {"title": "Task from view", "description": "", "status": "aktivan"}
|
||||
data = {"title": "Task from view", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)}
|
||||
serializer = TaskSerializer(data=data, context={"request": self.view.request})
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = core.settings
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
addopts = --import-mode=importlib
|
||||
@@ -31,6 +31,10 @@ services:
|
||||
environment:
|
||||
- PUBLIC_API_URL=${PUBLIC_API_URL}
|
||||
- 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:
|
||||
container_name: 004erpflower
|
||||
|
||||
@@ -1,7 +1,147 @@
|
||||
const CACHE_NAME = 'erp-shell-v1';
|
||||
const CACHE_NAME = 'erp-shell-v2';
|
||||
const API_CACHE_NAME = 'erp-api-v1';
|
||||
const WRITE_QUEUE_DB_NAME = 'erp-write-queue-db';
|
||||
const WRITE_QUEUE_STORE = 'requests';
|
||||
const WRITE_QUEUE_SYNC_TAG = 'erp-write-queue-sync';
|
||||
const OFFLINE_URL = '/offline.html';
|
||||
const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL];
|
||||
|
||||
const API_CACHE_BLOCKLIST = [
|
||||
'/api/token/',
|
||||
'/api/token/refresh/',
|
||||
'/api/fleet/pusher-auth/',
|
||||
];
|
||||
|
||||
function isCacheableApiGet(request, url) {
|
||||
if (request.method !== 'GET') return false;
|
||||
if (url.origin !== self.location.origin) return false;
|
||||
if (!url.pathname.startsWith('/api/')) return false;
|
||||
return !API_CACHE_BLOCKLIST.some((blocked) => url.pathname.startsWith(blocked));
|
||||
}
|
||||
|
||||
function isQueueableApiWrite(request, url) {
|
||||
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) return false;
|
||||
if (url.origin !== self.location.origin) return false;
|
||||
if (!url.pathname.startsWith('/api/')) return false;
|
||||
if (API_CACHE_BLOCKLIST.some((blocked) => url.pathname.startsWith(blocked))) return false;
|
||||
const contentType = (request.headers.get('content-type') || '').toLowerCase();
|
||||
if (contentType.includes('multipart/form-data')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function openWriteQueueDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(WRITE_QUEUE_DB_NAME, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(WRITE_QUEUE_STORE)) {
|
||||
db.createObjectStore(WRITE_QUEUE_STORE, { keyPath: 'id', autoIncrement: true });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function serializeHeaders(headers) {
|
||||
const serialized = {};
|
||||
headers.forEach((value, key) => {
|
||||
serialized[key] = value;
|
||||
});
|
||||
return serialized;
|
||||
}
|
||||
|
||||
function getAllQueuedRequests(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(WRITE_QUEUE_STORE, 'readonly');
|
||||
const store = tx.objectStore(WRITE_QUEUE_STORE);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function addQueuedRequest(db, queuedRequest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(WRITE_QUEUE_STORE, 'readwrite');
|
||||
const store = tx.objectStore(WRITE_QUEUE_STORE);
|
||||
const request = store.add(queuedRequest);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteQueuedRequest(db, id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(WRITE_QUEUE_STORE, 'readwrite');
|
||||
const store = tx.objectStore(WRITE_QUEUE_STORE);
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function queueWriteRequest(request) {
|
||||
const body = request.method === 'DELETE' ? null : await request.clone().text();
|
||||
const db = await openWriteQueueDb();
|
||||
const payload = {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: serializeHeaders(request.headers),
|
||||
body,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await addQueuedRequest(db, payload);
|
||||
}
|
||||
|
||||
function createQueuedResponse() {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
queued: true,
|
||||
offline: true,
|
||||
detail: 'Zahtjev je spremljen i bit će sinkroniziran kad veza bude dostupna.',
|
||||
}),
|
||||
{
|
||||
status: 202,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function scheduleWriteQueueSync() {
|
||||
if (!self.registration || !self.registration.sync) return;
|
||||
await self.registration.sync.register(WRITE_QUEUE_SYNC_TAG);
|
||||
}
|
||||
|
||||
async function replayQueuedRequests() {
|
||||
const db = await openWriteQueueDb();
|
||||
const queuedRequests = await getAllQueuedRequests(db);
|
||||
for (const queued of queuedRequests) {
|
||||
const headers = new Headers(queued.headers || {});
|
||||
const init = {
|
||||
method: queued.method,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
};
|
||||
if (queued.body != null && queued.method !== 'DELETE') {
|
||||
init.body = queued.body;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(queued.url, init);
|
||||
if (response.ok) {
|
||||
await deleteQueuedRequest(db, queued.id);
|
||||
continue;
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
throw new Error(`Replay failed with status ${response.status}`);
|
||||
}
|
||||
await deleteQueuedRequest(db, queued.id);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
@@ -11,22 +151,76 @@ self.addEventListener('install', (event) => {
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) =>
|
||||
Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
)
|
||||
)
|
||||
Promise.all([
|
||||
caches.keys().then((cacheNames) =>
|
||||
Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME && name !== API_CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
)
|
||||
),
|
||||
replayQueuedRequests().catch((error) => {
|
||||
console.error('Initial write-queue replay failed.', error);
|
||||
}),
|
||||
])
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
if (isQueueableApiWrite(request, url)) {
|
||||
event.respondWith(
|
||||
fetch(request.clone()).catch(async () => {
|
||||
try {
|
||||
await queueWriteRequest(request);
|
||||
} catch (error) {
|
||||
console.error('Failed to queue offline write request.', error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
queued: false,
|
||||
offline: true,
|
||||
detail: 'Zahtjev nije moguće spremiti offline.',
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
try {
|
||||
await scheduleWriteQueueSync();
|
||||
} catch (error) {
|
||||
console.warn('Background sync registration failed.', error);
|
||||
}
|
||||
return createQueuedResponse();
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (isCacheableApiGet(request, url)) {
|
||||
event.respondWith(
|
||||
caches.open(API_CACHE_NAME).then(async (cache) => {
|
||||
const cached = await cache.match(request);
|
||||
const networkFetch = fetch(request)
|
||||
.then((response) => {
|
||||
if (response && response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
|
||||
return cached || networkFetch;
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (request.mode === 'navigate') {
|
||||
@@ -60,3 +254,13 @@ self.addEventListener('fetch', (event) => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag !== WRITE_QUEUE_SYNC_TAG) return;
|
||||
event.waitUntil(replayQueuedRequests());
|
||||
});
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data !== 'FLUSH_WRITE_QUEUE') return;
|
||||
event.waitUntil(replayQueuedRequests());
|
||||
});
|
||||
|
||||
@@ -131,23 +131,20 @@ export default function ClientsSection({ onSetServiceContext = null }) {
|
||||
headClassName="text-xs uppercase tracking-wide text-text-muted"
|
||||
bodyClassName="divide-y divide-border-hairline"
|
||||
rowClassName="hover:bg-canvas-deep"
|
||||
isRowSelected={(item) => String(item.id) === String(selectedClientCraneId)}
|
||||
tableClassName="min-w-full text-sm"
|
||||
wrapperClassName="overflow-x-auto"
|
||||
renderRow={(item) => {
|
||||
const isSelected = String(item.id) === String(selectedClientCraneId);
|
||||
const cellClassName = isSelected ? 'bg-emerald-50' : '';
|
||||
const firstCellClassName = isSelected ? 'bg-emerald-50 text-emerald-800' : '';
|
||||
return (
|
||||
renderRow={(item) => (
|
||||
<>
|
||||
<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 cursor-pointer ${cellClassName}`} 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 ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>
|
||||
<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" 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.current_mileage?.toLocaleString('hr-HR')} km</td>
|
||||
<td className="py-2 pr-4 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>
|
||||
{item.service_interval_km
|
||||
? `${(Math.floor(item.current_mileage / item.service_interval_km) + 1) * item.service_interval_km} km`
|
||||
: '—'}
|
||||
</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
|
||||
? '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'
|
||||
@@ -156,7 +153,7 @@ export default function ClientsSection({ onSetServiceContext = null }) {
|
||||
</span>
|
||||
</td>
|
||||
</>
|
||||
);}}
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import { animated, useSpring, useTransition } from '@react-spring/web';
|
||||
import DashboardTopbar from './DashboardTopbar';
|
||||
import WorkOrderModal from './WorkOrderModal';
|
||||
import TaskCreateModal from './TaskCreateModal';
|
||||
@@ -112,6 +112,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
const [taskScope, setTaskScope] = useState('all');
|
||||
const [workOrderScope, setWorkOrderScope] = useState('context');
|
||||
const [selectedCraneRowId, setSelectedCraneRowId] = useState(null);
|
||||
const [expandedServiceTaskId, setExpandedServiceTaskId] = useState(null);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -270,12 +271,50 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
|| contextCrane?.client_name
|
||||
|| null
|
||||
), [clients, selectedClientId, contextCrane]);
|
||||
const contextCraneLabel = useMemo(() => {
|
||||
if (!hydratedContextCrane) return null;
|
||||
const serial = String(hydratedContextCrane.crane_serial_number || '').trim();
|
||||
if (serial) return `SN ${serial}`;
|
||||
return hydratedContextCrane.registration_number || '-';
|
||||
}, [hydratedContextCrane]);
|
||||
const serviceRecordsForContext = useMemo(() => {
|
||||
if (!selectedVehicleId) return [];
|
||||
return [...serviceRecords]
|
||||
.filter((record) => String(record.vehicle) === String(selectedVehicleId))
|
||||
.sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || '')));
|
||||
}, [serviceRecords, selectedVehicleId]);
|
||||
const serviceTaskGroups = useMemo(() => {
|
||||
if (!selectedVehicleId) return [];
|
||||
const contextTasks = [...tasks]
|
||||
.filter((task) => String(task.vehicle || '') === String(selectedVehicleId))
|
||||
.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
|
||||
const groups = contextTasks.map((task) => ({
|
||||
id: String(task.id),
|
||||
title: task.title || 'Radni zadatak',
|
||||
status: task.status || '-',
|
||||
task,
|
||||
records: serviceRecordsForContext.filter((record) => String(record.task || '') === String(task.id)),
|
||||
}));
|
||||
const unassignedRecords = serviceRecordsForContext.filter((record) => !record.task);
|
||||
if (unassignedRecords.length > 0) {
|
||||
groups.push({
|
||||
id: 'unassigned-task',
|
||||
title: 'Bez radnog zadatka',
|
||||
status: '-',
|
||||
task: null,
|
||||
records: unassignedRecords,
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}, [selectedVehicleId, tasks, serviceRecordsForContext]);
|
||||
const serviceTaskTransitions = useTransition(serviceTaskGroups, {
|
||||
keys: (group) => group.id,
|
||||
from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
|
||||
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
|
||||
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
|
||||
trail: 30,
|
||||
config: { tension: 230, friction: 26 },
|
||||
});
|
||||
const scopedWorkOrders = useMemo(() => {
|
||||
const sorted = [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
||||
if (workOrderScope === 'all') {
|
||||
@@ -363,6 +402,14 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
setCurrentPage(1);
|
||||
}, [workOrderScope, hydratedSelectedVehicleId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandedServiceTaskId) return;
|
||||
const exists = serviceTaskGroups.some((group) => String(group.id) === String(expandedServiceTaskId));
|
||||
if (!exists) {
|
||||
setExpandedServiceTaskId(null);
|
||||
}
|
||||
}, [expandedServiceTaskId, serviceTaskGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCraneRowId) return;
|
||||
const exists = cranes.some((item) => String(item.id) === String(selectedCraneRowId));
|
||||
@@ -421,6 +468,27 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
showToast('Servisni kontekst je postavljen.', 'success');
|
||||
}
|
||||
|
||||
async function openTaskModalFromContext() {
|
||||
if (!selectedClientId || !selectedVehicleId) {
|
||||
showToast('Odaberite kupca i dizalicu u Servisnom kontekstu prije kreiranja radnog zadatka.', 'warning');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const availableCranes = await ensureVehiclesCatalog();
|
||||
const selectedFromContext = availableCranes.find(
|
||||
(item) => String(item.id) === String(selectedVehicleId)
|
||||
);
|
||||
if (!selectedFromContext) {
|
||||
showToast('Odabrana dizalica nije dostupna u trenutnom kontekstu.', 'warning');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
showToast('Ne mogu dohvatiti dizalice. Provjerite mrežu ili prijavu.', 'error');
|
||||
return;
|
||||
}
|
||||
setIsTaskModalOpen(true);
|
||||
}
|
||||
|
||||
const workOrderColumns = [
|
||||
{ key: 'code', label: 'Putni nalog', className: 'px-4 py-3' },
|
||||
{ key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' },
|
||||
@@ -474,26 +542,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
}
|
||||
setIsWorkOrderModalOpen(true);
|
||||
}}
|
||||
onOpenTaskModal={async () => {
|
||||
if (!selectedClientId || !selectedVehicleId) {
|
||||
showToast('Odaberite kupca i dizalicu u Servisnom kontekstu prije kreiranja radnog zadatka.', 'warning');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const availableCranes = await ensureVehiclesCatalog();
|
||||
const selectedFromContext = availableCranes.find(
|
||||
(item) => String(item.id) === String(selectedVehicleId)
|
||||
);
|
||||
if (!selectedFromContext) {
|
||||
showToast('Odabrana dizalica nije dostupna u trenutnom kontekstu.', 'warning');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
showToast('Ne mogu dohvatiti dizalice. Provjerite mrežu ili prijavu.', 'error');
|
||||
return;
|
||||
}
|
||||
setIsTaskModalOpen(true);
|
||||
}}
|
||||
onOpenTaskModal={openTaskModalFromContext}
|
||||
onNewServiceRecord={({ craneId }) => {
|
||||
const crane = cranes.find((item) => String(item.id) === String(craneId));
|
||||
openServiceRecordForCrane(crane ?? null);
|
||||
@@ -640,8 +689,8 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
<h3 className="font-semibold text-text-main">Putni nalozi dizalica</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
{workOrderScope === 'context'
|
||||
? (selectedContextClientName && hydratedContextCrane?.registration_number
|
||||
? `Kontekst: ${selectedContextClientName} / ${hydratedContextCrane.registration_number}`
|
||||
? (selectedContextClientName && contextCraneLabel
|
||||
? `Kontekst: ${selectedContextClientName} / ${contextCraneLabel}`
|
||||
: 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.')
|
||||
: 'Prikaz svih putnih naloga.'}
|
||||
</p>
|
||||
@@ -777,12 +826,19 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-main">Servisni zapisi</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
{selectedContextClientName && hydratedContextCrane?.registration_number
|
||||
? `Kontekst: ${selectedContextClientName} / ${hydratedContextCrane.registration_number}`
|
||||
{selectedContextClientName && contextCraneLabel
|
||||
? `Kontekst: ${selectedContextClientName} / ${contextCraneLabel}`
|
||||
: 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.'}
|
||||
</p>
|
||||
</div>
|
||||
<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 && (
|
||||
<ServiceRecordCreateButton onClick={() => openServiceRecordForCrane(hydratedContextCrane)} />
|
||||
)}
|
||||
@@ -795,55 +851,101 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatedDataTable
|
||||
columns={[
|
||||
{ key: 'date', label: 'Datum', 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: 'cost', label: 'Trošak', className: 'px-4 py-3' },
|
||||
{ key: 'files', label: 'Datoteke', className: 'px-4 py-3' },
|
||||
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
|
||||
]}
|
||||
rows={serviceRecordsForContext}
|
||||
rowKey={(record) => record.id}
|
||||
loading={loading}
|
||||
loadingMessage="Učitavanje..."
|
||||
emptyMessage={!hydratedSelectedVehicleId
|
||||
? 'Odaberite dizalicu za prikaz servisnih zapisa.'
|
||||
: 'Nema aktivnih servisnih zapisa za odabranu dizalicu.'}
|
||||
renderRow={(record) => (
|
||||
<>
|
||||
<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.task_label || '-'}</td>
|
||||
<td className="max-w-md truncate px-4 py-3" title={record.description || ''}>
|
||||
{record.description || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">{formatCost(record.cost)}</td>
|
||||
<td className="px-4 py-3">{record.files_count ?? (record.photos_count ?? 0)}</td>
|
||||
<td className="space-x-2 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setServiceRecordBackTask(null);
|
||||
setSelectedServiceRecord(record);
|
||||
}}
|
||||
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
||||
>
|
||||
Detalji
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPhotoUpload({ open: true, serviceRecordId: record.id })}
|
||||
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
||||
>
|
||||
📎 Dodaj datoteke
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{!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
|
||||
columns={[
|
||||
{ key: 'date', label: 'Datum', className: 'px-4 py-3' },
|
||||
{ key: 'crane', label: 'Dizalica', 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: 'files', label: 'Datoteke', className: 'px-4 py-3' },
|
||||
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
|
||||
]}
|
||||
rows={group.records}
|
||||
rowKey={(record) => record.id}
|
||||
rowClassName="hover:bg-canvas-deep"
|
||||
renderRow={(record) => (
|
||||
<>
|
||||
<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="max-w-md truncate px-4 py-3" title={record.description || ''}>
|
||||
{record.description || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">{formatCost(record.cost)}</td>
|
||||
<td className="px-4 py-3">{record.files_count ?? (record.photos_count ?? 0)}</td>
|
||||
<td className="space-x-2 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setServiceRecordBackTask(group.task);
|
||||
setSelectedServiceRecord(record);
|
||||
}}
|
||||
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
||||
>
|
||||
Detalji
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPhotoUpload({ open: true, serviceRecordId: record.id })}
|
||||
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
||||
>
|
||||
📎 Dodaj datoteke
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</animated.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)}
|
||||
|
||||
@@ -884,43 +986,45 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
columns={vehicleColumns}
|
||||
rows={cranes}
|
||||
rowKey={(vehicle) => vehicle.id}
|
||||
rowClassName="hover:bg-canvas-deep"
|
||||
isRowSelected={(vehicle) => String(vehicle.id) === String(selectedCraneRowId)}
|
||||
loading={loading}
|
||||
loadingMessage="Učitavanje vozila..."
|
||||
emptyMessage="Nema dostupnih vozila."
|
||||
renderRow={(vehicle) => (
|
||||
<>
|
||||
<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)}
|
||||
>
|
||||
{vehicle.registration_number || '-'}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
|
||||
className="px-4 py-3"
|
||||
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
||||
>
|
||||
{vehicle.crane_serial_number || '-'}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
|
||||
className="px-4 py-3"
|
||||
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
||||
>
|
||||
{vehicle.make || '-'} {vehicle.model || ''}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
|
||||
className="px-4 py-3"
|
||||
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
||||
>
|
||||
{vehicle.client_name || '-'}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
|
||||
className="px-4 py-3"
|
||||
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
||||
>
|
||||
{vehicle.current_mileage ?? '-'}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
|
||||
className="px-4 py-3"
|
||||
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
||||
>
|
||||
<span className={vehicle.is_active ? 'text-emerald-700' : 'text-red-700'}>
|
||||
|
||||
@@ -51,6 +51,14 @@ export default function ServiceContextSelector({ onNewServiceRecord }) {
|
||||
const selectedClient = availableClients.find((c) => String(c.id) === String(selectedClientId));
|
||||
const selectedCrane = cranes.find((item) => String(item.id) === String(selectedVehicleId));
|
||||
const showSelector = editMode || !selectedClientId;
|
||||
const selectedCraneLabel = (() => {
|
||||
if (!selectedCrane) return '';
|
||||
const serial = String(selectedCrane.crane_serial_number || '').trim();
|
||||
if (serial) {
|
||||
return `SN ${serial} ${selectedCrane.make || ''} ${selectedCrane.model || ''}`.trim();
|
||||
}
|
||||
return `${selectedCrane.registration_number || '-'} ${selectedCrane.make || ''} ${selectedCrane.model || ''}`.trim();
|
||||
})();
|
||||
|
||||
function handleClientChange(e) {
|
||||
setSelectedClient(e.target.value);
|
||||
@@ -97,7 +105,7 @@ export default function ServiceContextSelector({ onNewServiceRecord }) {
|
||||
{clientCranes.length > 0
|
||||
? clientCranes.map((item) => (
|
||||
<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 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="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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
import { api } from '../../services/apiClient';
|
||||
|
||||
function getBrowserApiBase() {
|
||||
const apiUrl = import.meta.env.PUBLIC_API_URL || '';
|
||||
@@ -44,6 +45,109 @@ export function resolveMediaUrl(pathOrUrl) {
|
||||
return new URL(finalSrc, apiUrl).toString();
|
||||
}
|
||||
|
||||
function isProtectedMediaUrl(url) {
|
||||
return typeof url === 'string' && /\/api\/fleet\//i.test(url);
|
||||
}
|
||||
|
||||
function revokeObjectUrls(entries = {}) {
|
||||
Object.values(entries).forEach((url) => {
|
||||
if (typeof url === 'string' && url.startsWith('blob:')) {
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuthenticatedMediaSources(mediaPaths = []) {
|
||||
const [authenticatedSources, setAuthenticatedSources] = useState({});
|
||||
const authenticatedSourcesRef = useRef({});
|
||||
|
||||
const resolvedMediaPaths = useMemo(() => {
|
||||
const unique = new Set();
|
||||
if (!Array.isArray(mediaPaths)) {
|
||||
return [];
|
||||
}
|
||||
mediaPaths.forEach((item) => {
|
||||
const resolved = resolveMediaUrl(item);
|
||||
if (resolved) {
|
||||
unique.add(resolved);
|
||||
}
|
||||
});
|
||||
return Array.from(unique);
|
||||
}, [mediaPaths]);
|
||||
const resolvedMediaPathsKey = useMemo(() => resolvedMediaPaths.join('|'), [resolvedMediaPaths]);
|
||||
|
||||
useEffect(() => {
|
||||
authenticatedSourcesRef.current = authenticatedSources;
|
||||
}, [authenticatedSources]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const protectedPaths = resolvedMediaPaths.filter((url) => isProtectedMediaUrl(url));
|
||||
|
||||
if (protectedPaths.length === 0) {
|
||||
setAuthenticatedSources((previous) => {
|
||||
revokeObjectUrls(previous);
|
||||
return {};
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const nextSources = {};
|
||||
const createdObjectUrls = [];
|
||||
|
||||
const fetched = await Promise.all(
|
||||
protectedPaths.map(async (url) => {
|
||||
try {
|
||||
const blob = await api.get(url, { responseType: 'blob' });
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
createdObjectUrls.push(blobUrl);
|
||||
return [url, blobUrl];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
fetched.forEach((entry) => {
|
||||
if (!entry) return;
|
||||
const [url, blobUrl] = entry;
|
||||
nextSources[url] = blobUrl;
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
createdObjectUrls.forEach((blobUrl) => window.URL.revokeObjectURL(blobUrl));
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthenticatedSources((previous) => {
|
||||
revokeObjectUrls(previous);
|
||||
return nextSources;
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resolvedMediaPathsKey]);
|
||||
|
||||
useEffect(() => () => {
|
||||
revokeObjectUrls(authenticatedSourcesRef.current);
|
||||
}, []);
|
||||
|
||||
const getAuthenticatedMediaSrc = (pathOrUrl) => {
|
||||
const resolved = resolveMediaUrl(pathOrUrl);
|
||||
if (!resolved) {
|
||||
return '';
|
||||
}
|
||||
return authenticatedSources[resolved] || resolved;
|
||||
};
|
||||
|
||||
return {
|
||||
getAuthenticatedMediaSrc,
|
||||
};
|
||||
}
|
||||
|
||||
export default function WorkOrderImageCarousel({ images = [], editMode = false, emptyLabel = 'Nema priložene tehničke dokumentacije' }) {
|
||||
const normalizedImages = useMemo(() => (
|
||||
Array.isArray(images)
|
||||
@@ -51,6 +155,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
|
||||
: (typeof images === 'string' && images ? [images] : [])
|
||||
), [images]);
|
||||
const [currentImgIdx, setCurrentImgIdx] = useState(0);
|
||||
const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(normalizedImages);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentImgIdx(0);
|
||||
@@ -87,7 +192,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
|
||||
setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
|
||||
};
|
||||
|
||||
const finalSrc = resolveMediaUrl(normalizedImages[currentImgIdx] || '');
|
||||
const finalSrc = getAuthenticatedMediaSrc(normalizedImages[currentImgIdx] || '');
|
||||
const imageTransitions = useTransition(currentImgIdx, {
|
||||
from: { opacity: 0, transform: 'translate3d(8%,0,0) scale(1.01)' },
|
||||
enter: { opacity: 1, transform: 'translate3d(0%,0,0) scale(1)' },
|
||||
@@ -101,7 +206,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
|
||||
{imageTransitions((style, idx) => (
|
||||
<animated.img
|
||||
key={idx}
|
||||
src={resolveMediaUrl(normalizedImages[idx] || finalSrc)}
|
||||
src={getAuthenticatedMediaSrc(normalizedImages[idx] || finalSrc)}
|
||||
alt={`Dokumentacija s terena ${idx + 1}`}
|
||||
style={style}
|
||||
className="absolute inset-0 h-full w-full object-cover will-change-transform"
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
|
||||
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
||||
import { formatEntityCode } from '../../lib/displayIds';
|
||||
import { resolveMediaUrl } from './WorkOrderImageCarousel';
|
||||
import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
||||
|
||||
function readWorkOrderIdFromQuery() {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -121,6 +121,34 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
return `${formatEntityCode('PN', workOrder.id)}`;
|
||||
}, [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 (
|
||||
<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">
|
||||
@@ -144,7 +172,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
|
||||
<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">
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
|
||||
@@ -185,13 +213,13 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
record.photos.map((photo) => (
|
||||
<a
|
||||
key={photo.id}
|
||||
href={resolveMediaUrl(photo.optimized_url || photo.image_url)}
|
||||
href={getAuthenticatedMediaSrc(photo.optimized_url || photo.image_url)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex"
|
||||
>
|
||||
<img
|
||||
src={resolveMediaUrl(photo.optimized_url || photo.image_url)}
|
||||
src={getAuthenticatedMediaSrc(photo.optimized_url || photo.image_url)}
|
||||
alt={`Servisna slika ${photo.id}`}
|
||||
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>
|
||||
<InvoiceImagePreview
|
||||
imageUrl={resolveMediaUrl(invoice.image)}
|
||||
imageUrl={getAuthenticatedMediaSrc(invoice.image) || resolveMediaUrl(invoice.image)}
|
||||
alt={`Račun ${invoice.naziv_racuna}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
<p className="text-xs text-text-muted">Trošak / KM / Dizalica</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function AnimatedDataTable({
|
||||
headClassName = 'bg-canvas-deep text-left text-xs uppercase tracking-wide text-text-muted',
|
||||
bodyClassName = 'divide-y divide-border-hairline',
|
||||
rowClassName = 'hover:bg-canvas-deep',
|
||||
isRowSelected = () => false,
|
||||
wrapperClassName = 'overflow-x-auto',
|
||||
trail = 35,
|
||||
}) {
|
||||
@@ -23,9 +24,20 @@ export default function AnimatedDataTable({
|
||||
|
||||
const transitions = useTransition(rows, {
|
||||
keys: (row) => rowKey(row),
|
||||
from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
|
||||
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
|
||||
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
|
||||
from: { opacity: 0, transform: 'translate3d(0,8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||
enter: (row) => ({
|
||||
opacity: 1,
|
||||
transform: 'translate3d(0,0,0)',
|
||||
boxShadow: isRowSelected(row)
|
||||
? 'inset 0 0 0 2px rgba(16,185,129,0.75)'
|
||||
: 'inset 0 0 0 0px rgba(16,185,129,0)',
|
||||
}),
|
||||
update: (row) => ({
|
||||
boxShadow: isRowSelected(row)
|
||||
? 'inset 0 0 0 2px rgba(16,185,129,0.75)'
|
||||
: 'inset 0 0 0 0px rgba(16,185,129,0)',
|
||||
}),
|
||||
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||
trail,
|
||||
config: { tension: 230, friction: 26 },
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function ThemeToggle() {
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
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"
|
||||
>
|
||||
{isDark ? (
|
||||
|
||||
@@ -85,6 +85,16 @@ const isDev = import.meta.env.DEV;
|
||||
navigator.serviceWorker.register('/service-worker.js').catch(function (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;
|
||||
}
|
||||
|
||||
@@ -94,7 +104,7 @@ const isDev = import.meta.env.DEV;
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((key) => key.startsWith('erp-shell-'))
|
||||
.filter((key) => key.startsWith('erp-shell-') || key.startsWith('erp-api-'))
|
||||
.map((key) => caches.delete(key))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,74 +12,98 @@ const LS_VEHICLE = 'topbar_selected_vehicle';
|
||||
export const $selectedClientId = atom(null);
|
||||
export const $selectedVehicleId = atom(null);
|
||||
|
||||
function normalizeId(value) {
|
||||
return value ? String(value) : null;
|
||||
}
|
||||
|
||||
function emitServiceContextChanged(clientId, vehicleId) {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent('service-context:changed', {
|
||||
detail: {
|
||||
clientId: clientId || null,
|
||||
vehicleId: vehicleId || null,
|
||||
clientId: normalizeId(clientId),
|
||||
vehicleId: normalizeId(vehicleId),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/** Čita localStorage i puni atome. Idempotentno — sigurno pozvati više puta. */
|
||||
export function hydrateServiceContext() {
|
||||
function persistContext(clientId, vehicleId) {
|
||||
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) {
|
||||
localStorage.setItem(LS_CLIENT, clientId);
|
||||
} else {
|
||||
localStorage.removeItem(LS_CLIENT);
|
||||
}
|
||||
|
||||
if (vehicleId) {
|
||||
localStorage.setItem(LS_VEHICLE, vehicleId);
|
||||
} else {
|
||||
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. */
|
||||
export function clearServiceContext() {
|
||||
if ($selectedClientId.get() === null && $selectedVehicleId.get() === null) {
|
||||
return;
|
||||
}
|
||||
$selectedClientId.set(null);
|
||||
$selectedVehicleId.set(null);
|
||||
localStorage.removeItem(LS_CLIENT);
|
||||
localStorage.removeItem(LS_VEHICLE);
|
||||
persistContext(null, null);
|
||||
emitServiceContextChanged(null, null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user