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