diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..24ab30c
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,39 @@
+name: ERP CI Pipeline
+
+on: [push, pull_request]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ services:
+ db:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_DB: erp_db
+ POSTGRES_PASSWORD: password
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: '3.11'
+
+ - name: Install dependencies
+ run: |
+ pip install -r backend/requirements.txt
+ pip install pytest pytest-django
+
+ - name: Run Unit Tests
+ env:
+ DATABASE_URL: postgres://postgres:password@localhost:5432/erp_db
+ run: |
+ # Pokrećemo testove iz našeg backend direktorija
+ pytest backend/modules/invoicing/tests/test_services.py
+
+ - name: Run Task Management Tests
+ env:
+ DATABASE_URL: postgres://postgres:password@localhost:5432/erp_db
+ run: |
+ pytest backend/modules/task_management/tests/test_services.py
\ No newline at end of file
diff --git a/.github/instructions/NotificationSystem.instructions.md b/.github/instructions/NotificationSystem.instructions.md
new file mode 100644
index 0000000..ea95ac3
--- /dev/null
+++ b/.github/instructions/NotificationSystem.instructions.md
@@ -0,0 +1,8 @@
+---
+description: Describe when these instructions should be loaded by the agent based on task context
+# applyTo: 'Describe when these instructions should be loaded by the agent based on task context' # when provided, instructions will automatically be added to the request context when the pattern matches an attached file
+---
+
+
+
+Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fb8b2d4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,211 @@
+# ---> Python
+# Django
+staticfiles/
+
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+#lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# UV
+# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+#uv.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
+.pdm.toml
+.pdm-python
+.pdm-build/
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+# Ruff stuff:
+.ruff_cache/
+
+# PyPI configuration file
+.pypirc
+
+
+
+# build output
+dist/
+
+# generated types
+.astro/
+
+# dependencies
+node_modules/
+
+# logs
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# environment variables
+.env
+.env.production
+
+# macOS-specific files
+.DS_Store
+
+# jetbrains setting folder
+.idea/
+
+# Docker
+docker-compose.yml.backup
+docker-compose.override.yml
+docker-compose.override.yml.backup
\ No newline at end of file
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..d097b60
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,20 @@
+env/
+venv/
+.venv/
+__pycache__/
+__pycache__
+*.pyc
+*.pyo
+*.pyd
+.git/
+.gitignore
+db.sqlite3
+.env
+staticfiles/
+media/
+.vscode/
+
+# Docker higijena (Dodano)
+Dockerfile
+.dockerignore
+docker-compose*.yml
\ No newline at end of file
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..737773f
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,35 @@
+# --- Stage 1: Builder (Ovdje se događa kompajliranje) ---
+FROM python:3.11-slim AS builder
+
+# Instaliraj alate za build
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ libpq-dev \
+ gcc \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+COPY requirements.txt .
+# Instaliraj sve u /install direktorij
+RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
+
+
+# --- Stage 2: Final (Samo čisti runtime) ---
+FROM python:3.11-slim
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libpq5 && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+COPY --from=builder /install /usr/local
+COPY . .
+
+# 1. Kreiraj korisnika i postavi dozvole dok si još ROOT
+RUN useradd -m appuser && \
+ mkdir -p /app/staticfiles && \
+ chown -R appuser:appuser /app
+
+# 2. Sada prijeđi na ne-root korisnika
+USER appuser
+
+RUN chmod +x entrypoint.sh
+ENTRYPOINT ["/app/entrypoint.sh"]
\ No newline at end of file
diff --git a/backend/core/__init__.py b/backend/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/core/base_models.py b/backend/core/base_models.py
new file mode 100644
index 0000000..376c97d
--- /dev/null
+++ b/backend/core/base_models.py
@@ -0,0 +1,34 @@
+import uuid
+from django.db import models
+
+class BaseModel(models.Model):
+ """
+ Industrijski standardni model za ERP entitete.
+ Svi modeli u našim modulima nasljeđuju ovu klasu.
+ """
+ id = models.UUIDField(
+ primary_key=True,
+ default=uuid.uuid4,
+ editable=False,
+ help_text="Unikatni identifikator entiteta (UUID)."
+ )
+ created_at = models.DateTimeField(
+ auto_now_add=True,
+ verbose_name="Vrijeme kreiranja"
+ )
+ updated_at = models.DateTimeField(
+ auto_now=True,
+ verbose_name="Vrijeme zadnje izmjene"
+ )
+ is_active = models.BooleanField(
+ default=True,
+ verbose_name="Aktivan zapis"
+ )
+
+ class Meta:
+ abstract = True # Ovo je ključ: Django ne kreira tablicu za ovu klasu
+ ordering = ['-created_at']
+
+ def __str__(self):
+ # Defaultni prikaz koji olakšava debugging u adminu
+ return f"{self.__class__.__name__} ({str(self.id)[:8]})"
\ No newline at end of file
diff --git a/backend/core/celery.py b/backend/core/celery.py
new file mode 100644
index 0000000..4af78a7
--- /dev/null
+++ b/backend/core/celery.py
@@ -0,0 +1,15 @@
+# backend/core/celery.py
+
+import os
+from celery import Celery
+
+# Postavi Django settings modul
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
+
+app = Celery('core')
+
+# Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_'
+app.config_from_object('django.conf:settings', namespace='CELERY')
+
+# Automatski pronalazi taskove u svim 'modules.*'
+app.autodiscover_tasks()
\ No newline at end of file
diff --git a/backend/core/management/__init__.py b/backend/core/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/core/management/commands/__init__.py b/backend/core/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/core/management/commands/seed_all_models.py b/backend/core/management/commands/seed_all_models.py
new file mode 100644
index 0000000..b69308c
--- /dev/null
+++ b/backend/core/management/commands/seed_all_models.py
@@ -0,0 +1,385 @@
+import random
+import sys
+import uuid
+from datetime import timedelta
+from decimal import Decimal
+
+from django.contrib.auth import get_user_model
+from django.core.files.base import ContentFile
+from django.core.management.base import BaseCommand, CommandError
+from django.db import transaction
+from django.utils import timezone
+
+from modules.crm.models import Client
+from modules.fleet.models import (
+ Vehicle,
+ WorkOrder,
+ VehicleNotification,
+ VehicleServiceRecord,
+ VehicleServicePhoto,
+)
+from modules.invoicing.models import Invoice, InvoiceItem, InvoiceTransaction
+from modules.task_management.models import Task
+
+
+class Command(BaseCommand):
+ help = (
+ "Generira demo podatke za sve domenske modele. "
+ "Ako --count nije zadan, komanda će u terminalu pitati količinu po modelu."
+ )
+
+ model_labels = [
+ ("clients", "CRM klijenti"),
+ ("users", "korisnici"),
+ ("vehicles", "vozila"),
+ ("work_orders", "radni/putni nalozi"),
+ ("service_records", "servisni zapisi"),
+ ("notifications", "notifikacije"),
+ ("tasks", "task zapisi"),
+ ("invoices", "fakture"),
+ ("invoice_items", "stavke fakture"),
+ ("invoice_transactions", "transakcije fakture"),
+ ("service_photos", "fotografije servisa"),
+ ]
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--count",
+ type=int,
+ default=None,
+ help="Količina zapisa za svaki model. Ako nije zadano, pita se po modelu.",
+ )
+
+ def handle(self, *args, **options):
+ global_count = options["count"]
+ if global_count is not None and global_count < 0:
+ raise CommandError("--count mora biti >= 0")
+
+ counts = self._resolve_counts(global_count)
+
+ self.stdout.write(self.style.MIGRATE_HEADING("Pokrećem generiranje demo podataka..."))
+ with transaction.atomic():
+ clients = self._seed_clients(counts["clients"])
+ users = self._seed_users(counts["users"], clients)
+ vehicles = self._seed_vehicles(counts["vehicles"], users)
+ work_orders = self._seed_work_orders(counts["work_orders"], vehicles, users)
+ tasks = self._seed_tasks(counts["tasks"], users, vehicles)
+ service_records = self._seed_service_records(counts["service_records"], users, tasks)
+ self._seed_notifications(counts["notifications"], users)
+ invoices = self._seed_invoices(counts["invoices"], users, clients)
+ self._seed_invoice_items(counts["invoice_items"], invoices)
+ self._seed_invoice_transactions(counts["invoice_transactions"], invoices)
+ self._seed_service_photos(counts["service_photos"], service_records, users)
+
+ self.stdout.write(self.style.SUCCESS("Generiranje završeno."))
+
+ def _resolve_counts(self, global_count):
+ counts = {}
+ for key, label in self.model_labels:
+ counts[key] = self._ask_count(label, global_count)
+ return counts
+
+ def _ask_count(self, label, global_count):
+ if global_count is not None:
+ return global_count
+
+ if not sys.stdin.isatty():
+ raise CommandError(
+ "Detektiran je non-interactive terminal. "
+ "Pokreni s --count (npr. `python manage.py seed_all_models --count 10`) "
+ "ili interaktivno s TTY (`docker compose exec -it backend python manage.py seed_all_models`)."
+ )
+
+ while True:
+ raw = input(f"Koliko zapisa želiš generirati za {label}? [0]: ").strip()
+ if raw == "":
+ return 0
+ try:
+ value = int(raw)
+ if value < 0:
+ self.stdout.write(self.style.WARNING("Vrijednost mora biti >= 0."))
+ continue
+ return value
+ except ValueError:
+ self.stdout.write(self.style.WARNING("Unesi cijeli broj."))
+
+ def _seed_clients(self, count):
+ created = []
+ for i in range(count):
+ suffix = uuid.uuid4().hex[:8]
+ client = Client.objects.create(
+ name=f"Klijent {suffix}",
+ client_type=random.choice(["legal", "individual"]),
+ tax_id=f"{random.randint(10000000000, 99999999999)}",
+ email=f"client.{suffix}@example.test",
+ phone=f"+38591{random.randint(1000000, 9999999)}",
+ address=f"Ulica {random.randint(1, 120)}",
+ city=random.choice(["Zagreb", "Split", "Rijeka", "Osijek"]),
+ postal_code=str(random.randint(10000, 53000)),
+ )
+ created.append(client)
+
+ if created:
+ self.stdout.write(self.style.SUCCESS(f"Client: kreirano {len(created)}"))
+ return created or list(Client.objects.all())
+
+ def _seed_users(self, count, clients):
+ User = get_user_model()
+ created = []
+ clients_pool = clients or list(Client.objects.all())
+
+ for i in range(count):
+ suffix = uuid.uuid4().hex[:8]
+ is_kupac = random.choice([True, False])
+ client_profile = random.choice(clients_pool) if is_kupac and clients_pool else None
+ user = User.objects.create_user(
+ email=f"user.{suffix}@example.test",
+ password="password123",
+ first_name=f"Ime{i + 1}",
+ last_name=f"Prezime{i + 1}",
+ is_serviser=random.choice([True, False]),
+ is_team_member=random.choice([True, False]),
+ is_kupac=is_kupac if client_profile else False,
+ client_profile=client_profile,
+ telefon=f"+38595{random.randint(1000000, 9999999)}",
+ is_verified=random.choice([True, False]),
+ )
+ created.append(user)
+
+ if created:
+ self.stdout.write(self.style.SUCCESS(f"CustomUser: kreirano {len(created)}"))
+ return created or list(User.objects.all())
+
+ def _seed_vehicles(self, count, users):
+ created = []
+ users_pool = [u for u in users if getattr(u, "is_serviser", False)]
+ assigned_servicer_ids = set(
+ Vehicle.objects.exclude(assigned_servicer__isnull=True).values_list("assigned_servicer_id", flat=True)
+ )
+ clients_pool = list(Client.objects.all())
+
+ for _ in range(count):
+ reg = f"ZG-{random.randint(1000, 9999)}-{uuid.uuid4().hex[:2].upper()}"
+ available_servicers = [u for u in users_pool if u.id not in assigned_servicer_ids]
+ assigned = random.choice(available_servicers) if available_servicers and random.choice([True, False]) else None
+ asset_type = random.choice(["vehicle", "crane"])
+
+ # 40% vozila su klijentova (is_company_vehicle=False, client=random klijent)
+ is_company = random.random() > 0.4
+ client_obj = None if is_company else (random.choice(clients_pool) if clients_pool else None)
+
+ vehicle = Vehicle.objects.create(
+ asset_type=asset_type,
+ registration_number=reg,
+ crane_serial_number=f"{random.randint(0, 999999):06d}" if asset_type == "crane" else None,
+ make=random.choice(["VW", "Ford", "Mercedes", "Renault"]),
+ model=random.choice(["Model A", "Model B", "Model C"]),
+ year=random.randint(2008, timezone.now().year),
+ vin=f"VIN{uuid.uuid4().hex[:16].upper()}",
+ current_mileage=random.randint(10000, 250000),
+ service_interval_km=random.choice([10000, 15000, 20000]),
+ assigned_servicer=assigned,
+ is_company_vehicle=is_company,
+ client=client_obj,
+ )
+ if assigned:
+ assigned_servicer_ids.add(assigned.id)
+ created.append(vehicle)
+
+ if created:
+ self.stdout.write(self.style.SUCCESS(f"Vehicle: kreirano {len(created)} ({sum(1 for v in created if not v.is_company_vehicle)} klijentskih)"))
+ return created or list(Vehicle.objects.all())
+
+ def _seed_work_orders(self, count, vehicles, users):
+ created = []
+ if not vehicles or not users:
+ return created
+
+ for _ in range(count):
+ vehicle = random.choice(vehicles)
+ creator = random.choice(users)
+ start_mileage = max(0, vehicle.current_mileage - random.randint(0, 2000))
+ is_closed = random.choice([True, False])
+ end_mileage = start_mileage + random.randint(1, 600) if is_closed else None
+ status = "closed" if is_closed else "open"
+
+ order = WorkOrder.objects.create(
+ vehicle=vehicle,
+ creator=creator,
+ start_mileage=start_mileage,
+ end_mileage=end_mileage,
+ purpose=random.choice(["Redovni pregled", "Hitna intervencija", "Kontrola"]),
+ notes="Auto-generirani demo nalog.",
+ status=status,
+ )
+ created.append(order)
+
+ if created:
+ self.stdout.write(self.style.SUCCESS(f"WorkOrder: kreirano {len(created)}"))
+ return created or list(WorkOrder.objects.all())
+
+ def _seed_service_records(self, count, users, tasks):
+ created = []
+ if not users:
+ return created
+
+ tasks_pool = tasks or list(Task.objects.filter(is_active=True))
+ tasks_with_vehicle = [t for t in tasks_pool if t.vehicle_id]
+
+ for _ in range(count):
+ if not tasks_with_vehicle:
+ break
+ task = random.choice(tasks_with_vehicle)
+ vehicle = task.vehicle
+ performed_by = random.choice(users)
+ mileage = max(vehicle.current_mileage, random.randint(5000, 280000))
+ record = VehicleServiceRecord.objects.create(
+ vehicle=vehicle,
+ task=task,
+ performed_by=performed_by,
+ description=random.choice(
+ ["Zamjena ulja i filtera", "Kočnice i diskovi", "Dijagnostika motora", "Generalni servis"]
+ ),
+ parts="Filter ulja, ulje 5W30",
+ cost=Decimal(str(random.randint(50, 900))),
+ mileage=mileage,
+ next_service_due_at=mileage + random.choice([8000, 10000, 12000]),
+ )
+ created.append(record)
+
+ if created:
+ self.stdout.write(self.style.SUCCESS(f"VehicleServiceRecord: kreirano {len(created)}"))
+ return created or list(VehicleServiceRecord.objects.all())
+
+ def _seed_notifications(self, count, users):
+ created = []
+ users_pool = users or list(get_user_model().objects.all())
+ if not users_pool:
+ return created
+
+ for _ in range(count):
+ notification = VehicleNotification.objects.create(
+ recipient=random.choice(users_pool),
+ title=random.choice(["Novi nalog", "Servis uskoro", "Status ažuriran"]),
+ message="Auto-generirana notifikacija za testiranje.",
+ level=random.choice(["info", "warning", "critical"]),
+ is_sent=random.choice([True, False]),
+ is_read=random.choice([True, False]),
+ )
+ created.append(notification)
+
+ if created:
+ self.stdout.write(self.style.SUCCESS(f"VehicleNotification: kreirano {len(created)}"))
+ return created
+
+ def _seed_tasks(self, count, users, vehicles):
+ created = []
+ users_pool = users or list(get_user_model().objects.all())
+ # Preferiramo dizalice (asset_type='crane') za taskove
+ cranes_pool = [v for v in vehicles if getattr(v, 'asset_type', None) == 'crane']
+ vehicles_pool = cranes_pool or vehicles or list(Vehicle.objects.filter(asset_type='crane'))
+
+ for i in range(count):
+ status = random.choice(["aktivan", "servis"])
+ vehicle = random.choice(vehicles_pool) if vehicles_pool else None
+ task = Task.objects.create(
+ title=f"Task {uuid.uuid4().hex[:6]}",
+ description="Auto-generirani zadatak.",
+ status=status,
+ assigned_to=random.choice(users_pool) if users_pool else None,
+ vehicle=vehicle,
+ )
+ created.append(task)
+
+ if created:
+ self.stdout.write(self.style.SUCCESS(f"Task: kreirano {len(created)}"))
+ return created
+
+ def _seed_invoices(self, count, users, clients):
+ created = []
+ users_pool = users or list(get_user_model().objects.all())
+ clients_pool = clients or list(Client.objects.all())
+ if not users_pool or not clients_pool:
+ return created
+
+ for _ in range(count):
+ amount = Decimal(str(random.randint(100, 5000)))
+ invoice = Invoice.objects.create(
+ creator=random.choice(users_pool),
+ client=random.choice(clients_pool),
+ amount=amount,
+ currency="EUR",
+ tax_rate=Decimal("25.00"),
+ status=random.choice(["draft", "sent", "paid", "cancelled"]),
+ due_date=timezone.now().date() + timedelta(days=random.randint(7, 45)),
+ )
+ created.append(invoice)
+
+ if created:
+ self.stdout.write(self.style.SUCCESS(f"Invoice: kreirano {len(created)}"))
+ return created or list(Invoice.objects.all())
+
+ def _seed_invoice_items(self, count, invoices):
+ created = []
+ invoices_pool = invoices or list(Invoice.objects.all())
+ if not invoices_pool:
+ return created
+
+ for _ in range(count):
+ item = InvoiceItem.objects.create(
+ invoice=random.choice(invoices_pool),
+ description=random.choice(["Servisna usluga", "Rezervni dio", "Terenski rad"]),
+ quantity=Decimal(str(random.randint(1, 5))),
+ unit_price=Decimal(str(random.randint(20, 700))),
+ )
+ created.append(item)
+
+ if created:
+ self.stdout.write(self.style.SUCCESS(f"InvoiceItem: kreirano {len(created)}"))
+ return created
+
+ def _seed_invoice_transactions(self, count, invoices):
+ created = []
+ invoices_pool = invoices or list(Invoice.objects.all())
+ if not invoices_pool:
+ return created
+
+ for _ in range(count):
+ invoice = random.choice(invoices_pool)
+ txn = InvoiceTransaction.objects.create(
+ invoice=invoice,
+ invoice_number=invoice.invoice_number,
+ amount=invoice.amount,
+ payment_date=timezone.now().date() - timedelta(days=random.randint(0, 30)),
+ )
+ created.append(txn)
+
+ if created:
+ self.stdout.write(self.style.SUCCESS(f"InvoiceTransaction: kreirano {len(created)}"))
+ return created
+
+ def _seed_service_photos(self, count, service_records, users):
+ created = []
+ records_pool = service_records or list(VehicleServiceRecord.objects.all())
+ users_pool = users or list(get_user_model().objects.all())
+ if not records_pool:
+ return created
+
+ tiny_gif = (
+ b"\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00"
+ b"\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00"
+ b"\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b"
+ )
+
+ for i in range(count):
+ photo = VehicleServicePhoto(
+ service_record=random.choice(records_pool),
+ uploaded_by=random.choice(users_pool) if users_pool else None,
+ description="Auto-generirana servisna fotografija.",
+ )
+ photo.image.save(f"service_photo_{i}_{uuid.uuid4().hex[:6]}.gif", ContentFile(tiny_gif), save=True)
+ created.append(photo)
+
+ if created:
+ self.stdout.write(self.style.SUCCESS(f"VehicleServicePhoto: kreirano {len(created)}"))
+ return created
diff --git a/backend/core/management/commands/seed_fleet_assets.py b/backend/core/management/commands/seed_fleet_assets.py
new file mode 100644
index 0000000..d81a914
--- /dev/null
+++ b/backend/core/management/commands/seed_fleet_assets.py
@@ -0,0 +1,99 @@
+import random
+from django.core.management.base import BaseCommand, CommandError
+from django.db import transaction
+
+from modules.fleet.models import Vehicle
+
+
+class Command(BaseCommand):
+ help = "Kreira/demo-popunjava fleet assete: default 12 dizalica i 2 vozila."
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--cranes",
+ type=int,
+ default=12,
+ help="Broj dizalica za kreirati/ažurirati (default: 12).",
+ )
+ parser.add_argument(
+ "--vehicles",
+ type=int,
+ default=2,
+ help="Broj vozila za kreirati/ažurirati (default: 2).",
+ )
+
+ def handle(self, *args, **options):
+ cranes_count = options["cranes"]
+ vehicles_count = options["vehicles"]
+ if cranes_count < 0 or vehicles_count < 0:
+ raise CommandError("--cranes i --vehicles moraju biti >= 0")
+
+ with transaction.atomic():
+ created_cranes, updated_cranes = self._seed_assets(
+ asset_type="crane",
+ count=cranes_count,
+ reg_prefix="CRN",
+ make="Liebherr",
+ model="LTM",
+ mileage_start=12000,
+ service_interval=8000,
+ )
+ created_vehicles, updated_vehicles = self._seed_assets(
+ asset_type="vehicle",
+ count=vehicles_count,
+ reg_prefix="VEH",
+ make="Mercedes",
+ model="Sprinter",
+ mileage_start=45000,
+ service_interval=15000,
+ )
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ "Fleet seed završen: "
+ f"crane created={created_cranes}, updated={updated_cranes}; "
+ f"vehicle created={created_vehicles}, updated={updated_vehicles}"
+ )
+ )
+
+ def _seed_assets(
+ self,
+ *,
+ asset_type,
+ count,
+ reg_prefix,
+ make,
+ model,
+ mileage_start,
+ service_interval,
+ ):
+ created = 0
+ updated = 0
+
+ for index in range(1, count + 1):
+ reg = f"ZG-{reg_prefix}-{index:02d}"
+ vin = f"VIN-{reg_prefix}-{index:02d}"
+ defaults = {
+ "asset_type": asset_type,
+ "crane_serial_number": f"{random.randint(0, 999999):06d}" if asset_type == "crane" else None,
+ "make": make,
+ "model": f"{model}-{index:02d}",
+ "year": 2020 + (index % 4),
+ "vin": vin,
+ "current_mileage": mileage_start + (index * 1000),
+ "service_interval_km": service_interval,
+ "is_company_vehicle": True,
+ "client": None,
+ "assigned_servicer": None,
+ "is_active": True,
+ }
+ _, was_created = Vehicle.objects.update_or_create(
+ registration_number=reg,
+ defaults=defaults,
+ )
+ if was_created:
+ created += 1
+ else:
+ updated += 1
+
+ return created, updated
diff --git a/backend/core/management/commands/seed_knezljubo.py b/backend/core/management/commands/seed_knezljubo.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/core/serializers.py b/backend/core/serializers.py
new file mode 100644
index 0000000..b07a6b2
--- /dev/null
+++ b/backend/core/serializers.py
@@ -0,0 +1,14 @@
+from rest_framework import serializers
+
+class BaseSerializer(serializers.ModelSerializer):
+ """
+ Bazni serijalizator koji automatski uključuje ID i vremenske žigove.
+ Svi naši moduli će koristiti ovaj serializer umjesto DRF-ovog.
+ """
+ id = serializers.UUIDField(read_only=True)
+ created_at = serializers.DateTimeField(read_only=True)
+ updated_at = serializers.DateTimeField(read_only=True)
+
+ class Meta:
+ # Ovo će biti nadjačano u konkretnim serializerima
+ fields = ['id', 'created_at', 'updated_at']
\ No newline at end of file
diff --git a/backend/core/settings/__init__.py b/backend/core/settings/__init__.py
new file mode 100644
index 0000000..3b98daf
--- /dev/null
+++ b/backend/core/settings/__init__.py
@@ -0,0 +1,8 @@
+# backend/core/settings/__init__.py
+import os
+
+# Provjeravamo varijablu okruženja koju Docker šalje
+if os.environ.get('DEBUG') == 'False':
+ from .production import *
+else:
+ from .development import *
\ No newline at end of file
diff --git a/backend/core/settings/base.py b/backend/core/settings/base.py
new file mode 100644
index 0000000..ab9d1ea
--- /dev/null
+++ b/backend/core/settings/base.py
@@ -0,0 +1,215 @@
+import os
+from pathlib import Path
+from datetime import timedelta
+
+BASE_DIR = Path(__file__).resolve().parent.parent.parent
+# --- SIGURNOST I OKRUŽENJE (Docker Environment Variables) ---
+# Ako varijabla nije definirana u Dockeru, koristi se fallback (razvojni ključ)
+SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-fk#ywtdyi8=t6^vdbo!=br8op0rdwf*ks#sji20&mp2+nwjs_5')
+
+# Docker prosljeđuje string 'True' ili 'False'. Pretvaramo ga u Python Boolean.
+DEBUG = os.environ.get('DEBUG', 'True') == 'True'
+
+INSTALLED_APPS = [
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ 'corsheaders',
+ 'rest_framework',
+ 'django_extensions',
+ 'rest_framework_simplejwt',
+ 'modules.invoicing',
+ 'modules.task_management',
+ 'core.users.apps.UsersConfig',
+ 'modules.crm',
+ 'modules.fleet'
+]
+MIDDLEWARE = [
+ 'corsheaders.middleware.CorsMiddleware', # Mora biti prvi
+ 'django.middleware.security.SecurityMiddleware',
+ 'whitenoise.middleware.WhiteNoiseMiddleware', # Za statiku
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+]
+
+ROOT_URLCONF = 'core.urls'
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [],
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ 'context_processors': [
+ 'django.template.context_processors.request',
+ 'django.contrib.auth.context_processors.auth',
+ 'django.contrib.messages.context_processors.messages',
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = 'core.wsgi.application'
+
+# --- VALIDACIJA LOZINKI ---
+AUTH_PASSWORD_VALIDATORS = [
+ {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
+ {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
+ {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
+ {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
+]
+
+AUTH_USER_MODEL = 'users.CustomUser'
+
+
+# --- INTERNACIONALIZACIJA (Lokalizacija) ---
+LANGUAGE_CODE = 'hr' # Postavljeno na hrvatski jezik za ERP/Zadatke
+
+TIME_ZONE = 'Europe/Zagreb' # Sinkronizirano s našom vremenskom zonom
+
+USE_I18N = True
+
+USE_TZ = True
+
+
+CORS_ALLOW_CREDENTIALS = True
+# U bazi definiramo default, a u prod/dev override-amo
+CORS_ALLOWED_ORIGINS = os.environ.get('CORS_ALLOWED_ORIGINS', 'http://localhost:4321').split(',')
+
+CORS_ALLOW_METHODS = [
+ "DELETE",
+ "GET",
+ "OPTIONS",
+ "PATCH",
+ "POST",
+ "PUT",
+]
+
+CORS_ALLOW_HEADERS = [
+ 'accept',
+ 'accept-encoding',
+ 'authorization',
+ 'content-type',
+ 'dnt',
+ 'origin',
+ 'user-agent',
+ 'x-csrftoken',
+ 'x-requested-with',
+ 'access-control-request-private-network',
+]
+
+# Dopušta preflight zahtjeve unutar lokalne mreže (npr. s Docker mreže na tvoje računalo)
+CORS_ALLOW_PRIVATE_NETWORK = True
+CORS_PREFLIGHT_MAX_AGE = 86400
+# Dopusti slanje Authorization zaglavlja (JWT Tokena) kroz mrežne zahtjeve
+CORS_ALLOW_CREDENTIALS = True
+
+
+# --- REST FRAMEWORK & JWT POSTAVKE ---
+REST_FRAMEWORK = {
+ 'DEFAULT_AUTHENTICATION_CLASSES': (
+ 'rest_framework_simplejwt.authentication.JWTAuthentication',
+ 'rest_framework.authentication.SessionAuthentication',
+ ),
+ 'DEFAULT_PERMISSION_CLASSES': (
+ 'rest_framework.permissions.IsAuthenticated',
+ # 'rest_framework.permissions.AllowAny',
+ ),
+}
+
+SIMPLE_JWT = {
+ 'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
+ 'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
+ 'ROTATE_REFRESH_TOKENS': True,
+ 'BLACKLIST_AFTER_ROTATION': True, # Dodano
+ 'AUTH_HEADER_TYPES': ('Bearer',),
+}
+
+WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET', 'super-tajni-dugacki-kljuc-koji-ces-promijeniti')
+
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+
+# ==============================================================================
+# 🔥 CELERY & REDIS POSTAVKE (Lean Worker Model)
+# ==============================================================================
+
+# Čitamo REDIS_URL iz okruženja (Docker prosljeđuje 'redis://redis:6379/0').
+# Ako pokrećeš izvan Dockera, koristi localhost kao fallback.
+REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
+
+CELERY_BROKER_URL = REDIS_URL
+CELERY_RESULT_BACKEND = REDIS_URL
+
+# Ako želiš koristiti Redis kao brzi cache sustav unutar Djanga (izvrsno za ERP performanse)
+# Za ovo ti je potreban paket 'django-redis' u requirements.txt
+CACHES = {
+ 'default': {
+ 'BACKEND': 'django_redis.cache.RedisCache',
+ 'LOCATION': REDIS_URL, # Koristi isti Redis kontejner i bazu /0
+ 'OPTIONS': {
+ 'CLIENT_CLASS': 'django_redis.client.DefaultClient',
+ 'PARSER_KWARGS': {'decode_responses': True},
+ }
+ }
+}
+
+CELERY_ACCEPT_CONTENT = ['json']
+CELERY_TASK_SERIALIZER = 'json'
+
+# URL kojim će preglednik dohvaćati statiku
+STATIC_URL = '/static/'
+
+# Direktorij gdje će 'collectstatic' skupljati sve statičke datoteke (za produkciju)
+STATIC_ROOT = BASE_DIR / '/app/staticfiles'
+
+# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente)
+MEDIA_URL = '/media/'
+MEDIA_ROOT = BASE_DIR / 'media'
+
+
+# ==============================================================================
+# 🔥 Paperless postavke
+# ==============================================================================
+
+
+PAPERLESS_BASE_URL = os.environ.get('PAPERLESS_BASE_URL', 'http://paperless:8000') # paperless na drugoj docker compose instanci
+PAPERLESS_API_TOKEN = os.environ.get('PAPERLESS_API_TOKEN', '144b62542009c1c1a5a296b16601b4ab488f6a01')
+
+# ==============================================================================
+# 🔥 Email postavke (Google)
+# ==============================================================================
+
+# SMTP Konfiguracija
+EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
+EMAIL_HOST = 'smtp.gmail.com'
+EMAIL_PORT = 587
+EMAIL_USE_TLS = True
+
+# Čitanje iz .env varijabli
+EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', 'tvoj.email@gmail.com')
+EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', 'xxxx-xxxx-xxxx-xxxx')
+DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'tvoj.email@gmail.com')
+
+# ==============================================================================
+# 🔥 PUSHER CONFIGURATION
+# ==============================================================================
+
+# Pusher Configuration
+PUSHER_APP_ID = os.environ.get('PUSHER_APP_ID')
+PUSHER_KEY = os.environ.get('PUSHER_KEY')
+PUSHER_SECRET = os.environ.get('PUSHER_SECRET')
+PUSHER_CLUSTER = os.environ.get('PUSHER_CLUSTER', default='mt1')
+
+# Cache za fallback mehanizam
+CACHES = {
+ 'default': {
+ 'BACKEND': 'django.core.cache.backends.redis.RedisCache',
+ 'LOCATION': 'redis://127.0.0.1:6379/1',
+ }
+}
\ No newline at end of file
diff --git a/backend/core/settings/development.py b/backend/core/settings/development.py
new file mode 100644
index 0000000..ab6d84d
--- /dev/null
+++ b/backend/core/settings/development.py
@@ -0,0 +1,36 @@
+from .base import *
+
+DEBUG = True
+ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'backend']
+
+# SQLite za brzi lokalni razvoj (nema potrebe za instalacijom Postgresa na laptopu)
+# DATABASES = {
+# 'default': {
+# 'ENGINE': 'django.db.backends.sqlite3',
+# 'NAME': BASE_DIR / 'db.sqlite3',
+# }
+# }
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.postgresql',
+ 'NAME': os.environ.get('DB_NAME'),
+ 'USER': os.environ.get('DB_USER'),
+ 'PASSWORD': os.environ.get('DB_PASSWORD'),
+ 'HOST': os.environ.get('DB_HOST', 'db'),
+ 'PORT': os.environ.get('DB_PORT', '5432'),
+ }
+}
+
+# CORS za Astro razvojni port (4321)
+CORS_ALLOWED_ORIGINS = [
+ "http://localhost:4321",
+ "http://127.0.0.1:4321",
+]
+
+# Dodajemo Debug Toolbar za analizu performansi
+INSTALLED_APPS += ['debug_toolbar']
+MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
+INTERNAL_IPS = ['127.0.0.1']
+
+CELERY_TASK_ALWAYS_EAGER = True
\ No newline at end of file
diff --git a/backend/core/settings/production.py b/backend/core/settings/production.py
new file mode 100644
index 0000000..b377d1e
--- /dev/null
+++ b/backend/core/settings/production.py
@@ -0,0 +1,28 @@
+from .base import *
+
+DEBUG = False
+ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(',')
+
+# PostgreSQL za produkciju (industrijski standard)
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.postgresql',
+ 'NAME': os.environ.get('DB_NAME'),
+ 'USER': os.environ.get('DB_USER'),
+ 'PASSWORD': os.environ.get('DB_PASSWORD'),
+ 'HOST': os.environ.get('DB_HOST', 'db'),
+ 'PORT': os.environ.get('DB_PORT', '5432'),
+ }
+}
+
+# Sigurnosne postavke
+SECURE_SSL_REDIRECT = True
+SESSION_COOKIE_SECURE = True
+CSRF_COOKIE_SECURE = True
+
+# Produkcijski CORS (samo domena na kojoj vrti Astro)
+CORS_ALLOWED_ORIGINS = os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',')
+
+# Statika i Mediji
+STATIC_ROOT = '/app/staticfiles'
+MEDIA_ROOT = '/app/media'
\ No newline at end of file
diff --git a/backend/core/urls.py b/backend/core/urls.py
new file mode 100644
index 0000000..46b0cd9
--- /dev/null
+++ b/backend/core/urls.py
@@ -0,0 +1,33 @@
+# backend/core/urls.py
+from django.conf import settings
+from django.conf.urls.static import static
+from django.contrib import admin
+from django.urls import path, include
+from rest_framework_simplejwt.views import (
+ TokenObtainPairView,
+ TokenRefreshView,
+)
+
+urlpatterns = [
+ path('admin/', admin.site.urls),
+
+ # Uključujemo rute iz modula (svaki modul ima svoj urls.py)
+ path('api/crm/', include('modules.crm.urls')),
+ path('api/invoicing/', include('modules.invoicing.urls')),
+ path('api/tasks/', include('modules.task_management.urls')),
+ path('api/fleet/', include('modules.fleet.urls')),
+
+ # JWT Auth rute
+ path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
+ path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
+
+ # KORISNICI - registriramo ovdje
+ path('api/users/', include('core.users.urls')),
+]
+
+if settings.DEBUG:
+ import debug_toolbar
+ urlpatterns += [
+ path('__debug__/', include(debug_toolbar.urls)),
+ ]
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
\ No newline at end of file
diff --git a/backend/core/users/__init__.py b/backend/core/users/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/core/users/admin.py b/backend/core/users/admin.py
new file mode 100644
index 0000000..20d2880
--- /dev/null
+++ b/backend/core/users/admin.py
@@ -0,0 +1,27 @@
+from django.contrib import admin
+from django.contrib.auth.admin import UserAdmin
+from .models import CustomUser
+
+@admin.register(CustomUser)
+class CustomUserAdmin(UserAdmin):
+ model = CustomUser
+ # Definiramo koja polja se vide u admin listi i formama
+ list_display = ('email', 'first_name', 'last_name', 'occupation', 'work_position', 'residence', 'is_serviser', 'is_kupac', 'is_team_member', 'is_active', 'client_profile')
+ fieldsets = (
+ (None, {'fields': ('email', 'password')}),
+ ('Osobni podaci', {'fields': ('first_name', 'last_name', 'telefon', 'oib', 'occupation', 'work_position', 'residence')}),
+ ('ERP Status', {'fields': ('is_active', 'is_staff', 'is_superuser', 'is_serviser', 'is_kupac', 'is_team_member', 'is_verified', 'client_profile')}),
+ ('Važni datumi', {'fields': ('last_login', 'date_joined')}),
+ )
+
+ # 3. Add fieldsets (za kreiranje novog korisnika u adminu)
+ add_fieldsets = (
+ (None, {
+ 'classes': ('wide',),
+ 'fields': ('email', 'password', 'is_team_member'),
+ }),
+ )
+
+ search_fields = ('email', 'first_name', 'last_name', 'occupation', 'work_position', 'residence')
+ ordering = ('email',)
+ raw_id_fields = ('client_profile',)
diff --git a/backend/core/users/apps.py b/backend/core/users/apps.py
new file mode 100644
index 0000000..30920fd
--- /dev/null
+++ b/backend/core/users/apps.py
@@ -0,0 +1,7 @@
+# backend/core/users/apps.py
+from django.apps import AppConfig
+
+class UsersConfig(AppConfig):
+ name = 'core.users' # Ovo je putanja do modula
+ label = 'users' # Ovo je kratko ime koje Django koristi (app_label)
+ verbose_name = 'User Management'
\ No newline at end of file
diff --git a/backend/core/users/management/__init__.py b/backend/core/users/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/core/users/management/commands/__init__.py b/backend/core/users/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/core/users/management/commands/seed_all_models.py b/backend/core/users/management/commands/seed_all_models.py
new file mode 100644
index 0000000..4449128
--- /dev/null
+++ b/backend/core/users/management/commands/seed_all_models.py
@@ -0,0 +1,2 @@
+from core.management.commands.seed_all_models import Command # noqa: F401
+
diff --git a/backend/core/users/management/commands/seed_fleet_assets.py b/backend/core/users/management/commands/seed_fleet_assets.py
new file mode 100644
index 0000000..892e8aa
--- /dev/null
+++ b/backend/core/users/management/commands/seed_fleet_assets.py
@@ -0,0 +1,2 @@
+from core.management.commands.seed_fleet_assets import Command # noqa: F401
+
diff --git a/backend/core/users/management/commands/seed_knezljubo.py b/backend/core/users/management/commands/seed_knezljubo.py
new file mode 100644
index 0000000..76ba4cd
--- /dev/null
+++ b/backend/core/users/management/commands/seed_knezljubo.py
@@ -0,0 +1,107 @@
+"""
+seed_knezljubo.py
+
+Kreira demo klijenta "Knezljubo d.o.o." s jednim fleet vozilom (is_company_vehicle=True)
+i jednim vozilom u vlasništvu klijenta (is_company_vehicle=False).
+
+Idempotentna: ponavljanjem ne kreira duplikate (koristi get_or_create).
+
+Uporaba:
+ python manage.py seed_knezljubo
+ docker compose exec -it backend python manage.py seed_knezljubo
+"""
+
+from django.core.management.base import BaseCommand
+from django.db import transaction
+
+from modules.crm.models import Client
+from modules.fleet.models import Vehicle
+
+
+KNEZLJUBO_TAX_ID = "12345678901"
+FLEET_VIN = "VIN_FLEET_KNEZLJUBO001"
+CLIENT_VIN = "VIN_CLIENT_KNEZLJUBO002"
+
+
+class Command(BaseCommand):
+ help = "Kreira klijenta Knezljubo s fleet vozilom i vozilom u vlasništvu klijenta."
+
+ def handle(self, *args, **options):
+ with transaction.atomic():
+ client = self._seed_client()
+ fleet_vehicle = self._seed_fleet_vehicle(client)
+ client_vehicle = self._seed_client_vehicle(client)
+
+ self.stdout.write(self.style.SUCCESS(
+ f"\nZavršeno!\n"
+ f" Klijent : {client} (id={client.id})\n"
+ f" Fleet vozilo : {fleet_vehicle} [is_company_vehicle=True]\n"
+ f" Klijentovo : {client_vehicle} [is_company_vehicle=False]\n"
+ ))
+
+ # ── Klijent ───────────────────────────────────────────────────────────────
+
+ def _seed_client(self) -> Client:
+ client, created = Client.objects.get_or_create(
+ tax_id=KNEZLJUBO_TAX_ID,
+ defaults={
+ "name": "Knezljubo d.o.o.",
+ "client_type": "legal",
+ "email": "info@knezljubo.hr",
+ "phone": "+385912345678",
+ "address": "Ilica 100",
+ "city": "Zagreb",
+ "postal_code": "10000",
+ },
+ )
+ action = "Kreiran" if created else "Već postoji"
+ self.stdout.write(self.style.SUCCESS(f"Client: {action} — {client}"))
+ return client
+
+ # ── Fleet vozilo (in vlasništvu naše tvrtke, ali vezano za klijenta) ──────
+
+ def _seed_fleet_vehicle(self, client: Client) -> Vehicle:
+ """
+ Vozilo je dio naše fleet-a (is_company_vehicle=True) ALI je operativno
+ vezano za klijenta Knezljubo — npr. vozilo koje servisiramo po ugovoru.
+ """
+ vehicle, created = Vehicle.objects.get_or_create(
+ vin=FLEET_VIN,
+ defaults={
+ "registration_number": "ZG-0001-KZ",
+ "make": "Mercedes",
+ "model": "Sprinter",
+ "year": 2021,
+ "current_mileage": 48500,
+ "service_interval_km": 15000,
+ "is_company_vehicle": True, # dio naše flote
+ "client": client, # operativno vezan za klijenta
+ },
+ )
+ action = "Kreiran" if created else "Već postoji"
+ self.stdout.write(self.style.SUCCESS(f"Fleet vozilo: {action} — {vehicle}"))
+ return vehicle
+
+ # ── Vozilo u vlasništvu klijenta ──────────────────────────────────────────
+
+ def _seed_client_vehicle(self, client: Client) -> Vehicle:
+ """
+ Vozilo koje KLIJENT posjeduje — naša tvrtka ga servisira.
+ is_company_vehicle=False → filter u admin i frontend pokazuje ovo kao klijentovo.
+ """
+ vehicle, created = Vehicle.objects.get_or_create(
+ vin=CLIENT_VIN,
+ defaults={
+ "registration_number": "ZG-0002-KZ",
+ "make": "VW",
+ "model": "Crafter",
+ "year": 2019,
+ "current_mileage": 112000,
+ "service_interval_km": 10000,
+ "is_company_vehicle": False, # klijentovo vozilo
+ "client": client,
+ },
+ )
+ action = "Kreiran" if created else "Već postoji"
+ self.stdout.write(self.style.SUCCESS(f"Klijentovo vozilo: {action} — {vehicle}"))
+ return vehicle
diff --git a/backend/core/users/management/commands/seed_users.py b/backend/core/users/management/commands/seed_users.py
new file mode 100644
index 0000000..ab71ead
--- /dev/null
+++ b/backend/core/users/management/commands/seed_users.py
@@ -0,0 +1,52 @@
+# backend/core/users/management/commands/seed_users.py
+from django.core.management.base import BaseCommand
+from django.contrib.auth import get_user_model
+from django.db import transaction
+from modules.crm.models import Client
+
+User = get_user_model()
+
+class Command(BaseCommand):
+ help = 'Kreira testne korisnike za razvojno okruženje'
+
+ def handle(self, *args, **kwargs):
+ users_to_create = [
+ {
+ "email": "serviser@primjer.hr",
+ "first_name": "Ivan",
+ "last_name": "Serviser",
+ "is_serviser": True,
+ "is_team_member": True,
+ "password": "password123"
+ },
+ {
+ "email": "kupac@primjer.hr",
+ "first_name": "Marko",
+ "last_name": "Kupac",
+ "is_kupac": True,
+ "password": "password123"
+ }
+ ]
+
+ with transaction.atomic():
+ for user_data in users_to_create:
+ email = user_data.pop('email')
+ password = user_data.pop('password')
+
+ if user_data.get('is_kupac'):
+ client, _ = Client.objects.get_or_create(name="Testni Klijent DOO")
+ user_data['client_profile'] = client
+
+ # Provjeri postoji li korisnik
+ if not User.objects.filter(email=email).exists():
+ # KORISTI MANAGER UMJESTO MODELA
+ user = User.objects.create_user(
+ email=email,
+ password=password,
+ **user_data
+ )
+ self.stdout.write(self.style.SUCCESS(f'Kreiran korisnik: {email}'))
+ else:
+ self.stdout.write(self.style.WARNING(f'Korisnik već postoji: {email}'))
+
+ self.stdout.write(self.style.SUCCESS('Seeding završen!'))
\ No newline at end of file
diff --git a/backend/core/users/managers.py b/backend/core/users/managers.py
new file mode 100644
index 0000000..a2afdeb
--- /dev/null
+++ b/backend/core/users/managers.py
@@ -0,0 +1,30 @@
+from django.contrib.auth.models import BaseUserManager
+
+class CustomUserManager(BaseUserManager):
+ """
+ Custom manager za User model gdje koristimo email kao identifikator.
+ """
+ def create_user(self, email, password=None, **extra_fields):
+ if not email:
+ raise ValueError('Email adresa mora biti zadana.')
+
+ extra_fields.pop('username', None)
+
+ email = self.normalize_email(email)
+ user = self.model(email=email, **extra_fields)
+ user.set_password(password)
+ user.save(using=self._db)
+ return user
+
+ def create_superuser(self, email, password=None, **extra_fields):
+ extra_fields.setdefault('is_staff', True)
+ extra_fields.setdefault('is_superuser', True)
+ extra_fields.setdefault('is_active', True)
+ extra_fields.setdefault('is_serviser', True)
+
+ if extra_fields.get('is_staff') is not True:
+ raise ValueError('Superuser mora imati is_staff=True.')
+ if extra_fields.get('is_superuser') is not True:
+ raise ValueError('Superuser mora imati is_superuser=True.')
+
+ return self.create_user(email, password, **extra_fields)
\ No newline at end of file
diff --git a/backend/core/users/migrations/0001_initial.py b/backend/core/users/migrations/0001_initial.py
new file mode 100644
index 0000000..0807e52
--- /dev/null
+++ b/backend/core/users/migrations/0001_initial.py
@@ -0,0 +1,47 @@
+# Generated by Django 5.2.15 on 2026-06-29 13:34
+
+import django.contrib.auth.models
+import django.contrib.auth.validators
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CustomUser',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('password', models.CharField(max_length=128, verbose_name='password')),
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
+ ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
+ ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
+ ('email', models.EmailField(max_length=254, unique=True)),
+ ('telefon', models.CharField(blank=True, max_length=20)),
+ ('oib', models.CharField(blank=True, max_length=11, null=True)),
+ ('is_serviser', models.BooleanField(default=False)),
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
+ ],
+ options={
+ 'verbose_name': 'user',
+ 'verbose_name_plural': 'users',
+ 'abstract': False,
+ },
+ managers=[
+ ('objects', django.contrib.auth.models.UserManager()),
+ ],
+ ),
+ ]
diff --git a/backend/core/users/migrations/0002_alter_customuser_managers_customuser_client_profile_and_more.py b/backend/core/users/migrations/0002_alter_customuser_managers_customuser_client_profile_and_more.py
new file mode 100644
index 0000000..a258323
--- /dev/null
+++ b/backend/core/users/migrations/0002_alter_customuser_managers_customuser_client_profile_and_more.py
@@ -0,0 +1,40 @@
+# Generated by Django 5.2.15 on 2026-06-29 20:12
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('crm', '__first__'),
+ ('users', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterModelManagers(
+ name='customuser',
+ managers=[
+ ],
+ ),
+ migrations.AddField(
+ model_name='customuser',
+ name='client_profile',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='crm.client', verbose_name='Povezani klijent'),
+ ),
+ migrations.AddField(
+ model_name='customuser',
+ name='is_kupac',
+ field=models.BooleanField(default=False, verbose_name='Kupac'),
+ ),
+ migrations.AddField(
+ model_name='customuser',
+ name='is_verified',
+ field=models.BooleanField(default=False, verbose_name='Verificiran profil'),
+ ),
+ migrations.AddField(
+ model_name='customuser',
+ name='licence_number',
+ field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Broj licence'),
+ ),
+ ]
diff --git a/backend/core/users/migrations/0003_remove_customuser_username.py b/backend/core/users/migrations/0003_remove_customuser_username.py
new file mode 100644
index 0000000..6ef42f9
--- /dev/null
+++ b/backend/core/users/migrations/0003_remove_customuser_username.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.2.15 on 2026-06-30 06:10
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0002_alter_customuser_managers_customuser_client_profile_and_more'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='customuser',
+ name='username',
+ ),
+ ]
diff --git a/backend/core/users/migrations/0004_customuser_is_team_member.py b/backend/core/users/migrations/0004_customuser_is_team_member.py
new file mode 100644
index 0000000..09f47d0
--- /dev/null
+++ b/backend/core/users/migrations/0004_customuser_is_team_member.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.2.15 on 2026-07-06 14:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0003_remove_customuser_username'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='customuser',
+ name='is_team_member',
+ field=models.BooleanField(default=False, verbose_name='Član tima'),
+ ),
+ ]
+
diff --git a/backend/core/users/migrations/0005_customuser_occupation_residence.py b/backend/core/users/migrations/0005_customuser_occupation_residence.py
new file mode 100644
index 0000000..cdfd631
--- /dev/null
+++ b/backend/core/users/migrations/0005_customuser_occupation_residence.py
@@ -0,0 +1,21 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0004_customuser_is_team_member'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='customuser',
+ name='occupation',
+ field=models.CharField(blank=True, default='', max_length=120, verbose_name='Zanimanje'),
+ ),
+ migrations.AddField(
+ model_name='customuser',
+ name='residence',
+ field=models.CharField(blank=True, default='', max_length=255, verbose_name='Prebivalište'),
+ ),
+ ]
diff --git a/backend/core/users/migrations/0006_customuser_work_position.py b/backend/core/users/migrations/0006_customuser_work_position.py
new file mode 100644
index 0000000..ff69a26
--- /dev/null
+++ b/backend/core/users/migrations/0006_customuser_work_position.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0005_customuser_occupation_residence'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='customuser',
+ name='work_position',
+ field=models.CharField(blank=True, default='', max_length=120, verbose_name='Na radnom mjestu'),
+ ),
+ ]
diff --git a/backend/core/users/migrations/__init__.py b/backend/core/users/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/core/users/models.py b/backend/core/users/models.py
new file mode 100644
index 0000000..abd6880
--- /dev/null
+++ b/backend/core/users/models.py
@@ -0,0 +1,52 @@
+from django.contrib.auth.models import AbstractUser
+from django.core.exceptions import ValidationError
+from django.db import models
+from .managers import CustomUserManager
+
+class CustomUser(AbstractUser):
+ # Email koristimo za login, pa mora biti jedinstven
+ username=None
+ email = models.EmailField(unique=True)
+
+ # ERP Specifična polja
+ is_serviser = models.BooleanField(default=False) # Razlikovanje uloga
+ is_kupac = models.BooleanField(default=False, verbose_name="Kupac")
+ is_team_member = models.BooleanField(default=False, verbose_name="Član tima")
+
+ # Dodatne informacije o servisera
+ telefon = models.CharField(max_length=20, blank=True)
+ oib = models.CharField(max_length=11, blank=True, null=True)
+ occupation = models.CharField(max_length=120, blank=True, default='', verbose_name="Zanimanje")
+ residence = models.CharField(max_length=255, blank=True, default='', verbose_name="Prebivalište")
+ work_position = models.CharField(max_length=120, blank=True, default='', verbose_name="Na radnom mjestu")
+ licence_number = models.CharField(max_length=50, blank=True, null=True, verbose_name="Broj licence")
+ is_verified = models.BooleanField(default=False, verbose_name="Verificiran profil")
+
+ # Poveznica na klijenta (ako je korisnik kupac)
+ client_profile = models.ForeignKey(
+ 'crm.Client',
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='users', # Omogućuje pristup: klijent.users.all()
+ verbose_name="Povezani klijent"
+ )
+
+ objects = CustomUserManager()
+ # Govorimo Djangu da koristi email umjesto username-a
+ USERNAME_FIELD = 'email'
+ REQUIRED_FIELDS = ['first_name', 'last_name']
+
+ def __str__(self):
+ return f"{self.first_name} {self.last_name} ({self.email})"
+
+ def clean(self):
+ # if self.email == "bot@erp.hr":
+ # return # Preskoči validaciju za bota
+ super().clean()
+ if self.is_kupac and not self.client_profile:
+ raise ValidationError({'client_profile': "Korisnik koji je označen kao kupac mora imati povezan klijentski profil."})
+
+ def save(self, *args, **kwargs):
+ self.full_clean() # Prisiljava pokretanje clean() prije svakog save()
+ super().save(*args, **kwargs)
\ No newline at end of file
diff --git a/backend/core/users/serializers.py b/backend/core/users/serializers.py
new file mode 100644
index 0000000..9106620
--- /dev/null
+++ b/backend/core/users/serializers.py
@@ -0,0 +1,73 @@
+from rest_framework import serializers
+from django.core.exceptions import ObjectDoesNotExist
+from .models import CustomUser
+
+class UserSerializer(serializers.ModelSerializer):
+ assigned_vehicle = serializers.SerializerMethodField()
+ assigned_crane = serializers.SerializerMethodField()
+
+ class Meta:
+ model = CustomUser
+ fields = ['id', 'email', 'first_name', 'last_name', 'is_serviser', 'is_kupac', 'is_team_member', 'telefon', 'oib', 'occupation', 'residence', 'work_position', 'client_profile', 'assigned_vehicle', 'assigned_crane']
+ read_only_fields = ['id', 'email']
+ extra_kwargs = {
+ 'first_name': {'required': True, 'allow_blank': False}
+ }
+
+ def _get_assigned_asset(self, obj):
+ try:
+ return obj.assigned_vehicles
+ except ObjectDoesNotExist:
+ return None
+
+ def _serialize_asset(self, vehicle):
+ if not vehicle:
+ return None
+ return {
+ 'id': vehicle.id,
+ 'asset_type': vehicle.asset_type,
+ 'registration_number': vehicle.registration_number,
+ 'make': vehicle.make,
+ 'model': vehicle.model,
+ 'current_mileage': vehicle.current_mileage,
+ 'service_interval_km': vehicle.service_interval_km,
+ }
+
+ def get_assigned_vehicle(self, obj):
+ return self._serialize_asset(self._get_assigned_asset(obj))
+
+ def get_assigned_crane(self, obj):
+ return self._serialize_asset(self._get_assigned_asset(obj))
+
+ def validate(self, data):
+ """
+ Provodi poslovnu logiku na razini serializer-a.
+ """
+ is_kupac = data.get('is_kupac')
+ client_profile = data.get('client_profile')
+
+ # Logika: Ako je kupac, mora imati profil
+ if is_kupac and not client_profile:
+ raise serializers.ValidationError({
+ "client_profile": "Korisnik označen kao kupac mora imati dodijeljen klijentski profil."
+ })
+
+ # Logika: Ako ima profil, trebao bi biti kupac (opcionalno, ali preporučeno)
+ if client_profile and not is_kupac:
+ raise serializers.ValidationError({
+ "is_kupac": "Korisnik s dodijeljenim klijentskim profilom mora biti označen kao kupac."
+ })
+
+ return data
+
+
+class TeamMemberSerializer(serializers.ModelSerializer):
+ full_name = serializers.SerializerMethodField()
+
+ class Meta:
+ model = CustomUser
+ fields = ['id', 'email', 'full_name', 'is_team_member']
+ read_only_fields = fields
+
+ def get_full_name(self, obj):
+ return obj.get_full_name() or obj.email
\ No newline at end of file
diff --git a/backend/core/users/services.py b/backend/core/users/services.py
new file mode 100644
index 0000000..175f01e
--- /dev/null
+++ b/backend/core/users/services.py
@@ -0,0 +1,35 @@
+# backend/core/users/services.py
+
+import logging
+from django.contrib.auth import get_user_model
+from rest_framework.exceptions import ValidationError
+from .serializers import UserSerializer
+
+from typing import TYPE_CHECKING
+if TYPE_CHECKING:
+ from .models import CustomUser as User
+else:
+ User = get_user_model()
+
+logger = logging.getLogger(__name__)
+
+class UserService:
+ @staticmethod
+ def update_user_profile(user, data: dict) -> User:
+ """
+ Ažurira profil koristeći serializer za validaciju.
+ Ovo osigurava da poslovna pravila definirana u serializeru
+ budu poštivana i kod programskog ažuriranja.
+ """
+ # Koristimo serializer za validaciju dolaznih podataka
+ serializer = UserSerializer(user, data=data, partial=True)
+
+ if not serializer.is_valid():
+ logger.error(f"Neuspješna validacija profila korisnika {user.id}: {serializer.errors}")
+ raise ValidationError(serializer.errors)
+
+ # Spremanje validiranih podataka
+ updated_user = serializer.save()
+ logger.info(f"Korisnik {user.id} je uspješno ažurirao profil.")
+
+ return updated_user
\ No newline at end of file
diff --git a/backend/core/users/tests/test_models.py b/backend/core/users/tests/test_models.py
new file mode 100644
index 0000000..f511742
--- /dev/null
+++ b/backend/core/users/tests/test_models.py
@@ -0,0 +1,25 @@
+import pytest
+from django.contrib.auth import get_user_model
+
+User = get_user_model()
+
+@pytest.mark.django_db
+def test_create_user():
+ """Testira kreiranje standardnog korisnika."""
+ user = User.objects.create_user(
+ email="test@example.com",
+ password="password123"
+ )
+ assert user.email == "test@example.com"
+ assert user.check_password("password123")
+ assert user.is_active is True
+
+@pytest.mark.django_db
+def test_create_superuser():
+ """Testira kreiranje superuser-a."""
+ admin = User.objects.create_superuser(
+ email="admin@example.com",
+ password="password123"
+ )
+ assert admin.is_superuser is True
+ assert admin.is_staff is True
\ No newline at end of file
diff --git a/backend/core/users/tests/test_permissions.py b/backend/core/users/tests/test_permissions.py
new file mode 100644
index 0000000..690fcfa
--- /dev/null
+++ b/backend/core/users/tests/test_permissions.py
@@ -0,0 +1,37 @@
+# backend/core/users/tests/test_permissions.py
+
+import pytest
+from rest_framework import status
+from rest_framework.test import APIClient
+from django.contrib.auth import get_user_model
+
+User = get_user_model()
+
+@pytest.mark.django_db
+class TestUserPermissions:
+
+ def setup_method(self):
+ self.client = APIClient()
+ # Kreiramo dva korisnika
+ self.user1 = User.objects.create_user(email="user1@erp.hr", password="password123")
+ self.user2 = User.objects.create_user(email="user2@erp.hr", password="password123")
+
+ def test_user_cannot_edit_other_user(self):
+ """Osigurava da user1 ne može mijenjati podatke od user2."""
+ self.client.force_authenticate(user=self.user1)
+
+ # Pokušavamo patchati user2 (pretpostavljamo URL /api/users/{id}/)
+ response = self.client.patch(f'/api/users/{self.user2.id}/', {"first_name": "Haker"})
+
+ # Trebali bismo dobiti 403 Forbidden ili 404 (ovisno kako skrivaš postojanje drugih)
+ assert response.status_code in [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND]
+
+ def test_authenticated_user_can_edit_self(self):
+ """Osigurava da user1 može mijenjati svoje podatke."""
+ self.client.force_authenticate(user=self.user1)
+
+ response = self.client.patch(f'/api/users/{self.user1.id}/', {"first_name": "Ime"})
+
+ assert response.status_code == status.HTTP_200_OK
+ self.user1.refresh_from_db()
+ assert self.user1.first_name == "Ime"
\ No newline at end of file
diff --git a/backend/core/users/tests/test_seed_all_models_command.py b/backend/core/users/tests/test_seed_all_models_command.py
new file mode 100644
index 0000000..e531d06
--- /dev/null
+++ b/backend/core/users/tests/test_seed_all_models_command.py
@@ -0,0 +1,56 @@
+import uuid
+
+import pytest
+from django.contrib.auth import get_user_model
+
+from core.management.commands.seed_all_models import Command
+from modules.fleet.models import Vehicle, VehicleServiceRecord
+from modules.task_management.models import Task
+
+
+@pytest.mark.django_db
+def test_seed_service_records_creates_record_with_task_and_task_vehicle():
+ user = get_user_model().objects.create_user(
+ email=f"seed-test-{uuid.uuid4().hex[:8]}@example.test",
+ password="pass12345",
+ )
+ vehicle = Vehicle.objects.create(
+ registration_number=f"ST-{uuid.uuid4().hex[:6].upper()}",
+ asset_type="crane",
+ )
+ task = Task.objects.create(
+ title=f"Task {uuid.uuid4().hex[:6]}",
+ status="aktivan",
+ vehicle=vehicle,
+ assigned_to=user,
+ )
+
+ cmd = Command()
+ before = VehicleServiceRecord.objects.count()
+ cmd._seed_service_records(1, [user], [task])
+
+ assert VehicleServiceRecord.objects.count() == before + 1
+ record = VehicleServiceRecord.objects.order_by("-created_at").first()
+ assert record is not None
+ assert record.task_id == task.id
+ assert record.vehicle_id == task.vehicle_id
+
+
+@pytest.mark.django_db
+def test_seed_service_records_skips_tasks_without_vehicle():
+ user = get_user_model().objects.create_user(
+ email=f"seed-test-{uuid.uuid4().hex[:8]}@example.test",
+ password="pass12345",
+ )
+ task = Task.objects.create(
+ title=f"Task {uuid.uuid4().hex[:6]}",
+ status="aktivan",
+ vehicle=None,
+ assigned_to=user,
+ )
+
+ cmd = Command()
+ before = VehicleServiceRecord.objects.count()
+ cmd._seed_service_records(1, [user], [task])
+
+ assert VehicleServiceRecord.objects.count() == before
diff --git a/backend/core/users/tests/test_services.py b/backend/core/users/tests/test_services.py
new file mode 100644
index 0000000..ffb333c
--- /dev/null
+++ b/backend/core/users/tests/test_services.py
@@ -0,0 +1,35 @@
+import pytest
+from django.core.exceptions import ValidationError as DjangoValidationError
+from rest_framework.exceptions import ValidationError as DRFValidationError
+from core.users.services import UserService
+from django.contrib.auth import get_user_model
+
+User = get_user_model()
+
+@pytest.mark.django_db
+class TestUserService:
+
+ def setup_method(self):
+ self.user = User.objects.create_user(
+ email="servis@primjer.hr",
+ password="password123"
+ )
+
+ def test_update_profile_success(self):
+ """Testira uspješno ažuriranje profila preko servisa."""
+ new_data = {"first_name": "Novi", "last_name": "Korisnik"}
+
+ updated_user = UserService.update_user_profile(self.user, new_data)
+
+ assert updated_user.first_name == "Novi"
+ assert updated_user.last_name == "Korisnik"
+ # Provjera da email nije promijenjen ako nismo poslali
+ assert updated_user.email == "servis@primjer.hr"
+
+ def test_update_profile_invalid_data(self):
+ """Testira validaciju (npr. neispravan format podataka)."""
+ # Pretpostavimo da dodamo logiku u serializer koja brani prazno ime
+ invalid_data = {"first_name": ""}
+
+ with pytest.raises(DRFValidationError):
+ UserService.update_user_profile(self.user, invalid_data)
\ No newline at end of file
diff --git a/backend/core/users/tests/test_users.py b/backend/core/users/tests/test_users.py
new file mode 100644
index 0000000..d84ce7a
--- /dev/null
+++ b/backend/core/users/tests/test_users.py
@@ -0,0 +1,61 @@
+import pytest
+from django.urls import reverse
+from rest_framework import status
+from rest_framework.test import APIClient
+from django.contrib.auth import get_user_model
+
+User = get_user_model()
+
+@pytest.mark.django_db
+class TestUserAPI:
+
+ def setup_method(self):
+ self.client = APIClient()
+ self.user_data = {
+ "email": "test@primjer.hr",
+ "first_name": "Test",
+ "last_name": "Korisnik",
+ "password": "strongpassword123"
+ }
+ self.user = User.objects.create_user(**self.user_data)
+
+ def test_get_me_authenticated(self):
+ """Testira dohvat vlastitih podataka (uspjeh)."""
+ self.client.force_authenticate(user=self.user)
+ url = reverse('user-me') # Provjeri naziv u routeru
+
+ response = self.client.get(url)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data['email'] == self.user.email
+ assert 'is_serviser' in response.data
+
+ def test_get_me_unauthenticated(self):
+ """Testira pristup bez prijave (neuspjeh)."""
+ url = reverse('user-me')
+
+ response = self.client.get(url)
+
+ # Ovisno o tvojim permission_classes, ovdje bi trebalo biti 401 ili 403
+ assert response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]
+
+ def test_user_creation(self):
+ """Testira osnovno stvaranje korisnika u bazi."""
+ assert User.objects.count() == 1
+ assert self.user.is_serviser is False
+
+ def test_update_user_via_api(self):
+ """Testira cjelokupan tok: API -> ViewSet -> Service."""
+ self.client.force_authenticate(user=self.user)
+ url = reverse('user-detail', kwargs={'pk': self.user.id}) # Provjeri basename
+
+ new_data = {"first_name": "Inženjer"}
+
+ # Simuliramo PATCH zahtjev
+ response = self.client.patch(url, new_data)
+
+ # Osvježi instancu iz baze
+ self.user.refresh_from_db()
+
+ assert response.status_code == status.HTTP_200_OK
+ assert self.user.first_name == "Inženjer"
\ No newline at end of file
diff --git a/backend/core/users/urls.py b/backend/core/users/urls.py
new file mode 100644
index 0000000..2c70b25
--- /dev/null
+++ b/backend/core/users/urls.py
@@ -0,0 +1,13 @@
+# backend/core/users/urls.py
+from django.urls import path, include
+from rest_framework.routers import DefaultRouter
+from .views import UserViewSet
+
+router = DefaultRouter()
+# Registriramo UserViewSet pod imenom 'users'
+# Ovo će generirati rute poput /api/users/me/
+router.register(r'', UserViewSet, basename='user')
+
+urlpatterns = [
+ path('', include(router.urls)),
+]
\ No newline at end of file
diff --git a/backend/core/users/views.py b/backend/core/users/views.py
new file mode 100644
index 0000000..1cd6468
--- /dev/null
+++ b/backend/core/users/views.py
@@ -0,0 +1,44 @@
+# backend/core/users/views.py
+
+from rest_framework import viewsets, permissions
+from rest_framework.decorators import action
+from rest_framework.response import Response
+from django.contrib.auth import get_user_model
+from .serializers import TeamMemberSerializer, UserSerializer
+from .services import UserService
+
+User = get_user_model()
+
+class IsOwner(permissions.BasePermission):
+ """Prilagođeni permission: Korisnik može uređivati samo sebe."""
+ def has_object_permission(self, request, view, obj):
+ return obj.id == request.user.id
+
+class UserViewSet(viewsets.ModelViewSet):
+ queryset = User.objects.all()
+ serializer_class = UserSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ @action(detail=False, methods=['get'])
+ def me(self, request):
+ serializer = self.get_serializer(request.user)
+ return Response(serializer.data)
+
+ @action(detail=False, methods=['get'], url_path='team-members')
+ def team_members(self, request):
+ queryset = User.objects.filter(is_active=True, is_team_member=True).order_by('first_name', 'last_name', 'email')
+ serializer = TeamMemberSerializer(queryset, many=True)
+ return Response(serializer.data)
+
+ def perform_update(self, serializer):
+ # Delegiramo logiku servisu
+ UserService.update_user_profile(
+ user=self.request.user,
+ data=serializer.validated_data
+ )
+
+ def get_queryset(self):
+ # Korisnik vidi samo sebe (ili admini vide sve)
+ if self.request.user.is_staff:
+ return User.objects.all()
+ return User.objects.filter(id=self.request.user.id)
\ No newline at end of file
diff --git a/backend/core/utils.py b/backend/core/utils.py
new file mode 100644
index 0000000..c24b522
--- /dev/null
+++ b/backend/core/utils.py
@@ -0,0 +1,20 @@
+# backend/core/utils.py
+
+from datetime import datetime
+from decimal import Decimal, InvalidOperation
+
+def format_currency(value: str) -> Decimal:
+ """Standardizirano pretvaranje valuta iz stringa u Decimal."""
+ try:
+ # Čišćenje stringa (npr. uklanjanje 'kn' ili '.' separatora)
+ clean_value = value.replace(',', '.').replace(' ', '')
+ return Decimal(clean_value)
+ except (InvalidOperation, ValueError):
+ return Decimal('0.00')
+
+def parse_iso_date(date_str: str):
+ """Standardizirano parsiranje datuma za cijeli sustav."""
+ try:
+ return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
+ except (ValueError, TypeError):
+ return None
\ No newline at end of file
diff --git a/backend/core/validators.py b/backend/core/validators.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/core/wsgi.py b/backend/core/wsgi.py
new file mode 100644
index 0000000..6d36530
--- /dev/null
+++ b/backend/core/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for core project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
+
+application = get_wsgi_application()
diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh
new file mode 100644
index 0000000..2e20369
--- /dev/null
+++ b/backend/entrypoint.sh
@@ -0,0 +1,101 @@
+#!/bin/sh
+set -e
+
+export PATH="/usr/local/bin:$PATH"
+
+APP_DIR="/app"
+APP_USER="appuser"
+APP_UID=1000
+STATIC_ROOT="${STATIC_ROOT:-/app/staticfiles}"
+MEDIA_ROOT="${MEDIA_ROOT:-/app/media}"
+
+# Kreiraj korisnika 'appuser' s UID 1000 ako ne postoji
+if ! getent passwd "${APP_USER}" > /dev/null; then
+ echo "--> Kreiram ${APP_USER}..."
+ addgroup --system --gid ${APP_UID} ${APP_USER} || true
+ adduser --system --uid ${APP_UID} --ingroup ${APP_USER} --no-create-home --disabled-login ${APP_USER} || true
+fi
+
+# Ako smo root, pokušaj osigurati ownership. Ako nismo root, samo logiramo i nastavljamo.
+if [ "$(id -u)" -eq 0 ]; then
+ echo "--> (root) Kreiram/ajustiram direktorije i dozvole..."
+ mkdir -p "${STATIC_ROOT}" "${MEDIA_ROOT}" "${APP_DIR}"
+ # pokušaj chown, ali ne izlazi na grešku ako neuspije (možda host FS to ne dozvoljava)
+ if chown -R ${APP_USER}:${APP_USER} "${STATIC_ROOT}" "${MEDIA_ROOT}" "${APP_DIR}"; then
+ echo "--> Chown uspješan."
+ else
+ echo "--> Upozorenje: chown nije uspio (moguće root_squash ili mount bez chown dopuštenja)."
+ fi
+else
+ echo "--> Nismo root (uid=$(id -u)), preskačem chown. Provjeri vlasništvo na hostu ako su problemi s permisijama."
+fi
+
+# Helper: run command as appuser but return to the script (do not replace shell)
+run_as_appuser_now() {
+ if command -v gosu >/dev/null 2>&1; then
+ gosu ${APP_USER} "$@"
+ elif command -v su-exec >/dev/null 2>&1; then
+ su-exec ${APP_USER} "$@"
+ elif command -v runuser >/dev/null 2>&1; then
+ runuser -u ${APP_USER} -- "$@"
+ else
+ # If we're already non-root (appuser), run directly; if root and no helpers, run directly anyway (with warning)
+ if [ "$(id -u)" -ne 0 ]; then
+ "$@"
+ else
+ echo "--> Upozorenje: nema gosu/su-exec/runuser; izvršavam kao root (nepoželjno)."
+ "$@"
+ fi
+ fi
+}
+
+# Helper: exec the given command as appuser (replace current shell / PID 1)
+exec_as_appuser() {
+ # Ako smo već appuser, samo izvrši naredbu direktno
+ if [ "$(id -u -n)" = "${APP_USER}" ]; then
+ exec "$@"
+ # Inače, pokušaj koristiti alate za switch
+ elif command -v gosu >/dev/null 2>&1; then
+ exec gosu ${APP_USER} "$@"
+ elif command -v su-exec >/dev/null 2>&1; then
+ exec su-exec ${APP_USER} "$@"
+ else
+ # Fallback
+ exec "$@"
+ fi
+}
+
+if [ "$1" = "gunicorn" ]; then
+ echo "--> Pokrećem migracije..."
+ # Ako smo root, pokrenimo migrate kao appuser; ako smo non-root (već appuser), pokreni direktno
+ if [ "$(id -u)" -eq 0 ]; then
+ run_as_appuser_now python manage.py migrate --noinput
+ else
+ python manage.py migrate --noinput
+ fi
+
+ echo "--> Osiguravam prava nad statikom prije collectstatic..."
+ mkdir -p "${STATIC_ROOT}"
+ if [ "$(id -u)" -eq 0 ]; then
+ chown -R ${APP_USER}:${APP_USER} "${STATIC_ROOT}" || echo "--> chown statika nije uspio"
+ fi
+
+ echo "--> Skupljam statiku..."
+ if command -v gosu >/dev/null 2>&1 || command -v su-exec >/dev/null 2>&1 || command -v runuser >/dev/null 2>&1; then
+ run_as_appuser_now python manage.py collectstatic --noinput
+ else
+ python manage.py collectstatic --noinput
+ fi
+
+ echo "--> Pokrećem Gunicorn kao appuser..."
+ exec_as_appuser gunicorn --bind 0.0.0.0:8000 --workers 3 core.wsgi:application
+
+elif [ "$1" = "celery" ]; then
+ echo "--> Pokrećem Celery kao appuser..."
+ shift
+ # Replace PID1 with celery running as appuser
+ exec_as_appuser celery "$@"
+
+else
+ exec "$@"
+fi
\ No newline at end of file
diff --git a/backend/infrastructure/__init__.py b/backend/infrastructure/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/infrastructure/paperless_client.py b/backend/infrastructure/paperless_client.py
new file mode 100644
index 0000000..7a15632
--- /dev/null
+++ b/backend/infrastructure/paperless_client.py
@@ -0,0 +1,43 @@
+# backend/infrastructure/paperless_client.py
+
+import requests
+from django.conf import settings
+
+class PaperlessGateway:
+ """
+ Gateway za komunikaciju s Paperless-ngx API-jem.
+ """
+ def __init__(self):
+ self.base_url = settings.PAPERLESS_API_URL
+ self.headers = {
+ "Authorization": f"Token {settings.PAPERLESS_API_TOKEN}",
+ "Content-Type": "application/json"
+ }
+
+ def upload_document(self, file_content, filename):
+ """Šalje dokument u Paperless."""
+ url = f"{self.base_url}/documents/post_document/"
+ files = {'document': (filename, file_content)}
+ response = requests.post(url, headers={"Authorization": self.headers["Authorization"]}, files=files)
+ response.raise_for_status()
+ return response.json()
+
+ def get_document_status(self, task_id):
+ """Provjerava status procesiranja dokumenta."""
+ url = f"{self.base_url}/tasks/{task_id}/"
+ response = requests.get(url, headers=self.headers)
+ return response.json()
+
+ def get_document_metadata(self, document_id: int):
+ """Dohvaća metapodatke dokumenta iz Paperless-a."""
+ url = f"{self.base_url}/api/documents/{document_id}/"
+ response = requests.get(url, headers=self.headers)
+ response.raise_for_status()
+ return response.json()
+
+ def fetch_unprocessed_documents(self):
+ """Dohvaća listu dokumenata koji čekaju obradu."""
+ # Filtriramo dokumente koji nemaju naš 'processed' tag
+ url = f"{self.base_url}/api/documents/?query=is_tagged:false"
+ response = requests.get(url, headers=self.headers)
+ return response.json().get('results', [])
\ No newline at end of file
diff --git a/backend/infrastructure/pusher_service.py b/backend/infrastructure/pusher_service.py
new file mode 100644
index 0000000..3fa4152
--- /dev/null
+++ b/backend/infrastructure/pusher_service.py
@@ -0,0 +1,221 @@
+# backend/modules/fleet/services/pusher_service.py
+"""
+Pusher Integration Service for Real-time Notifications
+Handles broadcasting notifications to users via WebSocket channels
+"""
+
+import logging
+import json
+from typing import Optional, Dict, Any
+from django.conf import settings
+from django.core.serializers.json import DjangoJSONEncoder
+
+try:
+ import pusher
+ PUSHER_AVAILABLE = True
+except ImportError:
+ PUSHER_AVAILABLE = False
+ pusher = None
+
+logger = logging.getLogger(__name__)
+
+
+class PusherService:
+ """
+ Wrapper service for Pusher integration.
+ Handles event broadcasting, channel authentication, and error handling.
+ """
+
+ _instance = None
+ _client = None
+
+ def __new__(cls):
+ """Singleton pattern to reuse Pusher client"""
+ if cls._instance is None:
+ cls._instance = super(PusherService, cls).__new__(cls)
+ return cls._instance
+
+ @classmethod
+ def get_client(cls):
+ """Get or initialize Pusher client"""
+ if cls._client is None:
+ if not PUSHER_AVAILABLE:
+ logger.warning("Pusher library not installed. Install with: pip install pusher")
+ return None
+
+ try:
+ pusher_config = {
+ 'app_id': settings.PUSHER_APP_ID,
+ 'key': settings.PUSHER_KEY,
+ 'secret': settings.PUSHER_SECRET,
+ 'cluster': settings.PUSHER_CLUSTER,
+ 'ssl': True,
+ 'json_encoder': DjangoJSONEncoder,
+ }
+ cls._client = pusher.Pusher(**pusher_config)
+ except AttributeError as e:
+ logger.error(f"Pusher configuration missing: {e}")
+ return None
+
+ return cls._client
+
+ @staticmethod
+ def is_available() -> bool:
+ """Check if Pusher is available and configured"""
+ if not PUSHER_AVAILABLE:
+ return False
+
+ required_values = [
+ settings.PUSHER_APP_ID,
+ settings.PUSHER_KEY,
+ settings.PUSHER_SECRET,
+ settings.PUSHER_CLUSTER,
+ ]
+ return all(required_values)
+
+ @staticmethod
+ def get_private_channel_name(user_id: int) -> str:
+ """Generate private channel name for user"""
+ return f"private-user-{user_id}"
+
+ @staticmethod
+ def get_presence_channel_name() -> str:
+ """Get the presence channel name for fleet activity"""
+ return "presence-fleet"
+
+ @classmethod
+ def broadcast_notification(
+ cls,
+ user_id: int,
+ title: str,
+ message: str,
+ level: str = 'info',
+ notification_id: Optional[Any] = None,
+ metadata: Optional[Dict[str, Any]] = None
+ ) -> bool:
+ """
+ Broadcast a notification to a specific user via private channel.
+
+ Args:
+ user_id: ID of the user to notify
+ title: Notification title
+ message: Notification message
+ level: Notification level (info, warning, critical)
+ notification_id: ID of the VehicleNotification record
+ metadata: Additional metadata to send with notification
+
+ Returns:
+ True if broadcast successful, False otherwise
+ """
+ if not cls.is_available():
+ logger.debug("Pusher not available, skipping broadcast")
+ return False
+
+ client = cls.get_client()
+ if not client:
+ return False
+
+ channel_name = cls.get_private_channel_name(user_id)
+ event_name = "vehicle.notification.created"
+
+ from django.utils.timezone import now
+ event_data = {
+ 'id': cls._to_json_safe(notification_id),
+ 'title': title,
+ 'message': message,
+ 'level': level,
+ 'is_read': False,
+ 'created_at': now().isoformat(),
+ }
+
+ if metadata:
+ event_data['metadata'] = cls._to_json_safe(metadata)
+
+ try:
+ response = client.trigger(
+ channels=channel_name,
+ event_name=event_name,
+ data=event_data
+ )
+ logger.info(f"Notification broadcasted to {channel_name}: {event_name}")
+ return True
+ except Exception as e:
+ logger.exception(f"Error broadcasting notification to {channel_name}: {e}")
+ return False
+
+ @staticmethod
+ def _to_json_safe(value: Any) -> Any:
+ """
+ Convert complex Python/Django values (UUID, date, Decimal, etc.) into
+ JSON-serializable primitives using DjangoJSONEncoder.
+ """
+ return json.loads(json.dumps(value, cls=DjangoJSONEncoder))
+
+ @classmethod
+ def broadcast_to_multiple_users(
+ cls,
+ user_ids: list,
+ title: str,
+ message: str,
+ level: str = 'info',
+ notification_ids: Optional[Dict[int, int]] = None
+ ) -> Dict[int, bool]:
+ """
+ Broadcast the same notification to multiple users.
+
+ Args:
+ user_ids: List of user IDs to notify
+ title: Notification title
+ message: Notification message
+ level: Notification level
+ notification_ids: Dict mapping user_id to notification_id
+
+ Returns:
+ Dict of user_id -> success boolean
+ """
+ results = {}
+ for user_id in user_ids:
+ notification_id = notification_ids.get(user_id) if notification_ids else None
+ results[user_id] = cls.broadcast_notification(
+ user_id=user_id,
+ title=title,
+ message=message,
+ level=level,
+ notification_id=notification_id
+ )
+ return results
+
+ @classmethod
+ def authenticate_channel(cls, socket_id: str, channel_name: str, user_id: int) -> Optional[Dict[str, Any]]:
+ """
+ Authenticate a user for a private or presence channel.
+
+ Args:
+ socket_id: Pusher socket ID from client
+ channel_name: Channel name to authenticate
+ user_id: Current user ID
+
+ Returns:
+ Signed authentication data or None if authentication fails
+ """
+ if not cls.is_available():
+ logger.debug("Pusher not available for authentication")
+ return None
+
+ client = cls.get_client()
+ if not client:
+ return None
+
+ # Only allow subscription to user's own private channel
+ expected_channel = cls.get_private_channel_name(user_id)
+ if channel_name != expected_channel and channel_name != cls.get_presence_channel_name():
+ logger.warning(f"Unauthorized channel subscription attempt: {channel_name} by user {user_id}")
+ return None
+
+ try:
+ auth_data = client.authenticate(channel=channel_name, socket_id=socket_id)
+ logger.info(f"Channel {channel_name} authenticated for user {user_id}")
+ return auth_data
+ except Exception as e:
+ logger.exception(f"Error authenticating channel {channel_name}: {e}")
+ return None
\ No newline at end of file
diff --git a/backend/infrastructure/webhooks.py b/backend/infrastructure/webhooks.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/manage.py b/backend/manage.py
new file mode 100644
index 0000000..f2a662c
--- /dev/null
+++ b/backend/manage.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+ """Run administrative tasks."""
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/backend/modules/crm/__init__.py b/backend/modules/crm/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/modules/crm/admin.py b/backend/modules/crm/admin.py
new file mode 100644
index 0000000..7842090
--- /dev/null
+++ b/backend/modules/crm/admin.py
@@ -0,0 +1,86 @@
+from django import forms
+from django.contrib import admin
+from django.contrib.admin.widgets import FilteredSelectMultiple
+from django.utils.translation import gettext_lazy as _
+from modules.fleet.models import Crane
+from .models import Client
+
+
+class ClientAdminForm(forms.ModelForm):
+ customer_cranes = forms.ModelMultipleChoiceField(
+ label=_("Vozila kupca"),
+ queryset=Crane.objects.filter(is_active=True, asset_type='crane').order_by('registration_number'),
+ required=False,
+ widget=FilteredSelectMultiple(_("Dizalice"), is_stacked=False),
+ help_text=_("Odaberite postojeće dizalice koje pripadaju kupcu."),
+ )
+
+ class Meta:
+ model = Client
+ fields = '__all__'
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if self.instance.pk:
+ self.fields['customer_cranes'].initial = Crane.objects.filter(client=self.instance, is_active=True, asset_type='crane')
+
+
+@admin.register(Client)
+class ClientAdmin(admin.ModelAdmin):
+ form = ClientAdminForm
+ # Polja koja se prikazuju u listi
+ list_display = ('name', 'tax_id', 'city', 'client_type', 'vehicle_count', 'is_active', 'created_at')
+
+ # Polja koja omogućuju klik na objekt
+ list_display_links = ('name', 'tax_id')
+
+ # Filteri sa strane (vrlo korisno za ERP)
+ list_filter = ('client_type', 'is_active', 'created_at')
+
+ # Polja po kojima admin može pretraživati
+ search_fields = ('name', 'tax_id', 'email')
+
+ readonly_fields = ('created_at', 'updated_at', 'linked_cranes_preview')
+
+ # Grupiranje polja u formi za ljepši pregled
+ fieldsets = (
+ (_('Osnovni podaci'), {
+ 'fields': ('name', 'client_type', 'tax_id')
+ }),
+ (_('Kontakt podaci'), {
+ 'fields': ('email', 'phone', 'address', 'city', 'postal_code')
+ }),
+ (_('Vozila kupca'), {
+ 'fields': ('customer_cranes', 'linked_cranes_preview')
+ }),
+ (_('Sustavni podaci'), {
+ 'fields': ('is_active', 'created_at', 'updated_at'),
+ 'classes': ('collapse',)
+ }),
+ )
+
+ list_per_page = 50
+
+ @admin.display(description="Dizalice")
+ def vehicle_count(self, obj):
+ return obj.vehicles.filter(asset_type='crane').count()
+
+ @admin.display(description="Povezane dizalice")
+ def linked_cranes_preview(self, obj):
+ if not obj.pk:
+ return _("Spremite klijenta za prikaz povezanih dizalica.")
+ cranes = Crane.objects.filter(client=obj, is_active=True, asset_type='crane').order_by('registration_number')
+ if not cranes.exists():
+ return _("Nema povezanih dizalica.")
+ return ", ".join(str(crane) for crane in cranes)
+
+ def save_related(self, request, form, formsets, change):
+ super().save_related(request, form, formsets, change)
+ selected_cranes = form.cleaned_data.get('customer_cranes')
+ if selected_cranes is None:
+ return
+
+ selected_ids = list(selected_cranes.values_list('id', flat=True))
+ Crane.objects.filter(client=form.instance, asset_type='crane').exclude(pk__in=selected_ids).update(client=None)
+ if selected_ids:
+ Crane.objects.filter(pk__in=selected_ids).update(client=form.instance, is_company_vehicle=False, asset_type='crane')
\ No newline at end of file
diff --git a/backend/modules/crm/apps.py b/backend/modules/crm/apps.py
new file mode 100644
index 0000000..4cba3db
--- /dev/null
+++ b/backend/modules/crm/apps.py
@@ -0,0 +1,8 @@
+# /backend/modules/crm/apps.py
+
+from django.apps import AppConfig
+
+class FleetConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'modules.crm'
+ verbose_name = "Customer Relationship Management"
\ No newline at end of file
diff --git a/backend/modules/crm/migrations/0001_initial.py b/backend/modules/crm/migrations/0001_initial.py
new file mode 100644
index 0000000..560a9d4
--- /dev/null
+++ b/backend/modules/crm/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# Generated by Django 5.2.15 on 2026-06-29 20:13
+
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Client',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
+ ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
+ ('name', models.CharField(max_length=255, verbose_name='Naziv/Ime')),
+ ('client_type', models.CharField(choices=[('legal', 'Pravna osoba'), ('individual', 'Fizička osoba')], default='legal', max_length=20)),
+ ('tax_id', models.CharField(max_length=50, unique=True, verbose_name='OIB/VAT broj')),
+ ('email', models.EmailField(max_length=254, unique=True, verbose_name='Email')),
+ ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Telefon')),
+ ('address', models.TextField(verbose_name='Adresa')),
+ ('city', models.CharField(max_length=100, verbose_name='Grad')),
+ ('postal_code', models.CharField(max_length=20, verbose_name='Poštanski broj')),
+ ],
+ options={
+ 'verbose_name': 'Klijent',
+ 'verbose_name_plural': 'Klijenti',
+ 'ordering': ['name'],
+ },
+ ),
+ ]
diff --git a/backend/modules/crm/migrations/__init__.py b/backend/modules/crm/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/modules/crm/models.py b/backend/modules/crm/models.py
new file mode 100644
index 0000000..63d9b1f
--- /dev/null
+++ b/backend/modules/crm/models.py
@@ -0,0 +1,31 @@
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from core.base_models import BaseModel
+
+class Client(BaseModel):
+ # Tip klijenta: OIB/VAT broj je ključan za fakturiranje
+ CLIENT_TYPES = [
+ ('legal', _('Pravna osoba')),
+ ('individual', _('Fizička osoba')),
+ ]
+
+ name = models.CharField(max_length=255, verbose_name=_("Naziv/Ime"))
+ client_type = models.CharField(max_length=20, choices=CLIENT_TYPES, default='legal')
+
+ # Porezni podaci (za fakturiranje)
+ tax_id = models.CharField(max_length=50, unique=True, verbose_name=_("OIB/VAT broj"))
+
+ # Kontakt podaci
+ email = models.EmailField(unique=True, verbose_name=_("Email"))
+ phone = models.CharField(max_length=20, blank=True, null=True, verbose_name=_("Telefon"))
+ address = models.TextField(verbose_name=_("Adresa"))
+ city = models.CharField(max_length=100, verbose_name=_("Grad"))
+ postal_code = models.CharField(max_length=20, verbose_name=_("Poštanski broj"))
+
+ class Meta:
+ verbose_name = _("Klijent")
+ verbose_name_plural = _("Klijenti")
+ ordering = ['name']
+
+ def __str__(self):
+ return f"{self.name} ({self.tax_id})"
\ No newline at end of file
diff --git a/backend/modules/crm/serializers.py b/backend/modules/crm/serializers.py
new file mode 100644
index 0000000..5547937
--- /dev/null
+++ b/backend/modules/crm/serializers.py
@@ -0,0 +1,50 @@
+from rest_framework import serializers
+from rest_framework.exceptions import ValidationError as DRFValidationError
+from .models import Client
+from .services import ClientService
+
+
+class ClientSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Client
+ fields = [
+ 'id', 'name', 'client_type', 'tax_id',
+ 'email', 'phone', 'address', 'city', 'postal_code',
+ 'created_at', 'is_active'
+ ]
+ read_only_fields = ['id', 'created_at', 'is_active']
+
+ def _get_request_user(self):
+ """
+ Sigurno dohvaćanje korisnika iz contexta serializer-a.
+ Request iz RequestFactory može biti WSGIRequest bez atributa 'user',
+ zato koristimo getattr.
+ """
+ request = self.context.get('request') if self.context else None
+ return getattr(request, 'user', None)
+
+ def create(self, validated_data):
+ """
+ Delegate creation to service layer.
+ """
+ try:
+ user = self._get_request_user()
+ return ClientService.create_client(validated_data, user=user)
+ except DRFValidationError:
+ # re-raise as-is
+ raise
+ except Exception as exc:
+ # wrap non-DRF exceptions
+ raise DRFValidationError({"detail": str(exc)})
+
+ def update(self, instance, validated_data):
+ """
+ Delegate update to service layer.
+ """
+ try:
+ user = self._get_request_user()
+ return ClientService.update_client(instance, validated_data, user=user)
+ except DRFValidationError:
+ raise
+ except Exception as exc:
+ raise DRFValidationError({"detail": str(exc)})
\ No newline at end of file
diff --git a/backend/modules/crm/services.py b/backend/modules/crm/services.py
new file mode 100644
index 0000000..732b0ae
--- /dev/null
+++ b/backend/modules/crm/services.py
@@ -0,0 +1,65 @@
+from django.db import transaction, IntegrityError
+from rest_framework.exceptions import ValidationError
+from .models import Client
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class ClientService:
+ @staticmethod
+ def create_client(data: dict, user=None) -> Client:
+ """
+ Create a Client instance with model validation and handle IntegrityError.
+ """
+ # Instantiate model and run model-level validation
+ client = Client(**data)
+ try:
+ client.full_clean()
+ except Exception as e:
+ logger.error(f"Client validation failed: {e}")
+ raise ValidationError({"detail": str(e)})
+
+ try:
+ with transaction.atomic():
+ client.save()
+ except IntegrityError as exc:
+ logger.error(f"Integrity error creating client: {exc}")
+ # Convert DB integrity errors into DRF ValidationError for the API layer
+ raise ValidationError({"detail": str(exc)})
+
+ return client
+
+ @staticmethod
+ def update_client(instance: Client, data: dict, user=None) -> Client:
+ """
+ Update the given Client instance. Validate and save.
+ """
+ for attr, value in data.items():
+ setattr(instance, attr, value)
+
+ try:
+ instance.full_clean()
+ except Exception as e:
+ logger.error(f"Client validation failed on update: {e}")
+ raise ValidationError({"detail": str(e)})
+
+ try:
+ with transaction.atomic():
+ instance.save()
+ except IntegrityError as exc:
+ logger.error(f"Integrity error updating client: {exc}")
+ raise ValidationError({"detail": str(exc)})
+
+ return instance
+
+ @staticmethod
+ def delete_client(instance: Client, user=None) -> Client:
+ """
+ Soft-delete the client (if you want soft delete).
+ If hard delete is desired, call instance.delete() instead.
+ """
+ instance.is_active = False
+ instance.save()
+ return instance
\ No newline at end of file
diff --git a/backend/modules/crm/tests/test_models.py b/backend/modules/crm/tests/test_models.py
new file mode 100644
index 0000000..7d9d91b
--- /dev/null
+++ b/backend/modules/crm/tests/test_models.py
@@ -0,0 +1,75 @@
+import uuid
+from django.test import TestCase
+from django.core.exceptions import ValidationError
+from django.db import IntegrityError
+
+from modules.crm.models import Client
+
+
+class ClientModelTests(TestCase):
+ def setUp(self):
+ # osnovni validni podaci koje ćemo koristiti u testovima
+ self.base_data = {
+ "name": "Test Client",
+ "tax_id": str(uuid.uuid4()), # jedinstveno
+ "email": f"client+{uuid.uuid4()}@example.test",
+ "address": "Ulica 1",
+ "city": "Grad",
+ "postal_code": "10000",
+ }
+
+ def test_create_client_success(self):
+ client = Client.objects.create(
+ client_type="legal",
+ **self.base_data
+ )
+ self.assertIsNotNone(client.pk)
+ self.assertEqual(client.name, "Test Client")
+ self.assertEqual(client.client_type, "legal")
+ # default ordering ili drugi atributi mogu se dodatno provjeriti
+ self.assertTrue(str(client).startswith("Test Client"))
+ self.assertIn(client.tax_id, str(client))
+
+ def test_default_client_type_is_legal(self):
+ # Ako eksplicitno ne pošaljemo client_type, default je 'legal'
+ data = dict(self.base_data)
+ data["tax_id"] = str(uuid.uuid4())
+ data["email"] = f"client+{uuid.uuid4()}@example.test"
+ client = Client.objects.create(**data)
+ self.assertEqual(client.client_type, "legal")
+
+ def test_str_representation_contains_name_and_taxid(self):
+ client = Client.objects.create(client_type="individual", **self.base_data)
+ expected = f"{client.name} ({client.tax_id})"
+ self.assertEqual(str(client), expected)
+
+ def test_unique_tax_id_constraint(self):
+ # Prva kreacija prođe
+ Client.objects.create(client_type="legal", **self.base_data)
+ # Pokušaj dupliciranja tax_id-a treba podići IntegrityError
+ dup = dict(self.base_data)
+ dup["email"] = f"client+{uuid.uuid4()}@example.test" # unique email drugi
+ with self.assertRaises(IntegrityError):
+ Client.objects.create(client_type="legal", **dup)
+
+ def test_unique_email_constraint(self):
+ Client.objects.create(client_type="legal", **self.base_data)
+ dup = dict(self.base_data)
+ dup["tax_id"] = str(uuid.uuid4()) # unique tax_id drugi
+ with self.assertRaises(IntegrityError):
+ Client.objects.create(client_type="legal", **dup)
+
+ def test_full_clean_validates_email_format(self):
+ data = dict(self.base_data)
+ data["email"] = "not-an-email"
+ client = Client(**data)
+ with self.assertRaises(ValidationError):
+ client.full_clean() # EmailField validation je pokrenuta
+
+ def test_full_clean_requires_required_fields(self):
+ # Uklonimo obavezno polje name i očekujemo ValidationError
+ data = dict(self.base_data)
+ data.pop("name")
+ client = Client(**data)
+ with self.assertRaises(ValidationError):
+ client.full_clean()
\ No newline at end of file
diff --git a/backend/modules/crm/tests/test_serializers.py b/backend/modules/crm/tests/test_serializers.py
new file mode 100644
index 0000000..3feb968
--- /dev/null
+++ b/backend/modules/crm/tests/test_serializers.py
@@ -0,0 +1,43 @@
+import uuid
+from django.test import TestCase, RequestFactory
+from unittest.mock import patch
+from modules.crm.serializers import ClientSerializer
+from modules.crm.models import Client
+
+
+class ClientSerializerTests(TestCase):
+ def setUp(self):
+ self.factory = RequestFactory()
+ self.base_data = {
+ "name": "Ser Client",
+ "client_type": "legal",
+ "tax_id": str(uuid.uuid4()),
+ "email": f"client+{uuid.uuid4()}@example.test",
+ "address": "Street 1",
+ "city": "City",
+ "postal_code": "00000",
+ }
+
+ @patch("modules.crm.serializers.ClientService.create_client")
+ def test_create_calls_service(self, mock_create):
+ # Prepare mock return value
+ mock_instance = Client(**self.base_data)
+ mock_instance.pk = 1
+ mock_create.return_value = mock_instance
+
+ serializer = ClientSerializer(data=self.base_data, context={"request": self.factory.post("/")})
+ self.assertTrue(serializer.is_valid(), serializer.errors)
+ result = serializer.save()
+ mock_create.assert_called_once()
+ self.assertEqual(result.pk, 1)
+
+ @patch("modules.crm.serializers.ClientService.update_client")
+ def test_update_calls_service(self, mock_update):
+ client = Client.objects.create(**self.base_data)
+ mock_update.return_value = client
+
+ serializer = ClientSerializer(instance=client, data={"city": "UpdatedCity"}, partial=True, context={"request": self.factory.put("/")})
+ self.assertTrue(serializer.is_valid(), serializer.errors)
+ result = serializer.save()
+ mock_update.assert_called_once()
+ self.assertEqual(result, client)
\ No newline at end of file
diff --git a/backend/modules/crm/tests/test_services.py b/backend/modules/crm/tests/test_services.py
new file mode 100644
index 0000000..8b47578
--- /dev/null
+++ b/backend/modules/crm/tests/test_services.py
@@ -0,0 +1,36 @@
+import uuid
+from django.test import TestCase
+from rest_framework.exceptions import ValidationError
+from modules.crm.services import ClientService
+from modules.crm.models import Client
+
+
+class ClientServiceTests(TestCase):
+ def setUp(self):
+ self.base_data = {
+ "name": "Service Client",
+ "client_type": "legal",
+ "tax_id": str(uuid.uuid4()),
+ "email": f"client+{uuid.uuid4()}@example.test",
+ "address": "Street 1",
+ "city": "City",
+ "postal_code": "00000",
+ }
+
+ def test_create_client_success(self):
+ client = ClientService.create_client(self.base_data)
+ self.assertIsNotNone(client.pk)
+ self.assertEqual(client.name, "Service Client")
+
+ def test_create_client_duplicate_tax_id_raises(self):
+ # create first
+ Client.objects.create(**self.base_data)
+ dup = dict(self.base_data)
+ dup["email"] = f"client+{uuid.uuid4()}@example.test"
+ with self.assertRaises(ValidationError):
+ ClientService.create_client(dup)
+
+ def test_update_client_success(self):
+ client = Client.objects.create(**self.base_data)
+ updated = ClientService.update_client(client, {"city": "NewCity"})
+ self.assertEqual(updated.city, "NewCity")
\ No newline at end of file
diff --git a/backend/modules/crm/tests/test_views.py b/backend/modules/crm/tests/test_views.py
new file mode 100644
index 0000000..c8143f4
--- /dev/null
+++ b/backend/modules/crm/tests/test_views.py
@@ -0,0 +1,62 @@
+import uuid
+from django.test import TestCase, RequestFactory
+from django.contrib.auth import get_user_model
+from unittest.mock import patch
+from modules.crm.serializers import ClientSerializer
+from modules.crm.views import ClientViewSet
+from modules.crm.models import Client
+
+
+class ClientViewSetTests(TestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.user = User.objects.create_user(username=f"user_{uuid.uuid4().hex[:6]}", email=f"user+{uuid.uuid4()}@example.test", password="pass")
+ self.factory = RequestFactory()
+ self.view = ClientViewSet()
+ self.view.request = self.factory.post("/")
+ self.view.request.user = self.user
+
+ self.base_data = {
+ "name": "View Client",
+ "client_type": "legal",
+ "tax_id": str(uuid.uuid4()),
+ "email": f"client+{uuid.uuid4()}@example.test",
+ "address": "Street 1",
+ "city": "City",
+ "postal_code": "00000",
+ }
+
+ @patch("modules.crm.views.ClientService.create_client")
+ def test_perform_create_delegates_to_service(self, mock_create):
+ # make serializer valid
+ serializer = ClientSerializer(data=self.base_data, context={"request": self.view.request})
+ self.assertTrue(serializer.is_valid(), serializer.errors)
+
+ mock_inst = Client(**self.base_data)
+ mock_inst.pk = 1
+ mock_create.return_value = mock_inst
+
+ self.view.request = self.factory.post("/")
+ self.view.request.user = self.user
+
+ # perform_create should call serializer.save() which delegates to service
+ self.view.perform_create(serializer)
+ mock_create.assert_called_once()
+
+ @patch("modules.crm.views.ClientService.update_client")
+ def test_perform_update_delegates_to_service(self, mock_update):
+ client = Client.objects.create(**self.base_data)
+ serializer = ClientSerializer(instance=client, data={"city": "UpdatedCity"}, partial=True, context={"request": self.view.request})
+ self.assertTrue(serializer.is_valid(), serializer.errors)
+
+ mock_update.return_value = client
+
+ self.view.request = self.factory.put("/")
+ self.view.request.user = self.user
+ serializer.instance = client
+
+ self.view.perform_update(serializer)
+ mock_update.assert_called_once()
+ client.refresh_from_db()
+ # serializer.perform_update causes serializer.save() which we delegated; check instance still exists
+ self.assertIsNotNone(client.pk)
\ No newline at end of file
diff --git a/backend/modules/crm/urls.py b/backend/modules/crm/urls.py
new file mode 100644
index 0000000..57259cf
--- /dev/null
+++ b/backend/modules/crm/urls.py
@@ -0,0 +1,7 @@
+from rest_framework.routers import DefaultRouter
+from .views import ClientViewSet
+
+router = DefaultRouter()
+router.register(r'clients', ClientViewSet, basename='client')
+
+urlpatterns = router.urls
diff --git a/backend/modules/crm/views.py b/backend/modules/crm/views.py
new file mode 100644
index 0000000..55e54c8
--- /dev/null
+++ b/backend/modules/crm/views.py
@@ -0,0 +1,41 @@
+import logging
+from rest_framework import viewsets, permissions
+from rest_framework.exceptions import ValidationError
+from .models import Client
+from .serializers import ClientSerializer
+from .services import ClientService
+
+logger = logging.getLogger(__name__)
+
+
+class ClientViewSet(viewsets.ModelViewSet):
+ queryset = Client.objects.filter(is_active=True)
+ serializer_class = ClientSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def perform_create(self, serializer):
+ try:
+ instance = serializer.save()
+ serializer.instance = instance
+ except ValidationError as e:
+ logger.warning(f"Validation error creating client by {self.request.user}: {e}")
+ raise e
+ except Exception as e:
+ logger.error(f"Unexpected error creating client: {e}", exc_info=True)
+ raise ValidationError({"detail": "Došlo je do greške pri stvaranju klijenta."})
+
+ def perform_update(self, serializer):
+ try:
+ instance = serializer.save()
+ serializer.instance = instance
+ except ValidationError as e:
+ logger.warning(f"Validation error updating client by {self.request.user}: {e}")
+ raise e
+ except Exception as e:
+ logger.error(f"Unexpected error updating client: {e}", exc_info=True)
+ raise ValidationError({"detail": "Došlo je do greške pri ažuriranju klijenta."})
+
+ def perform_destroy(self, instance):
+ # Delegate deletion (soft-delete) to service layer
+ ClientService.delete_client(instance, user=self.request.user)
+ logger.info(f"Client {instance.id} soft-deleted by {self.request.user}")
\ No newline at end of file
diff --git a/backend/modules/fleet/__init__.py b/backend/modules/fleet/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/modules/fleet/admin.py b/backend/modules/fleet/admin.py
new file mode 100644
index 0000000..8bb9864
--- /dev/null
+++ b/backend/modules/fleet/admin.py
@@ -0,0 +1,197 @@
+from django.contrib import admin
+from django.contrib.admin import ModelAdmin
+from django.utils.html import format_html
+from .models import (
+ Crane,
+ Vehicle,
+ WorkOrder,
+ WorkOrderInvoice,
+ VehicleServiceRecord,
+ VehicleServicePhoto,
+ VehicleServiceAttachment,
+)
+
+
+class ServicePhotoInline(admin.TabularInline):
+ model = VehicleServicePhoto
+ extra = 0
+ fields = ('thumbnail_preview', 'image', 'description', 'uploaded_by', 'created_at')
+ readonly_fields = ('thumbnail_preview', 'created_at')
+ show_change_link = True
+
+ @admin.display(description="Pregled")
+ def thumbnail_preview(self, obj):
+ if obj.image:
+ return format_html(
+ ''
+ '
'
+ '',
+ obj.image.url,
+ obj.image.url,
+ )
+ return "—"
+
+class ServiceAttachmentInline(admin.TabularInline):
+ model = VehicleServiceAttachment
+ extra = 0
+ fields = ('file', 'description', 'uploaded_by', 'created_at')
+ readonly_fields = ('created_at',)
+ show_change_link = True
+
+
+class WorkOrderInline(admin.TabularInline):
+ model = WorkOrder
+ extra = 0
+ fields = ('creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
+ readonly_fields = ('date',)
+
+
+class ServiceRecordInline(admin.TabularInline):
+ model = VehicleServiceRecord
+ extra = 0
+ fields = ('task', 'performed_by', 'service_date', 'mileage', 'next_service_due_at', 'cost')
+ readonly_fields = ('service_date', 'next_service_due_at')
+
+
+class WorkOrderInvoiceInline(admin.TabularInline):
+ model = WorkOrderInvoice
+ extra = 0
+ fields = ('naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active')
+ readonly_fields = ('created_at',)
+
+
+@admin.register(Vehicle)
+class VehicleAdmin(admin.ModelAdmin):
+ list_display = (
+ 'registration_number',
+ 'crane_serial_number',
+ 'superstructure_working_hours',
+ 'chassis_working_hours',
+ 'make',
+ 'model',
+ 'year',
+ 'current_mileage',
+ 'service_interval_km',
+ 'next_service_at_display',
+ 'is_company_vehicle',
+ 'client',
+ 'assigned_servicer',
+ 'is_active',
+ )
+ list_filter = ('is_active', 'is_company_vehicle', 'make', 'service_interval_km')
+ search_fields = ('registration_number', 'vin', 'make', 'model', 'assigned_servicer__username', 'client__name')
+ readonly_fields = ()
+ inlines = (WorkOrderInline, ServiceRecordInline)
+ ordering = ('registration_number',)
+ fieldsets = (
+ (None, {
+ 'fields': ('asset_type', 'registration_number', 'vin', 'make', 'model', 'year'),
+ }),
+ ('Vlasništvo', {
+ 'fields': ('is_company_vehicle', 'client', 'assigned_servicer'),
+ }),
+ ('Servis', {
+ 'fields': ('current_mileage', 'service_interval_km', 'superstructure_working_hours', 'chassis_working_hours', 'is_active'),
+ }),
+ )
+
+ def next_service_at_display(self, obj):
+ try:
+ return obj.next_service_at()
+ except Exception:
+ return None
+ next_service_at_display.short_description = "Sljedeći servis @ (km)"
+ next_service_at_display.admin_order_field = 'current_mileage'
+
+ def get_queryset(self, request):
+ queryset = super().get_queryset(request)
+ return queryset.filter(asset_type='vehicle')
+
+ def save_model(self, request, obj, form, change):
+ obj.asset_type = 'vehicle'
+ super().save_model(request, obj, form, change)
+
+
+@admin.register(Crane)
+class CraneAdmin(VehicleAdmin):
+ list_display = (
+ 'registration_number',
+ 'crane_serial_number',
+ 'superstructure_working_hours',
+ 'chassis_working_hours',
+ 'make',
+ 'model',
+ 'year',
+ 'current_mileage',
+ 'service_interval_km',
+ 'next_service_at_display',
+ 'client',
+ 'assigned_servicer',
+ 'is_active',
+ )
+ list_filter = ('is_active', 'client', 'make', 'service_interval_km', 'crane_serial_number')
+ search_fields = ('registration_number', 'crane_serial_number', 'vin', 'make', 'model', 'assigned_servicer__username', 'client__name')
+ fieldsets = (
+ (None, {
+ 'fields': ('asset_type', 'registration_number', 'crane_serial_number', 'vin', 'make', 'model', 'year'),
+ }),
+ ('Povezivanje', {
+ 'fields': ('client', 'assigned_servicer', 'is_company_vehicle'),
+ }),
+ ('Servis', {
+ 'fields': ('current_mileage', 'service_interval_km', 'superstructure_working_hours', 'chassis_working_hours', 'is_active'),
+ }),
+ )
+
+ def get_queryset(self, request):
+ queryset = ModelAdmin.get_queryset(self, request)
+ return queryset.filter(asset_type='crane')
+
+ def save_model(self, request, obj, form, change):
+ obj.asset_type = 'crane'
+ ModelAdmin.save_model(self, request, obj, form, change)
+
+
+@admin.register(WorkOrder)
+class WorkOrderAdmin(admin.ModelAdmin):
+ list_display = ('id', 'vehicle', 'creator', 'date', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status')
+ list_filter = ('status', 'date', 'vehicle')
+ search_fields = ('vehicle__registration_number', 'creator__username', 'purpose')
+ readonly_fields = ('date',)
+ ordering = ('-date',)
+ inlines = (WorkOrderInvoiceInline,)
+
+ def distance_display(self, obj):
+ return obj.distance
+ distance_display.short_description = "Prijeđeno (km)"
+
+
+@admin.register(WorkOrderInvoice)
+class WorkOrderInvoiceAdmin(admin.ModelAdmin):
+ list_display = ('id', 'work_order', 'naziv_racuna', 'lokacija', 'datum', 'created_by', 'is_active')
+ list_filter = ('datum', 'is_active', 'work_order')
+ search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id')
+ ordering = ('-datum', '-created_at')
+
+@admin.register(VehicleServiceRecord)
+class VehicleServiceRecordAdmin(admin.ModelAdmin):
+ list_display = ('id', 'service_title', 'task', 'vehicle', 'performed_by', 'service_date', 'mileage', 'next_service_due_at', 'cost', 'short_description')
+ list_filter = ('service_date', 'performed_by', 'vehicle')
+ search_fields = ('service_title', 'vehicle__registration_number', 'performed_by__username', 'description', 'parts')
+ readonly_fields = ('id', 'service_date', 'next_service_due_at')
+ ordering = ('-service_date',)
+ inlines = (ServicePhotoInline, ServiceAttachmentInline)
+ fieldsets = (
+ (None, {
+ 'fields': ('id', 'service_title', 'task', 'vehicle', 'performed_by', 'service_date'),
+ }),
+ ('Detalji servisa', {
+ 'fields': ('description', 'parts', 'mileage', 'cost', 'next_service_due_at'),
+ }),
+ )
+
+ @admin.display(description="Opis")
+ def short_description(self, obj):
+ if obj.description:
+ return obj.description[:60] + ('…' if len(obj.description) > 60 else '')
+ return "—"
\ No newline at end of file
diff --git a/backend/modules/fleet/apps.py b/backend/modules/fleet/apps.py
new file mode 100644
index 0000000..1a22654
--- /dev/null
+++ b/backend/modules/fleet/apps.py
@@ -0,0 +1,8 @@
+# /backend/modules/fleet/apps.py
+
+from django.apps import AppConfig
+
+class FleetConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'modules.fleet'
+ verbose_name = "Fleet"
\ No newline at end of file
diff --git a/backend/modules/fleet/migrations/0001_initial.py b/backend/modules/fleet/migrations/0001_initial.py
new file mode 100644
index 0000000..2773eed
--- /dev/null
+++ b/backend/modules/fleet/migrations/0001_initial.py
@@ -0,0 +1,124 @@
+# Generated by Django 5.2.15 on 2026-07-01 11:39
+
+import django.db.models.deletion
+import modules.fleet.models
+import uuid
+from decimal import Decimal
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Vehicle',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
+ ('registration_number', models.CharField(max_length=20, unique=True, verbose_name='Registracija')),
+ ('make', models.CharField(blank=True, max_length=100, verbose_name='Proizvođač')),
+ ('model', models.CharField(blank=True, max_length=100, verbose_name='Model')),
+ ('year', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Godina')),
+ ('vin', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='VIN')),
+ ('current_mileage', models.PositiveIntegerField(default=0, verbose_name='Trenutna kilometraža (km)')),
+ ('service_interval_km', models.PositiveIntegerField(default=15000, verbose_name='Interval servisa (km)')),
+ ('is_active', models.BooleanField(default=True, verbose_name='Aktivno')),
+ ('assigned_servicer', 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')),
+ ],
+ options={
+ 'verbose_name': 'Vozilo',
+ 'verbose_name_plural': 'Vozila',
+ 'ordering': ['registration_number'],
+ },
+ ),
+ migrations.CreateModel(
+ name='VehicleNotification',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
+ ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
+ ('title', models.CharField(max_length=255, verbose_name='Naslov')),
+ ('message', models.TextField(verbose_name='Poruka')),
+ ('level', models.CharField(choices=[('info', 'Info'), ('warning', 'Upozorenje'), ('critical', 'Kritično')], default='info', max_length=20, verbose_name='Razina')),
+ ('is_sent', models.BooleanField(default=False, verbose_name='Poslano (e-mail)')),
+ ('is_read', models.BooleanField(default=False, verbose_name='Pročitano')),
+ ('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vehicle_notifications', to=settings.AUTH_USER_MODEL, verbose_name='Primatelj')),
+ ],
+ options={
+ 'verbose_name': 'Notifikacija za vozilo',
+ 'verbose_name_plural': 'Notifikacije za vozila',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='VehicleServiceRecord',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
+ ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
+ ('description', models.TextField(verbose_name='Opis posla')),
+ ('parts', models.TextField(blank=True, verbose_name='Korišteni dijelovi')),
+ ('cost', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='Trošak')),
+ ('service_date', models.DateField(auto_now_add=True, verbose_name='Datum servisa')),
+ ('mileage', models.PositiveIntegerField(verbose_name='Kilometraža pri servisu')),
+ ('next_service_due_at', models.PositiveIntegerField(blank=True, null=True, verbose_name='Kilometraža sljedećeg servisa')),
+ ('performed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='performed_vehicle_services', to=settings.AUTH_USER_MODEL, verbose_name='Izvršio')),
+ ('vehicle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='service_records', to='fleet.vehicle', verbose_name='Vozilo')),
+ ],
+ options={
+ 'verbose_name': 'Servisni zapis',
+ 'verbose_name_plural': 'Servisni zapisi',
+ 'ordering': ['-service_date'],
+ },
+ ),
+ migrations.CreateModel(
+ name='VehicleServicePhoto',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
+ ('image', models.ImageField(upload_to=modules.fleet.models._service_photo_upload_to, verbose_name='Fotografija')),
+ ('description', models.CharField(blank=True, max_length=255, verbose_name='Opis')),
+ ('is_active', models.BooleanField(default=True, verbose_name='Aktivan')),
+ ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_service_photos', to=settings.AUTH_USER_MODEL, verbose_name='Postavio')),
+ ('service_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='fleet.vehicleservicerecord', verbose_name='Servisni zapis')),
+ ],
+ options={
+ 'verbose_name': 'Fotografija servisa',
+ 'verbose_name_plural': 'Fotografije servisa',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='WorkOrder',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
+ ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
+ ('date', models.DateField(auto_now_add=True, verbose_name='Datum')),
+ ('start_mileage', models.PositiveIntegerField(verbose_name='Početna kilometraža')),
+ ('end_mileage', models.PositiveIntegerField(blank=True, null=True, verbose_name='Završna kilometraža')),
+ ('purpose', models.CharField(blank=True, max_length=255, verbose_name='Svrha')),
+ ('notes', models.TextField(blank=True, verbose_name='Bilješke')),
+ ('status', models.CharField(choices=[('open', 'Otvoren'), ('closed', 'Zatvoren')], default='open', max_length=20, verbose_name='Status')),
+ ('creator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_work_orders', to=settings.AUTH_USER_MODEL, verbose_name='Kreirao')),
+ ('vehicle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='work_orders', to='fleet.vehicle', verbose_name='Vozilo')),
+ ],
+ options={
+ 'verbose_name': 'Putni nalog',
+ 'verbose_name_plural': 'Putni nalozi',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/0002_alter_vehicle_assigned_servicer.py b/backend/modules/fleet/migrations/0002_alter_vehicle_assigned_servicer.py
new file mode 100644
index 0000000..c024ff2
--- /dev/null
+++ b/backend/modules/fleet/migrations/0002_alter_vehicle_assigned_servicer.py
@@ -0,0 +1,21 @@
+# Generated by Django 5.2.15 on 2026-07-03 02:15
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0001_initial'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='vehicle',
+ name='assigned_servicer',
+ field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_vehicles', to=settings.AUTH_USER_MODEL, verbose_name='Serviser'),
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/0003_add_is_company_vehicle_and_client_fk.py b/backend/modules/fleet/migrations/0003_add_is_company_vehicle_and_client_fk.py
new file mode 100644
index 0000000..81a6645
--- /dev/null
+++ b/backend/modules/fleet/migrations/0003_add_is_company_vehicle_and_client_fk.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.2.15 on 2026-07-06 08:28
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('crm', '0001_initial'),
+ ('fleet', '0002_alter_vehicle_assigned_servicer'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vehicle',
+ name='client',
+ field=models.ForeignKey(blank=True, help_text='Popunite samo za vozila kupaca (is_company_vehicle = Ne).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vehicles', to='crm.client', verbose_name='Kupac'),
+ ),
+ migrations.AddField(
+ model_name='vehicle',
+ name='is_company_vehicle',
+ field=models.BooleanField(default=True, help_text='Označite ako vozilo pripada našoj tvrtki. Poništite za vozila kupaca.', verbose_name='Vozilo tvrtke'),
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/0004_vehicleserviceattachment.py b/backend/modules/fleet/migrations/0004_vehicleserviceattachment.py
new file mode 100644
index 0000000..631d085
--- /dev/null
+++ b/backend/modules/fleet/migrations/0004_vehicleserviceattachment.py
@@ -0,0 +1,36 @@
+# Generated by Django 5.2.15 on 2026-07-06 11:45
+
+import django.db.models.deletion
+import modules.fleet.models
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('fleet', '0003_add_is_company_vehicle_and_client_fk'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='VehicleServiceAttachment',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
+ ('file', models.FileField(upload_to=modules.fleet.models._service_attachment_upload_to, verbose_name='Datoteka')),
+ ('description', models.CharField(blank=True, max_length=255, verbose_name='Opis')),
+ ('is_active', models.BooleanField(default=True, verbose_name='Aktivan')),
+ ('service_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='fleet.vehicleservicerecord', verbose_name='Servisni zapis')),
+ ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_service_attachments', to=settings.AUTH_USER_MODEL, verbose_name='Postavio')),
+ ],
+ options={
+ 'verbose_name': 'Prilog servisa',
+ 'verbose_name_plural': 'Prilozi servisa',
+ 'ordering': ['-created_at'],
+ },
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/0005_vehiclenotification_metadata.py b/backend/modules/fleet/migrations/0005_vehiclenotification_metadata.py
new file mode 100644
index 0000000..419c57b
--- /dev/null
+++ b/backend/modules/fleet/migrations/0005_vehiclenotification_metadata.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.15 on 2026-07-06 13:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0004_vehicleserviceattachment'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vehiclenotification',
+ name='metadata',
+ field=models.JSONField(blank=True, default=dict, verbose_name='Metapodaci'),
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/0006_crane_proxy_model.py b/backend/modules/fleet/migrations/0006_crane_proxy_model.py
new file mode 100644
index 0000000..0f9ad89
--- /dev/null
+++ b/backend/modules/fleet/migrations/0006_crane_proxy_model.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.2.15 on 2026-07-06 13:35
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0005_vehiclenotification_metadata'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Crane',
+ fields=[],
+ options={
+ 'verbose_name': 'Dizalica',
+ 'verbose_name_plural': 'Dizalice',
+ 'ordering': ['registration_number'],
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=('fleet.vehicle',),
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/0007_vehicle_asset_type.py b/backend/modules/fleet/migrations/0007_vehicle_asset_type.py
new file mode 100644
index 0000000..a287a9b
--- /dev/null
+++ b/backend/modules/fleet/migrations/0007_vehicle_asset_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.15 on 2026-07-06 15:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0006_crane_proxy_model'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vehicle',
+ name='asset_type',
+ field=models.CharField(choices=[('vehicle', 'Vozilo'), ('crane', 'Dizalica')], db_index=True, default='crane', max_length=20, verbose_name='Tip sredstva'),
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/0008_backfill_asset_type_vehicle.py b/backend/modules/fleet/migrations/0008_backfill_asset_type_vehicle.py
new file mode 100644
index 0000000..832e67e
--- /dev/null
+++ b/backend/modules/fleet/migrations/0008_backfill_asset_type_vehicle.py
@@ -0,0 +1,34 @@
+# Generated by Django 5.2.15 on 2026-07-06 13:55
+
+from django.db import migrations
+
+
+def backfill_asset_type_vehicle(apps, schema_editor):
+ Vehicle = apps.get_model('fleet', 'Vehicle')
+ WorkOrder = apps.get_model('fleet', 'WorkOrder')
+
+ work_order_vehicle_ids = WorkOrder.objects.values_list('vehicle_id', flat=True)
+ service_vehicle_ids = (
+ Vehicle.objects
+ .filter(
+ assigned_servicer__isnull=False,
+ is_company_vehicle=True,
+ client__isnull=True,
+ )
+ .exclude(id__in=work_order_vehicle_ids)
+ .values_list('id', flat=True)
+ )
+
+ Vehicle.objects.filter(id__in=service_vehicle_ids).update(asset_type='vehicle')
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0007_vehicle_asset_type'),
+ ]
+
+ operations = [
+ migrations.RunPython(backfill_asset_type_vehicle, migrations.RunPython.noop),
+ ]
+
diff --git a/backend/modules/fleet/migrations/0009_workorder_location_and_travel_times.py b/backend/modules/fleet/migrations/0009_workorder_location_and_travel_times.py
new file mode 100644
index 0000000..06b9d37
--- /dev/null
+++ b/backend/modules/fleet/migrations/0009_workorder_location_and_travel_times.py
@@ -0,0 +1,29 @@
+# Generated by Django 5.2.15 on 2026-07-07 17:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0008_backfill_asset_type_vehicle'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='workorder',
+ name='location',
+ field=models.CharField(blank=True, max_length=255, verbose_name='Lokacija odredišta'),
+ ),
+ migrations.AddField(
+ model_name='workorder',
+ name='travel_end_at',
+ field=models.DateTimeField(blank=True, null=True, verbose_name='Kraj puta'),
+ ),
+ migrations.AddField(
+ model_name='workorder',
+ name='travel_start_at',
+ field=models.DateTimeField(blank=True, null=True, verbose_name='Početak puta'),
+ ),
+ ]
+
diff --git a/backend/modules/fleet/migrations/0010_service_record_work_order_and_optional_mileage.py b/backend/modules/fleet/migrations/0010_service_record_work_order_and_optional_mileage.py
new file mode 100644
index 0000000..e5df8d1
--- /dev/null
+++ b/backend/modules/fleet/migrations/0010_service_record_work_order_and_optional_mileage.py
@@ -0,0 +1,42 @@
+# Generated by Django 5.2.15 on 2026-07-07 17:31
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def assign_work_order_from_vehicle(apps, schema_editor):
+ WorkOrder = apps.get_model('fleet', 'WorkOrder')
+ VehicleServiceRecord = apps.get_model('fleet', 'VehicleServiceRecord')
+
+ for record in VehicleServiceRecord.objects.filter(work_order__isnull=True).iterator():
+ work_order = (
+ WorkOrder.objects
+ .filter(vehicle_id=record.vehicle_id, is_active=True)
+ .order_by('-created_at')
+ .first()
+ )
+ if work_order is not None:
+ record.work_order_id = work_order.id
+ record.save(update_fields=['work_order'])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0009_workorder_location_and_travel_times'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vehicleservicerecord',
+ name='work_order',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='service_records', to='fleet.workorder', verbose_name='Radni zadatak'),
+ ),
+ migrations.AlterField(
+ model_name='vehicleservicerecord',
+ name='mileage',
+ field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Kilometraža pri servisu'),
+ ),
+ migrations.RunPython(assign_work_order_from_vehicle, migrations.RunPython.noop),
+ ]
+
diff --git a/backend/modules/fleet/migrations/0011_workorder_start_mileage_optional.py b/backend/modules/fleet/migrations/0011_workorder_start_mileage_optional.py
new file mode 100644
index 0000000..ec31f75
--- /dev/null
+++ b/backend/modules/fleet/migrations/0011_workorder_start_mileage_optional.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.2.15 on 2026-07-07 17:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0010_service_record_work_order_and_optional_mileage'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='workorder',
+ name='start_mileage',
+ field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Početna kilometraža'),
+ ),
+ ]
+
diff --git a/backend/modules/fleet/migrations/0012_workorder_purpose_choices.py b/backend/modules/fleet/migrations/0012_workorder_purpose_choices.py
new file mode 100644
index 0000000..eae24c0
--- /dev/null
+++ b/backend/modules/fleet/migrations/0012_workorder_purpose_choices.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.2.15 on 2026-07-07 17:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0011_workorder_start_mileage_optional'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='workorder',
+ name='purpose',
+ field=models.CharField(blank=True, choices=[('defektaza', 'Defektaža'), ('kontrola', 'Kontrola'), ('redovni_pregled', 'Redovni pregled')], max_length=50, verbose_name='Svrha'),
+ ),
+ ]
+
diff --git a/backend/modules/fleet/migrations/0013_vehicleservicerecord_task.py b/backend/modules/fleet/migrations/0013_vehicleservicerecord_task.py
new file mode 100644
index 0000000..13a7dfd
--- /dev/null
+++ b/backend/modules/fleet/migrations/0013_vehicleservicerecord_task.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.2.15 on 2026-07-07
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('task_management', '0002_task_work_order'),
+ ('fleet', '0012_workorder_purpose_choices'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vehicleservicerecord',
+ name='task',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='service_records',
+ to='task_management.task',
+ verbose_name='Task',
+ ),
+ ),
+ ]
+
diff --git a/backend/modules/fleet/migrations/0014_servicerecord_remove_workorder.py b/backend/modules/fleet/migrations/0014_servicerecord_remove_workorder.py
new file mode 100644
index 0000000..20a7cc5
--- /dev/null
+++ b/backend/modules/fleet/migrations/0014_servicerecord_remove_workorder.py
@@ -0,0 +1,29 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0013_vehicleservicerecord_task'),
+ ('task_management', '0003_task_vehicle'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='vehicleservicerecord',
+ name='work_order',
+ ),
+ migrations.AlterField(
+ model_name='vehicleservicerecord',
+ name='task',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='service_records',
+ to='task_management.task',
+ verbose_name='Radni zadatak',
+ ),
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/0015_workorderinvoice.py b/backend/modules/fleet/migrations/0015_workorderinvoice.py
new file mode 100644
index 0000000..fd34114
--- /dev/null
+++ b/backend/modules/fleet/migrations/0015_workorderinvoice.py
@@ -0,0 +1,47 @@
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import uuid
+
+
+def work_order_invoice_upload_to(instance, filename):
+ vehicle_reg = 'unknown'
+ try:
+ vehicle_reg = instance.work_order.vehicle.registration_number
+ except Exception:
+ vehicle_reg = 'unknown'
+ unique_name = f"{uuid.uuid4().hex}_{filename}"
+ return f"fleet/{vehicle_reg}/work_order_invoices/{unique_name}"
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0014_servicerecord_remove_workorder'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='WorkOrderInvoice',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('naziv_racuna', models.CharField(max_length=255, verbose_name='Naziv računa')),
+ ('lokacija', models.CharField(blank=True, max_length=255, verbose_name='Lokacija')),
+ ('datum', models.DateField(default=django.utils.timezone.now, verbose_name='Datum')),
+ ('opis', models.TextField(blank=True, verbose_name='Opis')),
+ ('image', models.ImageField(blank=True, null=True, upload_to=work_order_invoice_upload_to, verbose_name='Slika računa')),
+ ('is_active', models.BooleanField(default=True, verbose_name='Aktivan')),
+ ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_work_order_invoices', to=settings.AUTH_USER_MODEL, verbose_name='Kreirao')),
+ ('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='fleet.workorder', verbose_name='Putni nalog')),
+ ],
+ options={
+ 'verbose_name': 'Račun putnog naloga',
+ 'verbose_name_plural': 'Računi putnog naloga',
+ 'ordering': ['-datum', '-created_at'],
+ },
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/0016_workorder_servicer_vehicle_fields.py b/backend/modules/fleet/migrations/0016_workorder_servicer_vehicle_fields.py
new file mode 100644
index 0000000..decebd6
--- /dev/null
+++ b/backend/modules/fleet/migrations/0016_workorder_servicer_vehicle_fields.py
@@ -0,0 +1,36 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0015_workorderinvoice'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='workorder',
+ name='servicer_vehicle_end_mileage',
+ field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Vozilo servisera - završna kilometraža'),
+ ),
+ migrations.AddField(
+ model_name='workorder',
+ name='servicer_vehicle_fuel_cost',
+ field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Vozilo servisera - trošak goriva (EUR)'),
+ ),
+ migrations.AddField(
+ model_name='workorder',
+ name='servicer_vehicle_make_model',
+ field=models.CharField(blank=True, max_length=120, verbose_name='Vozilo servisera - marka/model'),
+ ),
+ migrations.AddField(
+ model_name='workorder',
+ name='servicer_vehicle_registration',
+ field=models.CharField(blank=True, max_length=20, verbose_name='Vozilo servisera - registracija'),
+ ),
+ migrations.AddField(
+ model_name='workorder',
+ name='servicer_vehicle_start_mileage',
+ field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Vozilo servisera - početna kilometraža'),
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/0017_vehicle_crane_serial_number.py b/backend/modules/fleet/migrations/0017_vehicle_crane_serial_number.py
new file mode 100644
index 0000000..474e803
--- /dev/null
+++ b/backend/modules/fleet/migrations/0017_vehicle_crane_serial_number.py
@@ -0,0 +1,23 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0016_workorder_servicer_vehicle_fields'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vehicle',
+ name='crane_serial_number',
+ field=models.CharField(
+ blank=True,
+ db_index=True,
+ help_text='Serijski broj dizalice mora imati točno 6 znamenki.',
+ max_length=6,
+ null=True,
+ verbose_name='Serijski broj dizalice',
+ ),
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/0018_vehicle_chassis_working_hours_and_more.py b/backend/modules/fleet/migrations/0018_vehicle_chassis_working_hours_and_more.py
new file mode 100644
index 0000000..c88c814
--- /dev/null
+++ b/backend/modules/fleet/migrations/0018_vehicle_chassis_working_hours_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2.15 on 2026-07-09 12:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0017_vehicle_crane_serial_number'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vehicle',
+ name='chassis_working_hours',
+ field=models.PositiveIntegerField(default=0, verbose_name='Radni sati podvozja'),
+ ),
+ migrations.AddField(
+ model_name='vehicle',
+ name='superstructure_working_hours',
+ field=models.PositiveIntegerField(default=0, verbose_name='Radni sati nadogradnje'),
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/0019_vehicleservicerecord_service_title.py b/backend/modules/fleet/migrations/0019_vehicleservicerecord_service_title.py
new file mode 100644
index 0000000..a1c7712
--- /dev/null
+++ b/backend/modules/fleet/migrations/0019_vehicleservicerecord_service_title.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0018_vehicle_chassis_working_hours_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vehicleservicerecord',
+ name='service_title',
+ field=models.CharField(blank=True, default='', max_length=255, verbose_name='Naziv servisnog zapisa'),
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/0020_alter_workorderinvoice_created_at_and_more.py b/backend/modules/fleet/migrations/0020_alter_workorderinvoice_created_at_and_more.py
new file mode 100644
index 0000000..aa4c4de
--- /dev/null
+++ b/backend/modules/fleet/migrations/0020_alter_workorderinvoice_created_at_and_more.py
@@ -0,0 +1,35 @@
+# Generated by Django 5.2.15 on 2026-07-09 16:41
+
+import modules.fleet.models
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0019_vehicleservicerecord_service_title'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='workorderinvoice',
+ name='created_at',
+ field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
+ ),
+ migrations.AlterField(
+ model_name='workorderinvoice',
+ name='id',
+ field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False),
+ ),
+ migrations.AlterField(
+ model_name='workorderinvoice',
+ name='image',
+ field=models.ImageField(blank=True, null=True, upload_to=modules.fleet.models._work_order_invoice_upload_to, verbose_name='Slika računa'),
+ ),
+ migrations.AlterField(
+ model_name='workorderinvoice',
+ name='updated_at',
+ field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/__init__.py b/backend/modules/fleet/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py
new file mode 100644
index 0000000..875c0a4
--- /dev/null
+++ b/backend/modules/fleet/models.py
@@ -0,0 +1,433 @@
+import uuid
+import re
+from django.db import models
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext_lazy as _
+from django.utils import timezone
+from core.base_models import BaseModel
+from decimal import Decimal
+
+def _service_photo_upload_to(instance, filename):
+ """
+ Putanja za upload:
+ fleet/{registration_number}/service_photos/{uuid4}_{original_filename}
+ Koristimo uuid jer instance.service_record.pk možda nije dostupna prije prvog save-a.
+ """
+ vehicle_reg = 'unknown'
+ try:
+ vehicle_reg = instance.service_record.vehicle.registration_number
+ except Exception:
+ # fallback
+ vehicle_reg = 'unknown'
+ unique_name = f"{uuid.uuid4().hex}_{filename}"
+ return f"fleet/{vehicle_reg}/service_photos/{unique_name}"
+
+def _service_attachment_upload_to(instance, filename):
+ """
+ Putanja za upload:
+ fleet/{registration_number}/service_attachments/{uuid4}_{original_filename}
+ """
+ vehicle_reg = 'unknown'
+ try:
+ vehicle_reg = instance.service_record.vehicle.registration_number
+ except Exception:
+ vehicle_reg = 'unknown'
+ unique_name = f"{uuid.uuid4().hex}_{filename}"
+ return f"fleet/{vehicle_reg}/service_attachments/{unique_name}"
+
+def _work_order_invoice_upload_to(instance, filename):
+ vehicle_reg = 'unknown'
+ try:
+ vehicle_reg = instance.work_order.vehicle.registration_number
+ except Exception:
+ vehicle_reg = 'unknown'
+ unique_name = f"{uuid.uuid4().hex}_{filename}"
+ return f"fleet/{vehicle_reg}/work_order_invoices/{unique_name}"
+
+
+class VehicleServicePhoto(BaseModel):
+ """
+ Fotografije kvarova / radova povezane s VehicleServiceRecord.
+ Pohranjuju se slike i opcionalni opis te tko ih je upload-ao.
+ """
+ service_record = models.ForeignKey(
+ 'fleet.VehicleServiceRecord',
+ on_delete=models.CASCADE,
+ related_name='photos',
+ verbose_name=_("Servisni zapis")
+ )
+ image = models.ImageField(
+ upload_to=_service_photo_upload_to,
+ verbose_name=_("Fotografija")
+ )
+ description = models.CharField(max_length=255, blank=True, verbose_name=_("Opis"))
+ uploaded_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name='uploaded_service_photos',
+ verbose_name=_("Postavio")
+ )
+ is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
+
+ class Meta:
+ verbose_name = _("Fotografija servisa")
+ verbose_name_plural = _("Fotografije servisa")
+ ordering = ['-created_at']
+
+ def __str__(self):
+ return f"Photo {self.pk} for {self.service_record} ({self.image.name if self.image else 'no-image'})"
+
+class VehicleServiceAttachment(BaseModel):
+ """
+ Datoteke (PDF, DOC, XLS...) povezane sa servisnim zapisom.
+ """
+ service_record = models.ForeignKey(
+ 'fleet.VehicleServiceRecord',
+ on_delete=models.CASCADE,
+ related_name='attachments',
+ verbose_name=_("Servisni zapis")
+ )
+ file = models.FileField(
+ upload_to=_service_attachment_upload_to,
+ verbose_name=_("Datoteka")
+ )
+ description = models.CharField(max_length=255, blank=True, verbose_name=_("Opis"))
+ uploaded_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name='uploaded_service_attachments',
+ verbose_name=_("Postavio")
+ )
+ is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
+
+ class Meta:
+ verbose_name = _("Prilog servisa")
+ verbose_name_plural = _("Prilozi servisa")
+ ordering = ['-created_at']
+
+ def __str__(self):
+ return f"Attachment {self.pk} for {self.service_record} ({self.file.name if self.file else 'no-file'})"
+
+class Vehicle(BaseModel):
+ ASSET_TYPE_CHOICES = [
+ ('vehicle', _("Vozilo")),
+ ('crane', _("Dizalica")),
+ ]
+
+ asset_type = models.CharField(
+ max_length=20,
+ choices=ASSET_TYPE_CHOICES,
+ default='crane',
+ db_index=True,
+ verbose_name=_("Tip sredstva"),
+ )
+ registration_number = models.CharField(max_length=20, unique=True, verbose_name=_("Registracija"))
+ make = models.CharField(max_length=100, blank=True, verbose_name=_("Proizvođač"))
+ model = models.CharField(max_length=100, blank=True, verbose_name=_("Model"))
+ year = models.PositiveSmallIntegerField(null=True, blank=True, verbose_name=_("Godina"))
+ vin = models.CharField(max_length=50, blank=True, null=True, unique=True, verbose_name=_("VIN"))
+ crane_serial_number = models.CharField(
+ max_length=6,
+ null=True,
+ blank=True,
+ db_index=True,
+ verbose_name=_("Serijski broj dizalice"),
+ help_text=_("Serijski broj dizalice mora imati točno 6 znamenki."),
+ )
+ superstructure_working_hours = models.PositiveIntegerField(
+ default=0,
+ verbose_name=_("Radni sati nadogradnje"),
+ )
+ chassis_working_hours = models.PositiveIntegerField(
+ default=0,
+ verbose_name=_("Radni sati podvozja"),
+ )
+ current_mileage = models.PositiveIntegerField(default=0, verbose_name=_("Trenutna kilometraža (km)"))
+ service_interval_km = models.PositiveIntegerField(default=15000, verbose_name=_("Interval servisa (km)"))
+ assigned_servicer = models.OneToOneField(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.PROTECT,
+ related_name='assigned_vehicles',
+ null=True,
+ blank=True,
+ verbose_name=_("Serviser")
+ )
+ # Označava je li vozilo u vlasništvu naše tvrtke (fleet) ili kupca
+ is_company_vehicle = models.BooleanField(
+ default=True,
+ verbose_name=_("Vozilo tvrtke"),
+ help_text=_("Označite ako vozilo pripada našoj tvrtki. Poništite za vozila kupaca.")
+ )
+ # Kupac kojemu pripada vozilo (samo za vozila kupaca)
+ client = models.ForeignKey(
+ 'crm.Client',
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='vehicles',
+ verbose_name=_("Kupac"),
+ help_text=_("Popunite samo za vozila kupaca (is_company_vehicle = Ne).")
+ )
+ is_active = models.BooleanField(default=True, verbose_name=_("Aktivno"))
+
+ class Meta:
+ verbose_name = _("Vozilo")
+ verbose_name_plural = _("Vozila")
+ ordering = ['registration_number']
+
+ def __str__(self):
+ return f"{self.registration_number} - {self.make} {self.model}"
+
+ def clean(self):
+ super().clean()
+ if self.asset_type == 'crane':
+ if not self.crane_serial_number and self._state.adding:
+ raise ValidationError({'crane_serial_number': _("Serijski broj dizalice je obavezan za dizalice.")})
+ if self.crane_serial_number and not re.fullmatch(r"[0-9]{6}", str(self.crane_serial_number or '')):
+ raise ValidationError({'crane_serial_number': _("Serijski broj dizalice mora sadržavati točno 6 znamenki.")})
+ elif self.crane_serial_number:
+ if not re.fullmatch(r"[0-9]{6}", str(self.crane_serial_number or '')):
+ raise ValidationError({'crane_serial_number': _("Serijski broj dizalice mora sadržavati točno 6 znamenki.")})
+
+ def next_service_at(self) -> int:
+ """
+ Vrati kilometražu pri kojoj sljedeći servis pada, bazirano na trenutnoj kilometraži i intervalu.
+ Npr. current=12000, interval=15000 -> next_service_at = 15000
+ Npr. current=32000, interval=15000 -> next_service_at = 45000
+ """
+ if self.service_interval_km <= 0:
+ return 0
+ blocks = (self.current_mileage // self.service_interval_km) + 1
+ return blocks * self.service_interval_km
+
+
+class Crane(Vehicle):
+ class Meta:
+ proxy = True
+ verbose_name = _("Dizalica")
+ verbose_name_plural = _("Dizalice")
+ ordering = ['registration_number']
+
+ def __str__(self):
+ details = " ".join(part for part in [self.make, self.model] if part)
+ if details:
+ return f"{self.registration_number} - {details}"
+ return self.registration_number
+
+class WorkOrder(BaseModel):
+ """
+ Putni nalog / radni nalog koji prati kilometražu vozila tijekom zadatka na terenu.
+ Kad se postavi end_mileage, model ažurira vehicle.current_mileage ako je end_mileage veći.
+ """
+ STATUS_CHOICES = [
+ ('open', 'Otvoren'),
+ ('closed', 'Zatvoren'),
+ ]
+ PURPOSE_CHOICES = [
+ ('defektaza', _("Defektaža")),
+ ('kontrola', _("Kontrola")),
+ ('redovni_pregled', _("Redovni pregled")),
+ ]
+
+ vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='work_orders', verbose_name=_("Vozilo"))
+ creator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='created_work_orders', verbose_name=_("Kreirao"))
+ date = models.DateField(auto_now_add=True, verbose_name=_("Datum"))
+ start_mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Početna kilometraža"))
+ end_mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Završna kilometraža"))
+ servicer_vehicle_registration = models.CharField(
+ max_length=20,
+ blank=True,
+ verbose_name=_("Vozilo servisera - registracija"),
+ )
+ servicer_vehicle_make_model = models.CharField(
+ max_length=120,
+ blank=True,
+ verbose_name=_("Vozilo servisera - marka/model"),
+ )
+ servicer_vehicle_start_mileage = models.PositiveIntegerField(
+ null=True,
+ blank=True,
+ verbose_name=_("Vozilo servisera - početna kilometraža"),
+ )
+ servicer_vehicle_end_mileage = models.PositiveIntegerField(
+ null=True,
+ blank=True,
+ verbose_name=_("Vozilo servisera - završna kilometraža"),
+ )
+ servicer_vehicle_fuel_cost = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ null=True,
+ blank=True,
+ verbose_name=_("Vozilo servisera - trošak goriva (EUR)"),
+ )
+ location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija odredišta"))
+ travel_start_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Početak puta"))
+ travel_end_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Kraj puta"))
+ purpose = models.CharField(max_length=50, choices=PURPOSE_CHOICES, blank=True, verbose_name=_("Svrha"))
+ notes = models.TextField(blank=True, verbose_name=_("Bilješke"))
+ status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open', verbose_name=_("Status"))
+
+ class Meta:
+ verbose_name = _("Putni nalog")
+ verbose_name_plural = _("Putni nalozi")
+ ordering = ['-created_at']
+
+ def __str__(self):
+ return f"WO-{self.pk} {self.vehicle.registration_number} ({self.status})"
+
+ @property
+ def distance(self):
+ if self.end_mileage is None or self.start_mileage is None:
+ return None
+ return max(0, self.end_mileage - self.start_mileage)
+
+ def save(self, *args, **kwargs):
+ self.full_clean() # Provjera validnosti prije svega
+ updating_vehicle = False
+ if self.end_mileage is not None:
+ # Ako je end_mileage veći od trenutne vehicle.current_mileage, ažuriraj vozilo.
+ if self.end_mileage > (self.vehicle.current_mileage or 0):
+ self.vehicle.current_mileage = self.end_mileage
+ updating_vehicle = True
+
+ super().save(*args, **kwargs)
+
+ if updating_vehicle:
+ # Spremi vozilo (nakon što nalog ima PK)
+ self.vehicle.save()
+
+ def clean(self):
+ super().clean()
+ if self.travel_start_at and self.travel_end_at and self.travel_end_at < self.travel_start_at:
+ raise ValidationError({"travel_end_at": _("Kraj puta ne može biti prije početka puta.")})
+
+
+class WorkOrderInvoice(BaseModel):
+ work_order = models.ForeignKey(
+ WorkOrder,
+ on_delete=models.CASCADE,
+ related_name='invoices',
+ verbose_name=_("Putni nalog"),
+ )
+ naziv_racuna = models.CharField(max_length=255, verbose_name=_("Naziv računa"))
+ lokacija = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija"))
+ datum = models.DateField(default=timezone.now, verbose_name=_("Datum"))
+ opis = models.TextField(blank=True, verbose_name=_("Opis"))
+ image = models.ImageField(
+ upload_to=_work_order_invoice_upload_to,
+ null=True,
+ blank=True,
+ verbose_name=_("Slika računa"),
+ )
+ created_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name='created_work_order_invoices',
+ verbose_name=_("Kreirao"),
+ )
+ is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
+
+ class Meta:
+ verbose_name = _("Račun putnog naloga")
+ verbose_name_plural = _("Računi putnog naloga")
+ ordering = ['-datum', '-created_at']
+
+ def __str__(self):
+ return f"{self.naziv_racuna} ({self.work_order_id})"
+
+
+class VehicleServiceRecord(BaseModel):
+ """
+ Zapisi o servisima/popravcima na vozilu.
+ """
+ vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='service_records', verbose_name=_("Vozilo"))
+ task = models.ForeignKey(
+ 'task_management.Task',
+ on_delete=models.SET_NULL,
+ related_name='service_records',
+ null=True,
+ blank=True,
+ verbose_name=_("Radni zadatak"),
+ )
+ performed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='performed_vehicle_services', verbose_name=_("Izvršio"))
+ service_title = models.CharField(max_length=255, blank=True, default='', verbose_name=_("Naziv servisnog zapisa"))
+ description = models.TextField(verbose_name=_("Opis posla"))
+ parts = models.TextField(blank=True, verbose_name=_("Korišteni dijelovi"))
+ cost = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'), verbose_name=_("Trošak"))
+ service_date = models.DateField(auto_now_add=True, verbose_name=_("Datum servisa"))
+ mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Kilometraža pri servisu"))
+ next_service_due_at = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Kilometraža sljedećeg servisa"))
+
+ class Meta:
+ verbose_name = _("Servisni zapis")
+ verbose_name_plural = _("Servisni zapisi")
+ ordering = ['-service_date']
+
+ def __str__(self):
+ mileage_display = self.mileage if self.mileage is not None else "-"
+ title = (self.service_title or '').strip()
+ if title:
+ return f"{title} ({self.vehicle.registration_number} @ {mileage_display})"
+ return f"Service {self.vehicle.registration_number} @ {mileage_display}"
+
+ def clean(self):
+ super().clean()
+ if self.task_id and self.vehicle_id:
+ task = self.task
+ if task.vehicle_id and task.vehicle_id != self.vehicle_id:
+ raise ValidationError({'task': _("Radni zadatak mora biti povezan s istom dizalicom kao servisni zapis.")})
+
+ def save(self, *args, **kwargs):
+ self.full_clean() # Provjera validnosti prije svega
+ # Ako nije zadan next_service_due_at, postavi prema vehicle.service_interval_km
+ if self.next_service_due_at is None and self.mileage is not None and self.vehicle and self.vehicle.service_interval_km:
+ self.next_service_due_at = self.mileage + self.vehicle.service_interval_km
+
+ # VAŽNO: ažuriraj vehicle.current_mileage PRIJE save-a kako bi refresh-om nakon toga korisnik vidio svježe podatke
+ if self.mileage is not None and self.mileage > (self.vehicle.current_mileage or 0):
+ self.vehicle.current_mileage = self.mileage
+ self.vehicle.save(update_fields=['current_mileage'])
+
+ super().save(*args, **kwargs)
+
+class VehicleNotification(BaseModel):
+ """
+ Jednostavni zapis notifikacije vezane uz fleet objekte.
+ Može se koristiti za in-app obavijesti ili kao log koji je poslan.
+ """
+ LEVEL_CHOICES = [
+ ('info', 'Info'),
+ ('warning', 'Upozorenje'),
+ ('critical', 'Kritično'),
+ ]
+
+ recipient = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name='vehicle_notifications',
+ verbose_name=_("Primatelj")
+ )
+ title = models.CharField(max_length=255, verbose_name=_("Naslov"))
+ message = models.TextField(verbose_name=_("Poruka"))
+ metadata = models.JSONField(default=dict, blank=True, verbose_name=_("Metapodaci"))
+ level = models.CharField(max_length=20, choices=LEVEL_CHOICES, default='info', verbose_name=_("Razina"))
+ is_sent = models.BooleanField(default=False, verbose_name=_("Poslano (e-mail)"))
+ is_read = models.BooleanField(default=False, verbose_name=_("Pročitano"))
+
+ class Meta:
+ verbose_name = _("Notifikacija za vozilo")
+ verbose_name_plural = _("Notifikacije za vozila")
+ ordering = ['-created_at']
+
+ def __str__(self):
+ return f"{self.title} -> {self.recipient} ({self.level})"
\ No newline at end of file
diff --git a/backend/modules/fleet/serializers.py b/backend/modules/fleet/serializers.py
new file mode 100644
index 0000000..5a72815
--- /dev/null
+++ b/backend/modules/fleet/serializers.py
@@ -0,0 +1,314 @@
+from rest_framework import serializers
+import re
+from .models import (
+ Crane,
+ Vehicle,
+ VehicleNotification,
+ WorkOrder,
+ WorkOrderInvoice,
+ VehicleServiceRecord,
+ VehicleServicePhoto,
+ VehicleServiceAttachment,
+)
+from . import services
+
+
+class VehicleSerializer(serializers.ModelSerializer):
+ # Čitljivo ime klijenta — read-only, za prikaz u frontendu i adminu
+ client_name = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Vehicle
+ fields = [
+ 'id', 'asset_type', 'registration_number', 'make', 'model', 'year', 'vin',
+ 'crane_serial_number',
+ 'superstructure_working_hours', 'chassis_working_hours',
+ 'current_mileage', 'service_interval_km',
+ 'is_company_vehicle', 'client', 'client_name',
+ 'assigned_servicer', 'is_active',
+ ]
+ read_only_fields = ['id', 'current_mileage', 'client_name']
+
+ def get_client_name(self, obj):
+ if obj.client_id:
+ return obj.client.name
+ return None
+
+ def create(self, validated_data):
+ validated_data.setdefault('asset_type', 'vehicle')
+ return services.VehicleService.create_vehicle(validated_data)
+
+ def update(self, instance, validated_data):
+ validated_data.pop('asset_type', None)
+ return services.VehicleService.update_vehicle(instance, validated_data)
+
+ def validate_crane_serial_number(self, value):
+ if value in (None, ''):
+ return None
+ normalized = str(value).strip()
+ if not re.fullmatch(r"[0-9]{6}", normalized):
+ raise serializers.ValidationError('Serijski broj dizalice mora sadržavati točno 6 znamenki.')
+ return normalized
+
+ def validate(self, attrs):
+ asset_type = attrs.get('asset_type', getattr(self.instance, 'asset_type', None))
+ if asset_type is None and getattr(self.Meta, 'model', None) is Crane:
+ asset_type = 'crane'
+ serial = attrs.get('crane_serial_number', getattr(self.instance, 'crane_serial_number', None))
+ is_create = self.instance is None
+ if asset_type == 'crane' and not serial and is_create:
+ raise serializers.ValidationError({
+ 'crane_serial_number': 'Serijski broj dizalice je obavezan za dizalice.'
+ })
+ return attrs
+
+class CraneSerializer(VehicleSerializer):
+ display_name = serializers.SerializerMethodField()
+
+ class Meta(VehicleSerializer.Meta):
+ model = Crane
+ fields = VehicleSerializer.Meta.fields + ['display_name']
+ read_only_fields = VehicleSerializer.Meta.read_only_fields + ['display_name']
+
+ def get_display_name(self, obj):
+ details = " ".join(part for part in [obj.make, obj.model] if part)
+ return f"{obj.registration_number} {details}".strip()
+
+ def create(self, validated_data):
+ validated_data['asset_type'] = 'crane'
+ return services.VehicleService.create_vehicle(validated_data)
+
+ def update(self, instance, validated_data):
+ validated_data.pop('asset_type', None)
+ return services.VehicleService.update_vehicle(instance, validated_data)
+
+class VehicleNotificationSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VehicleNotification
+ fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'is_read', 'created_at']
+ read_only_fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'created_at']
+ # is_read is writable via the "mark_read" action, but normally read-only in list
+
+class WorkOrderSerializer(serializers.ModelSerializer):
+ crane = serializers.SerializerMethodField()
+ crane_label = serializers.SerializerMethodField()
+
+ class Meta:
+ model = WorkOrder
+ fields = [
+ 'id', 'vehicle', 'crane', 'crane_label', 'creator', 'date',
+ 'start_mileage', 'end_mileage',
+ 'servicer_vehicle_registration', 'servicer_vehicle_make_model',
+ 'servicer_vehicle_start_mileage', 'servicer_vehicle_end_mileage',
+ 'servicer_vehicle_fuel_cost',
+ 'location', 'travel_start_at', 'travel_end_at',
+ 'purpose', 'notes', 'status'
+ ]
+ read_only_fields = ['id', 'date']
+ extra_kwargs = {
+ 'vehicle': {'required': False},
+ 'creator': {'required': False},
+ 'start_mileage': {'required': False, 'allow_null': True},
+ 'end_mileage': {'required': False, 'allow_null': True},
+ 'servicer_vehicle_registration': {'required': False, 'allow_blank': True},
+ 'servicer_vehicle_make_model': {'required': False, 'allow_blank': True},
+ 'servicer_vehicle_start_mileage': {'required': False, 'allow_null': True},
+ 'servicer_vehicle_end_mileage': {'required': False, 'allow_null': True},
+ 'servicer_vehicle_fuel_cost': {'required': False, 'allow_null': True},
+ 'location': {'required': False, 'allow_blank': True},
+ 'travel_start_at': {'required': False, 'allow_null': True},
+ 'travel_end_at': {'required': False, 'allow_null': True},
+ }
+
+ def get_crane(self, obj):
+ return obj.vehicle_id
+
+ def get_crane_label(self, obj):
+ if not obj.vehicle_id:
+ return None
+ details = " ".join(part for part in [obj.vehicle.registration_number, obj.vehicle.make, obj.vehicle.model] if part)
+ return details.strip()
+
+ def create(self, validated_data):
+ request = self.context.get('request')
+ user = getattr(request, 'user', None)
+
+ if not validated_data.get('vehicle'):
+ raise serializers.ValidationError({
+ 'vehicle': 'Odaberite dizalicu za putni nalog iz servisnog konteksta.'
+ })
+ if getattr(validated_data['vehicle'], 'asset_type', None) != 'crane':
+ raise serializers.ValidationError({
+ 'vehicle': 'Za putni nalog morate odabrati dizalicu (asset_type=crane).'
+ })
+
+ if user and 'creator' not in validated_data:
+ validated_data['creator'] = user
+
+ return services.VehicleService.create_work_order(validated_data)
+
+ def update(self, instance, validated_data):
+ return services.VehicleService.update_work_order(instance, validated_data)
+
+ def validate(self, attrs):
+ current = getattr(self, 'instance', None)
+ travel_start_at = attrs.get('travel_start_at', getattr(current, 'travel_start_at', None))
+ travel_end_at = attrs.get('travel_end_at', getattr(current, 'travel_end_at', None))
+ location = attrs.get('location', getattr(current, 'location', None))
+
+ start_mileage = attrs.get('start_mileage', getattr(current, 'start_mileage', None))
+ end_mileage = attrs.get('end_mileage', getattr(current, 'end_mileage', None))
+ servicer_start_mileage = attrs.get('servicer_vehicle_start_mileage', getattr(current, 'servicer_vehicle_start_mileage', None))
+ servicer_end_mileage = attrs.get('servicer_vehicle_end_mileage', getattr(current, 'servicer_vehicle_end_mileage', None))
+ servicer_fuel_cost = attrs.get('servicer_vehicle_fuel_cost', getattr(current, 'servicer_vehicle_fuel_cost', None))
+
+ if (travel_start_at is None) ^ (travel_end_at is None):
+ raise serializers.ValidationError({'travel_end_at': 'Unesite i početak i kraj puta, ili ostavite oba prazna.'})
+ if travel_start_at is not None and not str(location or '').strip():
+ raise serializers.ValidationError({'location': 'Lokacija je obavezna kada je definiran putni nalog.'})
+ if travel_start_at and travel_end_at and travel_end_at < travel_start_at:
+ raise serializers.ValidationError({'travel_end_at': 'Kraj puta ne može biti prije početka puta.'})
+ if end_mileage is not None and start_mileage is None:
+ raise serializers.ValidationError({'start_mileage': 'Početna kilometraža je obavezna ako je upisana završna.'})
+ if start_mileage is not None and end_mileage is not None and end_mileage < start_mileage:
+ raise serializers.ValidationError({'end_mileage': 'Završna kilometraža mora biti veća ili jednaka početnoj.'})
+ if servicer_end_mileage is not None and servicer_start_mileage is None:
+ raise serializers.ValidationError({'servicer_vehicle_start_mileage': 'Početna kilometraža vozila servisera je obavezna ako je upisana završna.'})
+ if servicer_start_mileage is not None and servicer_end_mileage is not None and servicer_end_mileage <= servicer_start_mileage:
+ raise serializers.ValidationError({'servicer_vehicle_end_mileage': 'Završna kilometraža vozila servisera mora biti veća od početne.'})
+ if servicer_fuel_cost is not None and servicer_fuel_cost < 0:
+ raise serializers.ValidationError({'servicer_vehicle_fuel_cost': 'Trošak goriva ne može biti negativan.'})
+ return attrs
+
+
+class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = WorkOrderInvoice
+ fields = [
+ 'id',
+ 'work_order',
+ 'naziv_racuna',
+ 'lokacija',
+ 'datum',
+ 'opis',
+ 'image',
+ 'created_by',
+ 'created_at',
+ ]
+ read_only_fields = ['id', 'created_by', 'created_at']
+
+
+class VehicleServiceRecordSerializer(serializers.ModelSerializer):
+ crane = serializers.SerializerMethodField()
+ crane_registration = serializers.SerializerMethodField()
+ photos_count = serializers.SerializerMethodField()
+ attachments_count = serializers.SerializerMethodField()
+ files_count = serializers.SerializerMethodField()
+ performed_by_name = serializers.SerializerMethodField()
+ vehicle_registration = serializers.SerializerMethodField()
+ task_label = serializers.SerializerMethodField()
+
+ class Meta:
+ model = VehicleServiceRecord
+ fields = [
+ 'id', 'vehicle', 'crane', 'vehicle_registration', 'crane_registration',
+ 'task', 'task_label',
+ 'performed_by', 'performed_by_name',
+ 'service_title', 'description', 'parts', 'cost',
+ 'service_date', 'mileage', 'next_service_due_at',
+ 'photos_count', 'attachments_count', 'files_count',
+ ]
+ read_only_fields = [
+ 'id', 'service_date', 'next_service_due_at',
+ 'photos_count', 'attachments_count', 'files_count',
+ 'performed_by_name', 'vehicle_registration', 'crane', 'crane_registration',
+ 'task_label',
+ ]
+ extra_kwargs = {
+ 'performed_by': {'required': False},
+ 'vehicle': {'required': False},
+ 'mileage': {'required': False, 'allow_null': True},
+ }
+
+ def get_photos_count(self, obj):
+ # prefetch_related('photos') u viewsetu sprječava N+1
+ return obj.photos.count()
+
+ def get_crane(self, obj):
+ return obj.vehicle_id
+
+ def get_attachments_count(self, obj):
+ return obj.attachments.count()
+
+ def get_files_count(self, obj):
+ return self.get_photos_count(obj) + self.get_attachments_count(obj)
+
+ def get_performed_by_name(self, obj):
+ if obj.performed_by_id:
+ return obj.performed_by.get_full_name() or obj.performed_by.username
+ return None
+
+ def get_vehicle_registration(self, obj):
+ if obj.vehicle_id:
+ return obj.vehicle.registration_number
+ return None
+
+ def get_crane_registration(self, obj):
+ return self.get_vehicle_registration(obj)
+
+ def get_task_label(self, obj):
+ if not obj.task_id:
+ return None
+ return obj.task.title
+
+ def create(self, validated_data):
+ request = self.context.get('request')
+ user = getattr(request, 'user', None)
+ task = validated_data.get('task')
+ if not task:
+ raise serializers.ValidationError({
+ 'task': 'Odaberite radni zadatak prije kreiranja servisnog zapisa.'
+ })
+ if not task.vehicle_id:
+ raise serializers.ValidationError({
+ 'task': 'Odabrani radni zadatak nema dodijeljenu dizalicu.'
+ })
+ validated_data['vehicle'] = task.vehicle
+
+ if user and not getattr(user, 'is_staff', False):
+ validated_data['performed_by'] = user
+ elif user and 'performed_by' not in validated_data:
+ validated_data['performed_by'] = user
+ return services.VehicleService.create_service_record(validated_data)
+
+class VehicleServicePhotoSerializer(serializers.ModelSerializer):
+ image = serializers.ImageField(required=True)
+
+ class Meta:
+ model = VehicleServicePhoto
+ fields = ['id', 'service_record', 'image', 'description', 'uploaded_by', 'created_at']
+ read_only_fields = ['id', 'created_at', 'uploaded_by']
+
+ def create(self, validated_data):
+ # Ako postoji request user, postavi uploaded_by
+ request = self.context.get('request') if self.context else None
+ user = getattr(request, 'user', None)
+ if user and 'uploaded_by' not in validated_data:
+ validated_data['uploaded_by'] = user
+ # Kreiranje direktno kroz model je ok; može se delegirati u services ako želiš dodatnu logiku
+ return VehicleServicePhoto.objects.create(**validated_data)
+
+class VehicleServiceAttachmentSerializer(serializers.ModelSerializer):
+ file = serializers.FileField(required=True)
+
+ class Meta:
+ model = VehicleServiceAttachment
+ fields = ['id', 'service_record', 'file', 'description', 'uploaded_by', 'created_at']
+ read_only_fields = ['id', 'created_at', 'uploaded_by']
+
+ def create(self, validated_data):
+ request = self.context.get('request') if self.context else None
+ user = getattr(request, 'user', None)
+ if user and 'uploaded_by' not in validated_data:
+ validated_data['uploaded_by'] = user
+ return VehicleServiceAttachment.objects.create(**validated_data)
\ No newline at end of file
diff --git a/backend/modules/fleet/services.py b/backend/modules/fleet/services.py
new file mode 100644
index 0000000..dfd32bf
--- /dev/null
+++ b/backend/modules/fleet/services.py
@@ -0,0 +1,412 @@
+from django.db import transaction
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from rest_framework.exceptions import ValidationError
+from modules.fleet.tasks import send_email_task
+from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord, VehicleNotification, VehicleServicePhoto
+from infrastructure.pusher_service import PusherService
+import logging
+
+logger = logging.getLogger(__name__)
+
+User = get_user_model()
+
+class NotificationService:
+ @staticmethod
+ def create_notification(recipient=None, title="", message="", level="info", send_email=False, metadata=None):
+ # 1. Kreiraj notifikaciju u DB-u
+ notif = VehicleNotification.objects.create(
+ recipient=recipient,
+ title=title[:255],
+ message=message,
+ metadata=metadata or {},
+ level=level,
+ is_sent=False
+ )
+
+ # 2. Broadcast preko WebSocket (Pusher) tek nakon uspješnog commit-a
+ if recipient:
+ transaction.on_commit(lambda: PusherService.broadcast_notification(
+ user_id=recipient.id,
+ title=title,
+ message=message,
+ level=level,
+ notification_id=notif.id,
+ metadata=metadata
+ ))
+
+ # 3. Slanje emaila ako je traženo
+ if send_email and recipient and getattr(recipient, 'email', None):
+ email_args = {
+ 'recipient_email': recipient.email,
+ 'subject': title,
+ 'message': message,
+ 'notif_id': notif.id
+ }
+
+ if settings.DEBUG or getattr(settings, 'CELERY_TASK_ALWAYS_EAGER', False):
+ send_email_task(**email_args)
+ else:
+ transaction.on_commit(lambda: send_email_task.delay(**email_args))
+
+ return notif
+class VehicleService:
+ @staticmethod
+ def create_vehicle(data: dict) -> Vehicle:
+ vehicle = Vehicle(**data)
+ try:
+ vehicle.full_clean()
+ except Exception as e:
+ raise ValidationError({"detail": str(e)})
+
+ vehicle.save()
+ return vehicle
+
+ @staticmethod
+ def update_vehicle(instance: Vehicle, data: dict) -> Vehicle:
+ for k, v in data.items():
+ setattr(instance, k, v)
+ try:
+ instance.full_clean()
+ except Exception as e:
+ raise ValidationError({"detail": str(e)})
+ instance.save()
+ return instance
+
+ @staticmethod
+ def create_work_order(data: dict) -> WorkOrder:
+ # expecting creator in data
+ wo = WorkOrder(**data)
+ try:
+ wo.full_clean()
+ except Exception as e:
+ raise ValidationError({"detail": str(e)})
+
+ with transaction.atomic():
+ wo.save()
+
+ # obavijesti creatora i serviser-a vozila
+ try:
+ # Notify the creator
+ NotificationService.create_notification(
+ recipient=wo.creator,
+ title=f"Putni nalog kreiran: WO-{wo.pk}",
+ message=f"Putni nalog za dizalicu {wo.vehicle.registration_number} je kreiran.",
+ metadata={
+ "entity_type": "work_order",
+ "work_order_id": str(wo.pk),
+ "vehicle_id": str(wo.vehicle_id),
+ "crane_id": str(wo.vehicle_id),
+ "section": "work-orders",
+ },
+ level="info",
+ send_email=False
+ )
+ # Notify assigned servicer if present
+ servicer = wo.vehicle.assigned_servicer
+ if servicer:
+ NotificationService.create_notification(
+ recipient=servicer,
+ title=f"Novi putni nalog za {wo.vehicle.registration_number}",
+ message=f"Imate novi putni nalog (WO-{wo.pk}) za dizalicu {wo.vehicle.registration_number}.",
+ metadata={
+ "entity_type": "work_order",
+ "work_order_id": str(wo.pk),
+ "vehicle_id": str(wo.vehicle_id),
+ "crane_id": str(wo.vehicle_id),
+ "section": "work-orders",
+ },
+ level="info",
+ send_email=False
+ )
+ except Exception:
+ logger.exception("Greška pri slanju notifikacija za create_work_order")
+
+ return wo
+
+ @staticmethod
+ def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder:
+ for k, v in data.items():
+ setattr(instance, k, v)
+ try:
+ instance.full_clean()
+ except Exception as e:
+ raise ValidationError({"detail": str(e)})
+ with transaction.atomic():
+ instance.save()
+ # Ako je nalog zatvoren, obavijestimo kreatora/servisera
+ if instance.status == 'closed':
+ try:
+ NotificationService.create_notification(
+ recipient=instance.creator,
+ title=f"Putni nalog zatvoren: WO-{instance.pk}",
+ message=f"Putni nalog za dizalicu {instance.vehicle.registration_number} je zatvoren.",
+ metadata={
+ "entity_type": "work_order",
+ "work_order_id": str(instance.pk),
+ "vehicle_id": str(instance.vehicle_id),
+ "crane_id": str(instance.vehicle_id),
+ "section": "work-orders",
+ },
+ level="info",
+ send_email=False
+ )
+ servicer = instance.vehicle.assigned_servicer
+ if servicer:
+ NotificationService.create_notification(
+ recipient=servicer,
+ title=f"Putni nalog zatvoren za {instance.vehicle.registration_number}",
+ message=f"Putni nalog (WO-{instance.pk}) je zatvoren.",
+ metadata={
+ "entity_type": "work_order",
+ "work_order_id": str(instance.pk),
+ "vehicle_id": str(instance.vehicle_id),
+ "crane_id": str(instance.vehicle_id),
+ "section": "work-orders",
+ },
+ level="info",
+ send_email=False
+ )
+ except Exception:
+ logger.exception("Greška pri slanju notifikacija za update_work_order")
+ return instance
+
+ @staticmethod
+ def delete_work_order(instance: WorkOrder, user=None) -> WorkOrder:
+ """
+ Soft-delete putnog naloga. Samo creator ili staff može obrisati.
+ Vraća deaktivirani instance.
+ """
+ # Provjera prava: samo creator ili staff
+ if not user:
+ raise ValidationError("Korisnik nije naveden za operaciju brisanja.")
+ if not (user.is_staff or instance.creator == user):
+ raise ValidationError("Nemate dozvolu za brisanje ovog putnog naloga.")
+
+ instance.is_active = False
+ instance.save(update_fields=['is_active'])
+
+ # Notifikacija o brisanju
+ try:
+ NotificationService.create_notification(
+ recipient=instance.creator,
+ title=f"Putni nalog obrisan: WO-{instance.pk}",
+ message=f"Putni nalog za dizalicu {instance.vehicle.registration_number} je obrisan od strane {user}.",
+ metadata={
+ "entity_type": "work_order",
+ "work_order_id": str(instance.pk),
+ "vehicle_id": str(instance.vehicle_id),
+ "crane_id": str(instance.vehicle_id),
+ "section": "work-orders",
+ "deleted": True,
+ },
+ level="warning",
+ send_email=False
+ )
+ servicer = instance.vehicle.assigned_servicer
+ if servicer:
+ NotificationService.create_notification(
+ recipient=servicer,
+ title=f"Putni nalog obrisan za {instance.vehicle.registration_number}",
+ message=f"Putni nalog (WO-{instance.pk}) je obrisan.",
+ metadata={
+ "entity_type": "work_order",
+ "work_order_id": str(instance.pk),
+ "vehicle_id": str(instance.vehicle_id),
+ "crane_id": str(instance.vehicle_id),
+ "section": "work-orders",
+ "deleted": True,
+ },
+ level="warning",
+ send_email=False
+ )
+ except Exception:
+ logger.exception("Greška pri slanju notifikacija za delete_work_order")
+
+ logger.info(f"WorkOrder {instance.pk} soft-deleted by {user}")
+ return instance
+
+ @staticmethod
+ def create_service_record(data: dict) -> VehicleServiceRecord:
+ rec = VehicleServiceRecord(**data)
+ try:
+ rec.full_clean()
+ except Exception as e:
+ raise ValidationError({"detail": str(e)})
+ with transaction.atomic():
+ rec.save()
+ # update vehicle mileage if needed
+ v = rec.vehicle
+ if rec.mileage and rec.mileage > (v.current_mileage or 0):
+ v.current_mileage = rec.mileage
+ v.save()
+
+ # notify assigned servicer / creator
+ try:
+ # Notify performed_by user
+ NotificationService.create_notification(
+ recipient=rec.performed_by,
+ title=f"Servis zabilježen: {rec.vehicle.registration_number}",
+ message=(
+ f"Zabilježen servis na dizalici {rec.vehicle.registration_number}"
+ + (f" pri kilometraži {rec.mileage}." if rec.mileage is not None else ".")
+ ),
+ metadata={
+ "entity_type": "service_record",
+ "service_record_id": str(rec.pk),
+ "vehicle_id": str(rec.vehicle_id),
+ "crane_id": str(rec.vehicle_id),
+ "section": "service-records",
+ },
+ level="info",
+ send_email=False
+ )
+ # Notify vehicle assigned servicer if different
+ servicer = rec.vehicle.assigned_servicer
+ if servicer and servicer != rec.performed_by:
+ NotificationService.create_notification(
+ recipient=servicer,
+ title=f"Servis na vašem vozilu {rec.vehicle.registration_number}",
+ message=f"Servis je izvršen na dizalici {rec.vehicle.registration_number}.",
+ metadata={
+ "entity_type": "service_record",
+ "service_record_id": str(rec.pk),
+ "vehicle_id": str(rec.vehicle_id),
+ "crane_id": str(rec.vehicle_id),
+ "section": "service-records",
+ },
+ level="info",
+ send_email=False
+ )
+ # If next service is soon (e.g., within 1000 km), create a warning notification
+ next_due = rec.next_service_due_at
+ if next_due is None and rec.mileage is not None:
+ next_due = rec.mileage + rec.vehicle.service_interval_km
+ if next_due is not None and rec.mileage is not None and (next_due - rec.mileage) <= 1000:
+ if servicer:
+ NotificationService.create_notification(
+ recipient=servicer,
+ title=f"Uskoro servis za {rec.vehicle.registration_number}",
+ message=f"Sljedeći servis za dizalicu {rec.vehicle.registration_number} očekuje se na ~{next_due} km.",
+ metadata={
+ "entity_type": "service_record",
+ "service_record_id": str(rec.pk),
+ "vehicle_id": str(rec.vehicle_id),
+ "crane_id": str(rec.vehicle_id),
+ "section": "service-records",
+ "next_service_due_at": next_due,
+ },
+ level="warning",
+ send_email=False
+ )
+ except Exception:
+ logger.exception("Greška pri slanju notifikacija za create_service_record")
+ return rec
+
+class VehicleServicePhotoService:
+ """
+ Servisna klasa za upravljanje VehicleServicePhoto zapisima.
+ Delegira upload, ažuriranje i brisanje s validacijom i sigurnosnim provjerama.
+ """
+
+ @staticmethod
+ def upload_photo(data: dict, uploaded_by=None) -> VehicleServicePhoto:
+ """
+ Kreiraj VehicleServicePhoto s file upload-om.
+ data treba sadržavati: service_record (ID ili instancu), image (UploadedFile), description (opcionalno).
+ """
+ if not uploaded_by:
+ raise ValidationError("Korisnik je obavezan za upload fotografije.")
+
+ # Ako je service_record ID ili UUID, dohvati instancu
+ service_record = data.get('service_record')
+
+ # Provjeri je li već VehicleServiceRecord instanca
+ if not isinstance(service_record, VehicleServiceRecord):
+ # Pokušaj dohvatiti po ID-u (int, str, UUID)
+ try:
+ service_record = VehicleServiceRecord.objects.get(pk=service_record)
+ except (VehicleServiceRecord.DoesNotExist, ValueError, TypeError):
+ raise ValidationError("Servisni zapis ne postoji ili ID nije valjan.")
+
+ # Kreiraj novi dict s istim podacima ali sigurno postavljenim service_record
+ photo_data = {
+ 'service_record': service_record,
+ 'image': data.get('image'),
+ 'description': data.get('description', ''),
+ 'uploaded_by': uploaded_by
+ }
+
+ photo = VehicleServicePhoto(**photo_data)
+ try:
+ photo.full_clean()
+ except Exception as e:
+ raise ValidationError({"detail": str(e)})
+
+ photo.save()
+ logger.info(f"Fotografija uploadana od strane {uploaded_by} za servisni zapis {service_record.id}")
+ return photo
+
+ @staticmethod
+ def update_photo(instance: VehicleServicePhoto, data: dict, user=None) -> VehicleServicePhoto:
+ """
+ Ažuriraj VehicleServicePhoto. Samo uploader ili staff mogu ažurirati.
+ """
+ # Sigurnosna provjera: samo uploader ili staff mogu ažurirati
+ if not (user and (user.is_staff or instance.uploaded_by == user)):
+ raise ValidationError("Nemate dozvolu za ažuriranje ove fotografije.")
+
+ for attr, value in data.items():
+ if attr != 'image': # ne dozvoli ponovno upload slike (opciono)
+ setattr(instance, attr, value)
+
+ try:
+ instance.full_clean()
+ except Exception as e:
+ raise ValidationError({"detail": str(e)})
+
+ instance.save()
+ logger.info(f"Fotografija {instance.id} ažurirana od strane {user}")
+ return instance
+
+ @staticmethod
+ def delete_photo(instance: VehicleServicePhoto, user=None) -> VehicleServicePhoto:
+ """
+ Soft-delete VehicleServicePhoto. Samo uploader ili staff mogu obrisati.
+ """
+ if not (user and (user.is_staff or instance.uploaded_by == user)):
+ raise ValidationError("Nemate dozvolu za brisanje ove fotografije.")
+
+ instance.is_active = False
+ instance.save(update_fields=['is_active'])
+ logger.info(f"Fotografija {instance.id} soft-deleted od strane {user}")
+ return instance
+
+class FleetService:
+ @staticmethod
+ def bulk_close_work_orders(work_orders_qs):
+ updated_vehicles = {}
+
+ with transaction.atomic():
+ for wo in work_orders_qs:
+ # 1. Preskoči ako je već zatvoren
+ if wo.status == 'closed':
+ continue
+
+ # 2. NOVO: Preskoči ako nalog nije kompletan (nema kilometražu)
+ if wo.end_mileage is None:
+ continue
+
+ # 3. Zatvori nalog
+ wo.status = 'closed'
+ wo.save()
+
+ # 4. Ažuriraj vozilo
+ vehicle = wo.vehicle
+ current_max = updated_vehicles.get(vehicle.pk, vehicle.current_mileage)
+ if wo.end_mileage > current_max:
+ updated_vehicles[vehicle.pk] = wo.end_mileage
+
+ # Masovno ažuriranje vozila
+ for v_id, new_mileage in updated_vehicles.items():
+ Vehicle.objects.filter(pk=v_id).update(current_mileage=new_mileage)
\ No newline at end of file
diff --git a/backend/modules/fleet/tasks.py b/backend/modules/fleet/tasks.py
new file mode 100644
index 0000000..3cd4a5a
--- /dev/null
+++ b/backend/modules/fleet/tasks.py
@@ -0,0 +1,165 @@
+# backend/modules/fleet/tasks.py
+from celery import shared_task
+from django.core.mail import send_mail
+from django.conf import settings
+import logging
+from io import BytesIO
+import base64
+from PIL import Image, UnidentifiedImageError
+from reportlab.lib.pagesizes import A4
+from reportlab.lib.utils import ImageReader
+from reportlab.pdfgen import canvas
+from .models import VehicleNotification
+
+logger = logging.getLogger(__name__)
+
+
+@shared_task(bind=True, max_retries=3, default_retry_delay=60)
+def send_notification_email(self, notification_id):
+ """
+ Celery task koji pošalje e-mail za VehicleNotification i označi is_sent = True.
+ Prihvaća notification_id (PK) i radi safe-lookup.
+ U slučaju greške rekreira retry.
+ """
+ try:
+ # Lokalni import kako bismo izbjegli kružne importove
+ from .models import VehicleNotification
+ notif = VehicleNotification.objects.select_related('recipient').get(pk=notification_id)
+ except Exception as exc:
+ logger.warning(f"Notification {notification_id} ne postoji ili nije dostupna: {exc}")
+ return
+
+ recipient = notif.recipient
+ recipient_email = getattr(recipient, 'email', None) if recipient else None
+
+ if not recipient_email:
+ logger.info(f"Notification {notification_id} nema email primatelja; preskačem slanje.")
+ return
+
+ subject = notif.title
+ message = notif.message
+ from_email = getattr(settings, "DEFAULT_FROM_EMAIL", None)
+
+ try:
+ send_mail(
+ subject=subject,
+ message=message,
+ from_email=from_email,
+ recipient_list=[recipient_email],
+ fail_silently=False,
+ )
+ notif.is_sent = True
+ notif.save(update_fields=['is_sent'])
+ logger.info(f"Notification email poslan za Notification(id={notification_id}) na {recipient_email}")
+ except Exception as exc:
+ logger.exception(f"Greška pri slanju emaila za Notification(id={notification_id}): {exc}")
+ try:
+ # Retry with exponential backoff handled by celery config; here max_retries = 3
+ raise self.retry(exc=exc)
+ except self.MaxRetriesExceededError:
+ logger.error(f"Max retries exceeded za Notification(id={notification_id})")
+
+@shared_task
+def send_email_task(recipient_email, subject, message, notif_id):
+ try:
+ send_mail(
+ subject=subject,
+ message=message,
+ from_email=settings.DEFAULT_FROM_EMAIL,
+ recipient_list=[recipient_email],
+ fail_silently=False,
+ )
+ VehicleNotification.objects.filter(id=notif_id).update(is_sent=True)
+ except Exception as exc:
+ logger.error(f"Async mail failed: {exc}")
+
+
+def _build_work_order_invoices_pdf(work_order):
+ buffer = BytesIO()
+ pdf = canvas.Canvas(buffer, pagesize=A4)
+ width, height = A4
+ y = height - 50
+
+ pdf.setFont("Helvetica-Bold", 14)
+ pdf.drawString(40, y, f"Računi putnog naloga WO-{work_order.pk}")
+ y -= 22
+ pdf.setFont("Helvetica", 10)
+ pdf.drawString(40, y, f"Dizalica: {work_order.vehicle.registration_number}")
+ y -= 16
+ pdf.drawString(40, y, f"Datum naloga: {work_order.date}")
+
+ invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')
+ if not invoices.exists():
+ y -= 24
+ pdf.drawString(40, y, "Nema računa za ovaj putni nalog.")
+ pdf.save()
+ return buffer.getvalue()
+
+ for index, invoice in enumerate(invoices, start=1):
+ pdf.showPage()
+ y = height - 50
+ pdf.setFont("Helvetica-Bold", 13)
+ pdf.drawString(40, y, f"Račun #{index}")
+ y -= 22
+ pdf.setFont("Helvetica", 10)
+ pdf.drawString(40, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}")
+ y -= 16
+ pdf.drawString(40, y, f"lokacija: {invoice.lokacija or '-'}")
+ y -= 16
+ pdf.drawString(40, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}")
+ y -= 16
+ pdf.drawString(40, y, f"opis: {(invoice.opis or '-')[:140]}")
+ y -= 20
+
+ if not invoice.image:
+ pdf.drawString(40, y, "slika: Nema slike.")
+ continue
+
+ try:
+ invoice.image.open('rb')
+ with Image.open(invoice.image) as source:
+ image = source.convert('RGB')
+ max_width = width - 80
+ max_height = y - 60
+ if max_height < 120:
+ pdf.showPage()
+ y = height - 60
+ max_height = y - 60
+
+ ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0)
+ draw_width = max(1, int(image.width * ratio))
+ draw_height = max(1, int(image.height * ratio))
+ image_reader = ImageReader(image)
+ pdf.drawImage(
+ image_reader,
+ 40,
+ y - draw_height,
+ width=draw_width,
+ height=draw_height,
+ preserveAspectRatio=True,
+ mask='auto',
+ )
+ except (UnidentifiedImageError, OSError):
+ pdf.drawString(40, y, "slika: Slika nije dostupna ili je oštećena.")
+ finally:
+ invoice.image.close()
+
+ pdf.save()
+ return buffer.getvalue()
+
+
+@shared_task
+def build_work_order_invoices_pdf_task(work_order_id):
+ from .models import WorkOrder
+
+ work_order = WorkOrder.objects.select_related('vehicle').filter(pk=work_order_id).first()
+ if work_order is None:
+ return {"error": "Putni nalog nije pronađen."}
+
+ pdf_bytes = _build_work_order_invoices_pdf(work_order)
+ pdf_b64 = base64.b64encode(pdf_bytes).decode('ascii')
+ filename = f"{work_order.pk}.work-order-invoices.pdf"
+ return {
+ "filename": filename,
+ "pdf_base64": pdf_b64,
+ }
\ No newline at end of file
diff --git a/backend/modules/fleet/tests/conftest.py b/backend/modules/fleet/tests/conftest.py
new file mode 100644
index 0000000..e26769f
--- /dev/null
+++ b/backend/modules/fleet/tests/conftest.py
@@ -0,0 +1,18 @@
+# conftest.py
+import pytest
+import uuid
+from django.contrib.auth import get_user_model
+
+@pytest.fixture
+def test_user():
+ return get_user_model().objects.create_user(
+ username=f"user_{uuid.uuid4().hex[:8]}",
+ email=f"user+{uuid.uuid4().hex[:8]}@example.test",
+ password="pass"
+ )
+
+@pytest.fixture(autouse=True)
+def setup_test_celery_settings(settings):
+ settings.CELERY_TASK_ALWAYS_EAGER = True
+ settings.CELERY_TASK_EAGER_PROPAGATES=True # Ovo osigurava da greške u tasku odmah pucaju u testu
+ settings.BROKER_BACKEND='memory', # Koristi in-memory broker umjesto Redis-a
diff --git a/backend/modules/fleet/tests/test_bulk_close_work_orders.py b/backend/modules/fleet/tests/test_bulk_close_work_orders.py
new file mode 100644
index 0000000..e3dc022
--- /dev/null
+++ b/backend/modules/fleet/tests/test_bulk_close_work_orders.py
@@ -0,0 +1,72 @@
+import pytest
+from modules.fleet.models import Vehicle, WorkOrder
+from modules.fleet.services import FleetService
+from django.contrib.auth import get_user_model
+
+@pytest.mark.django_db
+def test_bulk_close_work_orders_updates_mileage(test_user):
+
+ # 1. Kreiraj vozilo
+ vehicle = Vehicle.objects.create(registration_number="ZG-001", current_mileage=1000)
+
+ # 2. Kreiraj više otvorenih naloga
+ wo1 = WorkOrder.objects.create(vehicle=vehicle, creator=test_user, start_mileage=1000, end_mileage=1200)
+ wo2 = WorkOrder.objects.create(vehicle=vehicle, creator=test_user, start_mileage=1200, end_mileage=1500)
+
+ # 3. Pozovi bulk servis
+ queryset = WorkOrder.objects.filter(pk__in=[wo1.pk, wo2.pk])
+ FleetService.bulk_close_work_orders(queryset)
+
+ # 4. Provjeri rezultate
+ vehicle.refresh_from_db()
+ wo1.refresh_from_db()
+ wo2.refresh_from_db()
+
+ # Statusi moraju biti zatvoreni
+ assert wo1.status == 'closed'
+ assert wo2.status == 'closed'
+
+ # Kilometraža vozila mora biti najveća (1500)
+ assert vehicle.current_mileage == 1500
+
+@pytest.mark.django_db
+def test_bulk_close_ignores_already_closed_orders(test_user):
+
+ vehicle = Vehicle.objects.create(registration_number="ZG-002", current_mileage=1000)
+
+ # Već zatvoren nalog
+ wo = WorkOrder.objects.create(vehicle=vehicle, creator=test_user, start_mileage=1000, end_mileage=1100, status='closed')
+
+ # Pozovi servis
+ FleetService.bulk_close_work_orders(WorkOrder.objects.filter(pk=wo.pk))
+
+ # Provjeri da nalog nije "pukao" niti promijenio stanje
+ wo.refresh_from_db()
+ assert wo.status == 'closed'
+
+@pytest.mark.django_db
+def test_bulk_close_ignores_incomplete_work_order(test_user):
+
+ vehicle = Vehicle.objects.create(registration_number="ZG-003", current_mileage=1000)
+
+ # Nalog koji je otvoren i NEMA end_mileage
+ wo = WorkOrder.objects.create(
+ vehicle=vehicle,
+ creator=test_user,
+ start_mileage=1000,
+ end_mileage=None, # Incomplete
+ status='open'
+ )
+
+ # Pozovi servis
+ FleetService.bulk_close_work_orders(WorkOrder.objects.filter(pk=wo.pk))
+
+ # Provjeri: Nalog bi trebao ostati otvoren ili se možda ne bi smio zatvoriti
+ # jer podaci nisu kompletni
+ wo.refresh_from_db()
+ vehicle.refresh_from_db()
+
+ # Ovisno o tvojoj poslovnoj logici:
+ # Ako nalog nije kompletan, ne bi ga se smjelo zatvoriti.
+ assert wo.status == 'open'
+ assert vehicle.current_mileage == 1000
\ No newline at end of file
diff --git a/backend/modules/fleet/tests/test_crane_serial_number.py b/backend/modules/fleet/tests/test_crane_serial_number.py
new file mode 100644
index 0000000..0c4b014
--- /dev/null
+++ b/backend/modules/fleet/tests/test_crane_serial_number.py
@@ -0,0 +1,67 @@
+import uuid
+
+from django.test import TestCase
+
+from modules.fleet.serializers import CraneSerializer
+
+
+class CraneSerialNumberTests(TestCase):
+ def _base_payload(self):
+ suffix = uuid.uuid4().hex[:6].upper()
+ return {
+ "registration_number": f"ZG-CRN-{suffix}",
+ "make": "Liebherr",
+ "model": "LTM",
+ "year": 2024,
+ "current_mileage": 12000,
+ "service_interval_km": 8000,
+ "is_company_vehicle": True,
+ "is_active": True,
+ }
+
+ def test_create_crane_with_serial_number_succeeds(self):
+ payload = {
+ **self._base_payload(),
+ "crane_serial_number": "123456",
+ }
+ serializer = CraneSerializer(data=payload)
+ self.assertTrue(serializer.is_valid(), serializer.errors)
+
+ crane = serializer.save()
+ self.assertEqual(crane.asset_type, "crane")
+ self.assertEqual(crane.crane_serial_number, "123456")
+
+ def test_create_crane_without_serial_number_fails(self):
+ payload = self._base_payload()
+ serializer = CraneSerializer(data=payload)
+ self.assertFalse(serializer.is_valid())
+ self.assertIn("crane_serial_number", serializer.errors)
+
+ def test_crane_serial_number_industry_standard_validation(self):
+ valid_payload = {
+ **self._base_payload(),
+ "registration_number": f"ZG-CRN-{uuid.uuid4().hex[:6].upper()}",
+ "crane_serial_number": "000123",
+ }
+ valid_serializer = CraneSerializer(data=valid_payload)
+ self.assertTrue(valid_serializer.is_valid(), valid_serializer.errors)
+
+ invalid_serials = [
+ "12345", # prekratko
+ "1234567", # predugo
+ "12A456", # slova nisu dozvoljena
+ "12 456", # razmaci nisu dozvoljeni
+ "12-456", # specijalni znakovi nisu dozvoljeni
+ "123456", # unicode znamenke nisu dozvoljene (samo ASCII 0-9)
+ ]
+
+ for value in invalid_serials:
+ with self.subTest(serial=value):
+ payload = {
+ **self._base_payload(),
+ "registration_number": f"ZG-CRN-{uuid.uuid4().hex[:6].upper()}",
+ "crane_serial_number": value,
+ }
+ serializer = CraneSerializer(data=payload)
+ self.assertFalse(serializer.is_valid())
+ self.assertIn("crane_serial_number", serializer.errors)
diff --git a/backend/modules/fleet/tests/test_delete_work_order.py b/backend/modules/fleet/tests/test_delete_work_order.py
new file mode 100644
index 0000000..59ada02
--- /dev/null
+++ b/backend/modules/fleet/tests/test_delete_work_order.py
@@ -0,0 +1,24 @@
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from modules.fleet.models import Vehicle, WorkOrder, VehicleNotification
+from modules.fleet.services import VehicleService
+import uuid
+
+class WorkOrderDeleteTests(TestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.user = User.objects.create_user(username="u1", email=f"u1+{uuid.uuid4()}@example.test", password="pass")
+ self.other = User.objects.create_user(username="u2", email=f"u2+{uuid.uuid4()}@example.test", password="pass")
+ self.vehicle = Vehicle.objects.create(registration_number=f"V-{uuid.uuid4().hex[:6]}", current_mileage=1000)
+ self.wo = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, start_mileage=1000)
+
+ def test_delete_by_creator_creates_notification(self):
+ VehicleService.delete_work_order(self.wo, user=self.user)
+ self.wo.refresh_from_db()
+ self.assertFalse(self.wo.is_active)
+ notif = VehicleNotification.objects.filter(recipient=self.user, title__icontains="obrisan").exists()
+ self.assertTrue(notif)
+
+ def test_delete_by_non_creator_denied(self):
+ with self.assertRaises(Exception):
+ VehicleService.delete_work_order(self.wo, user=self.other)
\ No newline at end of file
diff --git a/backend/modules/fleet/tests/test_integration.py b/backend/modules/fleet/tests/test_integration.py
new file mode 100644
index 0000000..76d20b5
--- /dev/null
+++ b/backend/modules/fleet/tests/test_integration.py
@@ -0,0 +1,28 @@
+import pytest
+from django.core.exceptions import ValidationError
+from modules.fleet.models import Vehicle
+from django.contrib.auth import get_user_model
+
+@pytest.mark.django_db
+def test_vehicle_servicer_one_to_one_constraint(test_user):
+
+ # Prvo vozilo prolazi
+ Vehicle.objects.create(registration_number="ZG-111-AA", assigned_servicer=test_user)
+
+ # Drugo vozilo za istog servisera mora baciti IntegrityError
+ with pytest.raises(Exception): # Django IntegrityError
+ Vehicle.objects.create(registration_number="ZG-222-BB", assigned_servicer=test_user)
+
+@pytest.mark.django_db
+def test_vehicle_next_service_logic():
+ # current=12000, interval=15000 -> next_service_at = 15000
+ vehicle = Vehicle.objects.create(
+ registration_number="ZG-001",
+ current_mileage=12000,
+ service_interval_km=15000
+ )
+ assert vehicle.next_service_at() == 15000
+
+ # current=16000, interval=15000 -> next_service_at = 30000
+ vehicle.current_mileage = 16000
+ assert vehicle.next_service_at() == 30000
\ No newline at end of file
diff --git a/backend/modules/fleet/tests/test_models.py b/backend/modules/fleet/tests/test_models.py
new file mode 100644
index 0000000..fede85c
--- /dev/null
+++ b/backend/modules/fleet/tests/test_models.py
@@ -0,0 +1,33 @@
+import uuid
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
+
+
+class FleetModelTests(TestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.user = User.objects.create_user(username="tech", email=f"tech+{uuid.uuid4()}@example.test", password="pass")
+ self.vehicle = Vehicle.objects.create(
+ registration_number=f"ZG-{uuid.uuid4().hex[:6].upper()}",
+ make="Toyota",
+ model="Yaris",
+ current_mileage=12000,
+ service_interval_km=15000,
+ assigned_servicer=self.user
+ )
+
+ def test_next_service_at(self):
+ self.assertEqual(self.vehicle.next_service_at(), 15000)
+
+ def test_work_order_updates_vehicle_mileage(self):
+ wo = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, start_mileage=12000, end_mileage=12500)
+ self.vehicle.refresh_from_db()
+ self.assertEqual(self.vehicle.current_mileage, 12500)
+ self.assertEqual(wo.distance, 500)
+
+ def test_service_record_sets_next_service_due_and_updates_vehicle(self):
+ rec = VehicleServiceRecord.objects.create(vehicle=self.vehicle, performed_by=self.user, description="Oil change", mileage=13000, parts="oil;filter", cost="50.00")
+ self.vehicle.refresh_from_db()
+ self.assertEqual(rec.next_service_due_at, 13000 + self.vehicle.service_interval_km)
+ self.assertEqual(self.vehicle.current_mileage, 13000)
\ No newline at end of file
diff --git a/backend/modules/fleet/tests/test_notifications.py b/backend/modules/fleet/tests/test_notifications.py
new file mode 100644
index 0000000..a1a7a0a
--- /dev/null
+++ b/backend/modules/fleet/tests/test_notifications.py
@@ -0,0 +1,201 @@
+import uuid
+from django.test import TestCase, override_settings
+from django.core import mail
+from rest_framework.exceptions import ValidationError as DRFValidationError
+from django.contrib.auth import get_user_model
+from unittest.mock import patch
+
+from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord, VehicleNotification
+from modules.fleet.services import VehicleService, NotificationService
+
+
+class NotificationEmailTests(TestCase):
+ @override_settings(
+ EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
+ DEFAULT_FROM_EMAIL='no-reply@example.test'
+ )
+ def test_create_notification_sends_email_and_records_is_sent(self):
+ User = get_user_model()
+ user = User.objects.create_user(username=f"user_{uuid.uuid4().hex[:6]}", email=f"user+{uuid.uuid4()}@example.test", password="pass")
+
+ # Ensure outbox is empty
+ mail.outbox.clear()
+
+ title = "Test email notification"
+ message = "This is a test message."
+
+ with patch('django.db.transaction.on_commit', lambda f: f()):
+ notif = NotificationService.create_notification(
+ recipient=user,
+ title=title,
+ message=message,
+ level="info",
+ send_email=True
+ )
+
+ # One email should have been sent
+ self.assertEqual(len(mail.outbox), 1)
+ sent = mail.outbox[0]
+ self.assertIn(title, sent.subject)
+ self.assertIn(message, sent.body)
+ # The notification record should exist and flagged as sent
+ notif.refresh_from_db()
+ self.assertTrue(notif.is_sent)
+ self.assertEqual(notif.recipient, user)
+ self.assertEqual(notif.title, title)
+
+
+class NotificationRealtimeTests(TestCase):
+ @patch('modules.fleet.services.PusherService.broadcast_notification')
+ def test_create_notification_broadcasts_after_on_commit(self, mock_broadcast):
+ User = get_user_model()
+ user = User.objects.create_user(
+ username=f"ws_user_{uuid.uuid4().hex[:6]}",
+ email=f"ws+{uuid.uuid4()}@example.test",
+ password="pass"
+ )
+
+ with patch('django.db.transaction.on_commit', lambda fn: fn()):
+ notif = NotificationService.create_notification(
+ recipient=user,
+ title="Realtime test",
+ message="Provjera pusher poruke",
+ level="warning",
+ send_email=False,
+ metadata={"source": "unit-test"}
+ )
+
+ self.assertEqual(notif.recipient, user)
+ mock_broadcast.assert_called_once_with(
+ user_id=user.id,
+ title="Realtime test",
+ message="Provjera pusher poruke",
+ level="warning",
+ notification_id=notif.id,
+ metadata={"source": "unit-test"}
+ )
+
+ @patch('modules.fleet.services.PusherService.broadcast_notification')
+ def test_create_notification_without_recipient_does_not_broadcast(self, mock_broadcast):
+ NotificationService.create_notification(
+ recipient=None,
+ title="No recipient",
+ message="Broadcast se ne smije pozvati",
+ level="info",
+ send_email=False
+ )
+
+ mock_broadcast.assert_not_called()
+
+
+class WorkOrderAndServiceRecordNotificationTests(TestCase):
+ def setUp(self):
+ User = get_user_model()
+ # creator who will create work order
+ self.creator = User.objects.create_user(username=f"creator_{uuid.uuid4().hex[:6]}", email=f"creator+{uuid.uuid4()}@example.test", password="pass")
+ # assigned servicer (different user)
+ self.servicer = User.objects.create_user(username=f"servicer_{uuid.uuid4().hex[:6]}", email=f"servicer+{uuid.uuid4()}@example.test", password="pass")
+ # another technician who will perform the service
+ self.technician = User.objects.create_user(username=f"tech_{uuid.uuid4().hex[:6]}", email=f"tech+{uuid.uuid4()}@example.test", password="pass")
+
+ self.vehicle = Vehicle.objects.create(
+ registration_number=f"REG-{uuid.uuid4().hex[:6].upper()}",
+ make="TestMake",
+ model="TestModel",
+ current_mileage=10000,
+ service_interval_km=15000,
+ assigned_servicer=self.servicer
+ )
+
+ def test_create_work_order_creates_notifications_for_creator_and_servicer(self):
+ # Precondition: no notifications
+ VehicleNotification.objects.all().delete()
+
+ data = {
+ "vehicle": self.vehicle,
+ "creator": self.creator,
+ "start_mileage": 10000,
+ "end_mileage": 10120,
+ "purpose": "Routine visit"
+ }
+
+ wo = VehicleService.create_work_order(data)
+
+ # Ensure work order created
+ self.assertIsNotNone(wo.pk)
+
+ # Notifications: one for creator, one for assigned servicer
+ creator_notifs = VehicleNotification.objects.filter(recipient=self.creator)
+ servicer_notifs = VehicleNotification.objects.filter(recipient=self.servicer)
+
+ self.assertTrue(creator_notifs.exists(), "Creator should receive a notification")
+ self.assertTrue(servicer_notifs.exists(), "Assigned servicer should receive a notification")
+
+ def test_create_service_record_creates_notifications_for_performer_and_assigned_servicer(self):
+ # Precondition: no notifications
+ VehicleNotification.objects.all().delete()
+
+ data = {
+ "vehicle": self.vehicle,
+ "performed_by": self.technician,
+ "description": "Brake replacement",
+ "parts": "pads;discs",
+ "cost": "200.00",
+ "mileage": 10200
+ }
+
+ rec = VehicleService.create_service_record(data)
+
+ # Ensure service record created
+ self.assertIsNotNone(rec.pk)
+
+ # There should be a notification for the performer
+ perf_notifs = VehicleNotification.objects.filter(recipient=self.technician)
+ self.assertTrue(perf_notifs.exists(), "Performer should receive a notification")
+
+ # There should be a notification for the assigned servicer (different from performer)
+ servicer_notifs = VehicleNotification.objects.filter(recipient=self.servicer)
+ self.assertTrue(servicer_notifs.exists(), "Assigned servicer should receive a notification when different from performer")
+
+
+class DeleteWorkOrderPermissionTests(TestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.creator = User.objects.create_user(username=f"creator_{uuid.uuid4().hex[:6]}", email=f"creator+{uuid.uuid4()}@example.test", password="pass")
+ self.other = User.objects.create_user(username=f"other_{uuid.uuid4().hex[:6]}", email=f"other+{uuid.uuid4()}@example.test", password="pass")
+ self.staff = User.objects.create_user(username=f"admin_{uuid.uuid4().hex[:6]}", email=f"admin+{uuid.uuid4()}@example.test", password="pass", is_staff=True)
+
+ self.vehicle = Vehicle.objects.create(
+ registration_number=f"DEL-{uuid.uuid4().hex[:6].upper()}",
+ current_mileage=5000
+ )
+
+ self.wo = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.creator, start_mileage=5000)
+
+ def test_creator_can_delete_work_order_and_notification_created(self):
+ # Ensure active initially
+ self.wo.refresh_from_db()
+ self.assertTrue(self.wo.is_active)
+
+ # Delete by creator
+ VehicleService.delete_work_order(self.wo, user=self.creator)
+
+ # Refresh and assert deactivated
+ self.wo.refresh_from_db()
+ self.assertFalse(self.wo.is_active)
+
+ # Notification exists for creator
+ notif_exists = VehicleNotification.objects.filter(recipient=self.creator, title__icontains="obrisan").exists()
+ self.assertTrue(notif_exists)
+
+ def test_non_creator_cannot_delete_work_order(self):
+ with self.assertRaises(DRFValidationError):
+ VehicleService.delete_work_order(self.wo, user=self.other)
+
+ def test_staff_can_delete_work_order(self):
+ # staff should be allowed
+ VehicleService.delete_work_order(self.wo, user=self.staff)
+ self.wo.refresh_from_db()
+ self.assertFalse(self.wo.is_active)
+ # Notification exists for creator as well (as per implementation)
+ self.assertTrue(VehicleNotification.objects.filter(recipient=self.creator, title__icontains="obrisan").exists())
\ No newline at end of file
diff --git a/backend/modules/fleet/tests/test_photo_services.py b/backend/modules/fleet/tests/test_photo_services.py
new file mode 100644
index 0000000..3bc340c
--- /dev/null
+++ b/backend/modules/fleet/tests/test_photo_services.py
@@ -0,0 +1,362 @@
+import uuid
+from io import BytesIO
+from django.test import TestCase
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.contrib.auth import get_user_model
+from rest_framework.exceptions import ValidationError as DRFValidationError
+from PIL import Image
+
+from modules.fleet.models import Vehicle, VehicleServiceRecord, VehicleServicePhoto
+from modules.fleet.services import VehicleServicePhotoService
+
+
+User = get_user_model()
+
+
+def create_test_image(filename="test.jpg", size=(100, 100), color='red'):
+ """
+ Stvori test sliku u memoriji koristeći Pillow.
+ Vraća SimpleUploadedFile spreman za upload.
+ """
+ file = BytesIO()
+ image = Image.new('RGB', size=size, color=color)
+ image.save(file, 'JPEG')
+ file.seek(0)
+ return SimpleUploadedFile(
+ name=filename,
+ content=file.getvalue(),
+ content_type='image/jpeg'
+ )
+
+
+class VehicleServicePhotoServiceTests(TestCase):
+ def setUp(self):
+ # Create users
+ self.uploader = User.objects.create_user(
+ username=f"uploader_{uuid.uuid4().hex[:6]}",
+ email=f"uploader+{uuid.uuid4()}@example.test",
+ password="pass"
+ )
+ self.other_user = User.objects.create_user(
+ username=f"other_{uuid.uuid4().hex[:6]}",
+ email=f"other+{uuid.uuid4()}@example.test",
+ password="pass"
+ )
+ self.staff_user = User.objects.create_user(
+ username=f"admin_{uuid.uuid4().hex[:6]}",
+ email=f"admin+{uuid.uuid4()}@example.test",
+ password="pass",
+ is_staff=True
+ )
+
+ # Create vehicle and service record
+ self.vehicle = Vehicle.objects.create(
+ registration_number=f"SRVPHOTO-{uuid.uuid4().hex[:6].upper()}",
+ make="TestMake",
+ model="TestModel",
+ current_mileage=5000,
+ service_interval_km=10000
+ )
+
+ self.service_record = VehicleServiceRecord.objects.create(
+ vehicle=self.vehicle,
+ performed_by=self.uploader,
+ description="Test service",
+ mileage=5000,
+ parts="part1;part2"
+ )
+
+ def test_upload_photo_success(self):
+ """
+ Test uspješnog uploada fotografije s valjanim podacima.
+ """
+ image = create_test_image(filename="success.jpg")
+
+ data = {
+ 'service_record': self.service_record,
+ 'image': image,
+ 'description': 'Test brake replacement'
+ }
+
+ photo = VehicleServicePhotoService.upload_photo(data=data, uploaded_by=self.uploader)
+
+ self.assertIsNotNone(photo.pk)
+ self.assertEqual(photo.uploaded_by, self.uploader)
+ self.assertEqual(photo.service_record, self.service_record)
+ self.assertEqual(photo.description, 'Test brake replacement')
+ self.assertTrue(photo.image)
+
+ def test_upload_photo_with_service_record_id(self):
+ """
+ Test uploada koristeći service_record ID umjesto instancije.
+ """
+ image = create_test_image(filename="by_id.jpg")
+
+ data = {
+ 'service_record': self.service_record.id, # ID, ne instanca
+ 'image': image,
+ 'description': 'Upload by ID'
+ }
+
+ photo = VehicleServicePhotoService.upload_photo(data=data, uploaded_by=self.uploader)
+
+ self.assertIsNotNone(photo.pk)
+ self.assertEqual(photo.service_record, self.service_record)
+
+ def test_upload_photo_without_user_raises(self):
+ """
+ Test — upload bez korisnika (uploaded_by) baca ValidationError.
+ """
+ image = create_test_image(filename="no_user.jpg")
+
+ data = {
+ 'service_record': self.service_record,
+ 'image': image,
+ 'description': 'No user'
+ }
+
+ with self.assertRaises(DRFValidationError):
+ VehicleServicePhotoService.upload_photo(data=data, uploaded_by=None)
+
+ def test_upload_photo_invalid_service_record_raises(self):
+ """
+ Test — upload s nepostojeću service_record ID baca ValidationError.
+ """
+ image = create_test_image(filename="invalid_sr.jpg")
+
+ data = {
+ 'service_record': 99999, # ne postoji
+ 'image': image,
+ 'description': 'Invalid SR'
+ }
+
+ with self.assertRaises(DRFValidationError):
+ VehicleServicePhotoService.upload_photo(data=data, uploaded_by=self.uploader)
+
+ def test_update_photo_by_uploader_success(self):
+ """
+ Test ažuriranja fotografije od strane korisnika koji ju je uploadao.
+ """
+ image = create_test_image(filename="original.jpg")
+ photo = VehicleServicePhoto.objects.create(
+ service_record=self.service_record,
+ image=image,
+ description="Original description",
+ uploaded_by=self.uploader
+ )
+
+ update_data = {'description': 'Updated description'}
+ updated_photo = VehicleServicePhotoService.update_photo(
+ instance=photo,
+ data=update_data,
+ user=self.uploader
+ )
+
+ self.assertEqual(updated_photo.description, 'Updated description')
+ photo.refresh_from_db()
+ self.assertEqual(photo.description, 'Updated description')
+
+ def test_update_photo_by_staff_success(self):
+ """
+ Test ažuriranja fotografije od strane staff korisnika (može ažurirati bilo koju).
+ """
+ image = create_test_image(filename="staff_update.jpg")
+ photo = VehicleServicePhoto.objects.create(
+ service_record=self.service_record,
+ image=image,
+ description="Original",
+ uploaded_by=self.uploader
+ )
+
+ update_data = {'description': 'Staff updated'}
+ updated_photo = VehicleServicePhotoService.update_photo(
+ instance=photo,
+ data=update_data,
+ user=self.staff_user
+ )
+
+ self.assertEqual(updated_photo.description, 'Staff updated')
+
+ def test_update_photo_by_non_uploader_denied(self):
+ """
+ Test — korisnik koji nije uploader niti staff ne može ažurirati.
+ """
+ image = create_test_image(filename="denied_update.jpg")
+ photo = VehicleServicePhoto.objects.create(
+ service_record=self.service_record,
+ image=image,
+ description="Original",
+ uploaded_by=self.uploader
+ )
+
+ update_data = {'description': 'Hacker attempt'}
+
+ with self.assertRaises(DRFValidationError):
+ VehicleServicePhotoService.update_photo(
+ instance=photo,
+ data=update_data,
+ user=self.other_user
+ )
+
+ def test_update_photo_without_user_denied(self):
+ """
+ Test — ažuriranje bez korisnika (user=None) baca ValidationError.
+ """
+ image = create_test_image(filename="no_user_update.jpg")
+ photo = VehicleServicePhoto.objects.create(
+ service_record=self.service_record,
+ image=image,
+ description="Original",
+ uploaded_by=self.uploader
+ )
+
+ update_data = {'description': 'No auth'}
+
+ with self.assertRaises(DRFValidationError):
+ VehicleServicePhotoService.update_photo(
+ instance=photo,
+ data=update_data,
+ user=None
+ )
+
+ def test_delete_photo_by_uploader_success(self):
+ """
+ Test soft-delete fotografije od strane korisnika koji ju je uploadao.
+ """
+ image = create_test_image(filename="delete.jpg")
+ photo = VehicleServicePhoto.objects.create(
+ service_record=self.service_record,
+ image=image,
+ description="To delete",
+ uploaded_by=self.uploader
+ )
+
+ self.assertTrue(photo.is_active)
+
+ deleted_photo = VehicleServicePhotoService.delete_photo(
+ instance=photo,
+ user=self.uploader
+ )
+
+ self.assertFalse(deleted_photo.is_active)
+ photo.refresh_from_db()
+ self.assertFalse(photo.is_active)
+
+ def test_delete_photo_by_staff_success(self):
+ """
+ Test soft-delete od strane staff korisnika.
+ """
+ image = create_test_image(filename="staff_delete.jpg")
+ photo = VehicleServicePhoto.objects.create(
+ service_record=self.service_record,
+ image=image,
+ description="To delete",
+ uploaded_by=self.uploader
+ )
+
+ VehicleServicePhotoService.delete_photo(
+ instance=photo,
+ user=self.staff_user
+ )
+
+ photo.refresh_from_db()
+ self.assertFalse(photo.is_active)
+
+ def test_delete_photo_by_non_uploader_denied(self):
+ """
+ Test — korisnik koji nije uploader niti staff ne može obrisati.
+ """
+ image = create_test_image(filename="denied_delete.jpg")
+ photo = VehicleServicePhoto.objects.create(
+ service_record=self.service_record,
+ image=image,
+ description="Original",
+ uploaded_by=self.uploader
+ )
+
+ with self.assertRaises(DRFValidationError):
+ VehicleServicePhotoService.delete_photo(
+ instance=photo,
+ user=self.other_user
+ )
+
+ # Fotografija je i dalje aktivna
+ photo.refresh_from_db()
+ self.assertTrue(photo.is_active)
+
+ def test_delete_photo_without_user_denied(self):
+ """
+ Test — brisanje bez korisnika (user=None) baca ValidationError.
+ """
+ image = create_test_image(filename="no_user_delete.jpg")
+ photo = VehicleServicePhoto.objects.create(
+ service_record=self.service_record,
+ image=image,
+ description="Original",
+ uploaded_by=self.uploader
+ )
+
+ with self.assertRaises(DRFValidationError):
+ VehicleServicePhotoService.delete_photo(
+ instance=photo,
+ user=None
+ )
+
+ photo.refresh_from_db()
+ self.assertTrue(photo.is_active)
+
+ def test_update_photo_does_not_allow_image_field_update(self):
+ """
+ Test — update ne dozvoljava ponovno upload slike (ili je ignorirano).
+ Sadržaj image polja se ne mijenja ako je proslijeđen u update_data.
+ """
+ image1 = create_test_image(filename="original.jpg", color='red')
+ photo = VehicleServicePhoto.objects.create(
+ service_record=self.service_record,
+ image=image1,
+ description="Original",
+ uploaded_by=self.uploader
+ )
+
+ original_image_name = photo.image.name
+
+ # Pokušaj update s novom slikom
+ image2 = create_test_image(filename="new.jpg", color='blue')
+ update_data = {
+ 'description': 'Updated',
+ 'image': image2
+ }
+
+ VehicleServicePhotoService.update_photo(
+ instance=photo,
+ data=update_data,
+ user=self.uploader
+ )
+
+ photo.refresh_from_db()
+ # image bi trebao biti preskočen (ne ažurira se)
+ self.assertEqual(photo.image.name, original_image_name)
+ self.assertEqual(photo.description, 'Updated')
+
+ def test_multiple_photos_for_same_service_record(self):
+ """
+ Test — isti servisni zapis može imati više fotografija.
+ """
+ image1 = create_test_image(filename="photo1.jpg", color='red')
+ image2 = create_test_image(filename="photo2.jpg", color='blue')
+ image3 = create_test_image(filename="photo3.jpg", color='green')
+
+ data1 = {'service_record': self.service_record, 'image': image1, 'description': 'Photo 1'}
+ data2 = {'service_record': self.service_record, 'image': image2, 'description': 'Photo 2'}
+ data3 = {'service_record': self.service_record, 'image': image3, 'description': 'Photo 3'}
+
+ photo1 = VehicleServicePhotoService.upload_photo(data=data1, uploaded_by=self.uploader)
+ photo2 = VehicleServicePhotoService.upload_photo(data=data2, uploaded_by=self.uploader)
+ photo3 = VehicleServicePhotoService.upload_photo(data=data3, uploaded_by=self.other_user)
+
+ photos = VehicleServicePhoto.objects.filter(service_record=self.service_record, is_active=True)
+ self.assertEqual(photos.count(), 3)
+
+ # Provjeri da su sve aktivne
+ for photo in [photo1, photo2, photo3]:
+ self.assertTrue(photo.is_active)
\ No newline at end of file
diff --git a/backend/modules/fleet/tests/test_pusher_service.py b/backend/modules/fleet/tests/test_pusher_service.py
new file mode 100644
index 0000000..c1755fc
--- /dev/null
+++ b/backend/modules/fleet/tests/test_pusher_service.py
@@ -0,0 +1,88 @@
+# backend/modules/fleet/tests/test_pusher_service.py
+from django.test import TestCase
+from unittest.mock import Mock, patch
+import uuid
+
+from infrastructure.pusher_service import PusherService
+
+
+class PusherServiceTests(TestCase):
+ @patch("infrastructure.pusher_service.PusherService.get_client")
+ @patch("infrastructure.pusher_service.PusherService.is_available", return_value=True)
+ def test_broadcast_notification_success(self, _is_available, mock_get_client):
+ mock_client = Mock()
+ mock_get_client.return_value = mock_client
+ mock_client.trigger.return_value = {"message": "ok"}
+
+ ok = PusherService.broadcast_notification(
+ user_id=42,
+ title="Test naslov",
+ message="Test poruka",
+ level="info",
+ notification_id=123,
+ )
+
+ self.assertTrue(ok)
+ mock_client.trigger.assert_called_once()
+ _, kwargs = mock_client.trigger.call_args
+ self.assertEqual(kwargs["channels"], "private-user-42")
+ self.assertEqual(kwargs["event_name"], "vehicle.notification.created")
+ self.assertEqual(kwargs["data"]["id"], 123)
+
+ @patch("infrastructure.pusher_service.PusherService.get_client")
+ @patch("infrastructure.pusher_service.PusherService.is_available", return_value=True)
+ def test_authenticate_channel_allows_own_private_channel(self, _is_available, mock_get_client):
+ mock_client = Mock()
+ mock_get_client.return_value = mock_client
+ mock_client.authenticate.return_value = {"auth": "signed-token"}
+
+ auth_data = PusherService.authenticate_channel(
+ socket_id="123.456",
+ channel_name="private-user-7",
+ user_id=7,
+ )
+
+ self.assertEqual(auth_data, {"auth": "signed-token"})
+ mock_client.authenticate.assert_called_once_with(
+ channel="private-user-7",
+ socket_id="123.456",
+ )
+
+ @patch("infrastructure.pusher_service.PusherService.get_client")
+ @patch("infrastructure.pusher_service.PusherService.is_available", return_value=True)
+ def test_authenticate_channel_rejects_other_users_channel(self, _is_available, mock_get_client):
+ mock_client = Mock()
+ mock_get_client.return_value = mock_client
+
+ auth_data = PusherService.authenticate_channel(
+ socket_id="123.456",
+ channel_name="private-user-999",
+ user_id=7,
+ )
+
+ self.assertIsNone(auth_data)
+ mock_client.authenticate.assert_not_called()
+
+ @patch("infrastructure.pusher_service.PusherService.get_client")
+ @patch("infrastructure.pusher_service.PusherService.is_available", return_value=True)
+ def test_broadcast_notification_serializes_uuid_values(self, _is_available, mock_get_client):
+ mock_client = Mock()
+ mock_get_client.return_value = mock_client
+ mock_client.trigger.return_value = {"message": "ok"}
+
+ notification_id = uuid.uuid4()
+ metadata_uuid = uuid.uuid4()
+
+ ok = PusherService.broadcast_notification(
+ user_id=10,
+ title="UUID test",
+ message="UUID payload",
+ level="info",
+ notification_id=notification_id,
+ metadata={"entity_id": metadata_uuid},
+ )
+
+ self.assertTrue(ok)
+ _, kwargs = mock_client.trigger.call_args
+ self.assertEqual(kwargs["data"]["id"], str(notification_id))
+ self.assertEqual(kwargs["data"]["metadata"]["entity_id"], str(metadata_uuid))
\ No newline at end of file
diff --git a/backend/modules/fleet/tests/test_service_history_and_expenses.py b/backend/modules/fleet/tests/test_service_history_and_expenses.py
new file mode 100644
index 0000000..cc1aed5
--- /dev/null
+++ b/backend/modules/fleet/tests/test_service_history_and_expenses.py
@@ -0,0 +1,17 @@
+import pytest
+from django.db import models
+from modules.fleet.models import Vehicle, VehicleServiceRecord
+
+@pytest.mark.django_db
+def test_vehicle_total_cost_calculation(test_user):
+ # Kreiraj vozilo prema tvom modelu
+ vehicle = Vehicle.objects.create(registration_number="ZG-001")
+
+ VehicleServiceRecord.objects.create(vehicle=vehicle, performed_by=test_user, cost=100.00, mileage=5000, description="Servis 1")
+ VehicleServiceRecord.objects.create(vehicle=vehicle, performed_by=test_user, cost=50.50, mileage=5200, description="Servis 2")
+
+ # Agregacija kroz tvoj related_name='service_records'
+ total = vehicle.service_records.aggregate(models.Sum('cost'))['cost__sum']
+
+ # Decimal field vraća Decimal, pa uspoređujemo s float-om ili Decimal-om
+ assert float(total) == 150.50
\ No newline at end of file
diff --git a/backend/modules/fleet/tests/test_service_photo_upload_to.py b/backend/modules/fleet/tests/test_service_photo_upload_to.py
new file mode 100644
index 0000000..0d82265
--- /dev/null
+++ b/backend/modules/fleet/tests/test_service_photo_upload_to.py
@@ -0,0 +1,22 @@
+import pytest
+from unittest.mock import MagicMock
+from modules.fleet.models import _service_photo_upload_to
+
+@pytest.mark.django_db
+def test_service_photo_upload_path_fallback():
+ """
+ Testira fallback mehanizam: ako instanca nema service_record,
+ putanja mora sadržavati 'unknown'.
+ """
+ # Simuliramo instancu koja nema 'service_record' ili baca grešku
+ instance = MagicMock()
+ del instance.service_record # Simuliramo nedostajući atribut
+
+ filename = "test_kvar.jpg"
+
+ # Pozivamo funkciju
+ path = _service_photo_upload_to(instance, filename)
+
+ # Očekujemo da putanja sadrži 'unknown' kao fallback
+ assert "fleet/unknown/service_photos/" in path
+ assert filename in path # uuid je prefiks, pa provjeravamo da li je filename na kraju
\ No newline at end of file
diff --git a/backend/modules/fleet/tests/test_services.py b/backend/modules/fleet/tests/test_services.py
new file mode 100644
index 0000000..30b4756
--- /dev/null
+++ b/backend/modules/fleet/tests/test_services.py
@@ -0,0 +1,52 @@
+import uuid
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from modules.fleet.services import VehicleService
+from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
+
+
+class FleetServiceTests(TestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.user = User.objects.create_user(username="tech2", email=f"tech2+{uuid.uuid4()}@example.test", password="pass")
+ self.vehicle = Vehicle.objects.create(
+ registration_number=f"RI-{uuid.uuid4().hex[:6].upper()}",
+ make="VW",
+ model="Polo",
+ current_mileage=5000,
+ service_interval_km=10000,
+ assigned_servicer=self.user
+ )
+
+ def test_create_work_order_via_service(self):
+ data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "Delivery"}
+ wo = VehicleService.create_work_order(data)
+ self.vehicle.refresh_from_db()
+ self.assertEqual(self.vehicle.current_mileage, 5200)
+ self.assertEqual(wo.distance, 200)
+
+ def test_create_service_record_via_service(self):
+ data = {"vehicle": self.vehicle, "performed_by": self.user, "description": "Brake pads", "mileage": 5200, "cost": "120.00"}
+ rec = VehicleService.create_service_record(data)
+ self.vehicle.refresh_from_db()
+ self.assertEqual(rec.next_service_due_at, 5200 + self.vehicle.service_interval_km)
+ self.assertEqual(self.vehicle.current_mileage, 5200)
+
+ def test_vehicle_next_service_at_logic(self):
+ # current=12000, interval=15000 -> blocks=(12000//15000)+1 = 1 -> 1*15000 = 15000
+ v = Vehicle.objects.create(current_mileage=12000, service_interval_km=15000)
+ assert v.next_service_at() == 15000
+
+ # current=16000, interval=15000 -> blocks=(16000//15000)+1 = 2 -> 2*15000 = 30000
+ v.current_mileage = 16000
+ assert v.next_service_at() == 30000
+
+ def test_work_order_distance_property(self):
+ vehicle = Vehicle.objects.create(registration_number="ZG-004")
+ # Nalog bez završne kilometraže
+ wo = WorkOrder.objects.create(vehicle=vehicle, creator=self.user, start_mileage=1000)
+ assert wo.distance is None
+
+ # Nalog sa završnom kilometražom
+ wo.end_mileage = 1100
+ assert wo.distance == 100
\ No newline at end of file
diff --git a/backend/modules/fleet/tests/trest_travel_expenses.py b/backend/modules/fleet/tests/trest_travel_expenses.py
new file mode 100644
index 0000000..7193d8a
--- /dev/null
+++ b/backend/modules/fleet/tests/trest_travel_expenses.py
@@ -0,0 +1,30 @@
+import pytest
+from django.core.exceptions import ValidationError
+from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
+
+@pytest.mark.django_db
+def test_work_order_updates_vehicle_mileage():
+ # 1. Kreiraj vozilo (koristeći tvoj model 'Vehicle')
+ vehicle = Vehicle.objects.create(
+ registration_number="ZG-123-ABC",
+ current_mileage=5000
+ )
+
+ # 2. Kreiraj radni/putni nalog
+ # Pretpostavljam da ti treba i 'creator' zbog ForeignKey relacije
+ from django.contrib.auth import get_user_model
+ User = get_user_model()
+ creator = User.objects.create_user(username="testuser")
+
+ order = WorkOrder.objects.create(
+ vehicle=vehicle,
+ creator=creator,
+ start_mileage=5000,
+ end_mileage=5500,
+ purpose="Delivery"
+ )
+
+ # 3. Nalog je već pozvao save() pri kreiranju,
+ # pa bi vozilo trebalo biti ažurirano
+ vehicle.refresh_from_db()
+ assert vehicle.current_mileage == 5500
\ No newline at end of file
diff --git a/backend/modules/fleet/urls.py b/backend/modules/fleet/urls.py
new file mode 100644
index 0000000..dfaefc9
--- /dev/null
+++ b/backend/modules/fleet/urls.py
@@ -0,0 +1,22 @@
+from rest_framework.routers import DefaultRouter
+from django.urls import path
+from .views import (
+ CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet,
+ WorkOrderInvoiceViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth,
+)
+
+router = DefaultRouter()
+router.register(r'vehicles', VehicleViewSet, basename='vehicle')
+router.register(r'cranes', CraneViewSet, basename='crane')
+router.register(r'work-orders', WorkOrderViewSet, basename='workorder')
+router.register(r'work-order-invoices', WorkOrderInvoiceViewSet, basename='work-order-invoice')
+router.register(r'service-records', VehicleServiceRecordViewSet, basename='service-record')
+router.register(r'notifications', VehicleNotificationViewSet, basename='vehicle-notification')
+router.register(r'service-photos', VehicleServicePhotoViewSet, basename='service-photo')
+router.register(r'service-attachments', VehicleServiceAttachmentViewSet, basename='service-attachment')
+
+urlpatterns = router.urls
+
+urlpatterns += [
+ path('pusher-auth/', pusher_auth, name='pusher-auth'),
+]
\ No newline at end of file
diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py
new file mode 100644
index 0000000..3e22aba
--- /dev/null
+++ b/backend/modules/fleet/views.py
@@ -0,0 +1,1563 @@
+# /backend/modules/fleet/views.py
+
+from io import BytesIO
+import base64
+
+from PIL import Image, UnidentifiedImageError
+from django.conf import settings
+from django.core.mail import EmailMessage
+from django.http import HttpResponse
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.html import escape
+from django.db.models import Q
+import os
+from reportlab.lib.pagesizes import A4
+from reportlab.lib import colors
+from reportlab.lib.utils import ImageReader
+from reportlab.lib.styles import getSampleStyleSheet
+from reportlab.pdfgen import canvas
+from reportlab.platypus import Table, TableStyle, Paragraph
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.ttfonts import TTFont
+
+# Register Unicode-capable fonts (Vera supports HR characters: č, ć, đ, š, ž)
+_RL_FONTS_DIR = os.path.join(os.path.dirname(__import__('reportlab').__file__), 'fonts')
+pdfmetrics.registerFont(TTFont('Vera', os.path.join(_RL_FONTS_DIR, 'Vera.ttf')))
+pdfmetrics.registerFont(TTFont('Vera-Bold', os.path.join(_RL_FONTS_DIR, 'VeraBd.ttf')))
+pdfmetrics.registerFont(TTFont('Vera-Italic', os.path.join(_RL_FONTS_DIR, 'VeraIt.ttf')))
+from rest_framework import viewsets, permissions, status
+from rest_framework.decorators import action, api_view, permission_classes
+from rest_framework.response import Response
+from rest_framework.exceptions import PermissionDenied, ValidationError as DRFValidationError
+from rest_framework.parsers import MultiPartParser, FormParser
+from celery.exceptions import TimeoutError as CeleryTimeoutError
+from kombu.exceptions import OperationalError as KombuOperationalError
+from .models import (
+ Crane,
+ Vehicle,
+ VehicleNotification,
+ WorkOrder,
+ WorkOrderInvoice,
+ VehicleServiceRecord,
+ VehicleServicePhoto,
+ VehicleServiceAttachment,
+)
+from .serializers import (
+ CraneSerializer,
+ VehicleSerializer,
+ WorkOrderSerializer,
+ WorkOrderInvoiceSerializer,
+ VehicleServiceRecordSerializer,
+ VehicleNotificationSerializer,
+ VehicleServicePhotoSerializer,
+ VehicleServiceAttachmentSerializer,
+)
+from infrastructure.pusher_service import PusherService
+from .services import (
+ VehicleService,
+ VehicleServicePhotoService,
+ NotificationService,
+)
+from .tasks import build_work_order_invoices_pdf_task
+
+def _fleet_assets_queryset_for_user(user, model, *, asset_type=None):
+ queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True)
+ if asset_type:
+ queryset = queryset.filter(asset_type=asset_type)
+ if user.is_staff or getattr(user, 'is_serviser', False):
+ return queryset
+ return queryset
+
+def _service_records_queryset_for_user(user):
+ queryset = (
+ VehicleServiceRecord.objects
+ .select_related('vehicle', 'vehicle__client', 'performed_by', 'task', 'task__vehicle')
+ .prefetch_related('photos', 'attachments')
+ .all()
+ )
+ if user.is_staff:
+ return queryset
+ return queryset.filter(performed_by=user)
+
+
+def _work_orders_queryset_for_user(user):
+ queryset = WorkOrder.objects.select_related('vehicle', 'vehicle__client', 'creator').all()
+ if user.is_staff:
+ return queryset
+ if getattr(user, 'is_serviser', False):
+ return queryset.filter(
+ Q(creator=user) | Q(vehicle__assigned_servicer=user)
+ ).distinct()
+ return queryset.filter(creator=user)
+
+
+def _work_order_related_tasks_queryset(work_order):
+ from modules.task_management.models import Task
+
+ direct_task_ids = list(
+ work_order.work_order_tasks.filter(is_active=True).values_list('id', flat=True)
+ )
+ inferred_task_ids = list(
+ VehicleServiceRecord.objects.filter(
+ is_active=True,
+ vehicle_id=work_order.vehicle_id,
+ task_id__isnull=False,
+ task__is_active=True,
+ ).values_list('task_id', flat=True)
+ )
+ task_ids = list({*direct_task_ids, *inferred_task_ids})
+ if not task_ids:
+ return Task.objects.none()
+ return Task.objects.filter(id__in=task_ids, is_active=True).select_related('assigned_to', 'vehicle', 'work_order').order_by('-created_at')
+
+def _can_access_service_record(user, service_record):
+ service_record_id = getattr(service_record, 'pk', service_record)
+ return _service_records_queryset_for_user(user).filter(pk=service_record_id).exists()
+
+def _parse_positive_int(value, *, field_name, default, min_value, max_value):
+ if value in (None, ''):
+ return default
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError):
+ raise DRFValidationError({field_name: f"Neispravna vrijednost za {field_name}."})
+ if parsed < min_value or parsed > max_value:
+ raise DRFValidationError({field_name: f"{field_name} mora biti između {min_value} i {max_value}."})
+ return parsed
+
+def _parse_format(value):
+ selected = (value or 'webp').lower()
+ supported = {'webp': 'WEBP', 'jpeg': 'JPEG', 'jpg': 'JPEG', 'png': 'PNG'}
+ if selected not in supported:
+ raise DRFValidationError({"fmt": "Podržani formati su: webp, jpeg, png."})
+ extension = 'jpg' if selected in ('jpeg', 'jpg') else selected
+ return supported[selected], extension
+
+def _build_simple_pdf(title, rows):
+ buffer = BytesIO()
+ pdf = canvas.Canvas(buffer, pagesize=A4)
+ width, height = A4
+ y = height - 50
+
+ pdf.setFont("Vera-Bold", 14)
+ pdf.drawString(40, y, title)
+ y -= 24
+
+ pdf.setFont("Vera", 10)
+ for row in rows:
+ text = f"{row[0]}: {row[1]}"
+ if y < 60:
+ pdf.showPage()
+ pdf.setFont("Vera", 10)
+ y = height - 50
+ pdf.drawString(40, y, text[:140])
+ y -= 16
+
+ pdf.save()
+ return buffer.getvalue()
+
+
+def _draw_pdf_row(pdf, *, label, value, y, width, height):
+ if y < 60:
+ pdf.showPage()
+ pdf.setFont("Vera", 10)
+ y = height - 50
+ text = f"{label}: {value}"
+ pdf.drawString(40, y, text[:180])
+ return y - 16
+
+
+def _build_work_order_pdf(work_order):
+ vehicle = work_order.vehicle
+ creator = work_order.creator
+ invoices = list(work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at'))
+ now_local = timezone.localtime(timezone.now())
+ tz = timezone.get_current_timezone()
+
+ def _fmt_date(value):
+ if not value:
+ return '-'
+ if hasattr(value, 'strftime'):
+ return value.strftime('%d.%m.%Y.')
+ return str(value)
+
+ def _fmt_time(value):
+ if not value:
+ return '-'
+ return value.astimezone(tz).strftime('%H:%M')
+
+ def _fmt_eur(value):
+ try:
+ return f"{float(value):.2f} €"
+ except (TypeError, ValueError):
+ return "0,00 €"
+
+ def _paragraph(text, *, bold=False, size=9, align=0):
+ styles = getSampleStyleSheet()
+ base = styles['BodyText'].clone('wo-p')
+ base.fontName = 'Vera-Bold' if bold else 'Vera'
+ base.fontSize = size
+ base.leading = size + 1.5
+ base.alignment = align
+ return Paragraph(text, base)
+
+ def _hours_between(start_at, end_at):
+ if not start_at or not end_at:
+ return 0.0
+ delta = end_at - start_at
+ return max(0.0, round(delta.total_seconds() / 3600.0, 2))
+
+ company_name = getattr(getattr(vehicle, 'client', None), 'name', None) or "KNEZ LJUBO d.o.o."
+ creator_name = _user_display_name(creator) or creator.email
+ creator_occupation = (getattr(creator, 'occupation', None) or '').strip() or (
+ "Servisni inženjer" if getattr(creator, 'is_serviser', False) else "Djelatnik"
+ )
+ creator_residence = (getattr(creator, 'residence', None) or '').strip() or "-"
+ creator_work_position = (getattr(creator, 'work_position', None) or '').strip() or creator_occupation
+ travel_start = work_order.travel_start_at
+ travel_end = work_order.travel_end_at
+ travel_hours = _hours_between(travel_start, travel_end)
+ daily_qty = round(travel_hours / 8.0, 1) if travel_hours > 0 else 0.0
+ daily_rate = 0.0
+ daily_total = daily_qty * daily_rate
+ transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0)
+ grand_total = daily_total + transport_total
+ place_label = (work_order.location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
+ invoice_names = [inv.naziv_racuna for inv in invoices[:5] if inv.naziv_racuna]
+ attachments_text = ', '.join(invoice_names) if invoice_names else '-'
+
+ buffer = BytesIO()
+ pdf = canvas.Canvas(buffer, pagesize=A4)
+ page_w, page_h = A4
+ margin = 16
+ content_w = page_w - (2 * margin)
+ y = page_h - 18
+
+ def draw_table(y_pos, data, widths, styles, row_heights=None):
+ table = Table(data, colWidths=widths, rowHeights=row_heights)
+ table.setStyle(TableStyle(styles))
+ _, table_h = table.wrap(content_w, page_h)
+ if y_pos - table_h < 28:
+ pdf.showPage()
+ y_pos = page_h - 18
+ table.drawOn(pdf, margin, y_pos - table_h)
+ return y_pos - table_h - 4
+
+ # 1) Zaglavlje + određujem da
+ section_1_top = [[
+ _paragraph(f"Trgovačko društvo
{company_name}", size=8.8),
+ _paragraph("N A L O G
ZA SLUŽBENO PUTOVANJE", bold=True, size=11, align=1),
+ _paragraph(
+ f"Mjesto i datum:
{place_label} {_fmt_date(now_local.date())}
"
+ f"Broj naloga:
{work_order.pk}",
+ size=8.8,
+ ),
+ ]]
+ y = draw_table(
+ y,
+ section_1_top,
+ [content_w * 0.305, content_w * 0.35, content_w * 0.345],
+ [
+ ('GRID', (0, 0), (-1, -1), 0.9, colors.black),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ('LEFTPADDING', (0, 0), (-1, -1), 4),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 4),
+ ('TOPPADDING', (0, 0), (-1, -1), 3),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 3),
+ ],
+ row_heights=[44],
+ )
+ y = draw_table(
+ y,
+ [[_paragraph("Određujem da:", size=11)]],
+ [content_w],
+ [
+ ('GRID', (0, 0), (-1, -1), 0.9, colors.black),
+ ('LEFTPADDING', (0, 0), (-1, -1), 3),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 3),
+ ('TOPPADDING', (0, 0), (-1, -1), 2),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 2),
+ ],
+ row_heights=[14],
+ )
+ y = draw_table(
+ y,
+ [[
+ _paragraph(f"Ime i prezime:
{creator_name}
Prebivalište:
{creator_residence}", size=8.7),
+ _paragraph(f"Zanimanje:
{creator_occupation}
Na radnom mjestu:
{creator_work_position}", size=8.7),
+ ]],
+ [content_w * 0.66, content_w * 0.34],
+ [
+ ('GRID', (0, 0), (-1, -1), 0.9, 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), 2),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 2),
+ ],
+ row_heights=[58],
+ )
+
+ # 2) Putuje dana / mjesto / zadatak
+ y = draw_table(
+ y,
+ [
+ ["Službeno otputuje dana:", "U mjesto:", "Sa zadaćom:"],
+ [
+ "1 dana" if travel_start else "-",
+ work_order.location or '-',
+ work_order.purpose or '-',
+ ],
+ ],
+ [content_w / 3, content_w / 3, content_w / 3],
+ [
+ ('GRID', (0, 0), (-1, -1), 0.9, colors.black),
+ ('FONTNAME', (0, 0), (-1, 0), 'Vera'),
+ ('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'),
+ ('FONTSIZE', (0, 0), (-1, -1), 8.6),
+ ('LEFTPADDING', (0, 0), (-1, -1), 3),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 3),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ],
+ row_heights=[14, 19],
+ )
+
+ # 3) Trajanje + kilometraže
+ y = draw_table(
+ y,
+ [
+ ["Putovanje može trajati:", "Datum polaska:", "Odobravamo upotrebu vozila:"],
+ [
+ "1 dana" if travel_start else "-",
+ _fmt_date(travel_start.date() if travel_start else work_order.date),
+ f"{work_order.servicer_vehicle_make_model or '-'}, {work_order.servicer_vehicle_registration or '-'}",
+ ],
+ ["Troškovi putovanja terete:", "Posebni dodaci (predujam):", "Početno stanje kilometara:"],
+ [
+ company_name,
+ "-",
+ str(work_order.servicer_vehicle_start_mileage or work_order.start_mileage or '-'),
+ ],
+ ["", "", "Završno stanje kilometara:"],
+ ["", "", str(work_order.servicer_vehicle_end_mileage or work_order.end_mileage or '-')],
+ ],
+ [content_w / 3, content_w / 3, content_w / 3],
+ [
+ ('GRID', (0, 0), (-1, -1), 0.9, colors.black),
+ ('FONTNAME', (0, 0), (-1, -1), 'Vera'),
+ ('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'),
+ ('FONTNAME', (0, 3), (2, 3), 'Vera-Bold'),
+ ('FONTNAME', (2, 5), (2, 5), 'Vera-Bold'),
+ ('FONTSIZE', (0, 0), (-1, -1), 8.5),
+ ('LEFTPADDING', (0, 0), (-1, -1), 3),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 3),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ],
+ row_heights=[14, 18, 14, 18, 12, 14],
+ )
+
+ # 4) Obračun putnih troškova
+ section4_top = [
+ ["OBRAČUN PUTNIH TROŠKOVA", "", "", "", "", "", "", ""],
+ ["ODLAZAK Datum", "ODLAZAK Vrijeme", "POVRATAK Datum", "POVRATAK Vrijeme", "Broj sati", "Količina dnevnica", "Iznos dnevnice", "Ukupan iznos"],
+ [
+ _fmt_date(travel_start.date() if travel_start else work_order.date),
+ _fmt_time(travel_start),
+ _fmt_date(travel_end.date() if travel_end else work_order.date),
+ _fmt_time(travel_end),
+ str(travel_hours).replace('.', ','),
+ str(daily_qty).replace('.', ','),
+ _fmt_eur(daily_rate),
+ _fmt_eur(daily_total),
+ ],
+ ]
+ for _ in range(3):
+ section4_top.append(["", "", "", "", "", "", "", ""])
+ y = draw_table(
+ y,
+ section4_top,
+ [content_w * 0.12, content_w * 0.09, content_w * 0.12, content_w * 0.09, content_w * 0.11, content_w * 0.13, content_w * 0.17, content_w * 0.17],
+ [
+ ('SPAN', (0, 0), (7, 0)),
+ ('GRID', (0, 0), (-1, -1), 0.9, colors.black),
+ ('FONTNAME', (0, 0), (7, 0), 'Vera-Bold'),
+ ('FONTNAME', (0, 1), (7, 1), 'Vera'),
+ ('FONTNAME', (0, 2), (7, 2), 'Vera-Bold'),
+ ('FONTSIZE', (0, 0), (-1, -1), 8.2),
+ ('ALIGN', (0, 0), (7, 0), 'CENTER'),
+ ('ALIGN', (0, 1), (7, -1), 'CENTER'),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ('LEFTPADDING', (0, 0), (-1, -1), 2),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 2),
+ ],
+ row_heights=[15, 14, 14, 12, 12, 12],
+ )
+ y = draw_table(
+ y,
+ [
+ ["RELACIJA od", "RELACIJA do", "Vrsta prijevoznog sredstva", "Razred [km]", "Iznos za prijevoz", "Ukupan iznos"],
+ [place_label or '-', work_order.location or '-', work_order.servicer_vehicle_make_model or '-', str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)],
+ ["", "", "", "", "", ""],
+ ["", "", "", "", "", ""],
+ ],
+ [content_w * 0.18, content_w * 0.18, content_w * 0.20, content_w * 0.10, content_w * 0.17, content_w * 0.17],
+ [
+ ('GRID', (0, 0), (-1, -1), 0.9, colors.black),
+ ('FONTNAME', (0, 0), (-1, 0), 'Vera'),
+ ('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'),
+ ('FONTSIZE', (0, 0), (-1, -1), 8.2),
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ('LEFTPADDING', (0, 0), (-1, -1), 2),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 2),
+ ],
+ row_heights=[14, 14, 12, 12],
+ )
+
+ # 5) Dodatni troškovi
+ additional_rows = [["DODATNI TROŠKOVI", "", ""], ["NAZIV", "Broj računa", "Ukupan iznos"]]
+ for inv in invoices[:5]:
+ additional_rows.append([inv.naziv_racuna or '-', str(inv.pk), "-"])
+ while len(additional_rows) < 7:
+ additional_rows.append(["", "", ""])
+ y = draw_table(
+ y,
+ additional_rows,
+ [content_w * 0.53, content_w * 0.30, content_w * 0.17],
+ [
+ ('SPAN', (0, 0), (2, 0)),
+ ('GRID', (0, 0), (-1, -1), 0.9, colors.black),
+ ('FONTNAME', (0, 0), (2, 1), 'Vera-Bold'),
+ ('FONTNAME', (0, 2), (-1, -1), 'Vera'),
+ ('FONTSIZE', (0, 0), (-1, -1), 8.4),
+ ('ALIGN', (0, 0), (2, 0), 'CENTER'),
+ ('ALIGN', (2, 1), (2, -1), 'CENTER'),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ('LEFTPADDING', (0, 0), (-1, -1), 3),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 3),
+ ],
+ row_heights=[14, 14, 12, 12, 12, 12, 12],
+ )
+
+ # 6) Prilozi + ukupna isplata
+ y = draw_table(
+ y,
+ [[
+ _paragraph(f"Prilozi:
{attachments_text}", size=8.5),
+ _paragraph(
+ f"Ukupno za isplatu: {_fmt_eur(grand_total)}
"
+ f"Isplaćeno: {_fmt_eur(0)}
"
+ f"Ostaje za isplatu: {_fmt_eur(grand_total)}",
+ size=9,
+ ),
+ ]],
+ [content_w * 0.53, content_w * 0.47],
+ [
+ ('GRID', (0, 0), (-1, -1), 0.9, colors.black),
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
+ ('LEFTPADDING', (0, 0), (-1, -1), 3),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 3),
+ ('TOPPADDING', (0, 0), (-1, -1), 3),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 3),
+ ],
+ row_heights=[48],
+ )
+
+ y -= 16
+
+ # Potpisi
+ if y < 64:
+ pdf.showPage()
+ y = page_h - 64
+ pdf.setFont("Vera", 9)
+ pdf.drawString(margin + 48, y, f"U {place_label} dana {_fmt_date(now_local.date())}")
+ y -= 20
+ sig_w = (content_w - 30) / 3
+ sig_labels = ["(Likvidator)", "(Podnositelj računa)", "(Isplatio blagajnik)"]
+ for i, label in enumerate(sig_labels):
+ x = margin + 10 + (i * sig_w)
+ pdf.line(x, y, x + sig_w - 24, y)
+ pdf.drawCentredString(x + (sig_w - 24) / 2, y - 12, label)
+ y -= 26
+ pdf.line(margin + 10, y, margin + sig_w - 14, y)
+ pdf.drawCentredString(margin + (sig_w - 4) / 2, y - 12, "(Primalac)")
+ pdf.line(margin + sig_w + 10, y, margin + 2 * sig_w - 14, y)
+ pdf.drawCentredString(margin + sig_w + (sig_w - 4) / 2, y - 12, "(Potpis direktora)")
+ pdf.drawString(margin + (2 * sig_w) + 34, y + 4, "M.P.")
+
+ pdf.save()
+ return buffer.getvalue()
+
+
+def _build_work_order_service_records_pdf(work_order):
+ vehicle = work_order.vehicle
+ client_name = getattr(vehicle.client, 'name', None) or '-'
+ model_str = ' '.join(filter(None, [vehicle.make, vehicle.model])) or '-'
+ 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(work_order.pk)
+ generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y')
+
+ HEADER_H = 86
+ FOOTER_H = 72
+ MARGIN = 28
+ DAY_MAP = {
+ 0: 'PO',
+ 1: 'UT',
+ 2: 'SR',
+ 3: 'ČET',
+ 4: 'PET',
+ 5: 'SUB',
+ 6: 'NED',
+ }
+
+ def _fmt_date(value):
+ if not value:
+ return '-'
+ return value.strftime('%d.%m.%Y')
+
+ def _fmt_time(value):
+ if not value:
+ return '-'
+ return value.astimezone(timezone.get_current_timezone()).strftime('%H:%M')
+
+ def _hours_between(start_at, end_at):
+ if not start_at or not end_at:
+ return 0.0
+ delta = end_at - start_at
+ return max(0.0, round(delta.total_seconds() / 3600.0, 2))
+
+ service_rows = list(
+ VehicleServiceRecord.objects.filter(
+ is_active=True,
+ vehicle_id=work_order.vehicle_id,
+ )
+ .select_related('performed_by', 'task')
+ .prefetch_related('photos')
+ .order_by('service_date', 'created_at')
+ )
+ related_tasks = list(_work_order_related_tasks_queryset(work_order))
+ travel_hours = _hours_between(work_order.travel_start_at, work_order.travel_end_at)
+ travel_km = work_order.distance if work_order.distance is not None else 0
+ task_titles = [row.task.title for row in service_rows if getattr(row, 'task', None) and row.task.title]
+ notes_text = ' | '.join(task_titles[:2]) if task_titles else (work_order.notes or '')
+
+ 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):
+ w, h = A4
+ page_num = self._current_page
+
+ self.setStrokeColor(colors.black)
+ self.setLineWidth(0.7)
+ self.rect(MARGIN, h - HEADER_H, w - 2 * MARGIN, HEADER_H - 4, stroke=1, fill=0)
+
+ self.setFont("Vera-Bold", 11)
+ self.drawString(MARGIN + 6, h - 22, client_name[:44])
+ self.drawCentredString(w / 2, h - 22, "Izvještaj servisera")
+ self.setFont("Vera", 9)
+ self.drawRightString(w - MARGIN - 6, h - 22, f"Stranica {page_num}")
+
+ self.setLineWidth(0.5)
+ self.line(MARGIN, h - 36, w - MARGIN, h - 36)
+
+ labels = [
+ "Model:",
+ "Serijski broj:",
+ "Radni sati nadogradnje:",
+ "Radni sati podvozja:",
+ "Km:",
+ "Broj naloga:",
+ ]
+ values = [
+ model_str,
+ serial_str,
+ upgrade_hours_str,
+ chassis_hours_str,
+ mileage_str,
+ nalog_str,
+ ]
+ col_w = (w - 2 * MARGIN) / len(labels)
+ self.setFont("Vera", 7.2)
+ for i, (lbl, val) in enumerate(zip(labels, values)):
+ x = MARGIN + i * col_w + 3
+ self.drawString(x, h - 49, lbl)
+ value_text = str(val or '-')
+ value_font_size = 9
+ if i == len(labels) - 1:
+ value_font_size = 7
+ max_value_width = col_w - 8
+ while (
+ self.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width
+ and value_font_size > 5.5
+ ):
+ value_font_size -= 0.3
+ if self.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width:
+ trimmed = value_text
+ while (
+ len(trimmed) > 3
+ and self.stringWidth(f"{trimmed}...", "Vera-Bold", value_font_size) > max_value_width
+ ):
+ trimmed = trimmed[:-1]
+ value_text = f"{trimmed}..."
+ else:
+ value_text = value_text[:24]
+ self.setFont("Vera-Bold", value_font_size)
+ self.drawString(x, h - 61, value_text)
+ self.setFont("Vera", 7.2)
+ if i > 0:
+ self.line(MARGIN + i * col_w, h - 36, MARGIN + i * col_w, h - HEADER_H + 4)
+
+ footer_y = FOOTER_H
+ self.setLineWidth(0.5)
+ self.rect(MARGIN, footer_y + 42, w - 2 * MARGIN, 18, stroke=1, fill=0)
+ sig_labels = [f"Datum: {generated_date_str}", "Potpis servisera", "Pečat i potpis klijenta *1", "Pregledao"]
+ sig_w = (w - 2 * MARGIN) / 4
+ self.setFont("Vera", 7.2)
+ for i, lbl in enumerate(sig_labels):
+ x = MARGIN + i * sig_w
+ self.drawString(x + 4, footer_y + 45, lbl)
+ if i > 0:
+ self.line(x, footer_y + 42, x, footer_y + 60)
+ self.setFont("Vera", 6.5)
+ self.drawString(MARGIN, footer_y + 26, "*1- potpisom klijent potvrđuje da je suglasan s podacima u radnom nalogu")
+ self.drawCentredString(
+ w / 2,
+ footer_y + 14,
+ "Ovlašteni servis LIEBHERR Werk-Ehingen GmbH, LIEBHERR-Werk Nenzing GmbH, Liebherr-MCCTech Rostock GmbH",
+ )
+
+ 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-description')
+ description_style.fontName = 'Vera'
+ description_style.fontSize = 8.2
+ description_style.leading = 10.4
+
+ y = content_top
+
+ table1 = [
+ ["Naziv Tvrtke:", client_name, "Lokacija intervencije:", work_order.location or '-'],
+ ["Serviser:", _user_display_name(work_order.creator) or '-', "Asistirao:", '-'],
+ ]
+ y = draw_table(
+ y,
+ table1,
+ [95, 165, 110, content_width - 370],
+ TableStyle([
+ ('FONTNAME', (0, 0), (-1, -1), 'Vera'),
+ ('FONTSIZE', (0, 0), (-1, -1), 8.5),
+ ('FONTNAME', (0, 0), (0, -1), 'Vera-Bold'),
+ ('FONTNAME', (2, 0), (2, -1), 'Vera-Bold'),
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.black),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ('LEFTPADDING', (0, 0), (-1, -1), 4),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 4),
+ ]),
+ )
+
+ completion_label = "Da ☒ Ne ☐" if work_order.status == 'closed' else "Da ☐ Ne ☒"
+ table2 = [
+ ["Prijevozno sredstvo:", "Registracija:", "Broj narudžbe klijenta:", "Posao završen"],
+ [work_order.servicer_vehicle_make_model or '-', work_order.servicer_vehicle_registration or '-', "-", completion_label],
+ ]
+ y = draw_table(
+ y,
+ table2,
+ [152, 100, 147, content_width - 399],
+ TableStyle([
+ ('FONTNAME', (0, 0), (-1, -1), 'Vera'),
+ ('FONTSIZE', (0, 0), (-1, -1), 8),
+ ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.black),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
+ ('LEFTPADDING', (0, 0), (-1, -1), 3),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 3),
+ ]),
+ row_heights=[17, 17],
+ )
+
+ table3_headers = [
+ "Dan",
+ "Datum",
+ "Vrijeme rada\nod-do",
+ "Vrijeme putovanja\nod-do",
+ "Pauza\nh",
+ "Sati\nrada",
+ "Sati\nputa",
+ "Mjesto polaska / dolaska",
+ "Kilometri\nvozila",
+ ]
+ table3_rows = [table3_headers]
+
+ if work_order.travel_start_at or work_order.travel_end_at:
+ day_idx = work_order.travel_start_at.weekday() if work_order.travel_start_at else None
+ table3_rows.append([
+ DAY_MAP.get(day_idx, '-'),
+ _fmt_date(work_order.travel_start_at.date() if work_order.travel_start_at else work_order.date),
+ "-",
+ f"{_fmt_time(work_order.travel_start_at)} - {_fmt_time(work_order.travel_end_at)}",
+ "-",
+ "-",
+ str(travel_hours).replace('.', ','),
+ work_order.location or '-',
+ str(travel_km or '-'),
+ ])
+
+ for record in service_rows[:6]:
+ day_idx = record.service_date.weekday() if record.service_date else None
+ table3_rows.append([
+ DAY_MAP.get(day_idx, '-'),
+ _fmt_date(record.service_date),
+ "-",
+ "-",
+ "-",
+ "-",
+ "-",
+ getattr(record.vehicle, 'registration_number', '-') or '-',
+ str(record.mileage or '-'),
+ ])
+
+ if len(table3_rows) == 1:
+ table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "-", "-"])
+
+ total_hours_travel = str(travel_hours).replace('.', ',')
+ for _ in range(6):
+ table3_rows.append(["", "", "", "", "", "", "", "", ""])
+ table3_rows.append(["UKUPNO", "", "", "", "", "", total_hours_travel, "", str(travel_km or 0)])
+ y = draw_table(
+ y,
+ table3_rows,
+ [28, 54, 72, 72, 36, 40, 40, 97, content_width - 439],
+ TableStyle([
+ ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
+ ('FONTNAME', (0, 1), (-1, -1), 'Vera'),
+ ('FONTSIZE', (0, 0), (-1, -1), 7.8),
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.black),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ('ALIGN', (0, 0), (0, -1), 'CENTER'),
+ ('ALIGN', (1, 0), (6, -1), 'CENTER'),
+ ('SPAN', (0, -1), (5, -1)),
+ ('ALIGN', (0, -1), (5, -1), 'LEFT'),
+ ('FONTNAME', (0, -1), (0, -1), 'Vera-Bold'),
+ ('FONTNAME', (6, -1), (6, -1), 'Vera-Bold'),
+ ('FONTNAME', (8, -1), (8, -1), 'Vera-Bold'),
+ ('LEFTPADDING', (0, 0), (-1, -1), 3),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 3),
+ ('LEFTPADDING', (0, -1), (5, -1), 12),
+ ]),
+ )
+
+ if y < content_bottom + 24:
+ y = new_page()
+ pdf.setFont("Vera", 9)
+ pdf.drawString(MARGIN, y, f"Napomene: {notes_text or '-'}")
+
+ # Sljedeća stranica: servisni zapisi.
+ y = new_page()
+ y -= 14
+ pdf.setFont("Vera-Bold", 12)
+ pdf.drawString(MARGIN, y, "Servisni zapisi")
+ y -= 18
+
+ if not related_tasks:
+ pdf.setFont("Vera", 9)
+ pdf.drawString(MARGIN, y, "Nema povezanih taskova za ovaj putni nalog.")
+ else:
+ for task in related_tasks:
+ task_records = [
+ row for row in service_rows
+ if getattr(row, 'task_id', None) == task.id
+ ]
+ if not task_records:
+ continue
+
+ if y < content_bottom + 60:
+ y = new_page()
+ pdf.setFont("Vera-Bold", 10)
+ pdf.drawString(MARGIN, y, (task.title or f"Task #{task.id}")[:95])
+ y -= 14
+
+ for record in task_records:
+ if y < content_bottom + 64:
+ y = new_page()
+
+ description_table = [[Paragraph(record.description or '-', description_style)]]
+ y = draw_table(
+ y,
+ [["Servisni opis"], *description_table],
+ [content_width],
+ TableStyle([
+ ('FONTNAME', (0, 0), (-1, -1), 'Vera'),
+ ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
+ ('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),
+ ]),
+ )
+
+ parts_table = [[Paragraph(record.parts or '-', description_style)]]
+ y = draw_table(
+ y,
+ [["Korišteni dijelovi"], *parts_table],
+ [content_width],
+ TableStyle([
+ ('FONTNAME', (0, 0), (-1, -1), 'Vera'),
+ ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'),
+ ('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 record.photos.filter(is_active=True).all() if photo.image]
+ if not photos:
+ continue
+ 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 Exception:
+ 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
+
+ pdf.save()
+ return buffer.getvalue()
+
+def _first_non_empty(*values):
+ for value in values:
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return None
+
+
+def _user_display_name(user):
+ if not user:
+ return None
+ full_name = user.get_full_name()
+ if full_name:
+ return full_name
+ if getattr(user, 'email', None):
+ return user.email
+ if getattr(user, 'username', None):
+ return user.username
+ return str(getattr(user, 'pk', ''))
+
+def _send_document_email(*, recipient, subject, body, filename, pdf_bytes):
+ if not recipient:
+ raise DRFValidationError({"recipient": "Email primatelja je obavezan."})
+ message = EmailMessage(
+ subject=subject,
+ body=body,
+ from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', None),
+ to=[recipient],
+ )
+ message.attach(filename, pdf_bytes, 'application/pdf')
+ message.send(fail_silently=False)
+
+
+def _notify_pdf_request(*, user, work_order, doc_type, stage):
+ if user is None:
+ return
+ if doc_type == 'work_order':
+ doc_label = "putnog naloga"
+ elif doc_type == 'service_records':
+ doc_label = "servisnih zapisa putnog naloga"
+ else:
+ doc_label = "računa putnog naloga"
+
+ if stage == 'requested':
+ title = f"Zahtjev za PDF {doc_label}"
+ message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za WO-{work_order.pk}."
+ level = "info"
+ else:
+ title = f"PDF spreman ({doc_label})"
+ message = f"PDF dokument je uspješno generiran za WO-{work_order.pk}."
+ level = "success"
+
+ NotificationService.create_notification(
+ recipient=user,
+ title=title,
+ message=message,
+ level=level,
+ send_email=False,
+ metadata={
+ "entity_type": "work_order_pdf",
+ "work_order_id": str(work_order.pk),
+ "vehicle_id": str(work_order.vehicle_id),
+ "section": "work-orders",
+ "pdf_type": doc_type,
+ "stage": stage,
+ },
+ )
+
+@api_view(['POST'])
+@permission_classes([permissions.IsAuthenticated])
+def pusher_auth(request):
+ """
+ Authenticate user for private Pusher channel.
+ Endpoint koji frontend poziva za auth na privatnom kanalu.
+ """
+ socket_id = request.data.get('socket_id')
+ channel_name = request.data.get('channel_name')
+
+ if not socket_id or not channel_name:
+ return Response({'error': 'Missing socket_id or channel_name'}, status=400)
+
+ auth_data = PusherService.authenticate_channel(
+ socket_id=socket_id,
+ channel_name=channel_name,
+ user_id=request.user.id
+ )
+
+ if not auth_data:
+ return Response({'error': 'Authentication failed'}, status=403)
+
+ return Response(auth_data)
+
+class VehicleViewSet(viewsets.ModelViewSet):
+ serializer_class = VehicleSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return _fleet_assets_queryset_for_user(self.request.user, Vehicle, asset_type='vehicle')
+
+
+class CraneViewSet(viewsets.ModelViewSet):
+ serializer_class = CraneSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return _fleet_assets_queryset_for_user(self.request.user, Crane, asset_type='crane')
+
+
+class WorkOrderViewSet(viewsets.ModelViewSet):
+ serializer_class = WorkOrderSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return _work_orders_queryset_for_user(self.request.user)
+
+ @action(detail=True, methods=['get'], url_path='images')
+ def images(self, request, pk=None):
+ work_order = self.get_object()
+ service_record_id = request.query_params.get('service_record_id')
+ quality = _parse_positive_int(
+ request.query_params.get('q'),
+ field_name='q',
+ default=75,
+ min_value=30,
+ max_value=95,
+ )
+ width = _parse_positive_int(
+ request.query_params.get('w'),
+ field_name='w',
+ default=1280,
+ min_value=160,
+ max_value=3840,
+ )
+ image_format, _ = _parse_format(request.query_params.get('fmt'))
+ work_order_task_ids = list(
+ work_order.work_order_tasks.filter(is_active=True).values_list('id', flat=True)
+ )
+
+ if not work_order_task_ids:
+ return Response({
+ "work_order_id": work_order.pk,
+ "vehicle_id": work_order.vehicle_id,
+ "service_record_id": service_record_id or None,
+ "images": [],
+ }, status=status.HTTP_200_OK)
+
+ photos_qs = VehicleServicePhoto.objects.filter(
+ is_active=True,
+ service_record__is_active=True,
+ service_record__vehicle_id=work_order.vehicle_id,
+ service_record__task_id__in=work_order_task_ids,
+ ).select_related('service_record', 'uploaded_by').order_by('-created_at')
+
+ if not request.user.is_staff:
+ photos_qs = photos_qs.filter(service_record__performed_by=request.user)
+
+ if service_record_id:
+ record = _service_records_queryset_for_user(request.user).filter(
+ pk=service_record_id,
+ vehicle_id=work_order.vehicle_id,
+ task_id__in=work_order_task_ids,
+ ).first()
+ if record is None:
+ raise DRFValidationError({
+ "service_record_id": "Servisni zapis nije pronađen ili ne pripada ovom nalogu."
+ })
+ photos_qs = photos_qs.filter(service_record_id=record.pk)
+
+ images = []
+ for photo in photos_qs:
+ optimized_path = reverse('service-photo-optimized', kwargs={'pk': photo.pk})
+ optimized_url = request.build_absolute_uri(
+ f"{optimized_path}?w={width}&q={quality}&fmt={image_format.lower()}"
+ )
+ original_url = request.build_absolute_uri(photo.image.url) if photo.image else None
+ images.append({
+ "id": photo.pk,
+ "service_record_id": photo.service_record_id,
+ "description": photo.description,
+ "original_url": original_url,
+ "optimized_url": optimized_url,
+ "created_at": photo.created_at,
+ })
+
+ return Response({
+ "work_order_id": work_order.pk,
+ "vehicle_id": work_order.vehicle_id,
+ "service_record_id": service_record_id or None,
+ "images": images,
+ }, status=status.HTTP_200_OK)
+
+ @action(detail=True, methods=['get'], url_path='pdf')
+ def pdf(self, request, pk=None):
+ work_order = self.get_object()
+ _notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='requested')
+ pdf_bytes = _build_work_order_pdf(work_order)
+ _notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='completed')
+ response = HttpResponse(pdf_bytes, content_type='application/pdf')
+ response['Content-Disposition'] = f'attachment; filename="{work_order.pk}.work-order.pdf"'
+ return response
+
+ @action(detail=True, methods=['get'], url_path='service-records-pdf')
+ def service_records_pdf(self, request, pk=None):
+ work_order = self.get_object()
+ _notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='requested')
+ pdf_bytes = _build_work_order_service_records_pdf(work_order)
+ _notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed')
+ response = HttpResponse(pdf_bytes, content_type='application/pdf')
+ response['Content-Disposition'] = f'attachment; filename="{work_order.pk}.work-order-service-records.pdf"'
+ return response
+
+ @action(detail=True, methods=['get'], url_path='invoices-pdf')
+ def invoices_pdf(self, request, pk=None):
+ work_order = self.get_object()
+ _notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='requested')
+ try:
+ task_result = build_work_order_invoices_pdf_task.delay(str(work_order.pk))
+ payload = task_result.get(timeout=45)
+ except KombuOperationalError:
+ payload = build_work_order_invoices_pdf_task.apply(args=[str(work_order.pk)]).get()
+ except CeleryTimeoutError:
+ raise DRFValidationError({"detail": "Generiranje PDF računa traje predugo. Pokušajte ponovno."})
+
+ if not isinstance(payload, dict) or payload.get('error'):
+ raise DRFValidationError({"detail": payload.get('error') if isinstance(payload, dict) else "Neuspješno generiranje PDF računa."})
+ pdf_b64 = payload.get('pdf_base64')
+ if not pdf_b64:
+ raise DRFValidationError({"detail": "PDF sadržaj računa nije dostupan."})
+
+ try:
+ pdf_bytes = base64.b64decode(pdf_b64)
+ except (ValueError, TypeError):
+ raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."})
+
+ filename = payload.get('filename') or f"{work_order.pk}.work-order-invoices.pdf"
+ _notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='completed')
+ response = HttpResponse(pdf_bytes, content_type='application/pdf')
+ response['Content-Disposition'] = f'attachment; filename="{filename}"'
+ return response
+
+ @action(detail=True, methods=['get'], url_path='pdf-preview')
+ def pdf_preview(self, request, pk=None):
+ work_order = self.get_object()
+ invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')
+ invoice_rows = []
+ for invoice in invoices:
+ image_url = request.build_absolute_uri(invoice.image.url) if invoice.image else None
+ datum = invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'
+ invoice_rows.append(
+ f"""
+
+ | {escape(invoice.naziv_racuna)} |
+ {escape(invoice.lokacija or '-')} |
+ {escape(datum)} |
+ {escape(invoice.opis or '-')} |
+ {f'Otvori sliku' if image_url else '-'} |
+
+ """
+ )
+
+ pdf_url = request.build_absolute_uri(reverse('workorder-pdf', kwargs={'pk': work_order.pk}))
+ invoices_pdf_url = request.build_absolute_uri(reverse('workorder-invoices-pdf', kwargs={'pk': work_order.pk}))
+ html = f"""
+
+
+
+
+
+ Računi putnog naloga WO-{work_order.pk}
+
+
+
+
+
Putni nalog WO-{work_order.pk} — Računi
+
Dizalica: {escape(work_order.vehicle.registration_number)} | Datum naloga: {escape(str(work_order.date))}
+
+
+
+
Popis računa
+ {('
| naziv_računa | lokacija | datum | opis | slika |
' + ''.join(invoice_rows) + '
') if invoice_rows else '
Za ovaj putni nalog još nema unesenih računa.
'}
+
+
+
+ """
+ return HttpResponse(html, content_type='text/html; charset=utf-8')
+
+ @action(detail=True, methods=['get'], url_path='task-service-context')
+ def task_service_context(self, request, pk=None):
+ work_order = self.get_object()
+ tasks = _work_order_related_tasks_queryset(work_order).prefetch_related(
+ 'service_records',
+ 'service_records__photos',
+ 'service_records__performed_by',
+ 'service_records__vehicle',
+ )
+ payload = []
+ for task in tasks:
+ records_payload = []
+ for record in task.service_records.filter(is_active=True, vehicle_id=work_order.vehicle_id).all():
+ photos_payload = []
+ for photo in record.photos.filter(is_active=True).all():
+ if not photo.image:
+ continue
+ optimized_path = reverse('service-photo-optimized', kwargs={'pk': photo.pk})
+ photos_payload.append({
+ 'id': photo.pk,
+ 'image_url': request.build_absolute_uri(photo.image.url),
+ 'optimized_url': request.build_absolute_uri(f"{optimized_path}?w=1280&q=75&fmt=webp"),
+ 'description': photo.description,
+ })
+ records_payload.append({
+ 'id': record.pk,
+ 'service_title': record.service_title,
+ 'description': record.description,
+ 'service_date': record.service_date,
+ 'cost': str(record.cost),
+ 'mileage': record.mileage,
+ 'performed_by_name': _user_display_name(record.performed_by),
+ 'vehicle_registration': getattr(record.vehicle, 'registration_number', None),
+ 'photos': photos_payload,
+ })
+ payload.append({
+ 'id': task.pk,
+ 'title': task.title,
+ 'status': task.status,
+ 'description': task.description,
+ 'assigned_to_name': _user_display_name(task.assigned_to),
+ 'vehicle_registration': getattr(task.vehicle, 'registration_number', None),
+ 'service_records': records_payload,
+ })
+
+ return Response({
+ 'work_order_id': work_order.pk,
+ 'tasks': payload,
+ }, status=status.HTTP_200_OK)
+
+ @action(detail=True, methods=['post'], url_path='send-email')
+ def send_email(self, request, pk=None):
+ work_order = self.get_object()
+ recipient = _first_non_empty(
+ request.data.get('recipient'),
+ getattr(getattr(work_order.vehicle, 'client', None), 'email', None),
+ getattr(work_order.creator, 'email', None),
+ )
+ if not recipient:
+ raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."})
+
+ pdf_bytes = _build_work_order_pdf(work_order)
+ subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog WO-{work_order.pk}"
+ body = _first_non_empty(request.data.get('message')) or "U prilogu je PDF putnog naloga."
+ _send_document_email(
+ recipient=recipient,
+ subject=subject,
+ body=body,
+ filename=f"{work_order.pk}.work-order.pdf",
+ pdf_bytes=pdf_bytes,
+ )
+ return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK)
+
+
+class VehicleServiceRecordViewSet(viewsets.ModelViewSet):
+ serializer_class = VehicleServiceRecordSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ user = self.request.user
+ return _service_records_queryset_for_user(user)
+
+ @action(detail=True, methods=['get'], url_path='pdf')
+ def pdf(self, request, pk=None):
+ service_record = self.get_object()
+ rows = [
+ ("ID", str(service_record.pk)),
+ ("Datum", str(service_record.service_date)),
+ ("Dizalica", service_record.vehicle.registration_number),
+ ("Serviser", service_record.performed_by.get_full_name() or service_record.performed_by.email),
+ ("Kilometraza", str(service_record.mileage)),
+ ("Sljedeci servis", str(service_record.next_service_due_at or '-')),
+ ("Trosak", str(service_record.cost)),
+ ("Opis", service_record.description or '-'),
+ ("Dijelovi", service_record.parts or '-'),
+ ]
+ pdf_bytes = _build_simple_pdf(f"Servisni zapis {service_record.pk}", rows)
+ response = HttpResponse(pdf_bytes, content_type='application/pdf')
+ response['Content-Disposition'] = f'attachment; filename="{service_record.pk}.service-record.pdf"'
+ return response
+
+ @action(detail=True, methods=['post'], url_path='send-email')
+ def send_email(self, request, pk=None):
+ service_record = self.get_object()
+ recipient = _first_non_empty(
+ request.data.get('recipient'),
+ getattr(getattr(service_record.vehicle, 'client', None), 'email', None),
+ getattr(service_record.performed_by, 'email', None),
+ )
+ if not recipient:
+ raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."})
+
+ rows = [
+ ("ID", str(service_record.pk)),
+ ("Datum", str(service_record.service_date)),
+ ("Dizalica", service_record.vehicle.registration_number),
+ ("Kilometraza", str(service_record.mileage)),
+ ("Trosak", str(service_record.cost)),
+ ("Opis", service_record.description or '-'),
+ ("Dijelovi", service_record.parts or '-'),
+ ]
+ pdf_bytes = _build_simple_pdf(f"Servisni zapis {service_record.pk}", rows)
+ subject = _first_non_empty(request.data.get('subject')) or f"Servisni zapis {service_record.pk}"
+ body = _first_non_empty(request.data.get('message')) or (
+ f"U prilogu je PDF servisnog zapisa. Poslano {timezone.now().strftime('%d.%m.%Y %H:%M')}."
+ )
+ _send_document_email(
+ recipient=recipient,
+ subject=subject,
+ body=body,
+ filename=f"{service_record.pk}.service-record.pdf",
+ pdf_bytes=pdf_bytes,
+ )
+ return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK)
+
+
+class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
+ serializer_class = WorkOrderInvoiceSerializer
+ permission_classes = [permissions.IsAuthenticated]
+ parser_classes = (MultiPartParser, FormParser)
+
+ def get_queryset(self):
+ user = self.request.user
+ work_order_id = self.request.query_params.get('work_order_id')
+ qs = WorkOrderInvoice.objects.select_related('work_order', 'work_order__vehicle', 'created_by').filter(is_active=True)
+ if user.is_staff:
+ if work_order_id:
+ qs = qs.filter(work_order_id=work_order_id)
+ return qs
+ allowed_orders = _work_orders_queryset_for_user(user).values('id')
+ qs = qs.filter(work_order_id__in=allowed_orders)
+ if work_order_id:
+ qs = qs.filter(work_order_id=work_order_id)
+ return qs
+
+ def perform_create(self, serializer):
+ work_order = serializer.validated_data.get('work_order')
+ if work_order is None:
+ raise DRFValidationError({"work_order": "Putni nalog je obavezan."})
+ allowed = _work_orders_queryset_for_user(self.request.user).filter(pk=work_order.pk).exists()
+ if not allowed:
+ raise DRFValidationError({"work_order": "Nemate dozvolu za odabrani putni nalog."})
+ serializer.save(created_by=self.request.user)
+
+ def perform_destroy(self, instance):
+ instance.is_active = False
+ instance.save(update_fields=['is_active'])
+
+class VehicleNotificationViewSet(viewsets.ReadOnlyModelViewSet):
+ """
+ List all notifications for the authenticated user and provide an action
+ to mark a notification as read.
+ """
+ serializer_class = VehicleNotificationSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ # Prikaži samo aktivne notifikacije za trenutnog korisnika
+ user = self.request.user
+ return VehicleNotification.objects.filter(recipient=user, is_active=True).order_by('-created_at')
+
+ @action(detail=True, methods=['post'], url_path='mark-read')
+ def mark_read(self, request, pk=None):
+ """
+ Označi notifikaciju kao pročitanu.
+ URL: POST /api/fleet/notifications/{pk}/mark-read/
+ """
+ notif = self.get_object()
+ # sigurnosna provjera: dozvoljeno samo primatelju
+ if notif.recipient != request.user:
+ raise PermissionDenied("Nemate dozvolu za izmjenu ove notifikacije.")
+
+ notif.is_read = True
+ notif.save(update_fields=['is_read'])
+
+ serializer = self.get_serializer(notif)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+ @action(detail=False, methods=['post'], url_path='mark-all-read')
+ def mark_all_read(self, request):
+ """
+ Opcionalno: označi sve notifikacije trenutnog korisnika kao pročitane.
+ URL: POST /api/fleet/notifications/mark-all-read/
+ """
+ user = request.user
+ qs = VehicleNotification.objects.filter(recipient=user, is_active=True, is_read=False)
+ updated_count = qs.update(is_read=True)
+ return Response({"marked": updated_count}, status=status.HTTP_200_OK)
+
+class VehicleServicePhotoViewSet(viewsets.ModelViewSet):
+ """
+ ViewSet za upravljanje fotografijama servisnih zapisa.
+ Delegira sve operacije u VehicleServicePhotoService.
+ Podržava multipart/form-data upload preko POST i PATCH za ažuriranje metapodataka.
+ """
+ queryset = VehicleServicePhoto.objects.filter(is_active=True)
+ serializer_class = VehicleServicePhotoSerializer
+ permission_classes = [permissions.IsAuthenticated]
+ parser_classes = (MultiPartParser, FormParser)
+
+ def get_queryset(self):
+ """Filtriraj po service_record ID-u ako je proslijeđen u query parametrima."""
+ service_record_id = self.request.query_params.get('service_record_id')
+ user = self.request.user
+ qs = VehicleServicePhoto.objects.filter(is_active=True)
+ if not user.is_staff:
+ qs = qs.filter(service_record__performed_by=user)
+ if service_record_id:
+ qs = qs.filter(service_record_id=service_record_id)
+ return qs
+
+ @action(detail=True, methods=['get'], url_path='optimized')
+ def optimized(self, request, pk=None):
+ photo = self.get_object()
+ if not photo.image:
+ raise DRFValidationError({"detail": "Fotografija nije dostupna."})
+
+ width = _parse_positive_int(
+ request.query_params.get('w'),
+ field_name='w',
+ default=1280,
+ min_value=160,
+ max_value=3840,
+ )
+ quality = _parse_positive_int(
+ request.query_params.get('q'),
+ field_name='q',
+ default=75,
+ min_value=30,
+ max_value=95,
+ )
+ image_format, extension = _parse_format(request.query_params.get('fmt'))
+
+ try:
+ photo.image.open('rb')
+ with Image.open(photo.image) as source:
+ image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy()
+ if width and image.width > width:
+ ratio = width / float(image.width)
+ target_height = max(1, int(image.height * ratio))
+ image = image.resize((width, target_height), Image.Resampling.LANCZOS)
+
+ buffer = BytesIO()
+ save_kwargs = {"format": image_format, "optimize": True}
+ if image_format in ('WEBP', 'JPEG'):
+ save_kwargs["quality"] = quality
+ image.save(buffer, **save_kwargs)
+ except (UnidentifiedImageError, OSError):
+ raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."})
+ finally:
+ photo.image.close()
+
+ response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}")
+ response['Cache-Control'] = 'private, max-age=86400'
+ response['Content-Disposition'] = f'inline; filename="photo-{photo.pk}.{extension}"'
+ return response
+
+ def perform_create(self, serializer):
+ """
+ Delegiraj stvaranje u VehicleServicePhotoService.
+ serializer.validated_data sadrži sve potrebne podatke; dodaj trenutnog korisnika.
+ """
+ try:
+ data = serializer.validated_data
+ if not _can_access_service_record(self.request.user, data.get('service_record')):
+ raise DRFValidationError({"service_record": "Nemate dozvolu za ovaj servisni zapis."})
+ photo = VehicleServicePhotoService.upload_photo(
+ data=data,
+ uploaded_by=self.request.user
+ )
+ serializer.instance = photo
+ except DRFValidationError:
+ raise
+ except Exception as e:
+ raise DRFValidationError({"detail": str(e)})
+
+ def perform_update(self, serializer):
+ """
+ Delegiraj ažuriranje u VehicleServicePhotoService.
+ """
+ try:
+ instance = serializer.instance
+ data = serializer.validated_data
+ photo = VehicleServicePhotoService.update_photo(
+ instance=instance,
+ data=data,
+ user=self.request.user
+ )
+ serializer.instance = photo
+ except DRFValidationError:
+ raise
+ except Exception as e:
+ raise DRFValidationError({"detail": str(e)})
+
+ def perform_destroy(self, instance):
+ """
+ Delegiraj soft-delete u VehicleServicePhotoService.
+ """
+ try:
+ VehicleServicePhotoService.delete_photo(
+ instance=instance,
+ user=self.request.user
+ )
+ except DRFValidationError:
+ raise
+ except Exception as e:
+ raise DRFValidationError({"detail": str(e)})
+
+class VehicleServiceAttachmentViewSet(viewsets.ModelViewSet):
+ queryset = VehicleServiceAttachment.objects.filter(is_active=True)
+ serializer_class = VehicleServiceAttachmentSerializer
+ permission_classes = [permissions.IsAuthenticated]
+ parser_classes = (MultiPartParser, FormParser)
+
+ def get_queryset(self):
+ user = self.request.user
+ service_record_id = self.request.query_params.get('service_record_id')
+ qs = VehicleServiceAttachment.objects.filter(is_active=True)
+ if not user.is_staff:
+ qs = qs.filter(service_record__performed_by=user)
+ if service_record_id:
+ qs = qs.filter(service_record_id=service_record_id)
+ return qs
+
+ def perform_create(self, serializer):
+ if not _can_access_service_record(self.request.user, serializer.validated_data.get('service_record')):
+ raise DRFValidationError({"service_record": "Nemate dozvolu za ovaj servisni zapis."})
+ serializer.save(uploaded_by=self.request.user)
+
+ def perform_destroy(self, instance):
+ instance.is_active = False
+ instance.save(update_fields=['is_active'])
\ No newline at end of file
diff --git a/backend/modules/invoicing/__init__.py b/backend/modules/invoicing/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/modules/invoicing/admin.py b/backend/modules/invoicing/admin.py
new file mode 100644
index 0000000..79acb0e
--- /dev/null
+++ b/backend/modules/invoicing/admin.py
@@ -0,0 +1,43 @@
+# backend/modules/invoicing/admin.py
+
+from django.contrib import admin
+from .models import InvoiceTransaction
+
+@admin.register(InvoiceTransaction)
+class InvoiceTransactionEntryAdmin(admin.ModelAdmin):
+ """
+ Konfiguracija prikaza financijskih zapisa u Admin panelu.
+ """
+ # Prikaz stupaca u listi
+ list_display = ('invoice_number', 'work_order_ref', 'amount', 'created_at', 'is_active')
+
+ # Omogućuje brzo filtriranje
+ list_filter = ('created_at', 'is_active', 'work_order')
+ list_select_related = ('work_order__vehicle',)
+
+ # Omogućuje pretragu
+ search_fields = ('invoice_number', 'work_order__id', 'work_order__vehicle__registration_number')
+
+ # Polja koja se ne smiju mijenjati u adminu
+ readonly_fields = ('id', 'created_at', 'updated_at')
+
+ # Grupiranje polja za lakši pregled
+ fieldsets = (
+ (None, {
+ 'fields': ('invoice_number', 'work_order', 'amount', 'payment_date', 'is_active')
+ }),
+ ('Sustavni podaci', {
+ 'fields': ('id', 'created_at', 'updated_at'),
+ 'classes': ('collapse',)
+ }),
+ )
+
+ @admin.display(description="Povezani putni nalog")
+ def work_order_ref(self, obj):
+ if not obj.work_order_id:
+ return "—"
+ short_id = str(obj.work_order_id).split('-')[0].upper()
+ registration = getattr(obj.work_order.vehicle, 'registration_number', '')
+ if registration:
+ return f"WO-{short_id} ({registration})"
+ return f"WO-{short_id}"
\ No newline at end of file
diff --git a/backend/modules/invoicing/apps.py b/backend/modules/invoicing/apps.py
new file mode 100644
index 0000000..1ac53db
--- /dev/null
+++ b/backend/modules/invoicing/apps.py
@@ -0,0 +1,8 @@
+# /backend/modules/invoicing/apps.py
+
+from django.apps import AppConfig
+
+class FleetConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'modules.invoicing'
+ verbose_name = "Invoicing"
\ No newline at end of file
diff --git a/backend/modules/invoicing/migrations/0001_initial.py b/backend/modules/invoicing/migrations/0001_initial.py
new file mode 100644
index 0000000..c275010
--- /dev/null
+++ b/backend/modules/invoicing/migrations/0001_initial.py
@@ -0,0 +1,30 @@
+# Generated by Django 5.2.15 on 2026-06-29 13:34
+
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='FinancialEntry',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
+ ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
+ ('invoice_number', models.CharField(max_length=50)),
+ ('amount', models.DecimalField(decimal_places=2, max_digits=10)),
+ ],
+ options={
+ 'verbose_name': 'Financijski zapis',
+ 'verbose_name_plural': 'Financijski zapisi',
+ },
+ ),
+ ]
diff --git a/backend/modules/invoicing/migrations/0002_invoice_invoiceitem.py b/backend/modules/invoicing/migrations/0002_invoice_invoiceitem.py
new file mode 100644
index 0000000..bc543f0
--- /dev/null
+++ b/backend/modules/invoicing/migrations/0002_invoice_invoiceitem.py
@@ -0,0 +1,56 @@
+# Generated by Django 5.2.15 on 2026-06-30 05:55
+
+import django.db.models.deletion
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('crm', '0001_initial'),
+ ('invoicing', '0001_initial'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Invoice',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
+ ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
+ ('invoice_number', models.CharField(max_length=50, unique=True, verbose_name='Broj fakture')),
+ ('amount', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Iznos')),
+ ('currency', models.CharField(default='EUR', max_length=3)),
+ ('tax_rate', models.DecimalField(decimal_places=2, default=25.0, max_digits=4)),
+ ('status', models.CharField(choices=[('draft', 'Nacrt'), ('sent', 'Poslano'), ('paid', 'Plaćeno'), ('cancelled', 'Stornirano')], default='draft', max_length=20)),
+ ('due_date', models.DateField(verbose_name='Rok plaćanja')),
+ ('client', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='crm.client', verbose_name='Klijent')),
+ ('creator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name': 'Faktura',
+ 'verbose_name_plural': 'Fakture',
+ },
+ ),
+ migrations.CreateModel(
+ name='InvoiceItem',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
+ ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
+ ('description', models.CharField(max_length=255, verbose_name='Opis stavke')),
+ ('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Količina')),
+ ('unit_price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Cijena po jedinici')),
+ ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='invoicing.invoice', verbose_name='Faktura')),
+ ],
+ options={
+ 'verbose_name': 'Stavka fakture',
+ 'verbose_name_plural': 'Stavke fakture',
+ },
+ ),
+ ]
diff --git a/backend/modules/invoicing/migrations/0003_invoicetransaction_delete_financialentry.py b/backend/modules/invoicing/migrations/0003_invoicetransaction_delete_financialentry.py
new file mode 100644
index 0000000..c468206
--- /dev/null
+++ b/backend/modules/invoicing/migrations/0003_invoicetransaction_delete_financialentry.py
@@ -0,0 +1,34 @@
+# Generated by Django 5.2.15 on 2026-06-30 17:54
+
+import django.db.models.deletion
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('invoicing', '0002_invoice_invoiceitem'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='InvoiceTransaction',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
+ ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
+ ('invoice_number', models.CharField(max_length=50)),
+ ('amount', models.DecimalField(decimal_places=2, max_digits=10)),
+ ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transaction', to='invoicing.invoice', verbose_name='Transaction')),
+ ],
+ options={
+ 'verbose_name': 'Financijski zapis',
+ 'verbose_name_plural': 'Financijski zapisi',
+ },
+ ),
+ migrations.DeleteModel(
+ name='FinancialEntry',
+ ),
+ ]
diff --git a/backend/modules/invoicing/migrations/0004_invoicetransaction_payment_date_and_more.py b/backend/modules/invoicing/migrations/0004_invoicetransaction_payment_date_and_more.py
new file mode 100644
index 0000000..dd38f5d
--- /dev/null
+++ b/backend/modules/invoicing/migrations/0004_invoicetransaction_payment_date_and_more.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.2.15 on 2026-06-30 18:12
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('invoicing', '0003_invoicetransaction_delete_financialentry'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='invoicetransaction',
+ name='payment_date',
+ field=models.DateField(auto_now_add=True, default=django.utils.timezone.now),
+ preserve_default=False,
+ ),
+ migrations.AlterField(
+ model_name='invoicetransaction',
+ name='invoice',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='invoicing.invoice', verbose_name='Faktura'),
+ ),
+ ]
diff --git a/backend/modules/invoicing/migrations/0005_alter_invoicetransaction_payment_date.py b/backend/modules/invoicing/migrations/0005_alter_invoicetransaction_payment_date.py
new file mode 100644
index 0000000..e2cfbe7
--- /dev/null
+++ b/backend/modules/invoicing/migrations/0005_alter_invoicetransaction_payment_date.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.2.15 on 2026-06-30 18:13
+
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('invoicing', '0004_invoicetransaction_payment_date_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='invoicetransaction',
+ name='payment_date',
+ field=models.DateField(default=django.utils.timezone.now),
+ ),
+ ]
diff --git a/backend/modules/invoicing/migrations/0006_invoicetransaction_work_order.py b/backend/modules/invoicing/migrations/0006_invoicetransaction_work_order.py
new file mode 100644
index 0000000..b9197c6
--- /dev/null
+++ b/backend/modules/invoicing/migrations/0006_invoicetransaction_work_order.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.2.15 on 2026-07-07
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0012_workorder_purpose_choices'),
+ ('invoicing', '0005_alter_invoicetransaction_payment_date'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='invoicetransaction',
+ name='work_order',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='invoice_transactions',
+ to='fleet.workorder',
+ verbose_name='Putni nalog',
+ ),
+ ),
+ ]
+
diff --git a/backend/modules/invoicing/migrations/__init__.py b/backend/modules/invoicing/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/modules/invoicing/models.py b/backend/modules/invoicing/models.py
new file mode 100644
index 0000000..85e085a
--- /dev/null
+++ b/backend/modules/invoicing/models.py
@@ -0,0 +1,123 @@
+# backend/modules/invoicing/models.py
+
+import datetime
+from django.utils import timezone
+from core.base_models import BaseModel
+from django.db import models, transaction
+from django.utils.translation import gettext_lazy as _
+from django.conf import settings
+from .utils import generate_invoice_number
+
+class Invoice(BaseModel):
+ # Identifikacija (UUID i created_at dolaze iz BaseModel)
+ invoice_number = models.CharField(max_length=50, unique=True, verbose_name=_("Broj fakture"))
+
+ # Relacije
+ creator = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.PROTECT,
+ related_name="invoices"
+ )
+ client = models.ForeignKey(
+ 'crm.Client',
+ on_delete=models.PROTECT,
+ verbose_name=_("Klijent")
+ )
+
+ # Financijski detalji
+ amount = models.DecimalField(max_digits=12, decimal_places=2, verbose_name=_("Iznos"))
+ currency = models.CharField(max_length=3, default="EUR")
+ tax_rate = models.DecimalField(max_digits=4, decimal_places=2, default=25.00)
+
+ # Statusi
+ status = models.CharField(
+ max_length=20,
+ choices=[
+ ('draft', 'Nacrt'),
+ ('sent', 'Poslano'),
+ ('paid', 'Plaćeno'),
+ ('cancelled', 'Stornirano')
+ ],
+ default='draft'
+ )
+
+ due_date = models.DateField(verbose_name=_("Rok plaćanja"))
+
+ class Meta:
+ # Nasljeđujemo ordering iz BaseModel, ali ga možemo override-ati
+ verbose_name = _("Faktura")
+ verbose_name_plural = _("Fakture")
+
+ def __str__(self):
+ return f"{self.invoice_number} - {self.client}"
+
+ def get_total_amount(self):
+ return self.items.aggregate(total=models.Sum(models.F('quantity') * models.F('unit_price')))['total'] or 0
+
+ def save(self, *args, **kwargs):
+ if not self.invoice_number:
+ self.invoice_number = generate_invoice_number()
+ super().save(*args, **kwargs)
+
+class InvoiceItem(BaseModel):
+ # Relacija prema fakturi
+ invoice = models.ForeignKey(
+ Invoice,
+ on_delete=models.CASCADE,
+ related_name='items',
+ verbose_name=_("Faktura")
+ )
+
+ # Detalji stavke
+ description = models.CharField(
+ max_length=255,
+ verbose_name=_("Opis stavke")
+ )
+ quantity = models.DecimalField(
+ max_digits=10,
+ decimal_places=2,
+ default=1,
+ verbose_name=_("Količina")
+ )
+ unit_price = models.DecimalField(
+ max_digits=12,
+ decimal_places=2,
+ verbose_name=_("Cijena po jedinici")
+ )
+
+ # Automatski izračun (property ne zauzima prostor u bazi)
+ @property
+ def total(self):
+ return self.quantity * self.unit_price
+
+ class Meta:
+ verbose_name = _("Stavka fakture")
+ verbose_name_plural = _("Stavke fakture")
+
+ def __str__(self):
+ # Koristimo prednost BaseModel __str__ metode
+ return f"{self.description} ({self.quantity} x {self.unit_price})"
+
+class InvoiceTransaction(BaseModel):
+ # Polja specifična za fakturiranje
+ invoice = models.ForeignKey(
+ Invoice,
+ on_delete=models.PROTECT,
+ related_name='transactions',
+ verbose_name=_("Faktura")
+ )
+ work_order = models.ForeignKey(
+ 'fleet.WorkOrder',
+ on_delete=models.SET_NULL,
+ related_name='invoice_transactions',
+ null=True,
+ blank=True,
+ verbose_name=_("Putni nalog"),
+ )
+ invoice_number = models.CharField(max_length=50)
+ amount = models.DecimalField(max_digits=10, decimal_places=2)
+ payment_date = models.DateField(default=timezone.now)
+
+ class Meta:
+ verbose_name = "Financijski zapis"
+ verbose_name_plural = "Financijski zapisi"
\ No newline at end of file
diff --git a/backend/modules/invoicing/serializers.py b/backend/modules/invoicing/serializers.py
new file mode 100644
index 0000000..a58b2ec
--- /dev/null
+++ b/backend/modules/invoicing/serializers.py
@@ -0,0 +1,57 @@
+from django.db import transaction
+from core.serializers import BaseSerializer
+from rest_framework import serializers
+from .models import Invoice, InvoiceItem, InvoiceTransaction
+from .services import InvoiceService #
+
+class InvoiceItemSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = InvoiceItem
+ fields = ['description', 'quantity', 'unit_price']
+
+class InvoiceSerializer(serializers.ModelSerializer):
+ items = InvoiceItemSerializer(many=True) # Nested lista stavki
+
+ class Meta:
+ model = Invoice
+ fields = ['invoice_number', 'client', 'due_date', 'status', 'items', 'amount']
+ read_only_fields = ['invoice_number', 'creator', 'status', 'amount']
+ extra_kwargs = {
+ 'invoice_number': {'required': False, 'allow_null': True},
+ 'creator': {'required': False, 'allow_null': True}
+ }
+
+ def create(self, validated_data):
+ """
+ Delegiramo stvaranje servisu i vraćamo instancu Invoice.
+ """
+ items = validated_data.pop('items', [])
+ # Servisu šaljemo items u validated_data kao što servis očekuje
+ validated_data['items'] = items
+
+ # Pokušavamo dohvatiti korisnika iz contexta (ako postoji)
+ request = self.context.get('request')
+ user = getattr(request, 'user', None)
+
+ # Pozivamo servis unutar transaction.atomic (servis već radi atomic, ali dodatno osiguranje nije štetno)
+ invoice = InvoiceService.create_full_invoice(user=user, **validated_data)
+ return invoice
+
+class InvoiceTransactionSerializer(BaseSerializer):
+ invoice = serializers.PrimaryKeyRelatedField(queryset=Invoice.objects.all())
+
+ def validate_amount(self, value):
+ request = self.context.get('request')
+ # Ako request ne postoji (npr. unit test), tretiramo ga kao običnog korisnika (sigurnost prvo)
+ user = getattr(request, 'user', None)
+
+ # Ako user ne postoji ili nije staff, i iznos je <= 0 -> greška
+ if value <= 0 and (not user or not user.is_staff):
+ raise serializers.ValidationError("Iznos računa mora biti veći od 0.")
+
+ return value
+
+ class Meta:
+ model = InvoiceTransaction
+ fields = ['invoice', 'work_order', 'invoice_number', 'amount', 'payment_date', 'is_active']
+ read_only_fields = ['id', 'created_at', 'updated_at']
\ No newline at end of file
diff --git a/backend/modules/invoicing/services.py b/backend/modules/invoicing/services.py
new file mode 100644
index 0000000..f2f558d
--- /dev/null
+++ b/backend/modules/invoicing/services.py
@@ -0,0 +1,77 @@
+from django.utils import timezone
+from modules.invoicing.models import Invoice, InvoiceItem
+from modules.invoicing.utils import generate_invoice_number
+from django.db import transaction
+from django.contrib.auth import get_user_model
+from rest_framework.exceptions import ValidationError
+import logging
+from decimal import Decimal
+from modules.invoicing import tasks # premještamo ovdje ako treba
+
+logger = logging.getLogger(__name__)
+
+class InvoiceService:
+ @staticmethod
+ def create_full_invoice(user=None, **validated_data):
+ # 1. Obavezno dohvati stavke
+ items_data = validated_data.pop('items', [])
+
+ fields_to_remove = ['amount', 'creator', 'invoice_number']
+ for field in fields_to_remove:
+ validated_data.pop(field, None)
+
+ # 2. Sigurna provjera korisnika
+ if not user:
+ User = get_user_model()
+ try:
+ user = User.objects.get(username="paperless_bot")
+ except User.DoesNotExist:
+ raise ValidationError("Sistemski korisnik 'paperless_bot' ne postoji.")
+
+ with transaction.atomic():
+ # 3. Računanje iznosa (sigurno rukovanje s items_data)
+ total = sum(Decimal(item['quantity']) * Decimal(item['unit_price']) for item in items_data)
+
+ # 4. Kreiraj fakturu
+ invoice = Invoice.objects.create(
+ **validated_data,
+ amount=total,
+ creator=user,
+ invoice_number=generate_invoice_number()
+ )
+
+ # 5. Kreiraj stavke
+ for item_data in items_data:
+ InvoiceItem.objects.create(invoice=invoice, **item_data)
+
+ # 6. Pokreni task nakon commit-a
+ transaction.on_commit(lambda: tasks.process_paperless_document_task.delay(invoice.id))
+
+ return invoice
+
+ @staticmethod
+ def create_invoice_entry(data, user=None):
+ """
+ Kreira financijski zapis (transaction) na temelju validated data.
+ Očekuje se da data['invoice'] može biti instanca ili PK.
+ """
+ from modules.invoicing.models import InvoiceTransaction, Invoice
+
+ invoice = data.get("invoice")
+ if isinstance(invoice, int):
+ invoice = Invoice.objects.get(pk=invoice)
+ elif hasattr(invoice, "pk") is False:
+ raise ValidationError("Nevažeći invoice.")
+
+ amount = data.get("amount")
+ payment_date = data.get("payment_date", None)
+ work_order = data.get("work_order")
+
+ tx = InvoiceTransaction.objects.create(
+ invoice=invoice,
+ work_order=work_order,
+ invoice_number=invoice.invoice_number,
+ amount=amount,
+ payment_date=payment_date or timezone.now()
+ )
+ return tx
\ No newline at end of file
diff --git a/backend/modules/invoicing/tasks.py b/backend/modules/invoicing/tasks.py
new file mode 100644
index 0000000..dac2b62
--- /dev/null
+++ b/backend/modules/invoicing/tasks.py
@@ -0,0 +1,81 @@
+import uuid
+from celery import shared_task
+from datetime import date, timedelta
+from infrastructure.paperless_client import PaperlessGateway
+from core.utils import format_currency, parse_iso_date
+from django.contrib.auth import get_user_model
+import logging
+from decimal import Decimal
+from modules.crm.models import Client
+
+logger = logging.getLogger(__name__)
+
+@shared_task(bind=True, max_retries=3)
+def process_paperless_document_task(self, document_id):
+ """
+ Celery zadatak koji povezuje infrastrukturu i poslovnu logiku.
+ """
+ gateway = PaperlessGateway() # rename so we don't overwrite 'client'
+
+ try:
+ User = get_user_model()
+ # 1. Dohvat iz infrastrukture
+ raw_data = gateway.get_document_metadata(document_id)
+ raw_created = parse_iso_date(raw_data.get("created"))
+ correspondent_name = raw_data.get("correspondent")
+
+ # pokušaj dohvatiti crm klijenta
+ try:
+ crm_client = Client.objects.get(name=correspondent_name)
+ except Client.DoesNotExist:
+ logger.error(f"Klijent {correspondent_name} nije pronađen!")
+ # Kreiramo fallback klijenta ali popunimo UNIQUE polja jedinstvenim vrijednostima
+ unique_email = f"client+{uuid.uuid4()}@example.test"
+ unique_tax_id = str(uuid.uuid4())
+ crm_client, created = Client.objects.get_or_create(
+ name=correspondent_name,
+ defaults={
+ "email": unique_email,
+ "tax_id": unique_tax_id,
+ }
+ )
+ if created:
+ logger.info(f"Fallback klijent kreiran za '{correspondent_name}' (id={crm_client.id}).")
+
+ # 2. Transformacija i čišćenje podataka
+ total_raw = raw_data.get("total_amount", "0")
+ try:
+ amount_decimal = Decimal(str(total_raw))
+ except Exception:
+ amount_decimal = Decimal("0")
+
+ processed_data = {
+ "client": crm_client,
+ "invoice_number": raw_data.get("invoice_number") or raw_data.get("correspondent"),
+ "due_date": (raw_created or date.today()) + timedelta(days=30),
+ "amount": amount_decimal,
+ "created_at": raw_created,
+ }
+
+ from modules.invoicing.services import InvoiceService
+
+ lookup_field = User.USERNAME_FIELD
+ system_user = User.objects.filter(**{lookup_field: "bot@erp.hr"}).first()
+
+ if not system_user:
+ system_user = User(**{lookup_field: "bot@erp.hr"})
+ system_user.email = "bot@erp.hr"
+ system_user.first_name = "Paperless"
+ system_user.last_name = "Bot"
+ system_user.set_unusable_password()
+ system_user.save()
+
+ # Pozovi servis uvijek nakon što garantujemo da system_user postoji
+ InvoiceService.create_full_invoice(user=system_user, **processed_data)
+
+ logger.info(f"Dokument {document_id} uspješno obrađen.")
+
+ except Exception as exc:
+ logger.error(f"Greška pri obradi dokumenta {document_id}: {exc}")
+ # Automatski retry ako padne
+ raise self.retry(exc=exc, countdown=60)
\ No newline at end of file
diff --git a/backend/modules/invoicing/tests/conftest.py b/backend/modules/invoicing/tests/conftest.py
new file mode 100644
index 0000000..d07cfab
--- /dev/null
+++ b/backend/modules/invoicing/tests/conftest.py
@@ -0,0 +1,112 @@
+# backend/modules/invoicing/tests/conftest.py
+
+import pytest
+from django.contrib.auth import get_user_model
+from modules.invoicing.models import Invoice
+from unittest.mock import MagicMock
+from django.conf import settings
+from core.celery import app as celery_app
+import modules.invoicing.tasks as tasks
+import uuid
+
+User = get_user_model()
+
+@pytest.fixture
+def user():
+ # Dodan username
+ return User.objects.create_user(email="test@test.hr", password="password")
+
+@pytest.fixture
+def api_client(user):
+ from rest_framework.test import APIClient
+ client = APIClient()
+ client.force_authenticate(user=user)
+ return client
+
+@pytest.fixture
+def staff_user():
+ return User.objects.create_superuser(email='admin@erp.hr', password='password', first_name='Admin', last_name='User')
+
+@pytest.fixture
+def regular_user():
+ return User.objects.create_user(email='user@erp.hr', password='password', first_name='User', last_name='User')
+
+@pytest.fixture
+def serviser_a():
+ return User.objects.create_user(email="a@test.hr", password="password", first_name='A', last_name='User')
+
+@pytest.fixture
+def serviser_b():
+ return User.objects.create_user(email="b@test.hr", password="password", first_name='B', last_name='User')
+
+@pytest.fixture
+def test_client():
+ from modules.crm.models import Client
+ return Client.objects.create(name="Testni Klijent", tax_id="12345678901")
+
+@pytest.fixture
+def invoice_a(serviser_a, test_client): # Dodali smo test_client
+ from modules.invoicing.utils import generate_invoice_number
+ return Invoice.objects.create(
+ invoice_number=generate_invoice_number(),
+ creator=serviser_a,
+ client=test_client, # OVDJE JE BIO PROBLEM (bio je null)
+ amount=100.00,
+ due_date="2026-12-31"
+ )
+
+@pytest.fixture(autouse=True)
+def paperless_settings(settings):
+ # Postavi privremene vrijednosti za vrijeme trajanja testa
+ settings.PAPERLESS_API_URL = "http://mock-paperless.local"
+ settings.PAPERLESS_API_TOKEN = "mock-token"
+
+@pytest.fixture(autouse=True)
+def force_celery_eager(settings):
+ settings.CELERY_TASK_ALWAYS_EAGER = True
+ settings.CELERY_TASK_EAGER_PROPAGATES = True
+
+ # Ovo je ključ: prisilno ažuriraj konfiguraciju Celery aplikacije
+ celery_app.conf.update(
+ task_always_eager=True,
+ task_eager_propagates=True,
+ )
+
+@pytest.fixture(autouse=True)
+def mock_paperless_gateway(monkeypatch):
+ """
+ Koristi monkeypatch za zamjenu PaperlessGateway klase.
+ """
+ mock_client = MagicMock()
+ mock_client.get_document_metadata.return_value = {
+ "correspondent": "INV-2026-TEST",
+ "total_amount": "100.00",
+ "created": "2026-07-01"
+ }
+
+ # Kreiramo tvornicu koja vraća naš mock
+ MockGateway = MagicMock(return_value=mock_client)
+
+ # Zamijeni klasu unutar tasks modula
+ monkeypatch.setattr(tasks, "PaperlessGateway", MockGateway)
+
+ yield MockGateway
+
+@pytest.fixture
+def mock_client(db):
+ """Kreira klijenta kojeg koristi PaperlessGateway u testovima."""
+ from modules.crm.models import Client
+ return Client.objects.create(name="INV-2026-TEST") # Ime mora odgovarati mocku iz conftest.py
+
+@pytest.fixture
+def create_unique_client(db):
+ """Factory funkcija za kreiranje unikatnog klijenta."""
+ from modules.crm.models import Client
+ def _create_client(name):
+ return Client.objects.create(
+ name=name,
+ tax_id=str(uuid.uuid4()), # Garantira unikatnost
+ email=f"{uuid.uuid4()}@test.hr" # Garantira unikatnost
+ )
+ return _create_client
+
diff --git a/backend/modules/invoicing/tests/test_api.py b/backend/modules/invoicing/tests/test_api.py
new file mode 100644
index 0000000..d3bb8ab
--- /dev/null
+++ b/backend/modules/invoicing/tests/test_api.py
@@ -0,0 +1,46 @@
+import uuid
+from django.test import TestCase
+from django.urls import reverse
+from rest_framework.test import APIClient
+from django.contrib.auth import get_user_model
+from decimal import Decimal
+from datetime import date
+
+from modules.crm.models import Client
+from modules.invoicing.models import Invoice
+
+
+class InvoiceAPITests(TestCase):
+ def setUp(self):
+ self.api_client = APIClient()
+ User = get_user_model()
+ # Kreiraj test korisnika (ako treba autentikacija)
+ self.user = User.objects.create_user(
+ username=f"user_{uuid.uuid4().hex[:8]}",
+ email=f"user+{uuid.uuid4().hex[:8]}@example.test",
+ password="pass"
+ )
+ # Kreiraj jedinstvenog klijenta (email s uuid kako bismo izbjegli duplicate key)
+ self.client_obj = Client.objects.create(
+ name="API Test Client",
+ email=f"client+{uuid.uuid4()}@example.test"
+ )
+ # autentikacija ako je endpoint zaštićen
+ self.api_client.force_authenticate(user=self.user)
+
+ def test_api_creates_invoice_with_id(self):
+ url = reverse('invoice-list') # prilagodi ako je druga ruta
+ payload = {
+ "client": self.client_obj.id,
+ "due_date": date.today().isoformat(),
+ "items": [
+ {"description": "Test item", "quantity": "1.00", "unit_price": "10.00"}
+ ]
+ }
+
+ response = self.api_client.post(url, payload, format='json')
+ assert response.status_code in (200, 201)
+ # provjeri da se faktura stvorila i da vraća id/kod
+ created = Invoice.objects.filter(client=self.client_obj).first()
+ assert created is not None
+ assert created.amount == Decimal("10.00")
\ No newline at end of file
diff --git a/backend/modules/invoicing/tests/test_models.py b/backend/modules/invoicing/tests/test_models.py
new file mode 100644
index 0000000..5717ff4
--- /dev/null
+++ b/backend/modules/invoicing/tests/test_models.py
@@ -0,0 +1,18 @@
+import pytest
+from modules.invoicing.models import Invoice, InvoiceItem
+
+@pytest.mark.django_db
+def test_invoice_str_representation(invoice_a):
+ assert str(invoice_a) == f"{invoice_a.invoice_number} - {invoice_a.client}"
+
+@pytest.mark.django_db
+def test_invoice_total_calculation(invoice_a):
+ InvoiceItem.objects.create(invoice=invoice_a, description="Test", quantity=2, unit_price=50.00)
+ InvoiceItem.objects.create(invoice=invoice_a, description="Test2", quantity=1, unit_price=25.00)
+ assert invoice_a.get_total_amount() == 125.00
+
+@pytest.mark.django_db
+def test_invoice_number_autogenerate():
+ # Testiramo da li se automatski generira ako nije zadan
+ from modules.invoicing.models import Invoice
+ # (Za ovo ti treba 'creator' i 'client' fixture-i)
\ No newline at end of file
diff --git a/backend/modules/invoicing/tests/test_permissions.py b/backend/modules/invoicing/tests/test_permissions.py
new file mode 100644
index 0000000..4dac809
--- /dev/null
+++ b/backend/modules/invoicing/tests/test_permissions.py
@@ -0,0 +1,10 @@
+import pytest
+from rest_framework import status
+
+@pytest.mark.django_db
+def test_invoice_access_restriction(api_client, regular_user, invoice_a):
+ # invoice_a pripada serviseru_a, a api_client je regular_user
+ url = f'/api/invoicing/invoices/{invoice_a.id}/'
+ response = api_client.get(url)
+ # Trebao bi dobiti 404 ili 403 ovisno o tvojoj logici
+ assert response.status_code in [status.HTTP_404_NOT_FOUND, status.HTTP_403_FORBIDDEN]
\ No newline at end of file
diff --git a/backend/modules/invoicing/tests/test_serializers.py b/backend/modules/invoicing/tests/test_serializers.py
new file mode 100644
index 0000000..c2aa023
--- /dev/null
+++ b/backend/modules/invoicing/tests/test_serializers.py
@@ -0,0 +1,22 @@
+import pytest
+from modules.invoicing.serializers import InvoiceSerializer
+from unittest.mock import MagicMock
+
+@pytest.mark.django_db
+def test_invoice_serializer_valid(regular_user, test_client):
+ data = {
+ "client": test_client.id,
+ "due_date": "2026-12-31",
+ "status": "draft",
+ "items": [
+ {"description": "Servis", "quantity": 1, "unit_price": 100.00}
+ ]
+ }
+ # Context je ključan za pristup useru
+ request = MagicMock()
+ request.user = regular_user
+ serializer = InvoiceSerializer(data=data, context={'request': request})
+
+ assert serializer.is_valid(), serializer.errors
+ invoice = serializer.save()
+ assert invoice.amount == 100.00
\ No newline at end of file
diff --git a/backend/modules/invoicing/tests/test_services.py b/backend/modules/invoicing/tests/test_services.py
new file mode 100644
index 0000000..ad2fdc0
--- /dev/null
+++ b/backend/modules/invoicing/tests/test_services.py
@@ -0,0 +1,40 @@
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from datetime import date
+from decimal import Decimal
+
+from modules.crm.models import Client
+from modules.invoicing.models import Invoice, InvoiceItem
+from modules.invoicing.services import InvoiceService
+
+
+class InvoiceServiceTests(TestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.creator = User.objects.create_user(username="creator", email="c@example.com", password="pass")
+ self.client = Client.objects.create(name="Test Client")
+
+ def test_create_full_invoice_creates_invoice_and_items(self):
+ items = [
+ {"description": "Item A", "quantity": Decimal("2.00"), "unit_price": Decimal("10.00")},
+ {"description": "Item B", "quantity": Decimal("1.00"), "unit_price": Decimal("5.50")},
+ ]
+ validated_data = {
+ "client": self.client,
+ "due_date": date.today(),
+ "items": items,
+ }
+
+ invoice = InvoiceService.create_full_invoice(user=self.creator, **validated_data)
+
+ # Basic assertions
+ self.assertIsInstance(invoice, Invoice)
+ self.assertEqual(invoice.creator, self.creator)
+ self.assertEqual(invoice.client, self.client)
+ self.assertEqual(invoice.items.count(), 2)
+
+ # Total should be 2*10 + 1*5.5 = 25.5
+ self.assertEqual(invoice.amount, Decimal("25.50"))
+ item_descriptions = [i.description for i in invoice.items.all()]
+ self.assertIn("Item A", item_descriptions)
+ self.assertIn("Item B", item_descriptions)
\ No newline at end of file
diff --git a/backend/modules/invoicing/tests/test_tasks.py b/backend/modules/invoicing/tests/test_tasks.py
new file mode 100644
index 0000000..c20abbc
--- /dev/null
+++ b/backend/modules/invoicing/tests/test_tasks.py
@@ -0,0 +1,40 @@
+import uuid
+from django.test import TestCase
+from unittest.mock import patch
+from datetime import datetime
+from django.contrib.auth import get_user_model
+
+from modules.crm.models import Client
+from modules.invoicing import tasks
+
+
+class TasksTests(TestCase):
+ def setUp(self):
+ # Kreiramo klijenta koji vraća PaperlessGateway; email jedinstven radi izbjegavanja duplikata
+ self.client = Client.objects.create(
+ name="Paperless Corp",
+ email=f"paperless+{uuid.uuid4()}@example.test"
+ )
+ User = get_user_model()
+ # Kreiramo sistemskog korisnika koji već postoji (trebao bi se koristiti)
+ self.system_user = User.objects.create_user(
+ username="bot@erp.hr",
+ email="bot@erp.hr",
+ password="pass"
+ )
+
+ @patch("modules.invoicing.tasks.PaperlessGateway.get_document_metadata")
+ @patch("modules.invoicing.tasks.InvoiceService.create_full_invoice")
+ def test_process_paperless_document_calls_service(self, mock_create_full_invoice, mock_get_metadata):
+ # simuliramo odgovor infrastrukture
+ mock_get_metadata.return_value = {
+ "created": "2026-01-01T00:00:00Z",
+ "correspondent": "Paperless Corp",
+ "total_amount": "100.00"
+ }
+
+ # Pozovemo task sinkrono pomoću .apply (ispravno mapira argumente)
+ tasks.process_paperless_document_task.apply(args=(123,))
+
+ # Očekujemo da je pozvan servis za izradu fakture
+ mock_create_full_invoice.assert_called()
\ No newline at end of file
diff --git a/backend/modules/invoicing/tests/test_utils.py b/backend/modules/invoicing/tests/test_utils.py
new file mode 100644
index 0000000..4273b4c
--- /dev/null
+++ b/backend/modules/invoicing/tests/test_utils.py
@@ -0,0 +1,28 @@
+from django.test import TestCase
+from datetime import date
+from decimal import Decimal
+
+from django.contrib.auth import get_user_model
+from modules.invoicing.models import Invoice
+from modules.invoicing.utils import generate_invoice_number
+from modules.crm.models import Client
+
+
+class UtilsTests(TestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.user = User.objects.create_user(username="creator", email="c@example.com", password="pass")
+ self.client = Client.objects.create(name="Client Utils")
+
+ def test_generate_invoice_number_increments(self):
+ year = date.today().year
+ Invoice.objects.create(
+ invoice_number=f"INV-{year}-0001",
+ creator=self.user,
+ client=self.client,
+ amount=Decimal("1.00"),
+ due_date=date.today()
+ )
+ next_num = generate_invoice_number()
+ # očekujemo INV--0002
+ self.assertTrue(next_num.endswith("-0002"))
\ No newline at end of file
diff --git a/backend/modules/invoicing/tests/test_views.py b/backend/modules/invoicing/tests/test_views.py
new file mode 100644
index 0000000..990d851
--- /dev/null
+++ b/backend/modules/invoicing/tests/test_views.py
@@ -0,0 +1,46 @@
+from django.test import TestCase, RequestFactory
+from django.contrib.auth import get_user_model
+from unittest.mock import patch
+
+from modules.invoicing.views import InvoiceTransactionEntryViewSet
+from modules.invoicing.serializers import InvoiceTransactionSerializer
+from modules.invoicing.models import InvoiceTransaction
+from modules.invoicing.models import Invoice
+from modules.crm.models import Client
+from datetime import date
+from decimal import Decimal
+
+
+class ViewsTests(TestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.user = User.objects.create_user(username="u1", email="u1@example.com", password="pass")
+ self.client = Client.objects.create(name="Client Views")
+ self.invoice = Invoice.objects.create(
+ invoice_number="INV-TEST-0001",
+ creator=self.user,
+ client=self.client,
+ amount=Decimal("10.00"),
+ due_date=date.today()
+ )
+ self.factory = RequestFactory()
+ self.viewset = InvoiceTransactionEntryViewSet()
+
+ @patch("modules.invoicing.services.InvoiceService.create_invoice_entry")
+ def test_perform_create_calls_service(self, mock_create_invoice_entry):
+ # simuliramo serializer.validated_data i poziv perform_create
+ serializer = InvoiceTransactionSerializer(data={
+ "invoice": self.invoice.id,
+ "invoice_number": self.invoice.invoice_number,
+ "amount": "5.00",
+ "is_active": True
+ }, context={"request": self.factory.get("/")})
+ serializer.is_valid(raise_exception=True)
+
+ # Poziv perform_create koji bi trebao delegirati na servis
+ self.viewset.request = self.factory.post("/")
+ self.viewset.request.user = self.user
+
+ # Ako kod poziva InvoiceService.create_invoice_entry, mock se aktivira.
+ self.viewset.perform_create(serializer)
+ mock_create_invoice_entry.assert_called()
\ No newline at end of file
diff --git a/backend/modules/invoicing/urls.py b/backend/modules/invoicing/urls.py
new file mode 100644
index 0000000..ea35e15
--- /dev/null
+++ b/backend/modules/invoicing/urls.py
@@ -0,0 +1,8 @@
+from rest_framework.routers import DefaultRouter
+from .views import InvoiceViewSet, InvoiceTransactionEntryViewSet
+
+router = DefaultRouter()
+router.register(r'invoices', InvoiceViewSet, basename='invoice')
+router.register(r'transaction', InvoiceTransactionEntryViewSet, basename='financial-transaction-entry')
+
+urlpatterns = router.urls
\ No newline at end of file
diff --git a/backend/modules/invoicing/utils.py b/backend/modules/invoicing/utils.py
new file mode 100644
index 0000000..e98f2ba
--- /dev/null
+++ b/backend/modules/invoicing/utils.py
@@ -0,0 +1,26 @@
+import datetime
+from django.apps import apps
+from django.db import transaction
+
+def generate_invoice_number():
+ """
+ Generira novi broj fakture u formatu INV-YYYY-XXXX.
+ Koristi transakciju kako bi se spriječilo dupliciranje brojeva.
+ """
+ with transaction.atomic():
+ Invoice = apps.get_model('invoicing', 'Invoice')
+
+ current_year = datetime.date.today().year
+
+ # Zaključavamo tablicu za čitanje kako bismo izbjegli Race Condition
+ last_invoice = Invoice.objects.filter(
+ invoice_number__startswith=f"INV-{current_year}-"
+ ).select_for_update().order_by('-invoice_number').first()
+
+ if last_invoice:
+ last_num = int(last_invoice.invoice_number.split('-')[-1])
+ new_num = last_num + 1
+ else:
+ new_num = 1
+
+ return f"INV-{current_year}-{new_num:04d}"
\ No newline at end of file
diff --git a/backend/modules/invoicing/views.py b/backend/modules/invoicing/views.py
new file mode 100644
index 0000000..e34a266
--- /dev/null
+++ b/backend/modules/invoicing/views.py
@@ -0,0 +1,75 @@
+# backend/modules/invoicing/views.py
+
+import logging
+from rest_framework import viewsets, permissions, status
+from rest_framework.response import Response
+from rest_framework.exceptions import ValidationError
+
+from .models import InvoiceTransaction, Invoice
+from .serializers import InvoiceSerializer, InvoiceTransactionSerializer
+from .services import InvoiceService
+
+# Postavljanje loggera za modul
+logger = logging.getLogger(__name__)
+
+class InvoiceViewSet(viewsets.ModelViewSet):
+ queryset = Invoice.objects.all().select_related('client', 'creator')
+ serializer_class = InvoiceSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def perform_create(self, serializer):
+ validated_data = serializer.validated_data
+
+ # 2. Delegiraj SVE servisu
+ from .services import InvoiceService
+ invoice_instance = InvoiceService.create_full_invoice(
+ user=self.request.user,
+ **validated_data
+ )
+
+ serializer.instance = invoice_instance
+
+ def get_queryset(self):
+ """
+ Filtriraj samo aktivne zapise
+ """
+ # Korisnik vidi samo fakture koje je on kreirao
+ # (ili koje su povezane s njegovim profilom, ako si klijent)
+ if self.request.user.is_staff:
+ return Invoice.objects.all()
+ return Invoice.objects.filter(creator=self.request.user, is_active=True)
+
+class InvoiceTransactionEntryViewSet(viewsets.ModelViewSet):
+ """
+ ViewSet za upravljanje financijskim zapisima.
+ Korištenjem InvoiceService-a, ViewSet ostaje lagan i fokusiran samo na HTTP sloj.
+ """
+ queryset = InvoiceTransaction.objects.filter(is_active=True).select_related('invoice', 'work_order').order_by('-created_at')
+ serializer_class = InvoiceTransactionSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def perform_create(self, serializer):
+ """
+ Presrećemo proces kreiranja kako bismo koristili InvoiceService.
+ """
+ try:
+ # Proslijeđujemo validirane podatke i korisnika u servis
+ InvoiceService.create_invoice_entry(
+ data=serializer.validated_data,
+ user=self.request.user
+ )
+ except ValidationError as e:
+ # Ponovno podižemo iznimku kako bi DRF ispravno vratio 400 Bad Request
+ logger.warning(f"Validacijska greška pri kreiranju računa od strane {self.request.user}: {e}")
+ raise e
+ except Exception as e:
+ logger.error(f"Kritična greška pri kreiranju financijskog zapisa: {str(e)}", exc_info=True)
+ raise ValidationError({"error": "Došlo je do greške prilikom spremanja zapisa."})
+
+ def perform_destroy(self, instance):
+ """
+ Umjesto fizičkog brisanja, radimo soft-delete ako je potrebno.
+ """
+ instance.is_active = False
+ instance.save()
+ logger.info(f"Financijski zapis {instance.id} je deaktiviran od strane {self.request.user}")
\ No newline at end of file
diff --git a/backend/modules/services.py b/backend/modules/services.py
new file mode 100644
index 0000000..8d42dc1
--- /dev/null
+++ b/backend/modules/services.py
@@ -0,0 +1,70 @@
+from rest_framework.exceptions import ValidationError
+from django.db import transaction
+from .models import Vehicle, WorkOrder, VehicleServiceRecord
+from decimal import Decimal
+
+
+class VehicleService:
+ @staticmethod
+ def create_vehicle(data: dict) -> Vehicle:
+ vehicle = Vehicle(**data)
+ try:
+ vehicle.full_clean()
+ except Exception as e:
+ raise ValidationError({"detail": str(e)})
+
+ vehicle.save()
+ return vehicle
+
+ @staticmethod
+ def update_vehicle(instance: Vehicle, data: dict) -> Vehicle:
+ for k, v in data.items():
+ setattr(instance, k, v)
+ try:
+ instance.full_clean()
+ except Exception as e:
+ raise ValidationError({"detail": str(e)})
+ instance.save()
+ return instance
+
+ @staticmethod
+ def create_work_order(data: dict) -> WorkOrder:
+ # expecting creator in data
+ wo = WorkOrder(**data)
+ try:
+ wo.full_clean()
+ except Exception as e:
+ raise ValidationError({"detail": str(e)})
+
+ # save will handle updating vehicle mileage if end_mileage set
+ with transaction.atomic():
+ wo.save()
+ return wo
+
+ @staticmethod
+ def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder:
+ for k, v in data.items():
+ setattr(instance, k, v)
+ try:
+ instance.full_clean()
+ except Exception as e:
+ raise ValidationError({"detail": str(e)})
+ with transaction.atomic():
+ instance.save()
+ return instance
+
+ @staticmethod
+ def create_service_record(data: dict) -> VehicleServiceRecord:
+ rec = VehicleServiceRecord(**data)
+ try:
+ rec.full_clean()
+ except Exception as e:
+ raise ValidationError({"detail": str(e)})
+ with transaction.atomic():
+ rec.save()
+ # optionally update vehicle.current_mileage if rec.mileage > current
+ v = rec.vehicle
+ if rec.mileage and rec.mileage > (v.current_mileage or 0):
+ v.current_mileage = rec.mileage
+ v.save()
+ return rec
\ No newline at end of file
diff --git a/backend/modules/task_management/__init__.py b/backend/modules/task_management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/modules/task_management/admin.py b/backend/modules/task_management/admin.py
new file mode 100644
index 0000000..b049937
--- /dev/null
+++ b/backend/modules/task_management/admin.py
@@ -0,0 +1,21 @@
+# backend/modules/task_management/admin.py
+
+from django.contrib import admin
+from .models import Task
+
+@admin.register(Task)
+class TaskAdmin(admin.ModelAdmin):
+ """
+ Konfiguracija prikaza modela u Admin panelu.
+ """
+ # Koja polja želimo vidjeti u tabličnom prikazu
+ list_display = ('title', 'status', 'assigned_to', 'work_order', 'created_at')
+
+ # Omogućuje filtriranje s desne strane
+ list_filter = ('status', 'created_at', 'work_order')
+
+ # Omogućuje pretraživanje po tekstu
+ search_fields = ('title', 'description', 'work_order__id', 'work_order__vehicle__registration_number')
+
+ # Polja koja su samo za čitanje (npr. UUID koji se ne smije mijenjati)
+ readonly_fields = ('id', 'created_at', 'updated_at')
\ No newline at end of file
diff --git a/backend/modules/task_management/apps.py b/backend/modules/task_management/apps.py
new file mode 100644
index 0000000..73b93ac
--- /dev/null
+++ b/backend/modules/task_management/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class TaskManagementAppConfig(AppConfig):
+ name = 'modules.task_management'
diff --git a/backend/modules/task_management/migrations/0001_initial.py b/backend/modules/task_management/migrations/0001_initial.py
new file mode 100644
index 0000000..082d82e
--- /dev/null
+++ b/backend/modules/task_management/migrations/0001_initial.py
@@ -0,0 +1,34 @@
+# Generated by Django 5.2.15 on 2026-06-29 13:34
+
+import django.db.models.deletion
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Task',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
+ ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
+ ('title', models.CharField(max_length=255)),
+ ('description', models.TextField(blank=True)),
+ ('status', models.CharField(choices=[('aktivan', 'Aktivan'), ('servis', 'U tijeku'), ('neaktivan', 'Otkazano')], default='aktivan', max_length=20)),
+ ('assigned_to', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tasks', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ['-created_at'],
+ },
+ ),
+ ]
diff --git a/backend/modules/task_management/migrations/0002_task_work_order.py b/backend/modules/task_management/migrations/0002_task_work_order.py
new file mode 100644
index 0000000..9671ba2
--- /dev/null
+++ b/backend/modules/task_management/migrations/0002_task_work_order.py
@@ -0,0 +1,27 @@
+# Generated by Django 5.2.15 on 2026-07-07
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0012_workorder_purpose_choices'),
+ ('task_management', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='task',
+ name='work_order',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='tasks',
+ to='fleet.workorder',
+ ),
+ ),
+ ]
+
diff --git a/backend/modules/task_management/migrations/0003_task_vehicle.py b/backend/modules/task_management/migrations/0003_task_vehicle.py
new file mode 100644
index 0000000..d0211d1
--- /dev/null
+++ b/backend/modules/task_management/migrations/0003_task_vehicle.py
@@ -0,0 +1,37 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0013_vehicleservicerecord_task'),
+ ('task_management', '0002_task_work_order'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='task',
+ name='vehicle',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='tasks',
+ to='fleet.vehicle',
+ verbose_name='Dizalica',
+ ),
+ ),
+ migrations.AlterField(
+ model_name='task',
+ name='work_order',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='work_order_tasks',
+ to='fleet.workorder',
+ verbose_name='Putni nalog',
+ ),
+ ),
+ ]
diff --git a/backend/modules/task_management/migrations/__init__.py b/backend/modules/task_management/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/modules/task_management/models.py b/backend/modules/task_management/models.py
new file mode 100644
index 0000000..cb7d8e2
--- /dev/null
+++ b/backend/modules/task_management/models.py
@@ -0,0 +1,59 @@
+# backend/modules/task_management/models.py
+
+from django.db import models
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext_lazy as _
+from core.base_models import BaseModel # Uvozimo naš Base
+
+class Task(BaseModel):
+ """
+ Modularni Task model nasljeđuje sva polja iz BaseModel.
+ """
+ STATUS_CHOICES = [
+ ('aktivan', 'Aktivan'),
+ ('servis', 'U tijeku'),
+ ('neaktivan', 'Otkazano'),
+ ]
+ title = models.CharField(max_length=255)
+ description = models.TextField(blank=True)
+ status = models.CharField(
+ max_length=20,
+ choices=STATUS_CHOICES,
+ default='aktivan'
+ )
+ assigned_to = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True,
+ related_name='tasks'
+ )
+ vehicle = models.ForeignKey(
+ 'fleet.Vehicle',
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='tasks',
+ verbose_name=_("Dizalica"),
+ )
+ work_order = models.ForeignKey(
+ 'fleet.WorkOrder',
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='work_order_tasks',
+ verbose_name=_("Putni nalog"),
+ )
+
+ class Meta:
+ ordering = ['-created_at']
+
+ def __str__(self):
+ return f"{self.title} ({self.status})"
+
+ def clean(self):
+ super().clean()
+ if self.status == 'neaktivan' and not self.work_order_id:
+ raise ValidationError({
+ 'work_order': "Putni nalog je obavezan prije zatvaranja zadatka."
+ })
\ No newline at end of file
diff --git a/backend/modules/task_management/serializers.py b/backend/modules/task_management/serializers.py
new file mode 100644
index 0000000..b74f3b7
--- /dev/null
+++ b/backend/modules/task_management/serializers.py
@@ -0,0 +1,75 @@
+# backend/modules/task_management/serializers.py
+
+from rest_framework import serializers
+from .models import Task
+
+class TaskSerializer(serializers.ModelSerializer):
+ assigned_to_name = serializers.SerializerMethodField()
+ work_order_label = serializers.SerializerMethodField()
+ vehicle_registration = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Task
+ fields = [
+ 'id', 'title', 'description', 'status',
+ 'assigned_to', 'assigned_to_name',
+ 'vehicle', 'vehicle_registration',
+ 'work_order', 'work_order_label',
+ 'created_at',
+ ]
+ read_only_fields = ['id', 'created_at']
+
+ def validate_title(self, value):
+ """Primjer custom validacije: naziv ne smije biti prekratak."""
+ if len(value) < 3:
+ raise serializers.ValidationError("Naslov zadatka mora imati barem 3 znaka.")
+ return value
+
+ def validate(self, attrs):
+ current = getattr(self, 'instance', None)
+ status_value = attrs.get('status', getattr(current, 'status', 'aktivan'))
+ work_order = attrs.get('work_order', getattr(current, 'work_order', None))
+ vehicle = attrs.get('vehicle', getattr(current, 'vehicle', None))
+
+ if current is None and not vehicle:
+ raise serializers.ValidationError({
+ 'vehicle': "Dizalica je obavezna pri kreiranju taska."
+ })
+
+ if work_order and not vehicle:
+ vehicle = work_order.vehicle
+ attrs['vehicle'] = vehicle
+ if work_order and vehicle and work_order.vehicle_id != vehicle.id:
+ raise serializers.ValidationError({
+ 'work_order': "Putni nalog mora pripadati istoj dizalici kao task."
+ })
+
+ if status_value == 'neaktivan' and not work_order:
+ raise serializers.ValidationError({
+ 'work_order': "Putni nalog je obavezan prije zatvaranja zadatka."
+ })
+ return attrs
+
+ def get_work_order_label(self, obj):
+ if not obj.work_order_id:
+ return None
+ return f"PN-{str(obj.work_order_id).split('-')[0].upper()}"
+
+ def get_assigned_to_name(self, obj):
+ if not obj.assigned_to_id:
+ return None
+ full_name = obj.assigned_to.get_full_name()
+ if full_name:
+ return full_name
+ email = getattr(obj.assigned_to, 'email', None)
+ if email:
+ return email
+ username = getattr(obj.assigned_to, 'username', None)
+ if username:
+ return username
+ return str(obj.assigned_to_id)
+
+ def get_vehicle_registration(self, obj):
+ if not obj.vehicle_id:
+ return None
+ return obj.vehicle.registration_number
\ No newline at end of file
diff --git a/backend/modules/task_management/services.py b/backend/modules/task_management/services.py
new file mode 100644
index 0000000..4c7dd91
--- /dev/null
+++ b/backend/modules/task_management/services.py
@@ -0,0 +1,48 @@
+# backend/modules/task_management/services.py
+
+from .models import Task
+from rest_framework.exceptions import ValidationError
+from django.core.exceptions import ValidationError as DjangoValidationError
+import logging
+
+logger = logging.getLogger(__name__)
+
+class TaskService:
+ @staticmethod
+ def create_task_entry(data: dict, user=None) -> Task:
+ """
+ Univerzalna metoda: prihvaća podatke i opcionalnog korisnika.
+ """
+ # Poslovna pravila
+ if data.get('status') == 'DONE' and (user and not user.is_staff):
+ raise ValidationError({"status": "Samo admini mogu odmah označiti zadatak kao gotov."})
+
+ payload = dict(data)
+ if user and 'assigned_to' not in payload:
+ payload['assigned_to'] = user
+
+ entry = Task(**payload)
+ try:
+ entry.full_clean()
+ except DjangoValidationError as exc:
+ logger.error(f"Neuspješna validacija zadatka: {exc}")
+ raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
+ entry.save()
+ logger.info(f"Zadatak uspješno kreiran: {entry.id}")
+ return entry
+
+ @staticmethod
+ def update_task_entry(instance, data, user):
+ # Validacija za update (npr. samo vlasnik ili admin smije ažurirati)
+ if not user.is_staff and instance.assigned_to != user:
+ raise ValidationError("Nemate dozvolu za izmjenu ovog zadatka.")
+
+ # Spremanje
+ for attr, value in data.items():
+ setattr(instance, attr, value)
+ try:
+ instance.full_clean()
+ except DjangoValidationError as exc:
+ raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
+ instance.save()
+ return instance
\ No newline at end of file
diff --git a/backend/modules/task_management/tasks.py b/backend/modules/task_management/tasks.py
new file mode 100644
index 0000000..58eb627
--- /dev/null
+++ b/backend/modules/task_management/tasks.py
@@ -0,0 +1,37 @@
+# backend/modules/task_management/tasks.py
+
+from celery import shared_task
+from .models import Task
+from django.contrib.auth import get_user_model
+from modules.fleet.models import Vehicle, WorkOrder
+
+@shared_task
+def create_task_from_invoice(invoice_data):
+ """
+ Ovaj zadatak se poziva asinkrono.
+ """
+ User = get_user_model()
+ # Logika: dodijeli zadatak adminu ako je račun veći od 1000
+ amount = float(invoice_data.get('amount', 0))
+ admin_user = User.objects.filter(is_staff=True).first()
+ work_order = None
+ vehicle = None
+ work_order_id = invoice_data.get('work_order_id')
+ vehicle_id = invoice_data.get('vehicle_id')
+
+ if work_order_id:
+ work_order = WorkOrder.objects.filter(pk=work_order_id).first()
+ if work_order:
+ vehicle = work_order.vehicle
+ if vehicle is None and vehicle_id:
+ vehicle = Vehicle.objects.filter(pk=vehicle_id).first()
+
+ if amount > 1000:
+ Task.objects.create(
+ title=f"Provjera računa: {invoice_data['invoice_number']}",
+ description=f"Račun iznosi {amount}, potrebna revizija.",
+ assigned_to=admin_user,
+ status='aktivan',
+ vehicle=vehicle,
+ work_order=work_order,
+ )
\ No newline at end of file
diff --git a/backend/modules/task_management/tests/test_models.py b/backend/modules/task_management/tests/test_models.py
new file mode 100644
index 0000000..821cfb2
--- /dev/null
+++ b/backend/modules/task_management/tests/test_models.py
@@ -0,0 +1,12 @@
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+
+from modules.task_management.models import Task
+
+
+class TaskModelTests(TestCase):
+ def test_str_returns_title_and_status(self):
+ user = get_user_model().objects.create_user(username="u1", email="u1@example.test", password="pass")
+ task = Task.objects.create(title="My Task", description="Desc", assigned_to=user)
+ assert str(task).startswith("My Task")
+ assert "aktivan" in str(task) or "(" in str(task) # basic sanity
\ No newline at end of file
diff --git a/backend/modules/task_management/tests/test_serializers.py b/backend/modules/task_management/tests/test_serializers.py
new file mode 100644
index 0000000..444120f
--- /dev/null
+++ b/backend/modules/task_management/tests/test_serializers.py
@@ -0,0 +1,32 @@
+from django.test import TestCase
+from modules.task_management.serializers import TaskSerializer
+from rest_framework.exceptions import ValidationError
+from django.contrib.auth import get_user_model
+from modules.task_management.models import Task
+from modules.fleet.models import Vehicle, WorkOrder
+
+
+class TaskSerializerTests(TestCase):
+ def test_validate_title_too_short_raises(self):
+ serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan"})
+ with self.assertRaises(ValidationError):
+ serializer.is_valid(raise_exception=True)
+
+ def test_validate_title_ok(self):
+ serializer = TaskSerializer(data={"title": "Valid title", "description": "", "status": "aktivan"})
+ assert serializer.is_valid(), serializer.errors
+
+ def test_cannot_close_without_work_order(self):
+ serializer = TaskSerializer(data={"title": "Task 1", "description": "", "status": "neaktivan"})
+ with self.assertRaises(ValidationError):
+ serializer.is_valid(raise_exception=True)
+
+ def test_can_close_with_work_order(self):
+ User = get_user_model()
+ user = User.objects.create_user(username="task-user", email="task-user@example.test", password="pass")
+ vehicle = Vehicle.objects.create(registration_number="ZG-TSK-02", asset_type='crane')
+ work_order = WorkOrder.objects.create(vehicle=vehicle, creator=user, purpose='defektaza')
+ task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=user)
+
+ serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True)
+ assert serializer.is_valid(), serializer.errors
\ No newline at end of file
diff --git a/backend/modules/task_management/tests/test_services.py b/backend/modules/task_management/tests/test_services.py
new file mode 100644
index 0000000..179591f
--- /dev/null
+++ b/backend/modules/task_management/tests/test_services.py
@@ -0,0 +1,53 @@
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from rest_framework.exceptions import ValidationError
+from modules.task_management.services import TaskService
+from modules.task_management.models import Task
+from modules.fleet.models import Vehicle, WorkOrder
+
+
+class TaskServiceTests(TestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.user = User.objects.create_user(username="user1", email="u1@example.test", password="pass")
+ self.other_user = User.objects.create_user(username="user2", email="u2@example.test", password="pass")
+ self.staff = User.objects.create_user(username="admin", email="admin@example.test", password="pass", is_staff=True)
+
+ def test_create_task_entry_creates_task_and_assigns_user(self):
+ data = {"title": "Do work", "description": "Something", "status": "aktivan"}
+ entry = TaskService.create_task_entry(data=data, user=self.user)
+ assert isinstance(entry, Task)
+ assert entry.assigned_to == self.user
+ assert entry.title == "Do work"
+
+ def test_create_task_entry_short_title_raises(self):
+ data = {"title": "x", "description": "", "status": "aktivan"}
+ with self.assertRaises(ValidationError):
+ TaskService.create_task_entry(data=data, user=self.user)
+
+ def test_update_task_entry_permission_denied_for_non_owner(self):
+ task = Task.objects.create(title="T", description="", assigned_to=self.other_user)
+ with self.assertRaises(ValidationError):
+ TaskService.update_task_entry(instance=task, data={"title": "New"}, user=self.user)
+
+ def test_update_task_entry_allowed_for_owner(self):
+ task = Task.objects.create(title="Old", description="", assigned_to=self.user)
+ updated = TaskService.update_task_entry(instance=task, data={"title": "New"}, user=self.user)
+ assert updated.title == "New"
+
+ def test_update_task_entry_rejects_closing_without_work_order(self):
+ task = Task.objects.create(title="Za zatvaranje", description="", assigned_to=self.user, status='servis')
+ with self.assertRaises(ValidationError):
+ TaskService.update_task_entry(instance=task, data={"status": "neaktivan"}, user=self.user)
+
+ def test_update_task_entry_allows_closing_with_work_order(self):
+ vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01", asset_type='crane')
+ work_order = WorkOrder.objects.create(vehicle=vehicle, creator=self.user, purpose='defektaza')
+ task = Task.objects.create(title="Za zatvaranje", description="", assigned_to=self.user, status='servis')
+ updated = TaskService.update_task_entry(
+ instance=task,
+ data={"status": "neaktivan", "work_order": work_order},
+ user=self.user,
+ )
+ assert updated.status == "neaktivan"
+ assert updated.work_order == work_order
\ No newline at end of file
diff --git a/backend/modules/task_management/tests/test_tasks.py b/backend/modules/task_management/tests/test_tasks.py
new file mode 100644
index 0000000..8d705b3
--- /dev/null
+++ b/backend/modules/task_management/tests/test_tasks.py
@@ -0,0 +1,25 @@
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from modules.task_management.tasks import create_task_from_invoice
+from modules.task_management.models import Task
+
+
+class TaskCeleryTests(TestCase):
+ def setUp(self):
+ User = get_user_model()
+ # Create an admin user to be assigned the task
+ self.admin = User.objects.create_user(username="admin", email="admin@example.test", password="pass", is_staff=True)
+
+ def test_create_task_from_invoice_creates_task_for_large_amount(self):
+ invoice_data = {"amount": 1500, "invoice_number": "INV-100"}
+ # run the task function synchronously via .run (no bind)
+ create_task_from_invoice.run(invoice_data)
+ task = Task.objects.filter(title__icontains="INV-100").first()
+ assert task is not None
+ assert task.assigned_to == self.admin
+
+ def test_create_task_from_invoice_does_not_create_for_small_amount(self):
+ invoice_data = {"amount": 100, "invoice_number": "INV-101"}
+ create_task_from_invoice.run(invoice_data)
+ task = Task.objects.filter(title__icontains="INV-101").first()
+ assert task is None
\ No newline at end of file
diff --git a/backend/modules/task_management/tests/test_views.py b/backend/modules/task_management/tests/test_views.py
new file mode 100644
index 0000000..d8dd4f6
--- /dev/null
+++ b/backend/modules/task_management/tests/test_views.py
@@ -0,0 +1,54 @@
+from django.test import TestCase, RequestFactory
+from django.contrib.auth import get_user_model
+from unittest.mock import patch
+
+from modules.task_management.views import TaskViewSet
+from modules.task_management.serializers import TaskSerializer
+from modules.task_management.models import Task
+
+
+class TaskViewSetTests(TestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.user = User.objects.create_user(username="u1", email="u1@example.test", password="pass")
+ self.factory = RequestFactory()
+ self.view = TaskViewSet()
+ self.view.request = self.factory.post("/")
+ self.view.request.user = self.user
+
+ @patch("modules.task_management.views.TaskService.create_task_entry")
+ def test_perform_create_calls_service(self, mock_create):
+ data = {"title": "Task from view", "description": "", "status": "aktivan"}
+ serializer = TaskSerializer(data=data, context={"request": self.view.request})
+ assert serializer.is_valid(), serializer.errors
+
+ # Call perform_create, which should delegate to TaskService.create_task_entry
+ self.view.perform_create(serializer)
+ mock_create.assert_called_once()
+ args, kwargs = mock_create.call_args
+ assert "data" in kwargs and kwargs["user"] == self.user
+
+ @patch("modules.task_management.views.TaskService.update_task_entry")
+ def test_perform_update_calls_service_and_saves(self, mock_update):
+ # Create an instance and use serializer to update
+ task = Task.objects.create(title="Old", description="", assigned_to=self.user)
+ data = {"title": "Updated", "description": "new", "status": "aktivan"}
+ serializer = TaskSerializer(instance=task, data=data, partial=True, context={"request": self.view.request})
+ assert serializer.is_valid(), serializer.errors
+
+ # attach view.request and instance then call perform_update
+ self.view.request = self.factory.put("/")
+ self.view.request.user = self.user
+ serializer.instance = task
+
+ self.view.perform_update(serializer)
+ mock_update.assert_called_once_with(instance=task, data=serializer.validated_data, user=self.view.request.user)
+ # confirm instance saved with new title
+ task.refresh_from_db()
+ assert task.title == "Updated"
+
+ def test_perform_destroy_soft_deletes(self):
+ task = Task.objects.create(title="ToDelete", description="", assigned_to=self.user)
+ self.view.perform_destroy(task)
+ task.refresh_from_db()
+ assert task.is_active is False
\ No newline at end of file
diff --git a/backend/modules/task_management/urls.py b/backend/modules/task_management/urls.py
new file mode 100644
index 0000000..49ecc8e
--- /dev/null
+++ b/backend/modules/task_management/urls.py
@@ -0,0 +1,9 @@
+from rest_framework.routers import DefaultRouter
+from .views import TaskViewSet
+
+router = DefaultRouter()
+# Registriramo ViewSet pod putanjom 'tasks'
+# Rezultat: /api/task_management/tasks/
+router.register(r'tasks', TaskViewSet, basename='task')
+
+urlpatterns = router.urls
\ No newline at end of file
diff --git a/backend/modules/task_management/views.py b/backend/modules/task_management/views.py
new file mode 100644
index 0000000..3c402d0
--- /dev/null
+++ b/backend/modules/task_management/views.py
@@ -0,0 +1,64 @@
+# backend/modules/task_management/views.py
+
+import logging
+from rest_framework import viewsets, permissions
+from rest_framework.exceptions import ValidationError
+from .models import Task
+from .serializers import TaskSerializer
+from .services import TaskService
+
+# Postavljanje loggera za modul
+logger = logging.getLogger(__name__)
+
+class TaskViewSet(viewsets.ModelViewSet):
+ """
+ ViewSet koji koristi TaskService za rukovanje operacijama.
+ """
+ serializer_class = TaskSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ qs = Task.objects.filter(is_active=True).select_related(
+ 'assigned_to', 'vehicle', 'work_order', 'work_order__vehicle'
+ )
+ vehicle_id = self.request.query_params.get('vehicle')
+ if vehicle_id:
+ qs = qs.filter(vehicle_id=vehicle_id)
+ return qs
+
+ def perform_create(self, serializer):
+ """
+ Umjesto direktnog serializer.save(), pozivamo servis.
+ Proslijeđujemo validirane podatke (dict) i korisnika.
+ """
+ try:
+ entry = TaskService.create_task_entry(
+ data=serializer.validated_data,
+ user=self.request.user)
+ serializer.instance = entry
+ except ValidationError as e:
+ # Ponovno podižemo iznimku kako bi DRF ispravno vratio 400 Bad Request
+ logger.warning(f"Validacijska greška pri kreiranju zadatka od strane {self.request.user}: {e}")
+ raise e
+
+ def perform_update(self, serializer):
+ """
+ Servis obavlja biznis logiku (validaciju/modifikaciju),
+ a serializer.save() finalizira zapis.
+ """
+ # Servis sada samo modificira instancu u memoriji
+ TaskService.update_task_entry(
+ instance=serializer.instance,
+ data=serializer.validated_data,
+ user=self.request.user
+ )
+ # Sada DRF sigurno sprema izmijenjenu instancu
+ serializer.save()
+
+ def perform_destroy(self, instance):
+ """
+ Umjesto fizičkog brisanja, radimo soft-delete ako je potrebno.
+ """
+ instance.is_active = False
+ instance.save()
+ logger.info(f"Zapis zadatka {instance.id} je deaktiviran od strane {self.request.user}")
diff --git a/backend/pytest.ini b/backend/pytest.ini
new file mode 100644
index 0000000..cce1585
--- /dev/null
+++ b/backend/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+DJANGO_SETTINGS_MODULE = core.settings
+python_files = tests.py test_*.py *_tests.py
\ No newline at end of file
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..f2660a3
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,75 @@
+amqp==5.3.1
+annotated-types==0.7.0
+anyio==4.13.0
+asgiref==3.11.1
+billiard==4.2.4
+celery==5.6.3
+certifi==2026.5.20
+cffi==2.0.0
+charset-normalizer==3.4.7
+click==8.4.1
+click-didyoumean==0.3.1
+click-plugins==1.1.1.2
+click-repl==0.3.0
+colorama==0.4.6
+cryptography==49.0.0
+defusedxml==0.7.1
+deprecation==2.1.0
+Django==5.2.15
+django-cors-headers==4.9.0
+django-debug-toolbar>=4.0.0
+django_extensions>=4.1
+django-filter==25.2
+django-redis==7.0.0
+django-unfold>=0.100.0
+djangorestframework==3.17.1
+djangorestframework_simplejwt==5.5.1
+Faker==40.15.0
+flower==2.0.1
+fonttools==4.63.0
+fpdf2==2.8.7
+gunicorn==26.0.0
+idna==3.17
+img2pdf==0.6.3
+ocrmypdf==17.7.0
+opencv-python-headless>=4.10.0.82,<4.13.0
+packaging==26.2
+pdfminer.six==20260107
+pi_heif==1.4.0
+pikepdf==10.9.1
+pillow==12.2.0
+psycopg2-binary==2.9.12
+pycparser==3.0
+pydantic==2.13.4
+pydantic_core==2.46.4
+Pygments==2.20.0
+PyJWT==2.12.1
+pypdf==6.13.3
+pypdfium2==5.10.1
+pytesseract==0.3.13
+pytest>=9.1.1
+pytest-django>=4.12.0
+python-dateutil==2.9.0.post0
+python-dotenv==1.2.2
+pusher>=3.3.4
+redis==8.0.0
+reportlab==4.5.1
+requests==2.34.2
+rich==15.0.0
+six==1.17.0
+sqlparse==0.5.5
+typing-inspection==0.4.2
+typing_extensions==4.15.0
+tzdata==2026.2
+tzlocal==5.3.1
+uharfbuzz==0.55.0
+urllib3==2.7.0
+vine==5.1.0
+watchfiles==1.2.0
+wcwidth==0.7.0
+whitenoise==6.12.0
+# pinned versions for TF + numpy compatibility
+numpy>=1.24,<2.0
+tensorflow==2.15.0
+keras-ocr
+albumentations
\ No newline at end of file
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
new file mode 100644
index 0000000..9e1d0c7
--- /dev/null
+++ b/docker-compose.dev.yml
@@ -0,0 +1,51 @@
+services:
+ backend:
+ container_name: 004erpbackend
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ command: python manage.py runserver 0.0.0.0:8000
+ user: "1000:1000"
+ volumes:
+ - ./backend:/app
+ - media_volume:/app/media
+ ports:
+ - "8001:8000"
+ env_file:
+ - .env
+ depends_on:
+ db: { condition: service_healthy }
+ redis: { condition: service_started }
+
+ frontend:
+ container_name: 004erpfrontend
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ command: npm run dev -- --host 0.0.0.0 --force
+ ports:
+ - "4321:4321"
+ volumes:
+ - ./frontend:/app
+ - /app/node_modules
+ environment:
+ - PUBLIC_API_URL=${PUBLIC_API_URL}
+ - NODE_ENV=development
+
+ flower:
+ container_name: 004erpflower
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ command: celery -A core flower --port=5555
+ ports:
+ - "5555:5555"
+ env_file:
+ - .env
+ depends_on:
+ - redis
+ - worker
+
+volumes:
+ media_volume:
+ external: true # Ako je definiran u glavnom file-u, ovo ga povezuje
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..1608c2c
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,47 @@
+services:
+ db:
+ image: postgres:16-alpine
+ container_name: 004erpdb
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB}
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ volumes: [postgres_data:/var/lib/postgresql/data]
+
+ redis:
+ image: redis:7-alpine
+ container_name: 004erpredis
+ volumes: [redis_data:/data]
+ # Lean optimizacija: Ograničavamo maksimalnu potrošnju memorije samog Redisa na 100MB
+ # i aktiviramo LRU algoritam koji automatski briše najstarije ključeve ako se limit prijeđe.
+ command: redis-server --appendonly yes --maxmemory 100mb --maxmemory-policy allkeys-lru
+
+ worker:
+ container_name: 004erpworker
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ command: celery -A core worker --loglevel=info
+ user: "1000:1000"
+ volumes:
+ - ./backend:/app
+ - media_volume:/app/media
+ env_file:
+ - .env
+ depends_on:
+ db: { condition: service_healthy }
+ redis: { condition: service_started }
+ deploy:
+ resources:
+ limits:
+ memory: 512M
+
+volumes:
+ postgres_data:
+ redis_data:
+ media_volume: # Ovo je ključno za tvoj ERP
\ No newline at end of file
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..79a31e2
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,32 @@
+# Izolacija paketa (Kritično)
+node_modules/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# Astro i Build cache (Sprječava prijenos starih lokalnih buildova)
+.astro/
+dist/
+
+# Okruženje i Tajne (Sigurnost)
+.env
+.env.production
+.env.development
+.env.local
+*.env.bak
+
+# Kontrola verzija i Git
+.git/
+.gitignore
+
+# Docker higijena (Optimizacija cache slojeva)
+Dockerfile
+.dockerignore
+docker-compose*.yml
+
+# Operacijski sustav i Editori
+.DS_Store
+Thumbs.db
+.vscode/
+.idea/
\ No newline at end of file
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..831e56c
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,20 @@
+# Koristimo stabilnu Node.js sliku
+FROM node:22-alpine
+
+WORKDIR /app
+
+# Kopiramo datoteke koje definiraju pakete
+COPY package*.json ./
+
+# Instaliramo ovisnosti (u dev modu Compose će ionako povući sve što treba)
+RUN npm install
+
+# Kopiramo ostatak izvornog koda frontenda
+COPY . .
+
+# Otvaramo port 4321 na kojem će se vrtjeti Astro
+EXPOSE 4321
+
+# Zadana naredba (koju tvoj docker-compose.dev.yml ionako prepisuje,
+# ali je važno da je ovdje ispravan i čist fallback)
+CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "4321"]
\ No newline at end of file
diff --git a/frontend/astro.config.mjs b/frontend/astro.config.mjs
new file mode 100644
index 0000000..3a24e66
--- /dev/null
+++ b/frontend/astro.config.mjs
@@ -0,0 +1,62 @@
+// @ts-check
+import { defineConfig } from 'astro/config';
+import node from '@astrojs/node';
+import tailwindcss from '@tailwindcss/vite';
+import preact from '@astrojs/preact';
+const isDev = process.env.NODE_ENV === 'development';
+
+// https://astro.build/config
+export default defineConfig({
+ output: 'server',
+
+ adapter: node({
+ mode: 'standalone'
+ }),
+
+ // 🛰️ Postavke razvojnog poslužitelja unutar Docker mreže
+ server: {
+ host: true, // Sluša na 0.0.0.0 unutar kontejnera
+ port: 4321, // Unutarnji port kontejnera
+ },
+
+ vite: {
+ resolve: {
+ alias: {
+ react: 'preact/compat',
+ 'react-dom': 'preact/compat',
+ 'react-dom/test-utils': 'preact/test-utils',
+ 'react/jsx-runtime': 'preact/jsx-runtime',
+ 'react/jsx-dev-runtime': 'preact/jsx-dev-runtime',
+ },
+ },
+ define: {
+ __PUSHER_KEY__: JSON.stringify(process.env.PUBLIC_PUSHER_KEY || process.env.PUSHER_KEY || ''),
+ __PUSHER_CLUSTER__: JSON.stringify(process.env.PUBLIC_PUSHER_CLUSTER || process.env.PUSHER_CLUSTER || ''),
+ },
+ ssr: {
+ noExternal: ['@react-spring/web', '@react-spring/core', '@react-spring/animated', '@react-spring/shared'],
+ },
+ build: {
+ assetsInlineLimit: 1024,
+ },
+ plugins: [tailwindcss()],
+
+ // ⚙️ Ugrađene preporuke za Docker File Watching i Tihi Refresh (HMR)
+ server: isDev ? {
+ hmr: {
+ protocol: 'ws',
+ host: 'localhost',
+ // Mora odgovarati mapiranom portu u docker-compose (4321:4321)
+ clientPort: 4321,
+ },
+ watch: {
+ // Prisiljava Vite na "polling" sustav jer Windows/WSL2 volumeni nekad blokiraju inotify signale
+ usePolling: true,
+ interval: 100,
+ }
+ } : {}, // U produkciji je ovaj blok potpuno prazan radi maksimalnih performansi
+ },
+
+ // Redoslijed integracija: Tailwind pa Preact
+ integrations: [preact()],
+});
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..191e81f
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,5977 @@
+{
+ "name": "frontend",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "version": "0.0.1",
+ "dependencies": {
+ "@astrojs/node": "^10.1.2",
+ "@astrojs/preact": "^5.1.4",
+ "@nanostores/preact": "^1.1.0",
+ "@preact/signals": "^2.9.2",
+ "@react-spring/web": "^10.1.2",
+ "@tailwindcss/vite": "^4.3.0",
+ "astro": "^6.4.2",
+ "flowbite": "^4.0.2",
+ "idb": "^8.0.3",
+ "nanostores": "^1.3.0",
+ "preact": "^10.29.2",
+ "pusher-js": "^8.4.0",
+ "tailwindcss": "^4.3.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@astrojs/compiler": {
+ "version": "4.0.0",
+ "license": "MIT"
+ },
+ "node_modules/@astrojs/internal-helpers": {
+ "version": "0.10.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.4",
+ "@types/mdast": "^4.0.4",
+ "js-yaml": "^4.1.1",
+ "picomatch": "^4.0.4",
+ "retext-smartypants": "^6.2.0",
+ "shiki": "^4.0.2",
+ "smol-toml": "^1.6.0",
+ "unified": "^11.0.5"
+ }
+ },
+ "node_modules/@astrojs/markdown-remark": {
+ "version": "7.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "@astrojs/internal-helpers": "0.10.0",
+ "@astrojs/prism": "4.0.2",
+ "github-slugger": "^2.0.0",
+ "hast-util-from-html": "^2.0.3",
+ "hast-util-to-text": "^4.0.2",
+ "mdast-util-definitions": "^6.0.0",
+ "rehype-raw": "^7.0.0",
+ "rehype-stringify": "^10.0.1",
+ "remark-gfm": "^4.0.1",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.1.2",
+ "remark-smartypants": "^3.0.2",
+ "unified": "^11.0.5",
+ "unist-util-remove-position": "^5.0.0",
+ "unist-util-visit": "^5.1.0",
+ "unist-util-visit-parents": "^6.0.2",
+ "vfile": "^6.0.3"
+ }
+ },
+ "node_modules/@astrojs/node": {
+ "version": "10.1.4",
+ "license": "MIT",
+ "dependencies": {
+ "@astrojs/internal-helpers": "0.10.0",
+ "send": "^1.2.1",
+ "server-destroy": "^1.0.1"
+ },
+ "peerDependencies": {
+ "astro": "^6.3.0"
+ }
+ },
+ "node_modules/@astrojs/preact": {
+ "version": "5.1.5",
+ "license": "MIT",
+ "dependencies": {
+ "@astrojs/internal-helpers": "0.10.0",
+ "@preact/preset-vite": "^2.10.5",
+ "@preact/signals": "^2.8.2",
+ "devalue": "^5.8.1",
+ "preact-render-to-string": "^6.6.6",
+ "vite": "^7.3.2"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "peerDependencies": {
+ "preact": "^10.6.5"
+ }
+ },
+ "node_modules/@astrojs/prism": {
+ "version": "4.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "prismjs": "^1.30.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@astrojs/telemetry": {
+ "version": "3.3.2",
+ "license": "MIT",
+ "dependencies": {
+ "ci-info": "^4.4.0",
+ "dset": "^3.1.4",
+ "is-docker": "^4.0.0",
+ "is-wsl": "^3.1.1",
+ "which-pm-runs": "^1.1.0"
+ },
+ "engines": {
+ "node": "18.20.8 || ^20.3.0 || >=22.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/plugin-syntax-jsx": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-development": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/plugin-transform-react-jsx": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@capsizecss/unpack": {
+ "version": "4.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "fontkitten": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@clack/core": {
+ "version": "1.4.2",
+ "license": "MIT",
+ "dependencies": {
+ "fast-wrap-ansi": "^0.2.0",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 20.12.0"
+ }
+ },
+ "node_modules/@clack/prompts": {
+ "version": "1.6.0",
+ "license": "MIT",
+ "dependencies": {
+ "@clack/core": "1.4.2",
+ "fast-string-width": "^3.0.2",
+ "fast-wrap-ansi": "^0.2.0",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 20.12.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
+ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nanostores/preact": {
+ "version": "1.1.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": "^20.0.0 || >=22.0.0"
+ },
+ "peerDependencies": {
+ "nanostores": "^1.2.0",
+ "preact": ">=10.0.0"
+ }
+ },
+ "node_modules/@oslojs/encoding": {
+ "version": "1.1.0",
+ "license": "MIT"
+ },
+ "node_modules/@popperjs/core": {
+ "version": "2.11.8",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
+ "node_modules/@preact/preset-vite": {
+ "version": "2.10.5",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/plugin-transform-react-jsx": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-development": "^7.27.1",
+ "@prefresh/vite": "^2.4.11",
+ "@rollup/pluginutils": "^5.0.0",
+ "babel-plugin-transform-hook-names": "^1.0.2",
+ "debug": "^4.4.3",
+ "magic-string": "^0.30.21",
+ "picocolors": "^1.1.1",
+ "vite-prerender-plugin": "^0.5.8",
+ "zimmerframe": "^1.1.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "7.x",
+ "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x"
+ }
+ },
+ "node_modules/@preact/signals": {
+ "version": "2.9.2",
+ "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.9.2.tgz",
+ "integrity": "sha512-DvFPISNMSh3vPqRwPa1tAVAHl85aDq4pTyNu1bTGfrKr64F3EOCHjdUl9aUdohKBf1v9PRGLYuGFcJpfztkdoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@preact/signals-core": "^1.14.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ },
+ "peerDependencies": {
+ "preact": ">= 10.25.0 || >=11.0.0-0"
+ }
+ },
+ "node_modules/@preact/signals-core": {
+ "version": "1.14.3",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/@prefresh/babel-plugin": {
+ "version": "0.5.3",
+ "license": "MIT"
+ },
+ "node_modules/@prefresh/core": {
+ "version": "1.5.10",
+ "license": "MIT",
+ "peerDependencies": {
+ "preact": "^10.0.0 || ^11.0.0-0"
+ }
+ },
+ "node_modules/@prefresh/utils": {
+ "version": "1.2.1",
+ "license": "MIT"
+ },
+ "node_modules/@prefresh/vite": {
+ "version": "2.4.12",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.22.1",
+ "@prefresh/babel-plugin": "^0.5.2",
+ "@prefresh/core": "^1.5.0",
+ "@prefresh/utils": "^1.2.0",
+ "@rollup/pluginutils": "^4.2.1"
+ },
+ "peerDependencies": {
+ "preact": "^10.4.0 || ^11.0.0-0",
+ "vite": ">=2.0.0"
+ }
+ },
+ "node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": {
+ "version": "4.2.1",
+ "license": "MIT",
+ "dependencies": {
+ "estree-walker": "^2.0.1",
+ "picomatch": "^2.2.2"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/@prefresh/vite/node_modules/picomatch": {
+ "version": "2.3.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/@react-spring/animated": {
+ "version": "10.1.2",
+ "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.1.2.tgz",
+ "integrity": "sha512-yAsQ/bbp6+vko7WNCI1M00c6KLE9XKTGCrgRhQqS4JcK3oF5qBV4rHYrQEprvEvYXxt+1H5FsLS2eVValEPfFw==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/shared": "~10.1.2",
+ "@react-spring/types": "~10.1.2"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@react-spring/core": {
+ "version": "10.1.2",
+ "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.1.2.tgz",
+ "integrity": "sha512-lPGOAg0V+PV3ucopOajD+YCxoe8twALqAeG4c/+sqVjBsyUNNdx8qfz/DcNSPKA8PV3+AyQELlCxlDfap9cmBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/animated": "~10.1.2",
+ "@react-spring/shared": "~10.1.2",
+ "@react-spring/types": "~10.1.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-spring/donate"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@react-spring/rafz": {
+ "version": "10.1.2",
+ "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.1.2.tgz",
+ "integrity": "sha512-KC6vSFZyPnRJ2rXqipV9QqR4SaYbYXjvKfdYKWipNK2mWuV79gr20WmpVUOsTiEHGRY3WSOdWCHN+P9Pdaqb7Q==",
+ "license": "MIT"
+ },
+ "node_modules/@react-spring/shared": {
+ "version": "10.1.2",
+ "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.1.2.tgz",
+ "integrity": "sha512-47/8bNQ/o0uEmxEnPBuERlC29VSqiTn5P9ln9tUMSVkYKYxBtouYE686F5kAGj+eHRPoNCw7drxWE9nv2d1LMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/rafz": "~10.1.2",
+ "@react-spring/types": "~10.1.2"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@react-spring/types": {
+ "version": "10.1.2",
+ "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.1.2.tgz",
+ "integrity": "sha512-G4CWowmVPz+rDG1y9QRVq/prZNxiNwQaHC0kgT/MgY6jAGgdRgTV4VThpvJDwWvUitk3xZB4soP4d36fjeQ09g==",
+ "license": "MIT"
+ },
+ "node_modules/@react-spring/web": {
+ "version": "10.1.2",
+ "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.1.2.tgz",
+ "integrity": "sha512-KxDB3zaDqy9qFsu7fdxjyraAxweHH4k5TW5WGT/OuMK6hKaxSDfhrQfRBzfFaSVvMBf+NghbJFanllV4ia6i7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-spring/animated": "~10.1.2",
+ "@react-spring/core": "~10.1.2",
+ "@react-spring/shared": "~10.1.2",
+ "@react-spring/types": "~10.1.2",
+ "csstype": "^3.2.3"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@rollup/plugin-node-resolve": {
+ "version": "15.3.1",
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "@types/resolve": "1.20.2",
+ "deepmerge": "^4.2.2",
+ "is-module": "^1.0.0",
+ "resolve": "^1.22.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.78.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.4.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@shikijs/core": {
+ "version": "4.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/primitive": "4.3.0",
+ "@shikijs/types": "4.3.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.4",
+ "hast-util-to-html": "^9.0.5"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/engine-javascript": {
+ "version": "4.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.3.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "oniguruma-to-es": "^4.3.6"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/engine-oniguruma": {
+ "version": "4.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.3.0",
+ "@shikijs/vscode-textmate": "^10.0.2"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/langs": {
+ "version": "4.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.3.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/primitive": {
+ "version": "4.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.3.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/themes": {
+ "version": "4.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.3.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/types": {
+ "version": "4.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/vscode-textmate": {
+ "version": "10.0.2",
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.2",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "5.21.6",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.2"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.2",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.2",
+ "@tailwindcss/oxide-darwin-x64": "4.3.2",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.2",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.2",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.2",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
+ "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
+ "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
+ "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
+ "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
+ "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
+ "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
+ "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
+ "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.2",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
+ "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
+ "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
+ "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/postcss": {
+ "version": "4.3.2",
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.3.2",
+ "@tailwindcss/oxide": "4.3.2",
+ "postcss": "^8.5.15",
+ "tailwindcss": "4.3.2"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.2",
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.2",
+ "@tailwindcss/oxide": "4.3.2",
+ "tailwindcss": "4.3.2"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.13",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "license": "MIT"
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "license": "MIT"
+ },
+ "node_modules/@types/nlcst": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/resolve": {
+ "version": "1.20.2",
+ "license": "MIT"
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "license": "MIT"
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.2",
+ "license": "ISC"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/anymatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/array-iterate": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/astro": {
+ "version": "6.4.8",
+ "license": "MIT",
+ "dependencies": {
+ "@astrojs/compiler": "^4.0.0",
+ "@astrojs/internal-helpers": "0.10.0",
+ "@astrojs/markdown-remark": "7.2.0",
+ "@astrojs/telemetry": "3.3.2",
+ "@capsizecss/unpack": "^4.0.0",
+ "@clack/prompts": "^1.1.0",
+ "@oslojs/encoding": "^1.1.0",
+ "@rollup/pluginutils": "^5.3.0",
+ "aria-query": "^5.3.2",
+ "axobject-query": "^4.1.0",
+ "ci-info": "^4.4.0",
+ "clsx": "^2.1.1",
+ "common-ancestor-path": "^2.0.0",
+ "cookie": "^1.1.1",
+ "devalue": "^5.8.1",
+ "diff": "^8.0.3",
+ "dset": "^3.1.4",
+ "es-module-lexer": "^2.0.0",
+ "esbuild": "^0.27.3",
+ "flattie": "^1.1.1",
+ "fontace": "~0.4.1",
+ "get-tsconfig": "5.0.0-beta.4",
+ "github-slugger": "^2.0.0",
+ "html-escaper": "3.0.3",
+ "http-cache-semantics": "^4.2.0",
+ "js-yaml": "^4.1.1",
+ "jsonc-parser": "^3.3.1",
+ "magic-string": "^0.30.21",
+ "magicast": "^0.5.2",
+ "mrmime": "^2.0.1",
+ "neotraverse": "^0.6.18",
+ "obug": "^2.1.1",
+ "p-limit": "^7.3.0",
+ "p-queue": "^9.1.0",
+ "package-manager-detector": "^1.6.0",
+ "piccolore": "^0.1.3",
+ "picomatch": "^4.0.4",
+ "rehype": "^13.0.2",
+ "semver": "^7.7.4",
+ "shiki": "^4.0.2",
+ "smol-toml": "^1.6.0",
+ "svgo": "^4.0.1",
+ "tinyclip": "^0.1.12",
+ "tinyexec": "^1.0.4",
+ "tinyglobby": "^0.2.15",
+ "ultrahtml": "^1.6.0",
+ "unifont": "~0.7.4",
+ "unist-util-visit": "^5.1.0",
+ "unstorage": "^1.17.5",
+ "vfile": "^6.0.3",
+ "vite": "^7.3.2",
+ "vitefu": "^1.1.2",
+ "xxhash-wasm": "^1.1.0",
+ "yargs-parser": "^22.0.0",
+ "zod": "^4.3.6"
+ },
+ "bin": {
+ "astro": "bin/astro.mjs"
+ },
+ "engines": {
+ "node": ">=22.12.0",
+ "npm": ">=9.6.5",
+ "pnpm": ">=7.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/astrodotbuild"
+ },
+ "optionalDependencies": {
+ "sharp": "^0.34.0"
+ }
+ },
+ "node_modules/astro/node_modules/semver": {
+ "version": "7.8.5",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/babel-plugin-transform-hook-names": {
+ "version": "1.0.2",
+ "license": "MIT",
+ "peerDependencies": {
+ "@babel/core": "^7.12.10"
+ }
+ },
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.41",
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "license": "ISC"
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.4",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.38",
+ "caniuse-lite": "^1.0.30001799",
+ "electron-to-chromium": "^1.5.376",
+ "node-releases": "^2.0.48",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001800",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^5.0.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/ci-info": {
+ "version": "4.4.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/commander": {
+ "version": "11.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/common-ancestor-path": {
+ "version": "2.0.0",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cookie-es": {
+ "version": "1.2.3",
+ "license": "MIT"
+ },
+ "node_modules/crossws": {
+ "version": "0.3.5",
+ "license": "MIT",
+ "dependencies": {
+ "uncrypto": "^0.1.3"
+ }
+ },
+ "node_modules/css-select": {
+ "version": "5.2.2",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.2.2",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/csso": {
+ "version": "5.0.5",
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "~2.2.0"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/csso/node_modules/css-tree": {
+ "version": "2.2.1",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.0.28",
+ "source-map-js": "^1.0.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/csso/node_modules/mdn-data": {
+ "version": "2.0.28",
+ "license": "CC0-1.0"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decode-named-character-reference": {
+ "version": "1.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/defu": {
+ "version": "6.1.7",
+ "license": "MIT"
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/destr": {
+ "version": "2.0.5",
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.8.1",
+ "license": "MIT"
+ },
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/diff": {
+ "version": "8.0.4",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/dset": {
+ "version": "3.1.4",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.385",
+ "license": "ISC"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.21.6",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.0",
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.7",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "license": "MIT"
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "license": "MIT"
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "license": "MIT"
+ },
+ "node_modules/fast-string-truncated-width": {
+ "version": "3.0.3",
+ "license": "MIT"
+ },
+ "node_modules/fast-string-width": {
+ "version": "3.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-truncated-width": "^3.0.2"
+ }
+ },
+ "node_modules/fast-wrap-ansi": {
+ "version": "0.2.2",
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-width": "^3.0.2"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/flattie": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/flowbite": {
+ "version": "4.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@popperjs/core": "^2.9.3",
+ "flowbite-datepicker": "^2.0.0",
+ "mini-svg-data-uri": "^1.4.3",
+ "postcss": "^8.5.1",
+ "tailwindcss": "^4.1.12"
+ }
+ },
+ "node_modules/flowbite-datepicker": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/plugin-node-resolve": "^15.2.3",
+ "@tailwindcss/postcss": "^4.1.17"
+ }
+ },
+ "node_modules/fontace": {
+ "version": "0.4.1",
+ "license": "MIT",
+ "dependencies": {
+ "fontkitten": "^1.0.2"
+ }
+ },
+ "node_modules/fontkitten": {
+ "version": "1.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "tiny-inflate": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "5.0.0-beta.4",
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20.20.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/github-slugger": {
+ "version": "2.0.0",
+ "license": "ISC"
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "license": "ISC"
+ },
+ "node_modules/h3": {
+ "version": "1.15.11",
+ "license": "MIT",
+ "dependencies": {
+ "cookie-es": "^1.2.3",
+ "crossws": "^0.3.5",
+ "defu": "^6.1.6",
+ "destr": "^2.0.5",
+ "iron-webcrypto": "^1.2.1",
+ "node-mock-http": "^1.0.4",
+ "radix3": "^1.1.2",
+ "ufo": "^1.6.3",
+ "uncrypto": "^0.1.3"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hast-util-from-html": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "devlop": "^1.1.0",
+ "hast-util-from-parse5": "^8.0.0",
+ "parse5": "^7.0.0",
+ "vfile": "^6.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-parse5": {
+ "version": "8.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "devlop": "^1.0.0",
+ "hastscript": "^9.0.0",
+ "property-information": "^7.0.0",
+ "vfile": "^6.0.0",
+ "vfile-location": "^5.0.0",
+ "web-namespaces": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-is-element": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-parse-selector": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-raw": {
+ "version": "9.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "hast-util-from-parse5": "^8.0.0",
+ "hast-util-to-parse5": "^8.0.0",
+ "html-void-elements": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "parse5": "^7.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0",
+ "web-namespaces": "^2.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-html": {
+ "version": "9.0.5",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "html-void-elements": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "stringify-entities": "^4.0.0",
+ "zwitch": "^2.0.4"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-parse5": {
+ "version": "8.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "web-namespaces": "^2.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-text": {
+ "version": "4.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "hast-util-is-element": "^3.0.0",
+ "unist-util-find-after": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hastscript": {
+ "version": "9.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-parse-selector": "^4.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "3.0.3",
+ "license": "MIT"
+ },
+ "node_modules/html-void-elements": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/idb": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
+ "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
+ "license": "ISC"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "license": "ISC"
+ },
+ "node_modules/iron-webcrypto": {
+ "version": "1.2.1",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/brc-dd"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.2",
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-docker": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-inside-container": {
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^3.0.0"
+ },
+ "bin": {
+ "is-inside-container": "cli.js"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-inside-container/node_modules/is-docker": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-module": {
+ "version": "1.0.0",
+ "license": "MIT"
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "3.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "is-inside-container": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsonc-parser": {
+ "version": "3.3.1",
+ "license": "MIT"
+ },
+ "node_modules/kolorist": {
+ "version": "1.8.0",
+ "license": "MIT"
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/magicast": {
+ "version": "0.5.3",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-definitions": {
+ "version": "6.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "unist-util-visit": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "license": "CC0-1.0"
+ },
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/mini-svg-data-uri": {
+ "version": "1.4.4",
+ "license": "MIT",
+ "bin": {
+ "mini-svg-data-uri": "cli.js"
+ }
+ },
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/nanostores": {
+ "version": "1.4.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": "^20.0.0 || >=22.0.0"
+ }
+ },
+ "node_modules/neotraverse": {
+ "version": "0.6.18",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/nlcst-to-string": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/nlcst": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/node-fetch-native": {
+ "version": "1.6.7",
+ "license": "MIT"
+ },
+ "node_modules/node-html-parser": {
+ "version": "6.1.13",
+ "license": "MIT",
+ "dependencies": {
+ "css-select": "^5.1.0",
+ "he": "1.2.0"
+ }
+ },
+ "node_modules/node-mock-http": {
+ "version": "1.0.4",
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.50",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.3",
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/ofetch": {
+ "version": "1.5.1",
+ "license": "MIT",
+ "dependencies": {
+ "destr": "^2.0.5",
+ "node-fetch-native": "^1.6.7",
+ "ufo": "^1.6.1"
+ }
+ },
+ "node_modules/ohash": {
+ "version": "2.0.11",
+ "license": "MIT"
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/oniguruma-parser": {
+ "version": "0.12.2",
+ "license": "MIT"
+ },
+ "node_modules/oniguruma-to-es": {
+ "version": "4.3.6",
+ "license": "MIT",
+ "dependencies": {
+ "oniguruma-parser": "^0.12.2",
+ "regex": "^6.1.0",
+ "regex-recursion": "^6.0.2"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "7.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-queue": {
+ "version": "9.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^5.0.4",
+ "p-timeout": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-timeout": {
+ "version": "7.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/package-manager-detector": {
+ "version": "1.7.0",
+ "license": "MIT"
+ },
+ "node_modules/parse-latin": {
+ "version": "7.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/nlcst": "^2.0.0",
+ "@types/unist": "^3.0.0",
+ "nlcst-to-string": "^4.0.0",
+ "unist-util-modify-children": "^4.0.0",
+ "unist-util-visit-children": "^3.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "license": "MIT"
+ },
+ "node_modules/piccolore": {
+ "version": "0.1.3",
+ "license": "ISC"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.16",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/preact": {
+ "version": "10.29.3",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/preact-render-to-string": {
+ "version": "6.7.0",
+ "license": "MIT",
+ "peerDependencies": {
+ "preact": ">=10 || >= 11.0.0-0"
+ }
+ },
+ "node_modules/prismjs": {
+ "version": "1.30.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/property-information": {
+ "version": "7.2.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/pusher-js": {
+ "version": "8.5.0",
+ "license": "MIT",
+ "dependencies": {
+ "tweetnacl": "^1.0.3"
+ }
+ },
+ "node_modules/radix3": {
+ "version": "1.1.2",
+ "license": "MIT"
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/regex": {
+ "version": "6.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-recursion": {
+ "version": "6.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-utilities": {
+ "version": "2.3.0",
+ "license": "MIT"
+ },
+ "node_modules/rehype": {
+ "version": "13.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "rehype-parse": "^9.0.0",
+ "rehype-stringify": "^10.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/rehype-parse": {
+ "version": "9.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-from-html": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/rehype-raw": {
+ "version": "7.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-raw": "^9.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/rehype-stringify": {
+ "version": "10.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-to-html": "^9.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-smartypants": {
+ "version": "3.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "retext": "^9.0.0",
+ "retext-smartypants": "^6.0.0",
+ "unified": "^11.0.4",
+ "unist-util-visit": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/retext": {
+ "version": "9.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/nlcst": "^2.0.0",
+ "retext-latin": "^4.0.0",
+ "retext-stringify": "^4.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/retext-latin": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/nlcst": "^2.0.0",
+ "parse-latin": "^7.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/retext-smartypants": {
+ "version": "6.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/nlcst": "^2.0.0",
+ "nlcst-to-string": "^4.0.0",
+ "unist-util-visit": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/retext-stringify": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/nlcst": "^2.0.0",
+ "nlcst-to-string": "^4.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/sax": {
+ "version": "1.6.0",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/server-destroy": {
+ "version": "1.0.1",
+ "license": "ISC"
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "license": "ISC"
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/sharp/node_modules/semver": {
+ "version": "7.8.5",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shiki": {
+ "version": "4.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/core": "4.3.0",
+ "@shikijs/engine-javascript": "4.3.0",
+ "@shikijs/engine-oniguruma": "4.3.0",
+ "@shikijs/langs": "4.3.0",
+ "@shikijs/themes": "4.3.0",
+ "@shikijs/types": "4.3.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/simple-code-frame": {
+ "version": "1.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "kolorist": "^1.6.0"
+ }
+ },
+ "node_modules/sisteransi": {
+ "version": "1.0.5",
+ "license": "MIT"
+ },
+ "node_modules/smol-toml": {
+ "version": "1.7.0",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyyynthia"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.7.6",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stack-trace": {
+ "version": "1.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/svgo": {
+ "version": "4.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^11.1.0",
+ "css-select": "^5.1.0",
+ "css-tree": "^3.0.1",
+ "css-what": "^6.1.0",
+ "csso": "^5.0.5",
+ "picocolors": "^1.1.1",
+ "sax": "^1.5.0"
+ },
+ "bin": {
+ "svgo": "bin/svgo.js"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/svgo"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.2",
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tiny-inflate": {
+ "version": "1.0.3",
+ "license": "MIT"
+ },
+ "node_modules/tinyclip": {
+ "version": "0.1.15",
+ "license": "MIT",
+ "engines": {
+ "node": "^16.14.0 || >= 17.3.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/tweetnacl": {
+ "version": "1.0.3",
+ "license": "Unlicense"
+ },
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "license": "MIT"
+ },
+ "node_modules/ultrahtml": {
+ "version": "1.6.0",
+ "license": "MIT"
+ },
+ "node_modules/uncrypto": {
+ "version": "0.1.3",
+ "license": "MIT"
+ },
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unifont": {
+ "version": "0.7.4",
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.1.0",
+ "ofetch": "^1.5.1",
+ "ohash": "^2.0.11"
+ }
+ },
+ "node_modules/unist-util-find-after": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-modify-children": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "array-iterate": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-remove-position": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-visit": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-children": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unstorage": {
+ "version": "1.17.5",
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "^3.1.3",
+ "chokidar": "^5.0.0",
+ "destr": "^2.0.5",
+ "h3": "^1.15.10",
+ "lru-cache": "^11.2.7",
+ "node-fetch-native": "^1.6.7",
+ "ofetch": "^1.5.1",
+ "ufo": "^1.6.3"
+ },
+ "peerDependencies": {
+ "@azure/app-configuration": "^1.8.0",
+ "@azure/cosmos": "^4.2.0",
+ "@azure/data-tables": "^13.3.0",
+ "@azure/identity": "^4.6.0",
+ "@azure/keyvault-secrets": "^4.9.0",
+ "@azure/storage-blob": "^12.26.0",
+ "@capacitor/preferences": "^6 || ^7 || ^8",
+ "@deno/kv": ">=0.9.0",
+ "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0",
+ "@planetscale/database": "^1.19.0",
+ "@upstash/redis": "^1.34.3",
+ "@vercel/blob": ">=0.27.1",
+ "@vercel/functions": "^2.2.12 || ^3.0.0",
+ "@vercel/kv": "^1 || ^2 || ^3",
+ "aws4fetch": "^1.0.20",
+ "db0": ">=0.2.1",
+ "idb-keyval": "^6.2.1",
+ "ioredis": "^5.4.2",
+ "uploadthing": "^7.4.4"
+ },
+ "peerDependenciesMeta": {
+ "@azure/app-configuration": {
+ "optional": true
+ },
+ "@azure/cosmos": {
+ "optional": true
+ },
+ "@azure/data-tables": {
+ "optional": true
+ },
+ "@azure/identity": {
+ "optional": true
+ },
+ "@azure/keyvault-secrets": {
+ "optional": true
+ },
+ "@azure/storage-blob": {
+ "optional": true
+ },
+ "@capacitor/preferences": {
+ "optional": true
+ },
+ "@deno/kv": {
+ "optional": true
+ },
+ "@netlify/blobs": {
+ "optional": true
+ },
+ "@planetscale/database": {
+ "optional": true
+ },
+ "@upstash/redis": {
+ "optional": true
+ },
+ "@vercel/blob": {
+ "optional": true
+ },
+ "@vercel/functions": {
+ "optional": true
+ },
+ "@vercel/kv": {
+ "optional": true
+ },
+ "aws4fetch": {
+ "optional": true
+ },
+ "db0": {
+ "optional": true
+ },
+ "idb-keyval": {
+ "optional": true
+ },
+ "ioredis": {
+ "optional": true
+ },
+ "uploadthing": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/unstorage/node_modules/lru-cache": {
+ "version": "11.5.1",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-location": {
+ "version": "5.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-prerender-plugin": {
+ "version": "0.5.13",
+ "license": "MIT",
+ "dependencies": {
+ "kolorist": "^1.8.0",
+ "magic-string": "0.x >= 0.26.0",
+ "node-html-parser": "^6.1.12",
+ "simple-code-frame": "^1.3.0",
+ "source-map": "^0.7.4",
+ "stack-trace": "^1.0.0-pre2"
+ },
+ "peerDependencies": {
+ "vite": "5.x || 6.x || 7.x || 8.x"
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.3",
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/web-namespaces": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/which-pm-runs": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/xxhash-wasm": {
+ "version": "1.1.0",
+ "license": "MIT"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "license": "ISC"
+ },
+ "node_modules/yargs-parser": {
+ "version": "22.0.0",
+ "license": "ISC",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "1.2.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "license": "MIT"
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..de900d1
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "",
+ "type": "module",
+ "version": "0.0.1",
+ "engines": {
+ "node": ">=22.12.0",
+ "npm": ">=11.12.0"
+ },
+ "scripts": {
+ "dev": "astro dev",
+ "build": "astro build",
+ "preview": "astro preview",
+ "astro": "astro"
+ },
+ "overrides": {
+ "vite": "^7.1.0"
+ },
+ "dependencies": {
+ "@astrojs/node": "^10.1.2",
+ "@astrojs/preact": "^5.1.4",
+ "@nanostores/preact": "^1.1.0",
+ "@preact/signals": "^2.9.2",
+ "@react-spring/web": "^10.1.2",
+ "@tailwindcss/vite": "^4.3.0",
+ "astro": "^6.4.2",
+ "flowbite": "^4.0.2",
+ "idb": "^8.0.3",
+ "nanostores": "^1.3.0",
+ "preact": "^10.29.2",
+ "pusher-js": "^8.4.0",
+ "tailwindcss": "^4.3.0"
+ }
+}
diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest
new file mode 100644
index 0000000..8af5f58
--- /dev/null
+++ b/frontend/public/manifest.webmanifest
@@ -0,0 +1,19 @@
+{
+ "name": "ERP Generic",
+ "short_name": "ERP",
+ "description": "Offline-capable ERP dashboard for field service teams",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#f9fafb",
+ "theme_color": "#4f46e5",
+ "lang": "hr",
+ "icons": [
+ {
+ "src": "/pwa-icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/frontend/public/offline.html b/frontend/public/offline.html
new file mode 100644
index 0000000..e4b786c
--- /dev/null
+++ b/frontend/public/offline.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+ Offline | ERP Generic
+
+
+
+
+
Trenutno ste offline
+
+ Aplikacija i dalje radi s lokalno spremljenim podacima. Novi zapisi bit će sinkronizirani
+ čim se internetska veza vrati.
+
+
+
+
+
diff --git a/frontend/public/pwa-icon.svg b/frontend/public/pwa-icon.svg
new file mode 100644
index 0000000..9cb4c29
--- /dev/null
+++ b/frontend/public/pwa-icon.svg
@@ -0,0 +1,9 @@
+
diff --git a/frontend/public/service-worker.js b/frontend/public/service-worker.js
new file mode 100644
index 0000000..7a1fd8d
--- /dev/null
+++ b/frontend/public/service-worker.js
@@ -0,0 +1,62 @@
+const CACHE_NAME = 'erp-shell-v1';
+const OFFLINE_URL = '/offline.html';
+const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL];
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
+ );
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys().then((cacheNames) =>
+ Promise.all(
+ cacheNames
+ .filter((name) => name !== CACHE_NAME)
+ .map((name) => caches.delete(name))
+ )
+ )
+ );
+ self.clients.claim();
+});
+
+self.addEventListener('fetch', (event) => {
+ const { request } = event;
+ if (request.method !== 'GET') return;
+
+ const url = new URL(request.url);
+ if (url.origin !== self.location.origin) return;
+
+ if (request.mode === 'navigate') {
+ event.respondWith(
+ fetch(request)
+ .then((response) => {
+ const copy = response.clone();
+ caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
+ return response;
+ })
+ .catch(async () => {
+ const cached = await caches.match(request);
+ return cached || caches.match(OFFLINE_URL);
+ })
+ );
+ return;
+ }
+
+ if (['style', 'script', 'worker', 'font', 'image'].includes(request.destination)) {
+ event.respondWith(
+ caches.match(request).then((cached) => {
+ const networkFetch = fetch(request)
+ .then((response) => {
+ const copy = response.clone();
+ caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
+ return response;
+ })
+ .catch(() => cached);
+ return cached || networkFetch;
+ })
+ );
+ }
+});
diff --git a/frontend/src/components/AuthWidget.jsx b/frontend/src/components/AuthWidget.jsx
new file mode 100644
index 0000000..68b793f
--- /dev/null
+++ b/frontend/src/components/AuthWidget.jsx
@@ -0,0 +1,61 @@
+import { useStore } from '@nanostores/preact';
+import { useEffect, useState } from 'preact/hooks';
+import { $accessToken, $authReady, hydrateAuthFromStorage, logout } from '../stores/authStore';
+import { showToast } from '../stores/toastStore';
+
+export default function AuthWidget() {
+ const token = useStore($accessToken);
+ const authReady = useStore($authReady);
+ // hasMounted: SSR uvijek renderira "Prijava" link — identičan SSR i client DOM
+ const [hasMounted, setHasMounted] = useState(false);
+
+ useEffect(() => {
+ setHasMounted(true);
+ // AuthWidget je autonoman — ne oslanja se na to da netko drugi (FleetDashboardShell)
+ // pozove hydrateAuthFromStorage(). Idempotentna je (authHydrated flag unutar store-a
+ // osigurava da se localStorage čita samo jednom, čak i ako se poziva višestruko).
+ hydrateAuthFromStorage();
+ }, []);
+
+ // Dok SSR ili auth hydration nisu gotovi — prikaži neutralni "Prijava" link
+ // kako ne bi došlo do layout shifta ili hydration mismatch-a
+ if (!hasMounted || !authReady) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {!token ? (
+
+ Prijava
+
+ ) : (
+ <>
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx
new file mode 100644
index 0000000..31950f4
--- /dev/null
+++ b/frontend/src/components/Navbar.jsx
@@ -0,0 +1,100 @@
+import NotificationBell from './NotificationBell';
+import ThemeToggle from './ui/ThemeToggle';
+import AuthWidget from './AuthWidget';
+import UserDisplay from './ui/UserDisplay';
+import { useEffect, useMemo, useState } from 'preact/hooks';
+import { hydrateAuthFromStorage } from '../stores/authStore';
+
+const ITEMS = [
+ { id: 'dashboard', label: 'Dashboard', href: '/' },
+ { id: 'work-orders', label: 'Putni nalozi', href: '/putni-nalozi' },
+ { id: 'service-records', label: 'Servisni zapisi', href: '/servisni-zapisi' },
+ { id: 'vehicles', label: 'Vozila', href: '/vehicles' },
+ { id: 'clients', label: 'Klijenti', href: '/clients' },
+];
+
+export default function Navbar({ minimal = false }) {
+ const [pathname, setPathname] = useState('/');
+ const normalizedPath = useMemo(() => {
+ const value = String(pathname || '/');
+ if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
+ return value;
+ }, [pathname]);
+
+ useEffect(() => {
+ const updatePath = () => setPathname(window.location.pathname || '/');
+ updatePath();
+ window.addEventListener('astro:page-load', updatePath);
+ // Hidratira auth iz storage pri prvi puta učitavanja Navbar-a
+ hydrateAuthFromStorage();
+ return () => window.removeEventListener('astro:page-load', updatePath);
+ }, []);
+
+ const currentItem = useMemo(
+ () => ITEMS.find((item) => item.href === normalizedPath) || ITEMS[0],
+ [normalizedPath]
+ );
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/NotificationBell.jsx b/frontend/src/components/NotificationBell.jsx
new file mode 100644
index 0000000..c91a30c
--- /dev/null
+++ b/frontend/src/components/NotificationBell.jsx
@@ -0,0 +1,135 @@
+import { useStore } from '@nanostores/preact';
+import { useEffect, useMemo, useState } from 'preact/hooks';
+import {
+ $isRealtimeConnected,
+ $notifications,
+ $unreadNotifications,
+ connectNotifications,
+ fetchNotifications,
+ markAllNotificationsAsRead,
+ markNotificationAsRead,
+} from '../stores/notificationStore';
+import { $accessToken, $user } from '../stores/authStore';
+import NotificationDetailModal from './notifications/NotificationDetailModal';
+
+function formatTimestamp(value) {
+ if (!value) return '';
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return '';
+ return new Intl.DateTimeFormat('hr-HR', {
+ day: '2-digit',
+ month: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(date);
+}
+
+export default function NotificationBell() {
+ const [mounted, setMounted] = useState(false);
+ const [open, setOpen] = useState(false);
+ const [selectedNotification, setSelectedNotification] = useState(null);
+ const notifications = useStore($notifications);
+ const unread = useStore($unreadNotifications);
+ const isConnected = useStore($isRealtimeConnected);
+ const token = useStore($accessToken);
+ const user = useStore($user);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const latestNotifications = useMemo(
+ () => notifications.slice(0, 6),
+ [notifications]
+ );
+
+ useEffect(() => {
+ if (!token || !user?.id) return;
+ fetchNotifications();
+ connectNotifications();
+ }, [token, user?.id]);
+
+ // Stabilan placeholder za SSR + prvi client render.
+ if (!mounted || !token) {
+ return ;
+ }
+
+ return (
+
+
+
+ {open && (
+
+
+
+
Notifikacije
+
+ Realtime: {isConnected ? 'spojen' : 'nije spojen'}
+
+
+
+
+
+
+ {latestNotifications.length === 0 && (
+ - Nema notifikacija.
+ )}
+ {latestNotifications.map((notification) => (
+ - {
+ if (!notification.is_read) {
+ try {
+ await markNotificationAsRead(notification.id);
+ } catch {
+ // Error handling already done in store toast.
+ }
+ }
+ setSelectedNotification(notification);
+ setOpen(false);
+ }}
+ >
+
+
+ {notification.title}
+
+
+ {formatTimestamp(notification.created_at)}
+
+
+ {notification.message}
+
+ ))}
+
+
+ )}
+
+
setSelectedNotification(null)}
+ />
+
+ );
+}
diff --git a/frontend/src/components/ToastContainer.jsx b/frontend/src/components/ToastContainer.jsx
new file mode 100644
index 0000000..cd4b701
--- /dev/null
+++ b/frontend/src/components/ToastContainer.jsx
@@ -0,0 +1,50 @@
+import { useStore } from '@nanostores/preact';
+import { useEffect } from 'preact/hooks';
+import { $toasts, clearToast } from '../stores/toastStore';
+
+const TOAST_STYLES = {
+ success: 'border-emerald-200 bg-emerald-50 text-emerald-800',
+ error: 'border-red-200 bg-red-50 text-red-800',
+ warning: 'border-amber-200 bg-amber-50 text-amber-800',
+ info: 'border-sky-200 bg-sky-50 text-sky-800',
+};
+
+export default function ToastContainer() {
+ const toasts = useStore($toasts);
+ const toastList = Array.isArray(toasts) ? toasts : [];
+
+ useEffect(() => {
+ const timers = toastList.map((toast) =>
+ window.setTimeout(() => clearToast(toast.id), toast.timeout || 4500)
+ );
+ return () => timers.forEach((timer) => window.clearTimeout(timer));
+ }, [toastList]);
+
+ if (!toastList.length) {
+ return null;
+ }
+
+ return (
+
+ {toastList.map((toast) => (
+
+
+
{toast.message}
+
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/ToastTrigger.jsx b/frontend/src/components/ToastTrigger.jsx
new file mode 100644
index 0000000..75ad21f
--- /dev/null
+++ b/frontend/src/components/ToastTrigger.jsx
@@ -0,0 +1,13 @@
+import { showToast } from '../stores/toastStore';
+
+export default function ToastTrigger() {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/atom/ButtonDisplayCounter.jsx b/frontend/src/components/atom/ButtonDisplayCounter.jsx
new file mode 100644
index 0000000..c72257c
--- /dev/null
+++ b/frontend/src/components/atom/ButtonDisplayCounter.jsx
@@ -0,0 +1,15 @@
+import { useState } from 'preact/hooks';
+
+export default function ButtonDisplayCounter() {
+ const [count, setCount] = useState(0);
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/auth/LoginForm.jsx b/frontend/src/components/auth/LoginForm.jsx
new file mode 100644
index 0000000..fa52d94
--- /dev/null
+++ b/frontend/src/components/auth/LoginForm.jsx
@@ -0,0 +1,86 @@
+import { useStore } from '@nanostores/preact';
+import { useEffect, useState } from 'preact/hooks';
+import { $accessToken, $authReady, hydrateAuthFromStorage, loginWithCredentials } from '../../stores/authStore';
+import { showToast } from '../../stores/toastStore';
+
+export default function LoginForm() {
+ const token = useStore($accessToken);
+ const authReady = useStore($authReady);
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ hydrateAuthFromStorage();
+ }, []);
+
+ useEffect(() => {
+ if (authReady && token) {
+ window.location.href = '/';
+ }
+ }, [authReady, token]);
+
+ if (token) return null;
+
+ async function submit(event) {
+ event.preventDefault();
+ setError('');
+ setSubmitting(true);
+ try {
+ await loginWithCredentials(email.trim(), password);
+ showToast('Uspješna prijava.', 'success', 2500);
+ window.location.href = '/';
+ } catch (err) {
+ const message = err?.message || 'Prijava nije uspjela.';
+ setError(message);
+ showToast(message, 'error');
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ Prijava
+ Prijavite se za pristup dashboardu.
+
+
+
+ );
+}
diff --git a/frontend/src/components/crm/ClientsSection.jsx b/frontend/src/components/crm/ClientsSection.jsx
new file mode 100644
index 0000000..07fe446
--- /dev/null
+++ b/frontend/src/components/crm/ClientsSection.jsx
@@ -0,0 +1,171 @@
+// src/components/crm/ClientsSection.jsx
+// Prikaz liste klijenata i njihovih dizalica unutar dashboard sekcije.
+
+import { useStore } from '@nanostores/preact';
+import { useEffect, useState } from 'preact/hooks';
+import { animated, useTransition } from '@react-spring/web';
+import { $clients, $clientsLoading, fetchClients } from '../../stores/clientStore';
+import { $cranes } from '../../stores/fleetDashboardStore';
+import AnimatedDataTable from '../ui/AnimatedDataTable';
+import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
+import { showToast } from '../../stores/toastStore';
+
+export default function ClientsSection({ onSetServiceContext = null }) {
+ const clients = useStore($clients);
+ const loading = useStore($clientsLoading);
+ const cranes = useStore($cranes);
+ const [expandedId, setExpandedId] = useState(null);
+ const [hasMounted, setHasMounted] = useState(false);
+ const [selectedClientCraneId, setSelectedClientCraneId] = useState(null);
+
+ useEffect(() => {
+ setHasMounted(true);
+ }, []);
+
+ useEffect(() => {
+ if (!hasMounted) return;
+ if (clients.length === 0) fetchClients();
+ }, [hasMounted, clients.length]);
+
+ function cranesForClient(clientId) {
+ return cranes.filter((item) => String(item.client) === String(clientId));
+ }
+
+ const clientTransitions = useTransition(clients, {
+ keys: (client) => client.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 },
+ });
+
+ function handleSetServiceContext() {
+ if (!selectedClientCraneId) {
+ showToast('Prvo označite redak dizalice.', 'warning');
+ return;
+ }
+ onSetServiceContext?.(selectedClientCraneId);
+ }
+
+ return (
+
+
+
Klijenti
+
+
+
+
+
+
+ {!hasMounted && (
+ Učitavanje…
+ )}
+
+ {hasMounted && loading && (
+ Učitavanje…
+ )}
+
+ {hasMounted && !loading && clients.length === 0 && (
+
+ Nema klijenata. Dodajte klijente u Django admin ili koristite CRM API.
+
+ )}
+
+ {hasMounted && !loading && clients.length > 0 && (
+
+ {clientTransitions((style, client) => {
+ const cvs = cranesForClient(client.id);
+ const isOpen = expandedId === client.id;
+ const craneColumns = [
+ { key: 'registration', label: 'Oznaka', className: 'py-2 pr-4 text-left' },
+ { key: 'model', label: 'Make / Model', className: 'py-2 pr-4 text-left' },
+ { key: 'km', label: 'Km', className: 'py-2 pr-4 text-left' },
+ { key: 'nextService', label: 'Sljedeći servis', className: 'py-2 pr-4 text-left' },
+ { key: 'type', label: 'Vrsta', className: 'py-2 text-left' },
+ ];
+ return (
+
+
+
+ {isOpen && (
+
+ {cvs.length === 0 ? (
+
+ Nema dizalica dodijeljenih ovom klijentu.
+ Dodajte dizalicu u Django admin i povežite je s klijentom.
+
+ ) : (
+
item.id}
+ headClassName="text-xs uppercase tracking-wide text-text-muted"
+ bodyClassName="divide-y divide-border-hairline"
+ rowClassName="hover:bg-canvas-deep"
+ tableClassName="min-w-full text-sm"
+ wrapperClassName="overflow-x-auto"
+ renderRow={(item) => {
+ const isSelected = String(item.id) === String(selectedClientCraneId);
+ const cellClassName = isSelected ? 'bg-emerald-50' : '';
+ const firstCellClassName = isSelected ? 'bg-emerald-50 text-emerald-800' : '';
+ return (
+ <>
+ setSelectedClientCraneId(item.id)}>{item.registration_number} |
+ setSelectedClientCraneId(item.id)}>{item.make} {item.model} {item.year ? `(${item.year})` : ''} |
+ setSelectedClientCraneId(item.id)}>{item.current_mileage?.toLocaleString('hr-HR')} km |
+ setSelectedClientCraneId(item.id)}>
+ {item.service_interval_km
+ ? `${(Math.floor(item.current_mileage / item.service_interval_km) + 1) * item.service_interval_km} km`
+ : '—'}
+ |
+ setSelectedClientCraneId(item.id)}>
+
+ {item.is_company_vehicle ? 'Fleet' : 'Kupac'}
+
+ |
+ >
+ );}}
+ />
+ )}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/dashboard/DashboardTopbar.jsx b/frontend/src/components/dashboard/DashboardTopbar.jsx
new file mode 100644
index 0000000..492e9fb
--- /dev/null
+++ b/frontend/src/components/dashboard/DashboardTopbar.jsx
@@ -0,0 +1,83 @@
+import { showToast } from '../../stores/toastStore';
+import ServiceContextSelector from './ServiceContextSelector';
+
+const NAV_ITEMS = [
+ { key: 'dashboard', label: 'Dashboard' },
+ { key: 'work-orders', label: 'Putni nalozi' },
+ { key: 'service-records', label: 'Servisni zapisi' },
+ { key: 'cranes', label: 'Dizalice' },
+ { key: 'clients', label: 'Klijenti' },
+];
+
+export default function DashboardTopbar({
+ activeSection,
+ onSectionChange,
+ onOpenWorkOrderModal,
+ onOpenTaskModal,
+ pendingSyncCount,
+ onNewServiceRecord,
+ showSectionNav = true,
+}) {
+ const sectionTitle = NAV_ITEMS.find((item) => item.key === activeSection)?.label || 'Dashboard';
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/dashboard/FleetDashboardShell.jsx b/frontend/src/components/dashboard/FleetDashboardShell.jsx
new file mode 100644
index 0000000..aae82ef
--- /dev/null
+++ b/frontend/src/components/dashboard/FleetDashboardShell.jsx
@@ -0,0 +1,1036 @@
+import { useStore } from '@nanostores/preact';
+import { useEffect, useMemo, useState } from 'preact/hooks';
+import { animated, useSpring } from '@react-spring/web';
+import DashboardTopbar from './DashboardTopbar';
+import WorkOrderModal from './WorkOrderModal';
+import TaskCreateModal from './TaskCreateModal';
+import TaskServiceRecordsModal from './TaskServiceRecordsModal';
+import WorkOrderDetailModal from './WorkOrderDetailModal';
+import SyncLogPanel from './SyncLogPanel';
+import ServiceRecordForm from '../fleet/ServiceRecordForm';
+import ServicePhotoUpload from '../fleet/ServicePhotoUpload';
+import ServiceRecordDetailModal from '../fleet/ServiceRecordDetailModal';
+import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
+import ClientsSection from '../crm/ClientsSection';
+import { $clients } from '../../stores/clientStore';
+import { $selectedClientId, $selectedVehicleId, hydrateServiceContext, setServiceContext } from '../../stores/serviceContextStore';
+import {
+ $dashboardError,
+ $dashboardLoading,
+ $offlineQueueCount,
+ $dashboardStats,
+ $cranes,
+ $workOrdersWithVehicle,
+ $serviceRecords,
+ createWorkOrder,
+ fetchFleetDashboardData,
+ ensureVehiclesCatalog,
+ triggerOfflineSync,
+ updateWorkOrder,
+} from '../../stores/fleetDashboardStore';
+import { showToast } from '../../stores/toastStore';
+import { $accessToken, $authReady, $user, hydrateAuthFromStorage, validateToken } from '../../stores/authStore';
+import { cacheCraneSerial, cacheCraneSerialsFromVehicles } from '../../stores/craneSerialStore';
+import { formatEntityCode } from '../../lib/displayIds';
+import { $tasks, fetchTasks, isTaskActive, updateTask } from '../../stores/taskStore';
+import AnimatedDataTable from '../ui/AnimatedDataTable';
+import AnimatedPage from '../ui/AnimatedPage';
+
+function AnimatedStatusBadge({ status }) {
+ const normalizedStatus = status === 'closed' ? 'closed' : 'open';
+ const springStyle = useSpring({
+ backgroundColor: normalizedStatus === 'closed' ? 'rgba(16, 185, 129, 0.16)' : 'rgba(99, 102, 241, 0.16)',
+ color: normalizedStatus === 'closed' ? 'rgb(4, 120, 87)' : 'rgb(67, 56, 202)',
+ config: { tension: 240, friction: 24 },
+ });
+
+ return (
+
+ {normalizedStatus}
+
+ );
+}
+
+function formatDateTime(value) {
+ if (!value) return '-';
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return '-';
+ return parsed.toLocaleString('hr-HR');
+}
+
+function formatDate(value) {
+ if (!value) return '-';
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return value;
+ return parsed.toLocaleDateString('hr-HR');
+}
+
+function formatCost(value) {
+ const numeric = Number(value);
+ if (Number.isNaN(numeric)) return '-';
+ return `${numeric.toFixed(2)} EUR`;
+}
+
+const PAGE_MODE_TO_SECTION = {
+ dashboard: 'dashboard',
+ 'work-orders': 'work-orders',
+ 'service-records': 'service-records',
+ vehicles: 'cranes',
+ clients: 'clients',
+};
+
+export default function FleetDashboardShell({ initialSection = 'dashboard', pageMode = 'dashboard' }) {
+ const resolvedInitialSection = initialSection || PAGE_MODE_TO_SECTION[pageMode] || 'dashboard';
+ const [activeSection, setActiveSection] = useState(resolvedInitialSection);
+ const [isWorkOrderModalOpen, setIsWorkOrderModalOpen] = useState(false);
+ const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
+ const [selectedWorkOrder, setSelectedWorkOrder] = useState(null);
+ const [selectedTask, setSelectedTask] = useState(null);
+ const [serviceRecordBackTask, setServiceRecordBackTask] = useState(null);
+ const [serviceRecordFormBackTask, setServiceRecordFormBackTask] = useState(null);
+ const [serviceRecordOpen, setServiceRecordOpen] = useState(false);
+ const [serviceRecordCrane, setServiceRecordCrane] = useState(null);
+ const [selectedServiceRecord, setSelectedServiceRecord] = useState(null);
+ // photoUpload: { open: bool, serviceRecordId: string|null }
+ const [photoUpload, setPhotoUpload] = useState({ open: false, serviceRecordId: null });
+ const loading = useStore($dashboardLoading);
+ const error = useStore($dashboardError);
+ const stats = useStore($dashboardStats);
+ const workOrders = useStore($workOrdersWithVehicle);
+ const serviceRecords = useStore($serviceRecords);
+ const tasks = useStore($tasks);
+ const cranes = useStore($cranes);
+ const clients = useStore($clients);
+ const offlineQueueCount = useStore($offlineQueueCount);
+ const user = useStore($user);
+ const token = useStore($accessToken);
+ const authReady = useStore($authReady);
+ const selectedClientId = useStore($selectedClientId);
+ const selectedVehicleId = useStore($selectedVehicleId);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [taskFilter, setTaskFilter] = useState('active');
+ const [taskScope, setTaskScope] = useState('all');
+ const [workOrderScope, setWorkOrderScope] = useState('context');
+ const [selectedCraneRowId, setSelectedCraneRowId] = useState(null);
+ const [hasMounted, setHasMounted] = useState(false);
+
+ useEffect(() => {
+ setHasMounted(true);
+ hydrateAuthFromStorage();
+ hydrateServiceContext();
+ }, []);
+
+ useEffect(() => {
+ const nextSection = String(resolvedInitialSection || 'dashboard');
+ setActiveSection(nextSection);
+ }, [resolvedInitialSection]);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+ const syncContextFromStorage = () => {
+ const persistedClientId = localStorage.getItem('topbar_selected_client');
+ const persistedVehicleId = localStorage.getItem('topbar_selected_vehicle');
+ if (!persistedVehicleId) {
+ return;
+ }
+ if (String(selectedVehicleId || '') === String(persistedVehicleId)) {
+ return;
+ }
+ setServiceContext(persistedClientId, persistedVehicleId);
+ };
+
+ syncContextFromStorage();
+ window.addEventListener('storage', syncContextFromStorage);
+ window.addEventListener('focus', syncContextFromStorage);
+ window.addEventListener('service-context:changed', syncContextFromStorage);
+ window.addEventListener('astro:page-load', syncContextFromStorage);
+ return () => {
+ window.removeEventListener('storage', syncContextFromStorage);
+ window.removeEventListener('focus', syncContextFromStorage);
+ window.removeEventListener('service-context:changed', syncContextFromStorage);
+ window.removeEventListener('astro:page-load', syncContextFromStorage);
+ };
+ }, [selectedVehicleId]);
+
+ useEffect(() => {
+ if (authReady && !token) {
+ window.location.href = '/login';
+ }
+ }, [authReady, token]);
+
+ function openWorkOrderDetails(workOrder) {
+ if (!workOrder) {
+ return;
+ }
+ const targetCraneId = workOrder.crane || workOrder.vehicle;
+ const craneFromCatalog = cranes.find((item) => String(item.id) === String(targetCraneId)) || null;
+ const serialFromContext = craneFromCatalog?.crane_serial_number || workOrder.craneInfo?.crane_serial_number;
+ cacheCraneSerial(targetCraneId, serialFromContext);
+ setSelectedWorkOrder({
+ ...workOrder,
+ craneInfo: craneFromCatalog || workOrder.craneInfo || null,
+ });
+ }
+
+ useEffect(() => {
+ cacheCraneSerialsFromVehicles(cranes);
+ }, [cranes]);
+
+ useEffect(() => {
+ if (!authReady) return;
+ let active = true;
+
+ (async () => {
+ await validateToken();
+ if (!active) return;
+ await Promise.all([
+ fetchFleetDashboardData(),
+ fetchTasks(),
+ ]);
+ })();
+
+ return () => {
+ active = false;
+ };
+ }, [authReady]);
+
+ useEffect(() => {
+ function handleNavbarNavigate(event) {
+ const section = event?.detail;
+ if (typeof section === 'string' && section.length > 0) {
+ setActiveSection(section);
+ }
+ }
+
+ window.addEventListener('navbar:navigate', handleNavbarNavigate);
+ return () => window.removeEventListener('navbar:navigate', handleNavbarNavigate);
+ }, []);
+
+ useEffect(() => {
+ function handleNotificationOpenEntity(event) {
+ const detail = event?.detail || {};
+ const section = detail.section;
+ if (typeof section === 'string' && section.length > 0) {
+ setActiveSection(section);
+ }
+
+ if (detail.entityType === 'work_order' && detail.entityId) {
+ if (detail.vehicleId) {
+ const targetCrane = cranes.find((item) => String(item.id) === String(detail.craneId || detail.vehicleId));
+ if (targetCrane) {
+ setServiceContext(targetCrane.client || null, targetCrane.id);
+ }
+ }
+ const targetWorkOrder = workOrders.find((item) => String(item.id) === String(detail.entityId));
+ if (targetWorkOrder) {
+ openWorkOrderDetails(targetWorkOrder);
+ } else {
+ showToast('Traženi putni nalog nije pronađen u trenutnom prikazu.', 'warning');
+ }
+ return;
+ }
+
+ if (detail.entityType === 'service_record' && detail.entityId) {
+ if (detail.vehicleId) {
+ const targetCrane = cranes.find((item) => String(item.id) === String(detail.craneId || detail.vehicleId));
+ if (targetCrane) {
+ setServiceContext(targetCrane.client || null, targetCrane.id);
+ }
+ }
+ const targetServiceRecord = serviceRecords.find((item) => String(item.id) === String(detail.entityId));
+ if (targetServiceRecord) {
+ setServiceRecordBackTask(null);
+ setSelectedServiceRecord(targetServiceRecord);
+ } else {
+ showToast('Traženi servisni zapis nije pronađen u trenutnom prikazu.', 'warning');
+ }
+ return;
+ }
+
+ if (detail.entityType === 'service_context' && detail.vehicleId) {
+ const targetCrane = cranes.find((item) => String(item.id) === String(detail.vehicleId));
+ if (targetCrane) {
+ setServiceContext(targetCrane.client || null, targetCrane.id);
+ setSelectedCraneRowId(targetCrane.id);
+ }
+ }
+ }
+
+ window.addEventListener('notification:open-entity', handleNotificationOpenEntity);
+ return () => window.removeEventListener('notification:open-entity', handleNotificationOpenEntity);
+ }, [serviceRecords, cranes, workOrders]);
+
+ const contextCrane = useMemo(() => (
+ cranes.find((item) => String(item.id) === String(selectedVehicleId)) || null
+ ), [cranes, selectedVehicleId]);
+ const hydratedSelectedVehicleId = hasMounted ? selectedVehicleId : null;
+ const hydratedContextCrane = hasMounted ? contextCrane : null;
+ const selectedContextClientName = useMemo(() => (
+ clients.find((client) => String(client.id) === String(selectedClientId))?.name
+ || contextCrane?.client_name
+ || null
+ ), [clients, selectedClientId, contextCrane]);
+ const serviceRecordsForContext = useMemo(() => {
+ if (!selectedVehicleId) return [];
+ return [...serviceRecords]
+ .filter((record) => String(record.vehicle) === String(selectedVehicleId))
+ .sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || '')));
+ }, [serviceRecords, selectedVehicleId]);
+ const scopedWorkOrders = useMemo(() => {
+ const sorted = [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
+ if (workOrderScope === 'all') {
+ return sorted;
+ }
+ if (!hydratedSelectedVehicleId) {
+ return [];
+ }
+ return sorted.filter((order) => String(order.vehicle) === String(hydratedSelectedVehicleId));
+ }, [workOrders, workOrderScope, hydratedSelectedVehicleId]);
+ const pageSize = 10;
+ const totalPages = Math.max(1, Math.ceil(scopedWorkOrders.length / pageSize));
+ const latestWorkOrders = useMemo(
+ () => scopedWorkOrders.slice((currentPage - 1) * pageSize, currentPage * pageSize),
+ [scopedWorkOrders, currentPage]
+ );
+ const serviceRecordWorkOrders = useMemo(() => {
+ const craneId = serviceRecordCrane?.id || contextCrane?.id;
+ if (!craneId) return [];
+ return workOrders
+ .filter((order) => String(order.vehicle) === String(craneId))
+ .sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
+ }, [workOrders, serviceRecordCrane, contextCrane]);
+ const serviceRecordTasks = useMemo(() => {
+ const craneId = serviceRecordCrane?.id || contextCrane?.id;
+ if (!craneId) return [];
+ return tasks
+ .filter((task) => isTaskActive(task))
+ .filter((task) => task.vehicle && String(task.vehicle) === String(craneId))
+ .sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
+ }, [tasks, serviceRecordCrane, contextCrane]);
+ const isTeamMember = Boolean(user?.is_team_member);
+ const isServiser = Boolean(user?.is_serviser);
+ const isCustomer = Boolean(user?.is_kupac);
+ const canViewAllTasks = isTeamMember && (isServiser || !isCustomer);
+ const canToggleTaskScope = isTeamMember && isServiser;
+ const isTaskAssignedToCurrentUser = (task) => (
+ Boolean(user?.id) && String(task?.assigned_to || '') === String(user.id)
+ );
+
+ useEffect(() => {
+ setTaskScope(canViewAllTasks ? 'all' : 'mine');
+ }, [canViewAllTasks]);
+
+ const dashboardTasks = useMemo(() => {
+ const sorted = [...tasks].sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
+ const scoped = (() => {
+ if (!canViewAllTasks) {
+ return sorted.filter((task) => isTaskAssignedToCurrentUser(task));
+ }
+ if (canToggleTaskScope && taskScope === 'mine') {
+ return sorted.filter((task) => isTaskAssignedToCurrentUser(task));
+ }
+ return sorted;
+ })();
+ if (taskFilter === 'active') {
+ return scoped.filter((task) => isTaskActive(task));
+ }
+ if (taskFilter === 'inactive') {
+ return scoped.filter((task) => !isTaskActive(task));
+ }
+ return scoped;
+ }, [tasks, taskFilter, canViewAllTasks, canToggleTaskScope, taskScope, user?.id]);
+ const selectedTaskServiceRecords = useMemo(() => {
+ if (!selectedTask?.id) return [];
+ return [...serviceRecords]
+ .filter((record) => String(record.task || '') === String(selectedTask.id))
+ .sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || '')));
+ }, [serviceRecords, selectedTask?.id]);
+ const selectedTaskWorkOrders = useMemo(() => {
+ if (!selectedTask) return [];
+ const byVehicle = selectedTask.vehicle
+ ? workOrders.filter((order) => String(order.vehicle) === String(selectedTask.vehicle))
+ : workOrders;
+ return [...byVehicle].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
+ }, [selectedTask, workOrders]);
+
+ useEffect(() => {
+ if (currentPage > totalPages) {
+ setCurrentPage(totalPages);
+ }
+ }, [currentPage, totalPages]);
+
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [workOrderScope, hydratedSelectedVehicleId]);
+
+ useEffect(() => {
+ if (!selectedCraneRowId) return;
+ const exists = cranes.some((item) => String(item.id) === String(selectedCraneRowId));
+ if (!exists) {
+ setSelectedCraneRowId(null);
+ }
+ }, [cranes, selectedCraneRowId]);
+
+ async function handleCreateWorkOrder(formData) {
+ try {
+ await createWorkOrder(formData);
+ showToast('Putni nalog je spremljen.', 'success');
+ await fetchFleetDashboardData({ silent: true });
+ } catch (error) {
+ showToast('Kreiranje naloga nije uspjelo.', 'error');
+ }
+ }
+
+ async function handleUpdateWorkOrder(workOrder, changes) {
+ try {
+ await updateWorkOrder(workOrder.id, changes);
+ showToast('Nalog je uspješno ažuriran.', 'success');
+ await fetchFleetDashboardData({ silent: true });
+ } catch (error) {
+ showToast('Ažuriranje naloga nije uspjelo.', 'error');
+ }
+ }
+
+ function openServiceRecordForCrane(crane) {
+ setServiceRecordFormBackTask(null);
+ setServiceRecordCrane(crane ?? null);
+ setServiceRecordOpen(true);
+ }
+
+ async function handleSetServiceContext() {
+ if (!selectedCraneRowId) {
+ showToast('Prvo označite redak dizalice.', 'warning');
+ return;
+ }
+ const selectedCrane = cranes.find((item) => String(item.id) === String(selectedCraneRowId));
+ handleSetServiceContextForCrane(selectedCrane);
+ }
+
+ function handleSetServiceContextForCraneId(craneId) {
+ const selectedCrane = cranes.find((item) => String(item.id) === String(craneId));
+ handleSetServiceContextForCrane(selectedCrane);
+ }
+
+ function handleSetServiceContextForCrane(selectedCrane) {
+ if (!selectedCrane) {
+ showToast('Odabrana dizalica više nije dostupna.', 'warning');
+ return;
+ }
+ setSelectedCraneRowId(selectedCrane.id);
+ setServiceContext(selectedCrane.client || null, selectedCrane.id);
+ showToast('Servisni kontekst je postavljen.', 'success');
+ }
+
+ const workOrderColumns = [
+ { key: 'code', label: 'Putni nalog', className: 'px-4 py-3' },
+ { key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' },
+ { key: 'createdAt', label: 'Kreirano', className: 'px-4 py-3' },
+ { key: 'status', label: 'Status', className: 'px-4 py-3' },
+ { key: 'creator', label: 'Creator', className: 'px-4 py-3' },
+ { key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
+ ];
+ const vehicleColumns = [
+ { key: 'registration', label: 'Registracija', className: 'px-4 py-3' },
+ { key: 'serialNumber', label: 'SN', className: 'px-4 py-3' },
+ { key: 'model', label: 'Marka / Model', className: 'px-4 py-3' },
+ { key: 'client', label: 'Kupac', className: 'px-4 py-3' },
+ { key: 'mileage', label: 'Kilometraža', className: 'px-4 py-3' },
+ { key: 'status', label: 'Status', className: 'px-4 py-3' },
+ ];
+ const taskColumns = [
+ { key: 'title', label: 'Radni zadatak', className: 'px-4 py-3' },
+ { key: 'status', label: 'Status', className: 'px-4 py-3' },
+ { key: 'createdAt', label: 'Vrijeme izrade', className: 'px-4 py-3' },
+ { key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' },
+ { key: 'workOrder', label: 'Putni nalog', className: 'px-4 py-3' },
+ { key: 'assigned', label: 'Dodijeljeno', className: 'px-4 py-3' },
+ ];
+ const isDashboardPage = pageMode === 'dashboard';
+ const showWorkOrders = pageMode === 'work-orders';
+ const showServiceRecords = pageMode === 'service-records';
+ const showClients = pageMode === 'clients';
+ const showVehicles = pageMode === 'vehicles';
+
+ return (
+
+
+ {
+ try {
+ const availableCranes = await ensureVehiclesCatalog();
+ const selectedFromContext = availableCranes.find(
+ (item) => String(item.id) === String(selectedVehicleId)
+ );
+ if (!selectedFromContext) {
+ showToast('Odaberite dizalicu u Servisnom kontekstu prije kreiranja naloga.', 'warning');
+ return;
+ }
+ } catch {
+ showToast('Ne mogu dohvatiti dizalice. Provjerite mrežu ili prijavu.', 'error');
+ return;
+ }
+ setIsWorkOrderModalOpen(true);
+ }}
+ onOpenTaskModal={async () => {
+ if (!selectedClientId || !selectedVehicleId) {
+ showToast('Odaberite kupca i dizalicu u Servisnom kontekstu prije kreiranja radnog zadatka.', 'warning');
+ return;
+ }
+ try {
+ const availableCranes = await ensureVehiclesCatalog();
+ const selectedFromContext = availableCranes.find(
+ (item) => String(item.id) === String(selectedVehicleId)
+ );
+ if (!selectedFromContext) {
+ showToast('Odabrana dizalica nije dostupna u trenutnom kontekstu.', 'warning');
+ return;
+ }
+ } catch {
+ showToast('Ne mogu dohvatiti dizalice. Provjerite mrežu ili prijavu.', 'error');
+ return;
+ }
+ setIsTaskModalOpen(true);
+ }}
+ onNewServiceRecord={({ craneId }) => {
+ const crane = cranes.find((item) => String(item.id) === String(craneId));
+ openServiceRecordForCrane(crane ?? null);
+ }}
+ pendingSyncCount={offlineQueueCount}
+ />
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {isDashboardPage && (
+
+
+ Otvoreni nalozi
+ {stats.openWorkOrders}
+
+
+ Servisi danas
+ {stats.servicesToday}
+
+
+ Upozorenja
+ {stats.warnings}
+
+
+ Dovršeni nalozi
+ {stats.closedWorkOrders}
+
+
+ )}
+ {isDashboardPage && (
+
+
+
Radni zadaci
+
+ {canToggleTaskScope && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+ task.id}
+ loading={loading}
+ loadingMessage="Učitavanje radnih zadataka..."
+ emptyMessage="Nema radnih zadataka za odabrani filter."
+ rowClassName={(task) => {
+ const status = String(task?.status || '').toLowerCase();
+ if (status === 'hitno') {
+ return 'bg-amber-50 hover:bg-amber-100';
+ }
+ return 'hover:bg-canvas-deep';
+ }}
+ renderRow={(task) => (
+ <>
+
+
+ |
+
+
+ {task.status || '-'}
+
+ |
+ {formatDateTime(task.created_at)} |
+
+ {task.vehicle_registration
+ || cranes.find((item) => String(item.id) === String(task.vehicle))?.registration_number
+ || '-'}
+ |
+ {task.work_order_label || '-'} |
+ {task.assigned_to_name || '-'} |
+ >
+ )}
+ />
+
+ )}
+ {isDashboardPage && (
+
+
+
+ )}
+
+ {showWorkOrders && (
+
+
+
+
Putni nalozi dizalica
+
+ {workOrderScope === 'context'
+ ? (selectedContextClientName && hydratedContextCrane?.registration_number
+ ? `Kontekst: ${selectedContextClientName} / ${hydratedContextCrane.registration_number}`
+ : 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.')
+ : 'Prikaz svih putnih naloga.'}
+
+
+
+
+
+
+
+
+
+ workOrder.id}
+ loading={loading}
+ loadingMessage="Učitavanje..."
+ emptyMessage={
+ workOrderScope === 'context'
+ ? (!hydratedSelectedVehicleId
+ ? 'Odaberite dizalicu za prikaz putnih naloga iz servisnog konteksta.'
+ : 'Nema dostupnih putnih naloga za odabrani servisni kontekst.')
+ : 'Nema dostupnih putnih naloga.'
+ }
+ renderRow={(workOrder) => (
+ <>
+ openWorkOrderDetails(workOrder)}
+ >
+ {formatEntityCode('PN', workOrder.id)}
+ |
+
+ {[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ') || workOrder.craneLabel || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`}
+ |
+
+ {formatDate(workOrder.date)}
+ |
+
+
+ |
+
+ {workOrder.creator || '-'}
+ |
+
+
+ |
+ >
+ )}
+ />
+
+
+ Prikaz {scopedWorkOrders.length ? (currentPage - 1) * pageSize + 1 : 0}
+ -
+ {scopedWorkOrders.length ? Math.min(currentPage * pageSize, scopedWorkOrders.length) : 0} od {scopedWorkOrders.length}
+
+
+
+
+ {currentPage}/{totalPages}
+
+
+
+
+
+ )}
+
+ {showServiceRecords && (
+
+
+
+
Servisni zapisi
+
+ {selectedContextClientName && hydratedContextCrane?.registration_number
+ ? `Kontekst: ${selectedContextClientName} / ${hydratedContextCrane.registration_number}`
+ : 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.'}
+
+
+
+ {hydratedSelectedVehicleId && hydratedContextCrane && (
+ openServiceRecordForCrane(hydratedContextCrane)} />
+ )}
+
+
+
+ record.id}
+ loading={loading}
+ loadingMessage="Učitavanje..."
+ emptyMessage={!hydratedSelectedVehicleId
+ ? 'Odaberite dizalicu za prikaz servisnih zapisa.'
+ : 'Nema aktivnih servisnih zapisa za odabranu dizalicu.'}
+ renderRow={(record) => (
+ <>
+ {formatDate(record.service_date)} |
+ {record.crane_registration || record.vehicle_registration || '-'} |
+ {record.task_label || '-'} |
+
+ {record.description || '-'}
+ |
+ {formatCost(record.cost)} |
+ {record.files_count ?? (record.photos_count ?? 0)} |
+
+
+
+ |
+ >
+ )}
+ />
+
+ )}
+
+ {showClients && (
+ <>
+ {/* ── Tablica klijenata i njihovih dizalica ───────── */}
+
+
+
+ >
+ )}
+
+ {showVehicles && (
+
+
+
Vozila i dizalice
+
+
+
+
+
+ vehicle.id}
+ loading={loading}
+ loadingMessage="Učitavanje vozila..."
+ emptyMessage="Nema dostupnih vozila."
+ renderRow={(vehicle) => (
+ <>
+ setSelectedCraneRowId(vehicle.id)}
+ >
+ {vehicle.registration_number || '-'}
+ |
+ setSelectedCraneRowId(vehicle.id)}
+ >
+ {vehicle.crane_serial_number || '-'}
+ |
+ setSelectedCraneRowId(vehicle.id)}
+ >
+ {vehicle.make || '-'} {vehicle.model || ''}
+ |
+ setSelectedCraneRowId(vehicle.id)}
+ >
+ {vehicle.client_name || '-'}
+ |
+ setSelectedCraneRowId(vehicle.id)}
+ >
+ {vehicle.current_mileage ?? '-'}
+ |
+ setSelectedCraneRowId(vehicle.id)}
+ >
+
+ {vehicle.is_active ? 'Aktivno' : 'Neaktivno'}
+
+ |
+ >
+ )}
+ />
+
+ )}
+
+
+
+ setIsWorkOrderModalOpen(false)}
+ onSubmit={handleCreateWorkOrder}
+ />
+
+ setIsTaskModalOpen(false)}
+ onSuccess={() => fetchFleetDashboardData({ silent: true })}
+ />
+
+ setSelectedWorkOrder(null)}
+ onSubmit={handleUpdateWorkOrder}
+ />
+
+ {/* Modali su na root razini div-a — van i —
+ kako bi fixed pozicioniranje i z-index radili ispravno */}
+ {
+ setServiceRecordOpen(false);
+ setServiceRecordCrane(null);
+ setServiceRecordFormBackTask(null);
+ }}
+ onSuccess={() => fetchFleetDashboardData({ silent: true })}
+ onBack={serviceRecordFormBackTask
+ ? () => {
+ setServiceRecordOpen(false);
+ setServiceRecordCrane(null);
+ setSelectedTask(serviceRecordFormBackTask);
+ setServiceRecordFormBackTask(null);
+ }
+ : null}
+ backLabel="Natrag na radni zadatak"
+ />
+
+ setPhotoUpload({ open: false, serviceRecordId: null })}
+ onSuccess={() => fetchFleetDashboardData({ silent: true })}
+ />
+
+ {
+ setSelectedServiceRecord(null);
+ setServiceRecordBackTask(null);
+ }}
+ onUpdated={(updatedRecord) => {
+ setSelectedServiceRecord(updatedRecord);
+ fetchFleetDashboardData({ silent: true });
+ }}
+ onBack={serviceRecordBackTask
+ ? () => {
+ setSelectedServiceRecord(null);
+ setSelectedTask(serviceRecordBackTask);
+ }
+ : null}
+ backLabel="Natrag na popis servisnih zapisa"
+ />
+
+ setSelectedTask(null)}
+ onOpenServiceRecord={(record) => {
+ setServiceRecordBackTask(selectedTask);
+ setSelectedTask(null);
+ setSelectedServiceRecord(record);
+ }}
+ onAssignWorkOrder={async (task, workOrderId) => {
+ const updatedTask = await updateTask(task.id, { work_order: workOrderId });
+ setSelectedTask(updatedTask);
+ return updatedTask;
+ }}
+ onCreateServiceRecord={(task) => {
+ const taskCrane = cranes.find((item) => String(item.id) === String(task?.vehicle));
+ setSelectedTask(null);
+ setServiceRecordFormBackTask(task);
+ setServiceRecordCrane(taskCrane ?? null);
+ setServiceRecordOpen(true);
+ }}
+ />
+
+ );
+}
diff --git a/frontend/src/components/dashboard/ServiceContextSelector.jsx b/frontend/src/components/dashboard/ServiceContextSelector.jsx
new file mode 100644
index 0000000..ccf4b43
--- /dev/null
+++ b/frontend/src/components/dashboard/ServiceContextSelector.jsx
@@ -0,0 +1,172 @@
+// src/components/dashboard/ServiceContextSelector.jsx
+//
+// Persistentni selektor "Servisni kontekst" — odabir kupca + dizalice.
+// Stanje se čuva u localStorage i u serviceContextStore (reaktivno).
+//
+// USAGE:
+// ...} />
+
+import { useStore } from '@nanostores/preact';
+import { useEffect, useState } from 'preact/hooks';
+import { $clients, fetchClients } from '../../stores/clientStore';
+import { $cranes } from '../../stores/fleetDashboardStore';
+import { $accessToken } from '../../stores/authStore';
+import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
+import {
+ $selectedClientId, $selectedVehicleId,
+ hydrateServiceContext, setSelectedClient, setSelectedVehicle, clearServiceContext,
+} from '../../stores/serviceContextStore';
+
+export default function ServiceContextSelector({ onNewServiceRecord }) {
+ const clients = useStore($clients);
+ const cranes = useStore($cranes);
+ const accessToken = useStore($accessToken);
+ const selectedClientId = useStore($selectedClientId);
+ const selectedVehicleId = useStore($selectedVehicleId);
+
+ const [editMode, setEditMode] = useState(false);
+ const [hasMounted, setHasMounted] = useState(false);
+
+ useEffect(() => {
+ setHasMounted(true);
+ hydrateServiceContext();
+ }, []);
+
+ useEffect(() => {
+ if (!accessToken) return;
+ if (clients.length > 0) return;
+ fetchClients();
+ }, [accessToken, clients.length]);
+
+ const fallbackClients = Array.from(
+ new Map(
+ cranes
+ .filter((item) => item?.client && item?.client_name)
+ .map((item) => [String(item.client), { id: item.client, name: item.client_name }])
+ ).values()
+ );
+ const availableClients = clients.length > 0 ? clients : fallbackClients;
+
+ const clientCranes = cranes.filter((item) => String(item.client) === String(selectedClientId));
+ const selectedClient = availableClients.find((c) => String(c.id) === String(selectedClientId));
+ const selectedCrane = cranes.find((item) => String(item.id) === String(selectedVehicleId));
+ const showSelector = editMode || !selectedClientId;
+
+ function handleClientChange(e) {
+ setSelectedClient(e.target.value);
+ }
+
+ function handleVehicleChange(e) {
+ setSelectedVehicle(e.target.value);
+ }
+
+ function handleClearSelection() {
+ clearServiceContext();
+ setEditMode(false);
+ }
+
+ if (!hasMounted) return null;
+
+ return (
+
+ {showSelector ? (
+
+ Servisni kontekst:
+
+
+
+ {selectedClientId && (
+
+ )}
+
+ {selectedClientId && selectedVehicleId && (
+
+ )}
+
+ {editMode && (
+
+ )}
+
+ ) : (
+
+ Servisni kontekst:
+
+
+ {selectedClient?.name ?? '…'}
+
+
+ {selectedCrane && (
+ <>
+ /
+
+ {selectedCrane.registration_number} {selectedCrane.make} {selectedCrane.model}
+
+ >
+ )}
+
+ {selectedVehicleId && onNewServiceRecord && (
+ {
+ onNewServiceRecord({ craneId: selectedVehicleId, clientId: selectedClientId });
+ }}
+ />
+ )}
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/dashboard/SyncLogPanel.jsx b/frontend/src/components/dashboard/SyncLogPanel.jsx
new file mode 100644
index 0000000..46136f0
--- /dev/null
+++ b/frontend/src/components/dashboard/SyncLogPanel.jsx
@@ -0,0 +1,57 @@
+import { useStore } from '@nanostores/preact';
+import { $syncLog } from '../../stores/fleetDashboardStore';
+import AnimatedDataTable from '../ui/AnimatedDataTable';
+
+function rowStyle(status) {
+ if (status === 'success') return 'text-emerald-700';
+ if (status === 'failed') return 'text-red-700';
+ return 'text-amber-700';
+}
+
+function formatDate(value) {
+ if (!value) return '-';
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return '-';
+ return new Intl.DateTimeFormat('hr-HR', {
+ day: '2-digit',
+ month: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ }).format(date);
+}
+
+export default function SyncLogPanel() {
+ const rows = useStore($syncLog);
+ const syncColumns = [
+ { key: 'time', label: 'Vrijeme', className: 'px-4 py-2' },
+ { key: 'status', label: 'Status', className: 'px-4 py-2' },
+ { key: 'type', label: 'Tip', className: 'px-4 py-2' },
+ { key: 'detail', label: 'Detalj', className: 'px-4 py-2' },
+ ];
+
+ return (
+
+
+
Status sinkronizacije
+
Što je poslano, što je ostalo u redu čekanja i što nije uspjelo.
+
+
+ item.id}
+ emptyMessage="Još nema zapisa sinkronizacije."
+ wrapperClassName="max-h-72 overflow-auto"
+ renderRow={(item) => (
+ <>
+ {formatDate(item.created_at)} |
+ {item.status} |
+ {item.entity} |
+ {item.detail || '-'} |
+ >
+ )}
+ />
+
+ );
+}
diff --git a/frontend/src/components/dashboard/TaskCreateModal.jsx b/frontend/src/components/dashboard/TaskCreateModal.jsx
new file mode 100644
index 0000000..4ceef80
--- /dev/null
+++ b/frontend/src/components/dashboard/TaskCreateModal.jsx
@@ -0,0 +1,188 @@
+import { useEffect, useState } from 'preact/hooks';
+import { createTask, getStatusLabel } from '../../stores/taskStore';
+import { formatEntityCode, formatPurposeLabel } from '../../lib/displayIds';
+
+const STATUS_OPTIONS = ['aktivan', 'servis', 'neaktivan'];
+
+const INITIAL_FORM = {
+ title: '',
+ description: '',
+ status: 'aktivan',
+ work_order: '',
+};
+
+export default function TaskCreateModal({ open, workOrders = [], contextCrane = null, onClose, onSuccess }) {
+ const [form, setForm] = useState(INITIAL_FORM);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ if (!open) {
+ setForm(INITIAL_FORM);
+ setSubmitting(false);
+ setError('');
+ }
+ }, [open]);
+
+ if (!open) return null;
+
+ function setField(name, value) {
+ setForm((prev) => ({ ...prev, [name]: value }));
+ }
+
+ async function submit(event) {
+ event.preventDefault();
+ setError('');
+
+ const title = String(form.title || '').trim();
+ if (!title) {
+ setError('Naslov zadatka je obavezan.');
+ return;
+ }
+ if (form.status === 'neaktivan' && !form.work_order) {
+ setError('Putni nalog je obavezan prije zatvaranja zadatka.');
+ return;
+ }
+ if (!contextCrane?.id) {
+ setError('Odaberite dizalicu u Servisnom kontekstu prije kreiranja zadatka.');
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ await createTask({
+ title,
+ description: String(form.description || '').trim(),
+ status: form.status || 'aktivan',
+ vehicle: contextCrane.id,
+ work_order: form.work_order || null,
+ });
+ await onSuccess?.();
+ onClose?.();
+ } catch (err) {
+ setError(err?.message || 'Neuspješno kreiranje zadatka.');
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
event.stopPropagation()}
+ >
+
+
+
Novi radni zadatak
+
+ Kontekst dizalice: {contextCrane?.registration_number || 'Nije odabrana'}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/dashboard/TaskServiceRecordsModal.jsx b/frontend/src/components/dashboard/TaskServiceRecordsModal.jsx
new file mode 100644
index 0000000..94899cc
--- /dev/null
+++ b/frontend/src/components/dashboard/TaskServiceRecordsModal.jsx
@@ -0,0 +1,199 @@
+import { useEffect, useMemo, useState } from 'preact/hooks';
+import ModalShell from '../ui/ModalShell';
+import { formatEntityCode } from '../../lib/displayIds';
+
+function formatDate(value) {
+ if (!value) return '-';
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return value;
+ return parsed.toLocaleDateString('hr-HR');
+}
+
+function formatCost(value) {
+ const numeric = Number(value);
+ if (Number.isNaN(numeric)) return '-';
+ return `${numeric.toFixed(2)} EUR`;
+}
+
+export default function TaskServiceRecordsModal({
+ open,
+ task,
+ records = [],
+ workOrders = [],
+ onClose,
+ onOpenServiceRecord,
+ onAssignWorkOrder,
+ onCreateServiceRecord,
+}) {
+ const [showWorkOrderEditor, setShowWorkOrderEditor] = useState(false);
+ const [selectedWorkOrderId, setSelectedWorkOrderId] = useState('');
+ const [assigningWorkOrder, setAssigningWorkOrder] = useState(false);
+ const [assignError, setAssignError] = useState('');
+
+ useEffect(() => {
+ if (!open || typeof window === 'undefined') return;
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }, [open]);
+
+ useEffect(() => {
+ if (!task) return;
+ setSelectedWorkOrderId(task.work_order ? String(task.work_order) : '');
+ setShowWorkOrderEditor(!task.work_order);
+ setAssignError('');
+ setAssigningWorkOrder(false);
+ }, [task?.id, task?.work_order]);
+
+ const currentWorkOrder = useMemo(
+ () => workOrders.find((item) => String(item.id) === String(task?.work_order || selectedWorkOrderId)) || null,
+ [workOrders, task?.work_order, selectedWorkOrderId]
+ );
+
+ const sortedWorkOrders = useMemo(
+ () => [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))),
+ [workOrders]
+ );
+
+ const handleWorkOrderChange = async (event) => {
+ const nextValue = String(event.currentTarget.value || '');
+ setSelectedWorkOrderId(nextValue);
+ setAssignError('');
+ setAssigningWorkOrder(true);
+ try {
+ await onAssignWorkOrder?.(task, nextValue || null);
+ setShowWorkOrderEditor(false);
+ } catch (error) {
+ setAssignError(error?.message || 'Neuspješno spremanje putnog naloga za radni zadatak.');
+ } finally {
+ setAssigningWorkOrder(false);
+ }
+ };
+
+ if (!open || !task) return null;
+
+ return (
+
+
+
+
+
Radni zadatak detalji
+
+ Radni zadatak: {task.title || '-'} • Status: {task.status || '-'} • Zapisa: {records.length}
+
+
+
+
+
+
+
+
+
Putni nalozi
+ {currentWorkOrder && !showWorkOrderEditor ? (
+
+
+ {formatEntityCode('PN', currentWorkOrder.id)} • {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} • {currentWorkOrder.status || '-'}
+
+
+
+ ) : (
+
+
+
+ {assigningWorkOrder ? 'Spremanje odabira putnog naloga...' : 'Odabir se automatski sprema u bazu.'}
+
+
+ )}
+ {assignError && (
+
+ {assignError}
+
+ )}
+
+
+
+
+
Povezani servisni zapisi
+
+
+ {records.length === 0 ? (
+
+ Nema povezanih servisnih zapisa za odabrani radni zadatak.
+
+ ) : (
+
+
+
+
+ | Datum |
+ Opis |
+ Trošak |
+ Datoteke |
+ Akcija |
+
+
+
+ {records.map((record) => (
+
+ | {formatDate(record.service_date)} |
+
+ {record.description || '-'}
+ |
+ {formatCost(record.cost)} |
+ {record.files_count ?? (record.photos_count ?? 0)} |
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/dashboard/WorkOrderDetailModal.jsx b/frontend/src/components/dashboard/WorkOrderDetailModal.jsx
new file mode 100644
index 0000000..cde4772
--- /dev/null
+++ b/frontend/src/components/dashboard/WorkOrderDetailModal.jsx
@@ -0,0 +1,1115 @@
+import { useEffect, useMemo, useState } from 'preact/hooks';
+import { useStore } from '@nanostores/preact';
+import WorkOrderImageCarousel from './WorkOrderImageCarousel';
+import ModalShell from '../ui/ModalShell';
+import {
+ createWorkOrderInvoice,
+ fetchVehicleById,
+ fetchTeamMembers,
+ updateVehicle,
+ fetchWorkOrderInvoices,
+ fetchWorkOrderImages,
+ openWorkOrderInvoicesPdfPage,
+ sendWorkOrderEmail,
+} from '../../stores/fleetDashboardStore';
+import { showToast } from '../../stores/toastStore';
+import { $craneSerialByVehicle, cacheCraneSerial, getCachedCraneSerial } from '../../stores/craneSerialStore';
+import { formatEntityCode } from '../../lib/displayIds';
+
+const PURPOSE_OPTIONS = [
+ { value: 'defektaza', label: 'Defektaža' },
+ { value: 'kontrola', label: 'Kontrola' },
+ { value: 'redovni_pregled', label: 'Redovni pregled' },
+];
+
+export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmit }) {
+ const serialByVehicle = useStore($craneSerialByVehicle);
+ const [isEditing, setIsEditing] = useState(false);
+ const [form, setForm] = useState({
+ has_travel_order: false,
+ status: 'open',
+ start_mileage: '',
+ end_mileage: '',
+ location: '',
+ travel_start_at: '',
+ travel_end_at: '',
+ purpose: '',
+ notes: '',
+ crane_current_mileage: '',
+ crane_superstructure_working_hours: '',
+ crane_chassis_working_hours: '',
+ });
+ const [imageFiles, setImageFiles] = useState([]);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState('');
+ const [emailSending, setEmailSending] = useState(false);
+ const [widgetError, setWidgetError] = useState('');
+ const [emailModalOpen, setEmailModalOpen] = useState(false);
+ const [teamMembers, setTeamMembers] = useState([]);
+ const [loadingTeamMembers, setLoadingTeamMembers] = useState(false);
+ const [selectedTeamMemberIds, setSelectedTeamMemberIds] = useState([]);
+ const [sendToAllTeamMembers, setSendToAllTeamMembers] = useState(false);
+ const [emailPayload, setEmailPayload] = useState({
+ subject: '',
+ message: '',
+ });
+ const [invoices, setInvoices] = useState([]);
+ const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
+ const [invoiceSaving, setInvoiceSaving] = useState(false);
+ const [invoiceForm, setInvoiceForm] = useState({
+ naziv_racuna: '',
+ lokacija: '',
+ datum: new Date().toISOString().slice(0, 10),
+ opis: '',
+ imageFile: null,
+ });
+ const [resolvedCraneSerialNumber, setResolvedCraneSerialNumber] = useState('');
+ const [mapPickerOpen, setMapPickerOpen] = useState(false);
+ const [locationQuery, setLocationQuery] = useState('');
+ const [locationResults, setLocationResults] = useState([]);
+ const [selectedLocation, setSelectedLocation] = useState(null);
+ const [searchingLocation, setSearchingLocation] = useState(false);
+ const [mapError, setMapError] = useState('');
+
+ const targetCraneId = useMemo(
+ () => workOrder?.crane || workOrder?.vehicle || null,
+ [workOrder?.crane, workOrder?.vehicle]
+ );
+ const cachedCraneSerial = useMemo(
+ () => getCachedCraneSerial(targetCraneId),
+ [serialByVehicle, targetCraneId]
+ );
+
+ const craneLabel = useMemo(() => {
+ if (!workOrder) return '-';
+ return workOrder.craneLabel || workOrder.craneInfo?.display_name || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`;
+ }, [workOrder]);
+ const normalizedPurposeOptions = useMemo(() => {
+ const known = new Set(PURPOSE_OPTIONS.map((item) => item.value));
+ const current = form.purpose?.trim();
+ if (current && !known.has(current)) {
+ return [{ value: current, label: `${current} (postojeća vrijednost)` }, ...PURPOSE_OPTIONS];
+ }
+ return PURPOSE_OPTIONS;
+ }, [form.purpose]);
+
+ useEffect(() => {
+ if (!open || typeof window === 'undefined') return;
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }, [open]);
+
+ useEffect(() => {
+ if (!open || !workOrder) return;
+ setIsEditing(false);
+ const initialSerial = String(workOrder.craneInfo?.crane_serial_number || cachedCraneSerial || '').trim();
+ setResolvedCraneSerialNumber(initialSerial);
+ cacheCraneSerial(targetCraneId, initialSerial);
+ setForm({
+ has_travel_order: !!(workOrder.travel_start_at || workOrder.travel_end_at || workOrder.location || workOrder.start_mileage != null || workOrder.end_mileage != null),
+ status: workOrder.status || 'open',
+ start_mileage: String(workOrder.start_mileage ?? ''),
+ end_mileage: workOrder.end_mileage == null ? '' : String(workOrder.end_mileage),
+ location: workOrder.location || '',
+ travel_start_at: workOrder.travel_start_at ? String(workOrder.travel_start_at).slice(0, 16) : '',
+ travel_end_at: workOrder.travel_end_at ? String(workOrder.travel_end_at).slice(0, 16) : '',
+ purpose: workOrder.purpose || '',
+ notes: workOrder.notes || '',
+ crane_current_mileage: String(workOrder.craneInfo?.current_mileage ?? ''),
+ crane_superstructure_working_hours: String(workOrder.craneInfo?.superstructure_working_hours ?? ''),
+ crane_chassis_working_hours: String(workOrder.craneInfo?.chassis_working_hours ?? ''),
+ });
+ setSubmitting(false);
+ setError('');
+ setWidgetError('');
+ setEmailSending(false);
+ setEmailModalOpen(false);
+ setTeamMembers([]);
+ setLoadingTeamMembers(false);
+ setSelectedTeamMemberIds([]);
+ setSendToAllTeamMembers(false);
+ setEmailPayload({
+ subject: '',
+ message: '',
+ });
+ setInvoices([]);
+ setInvoiceModalOpen(false);
+ setInvoiceSaving(false);
+ setInvoiceForm({
+ naziv_racuna: '',
+ lokacija: '',
+ datum: new Date().toISOString().slice(0, 10),
+ opis: '',
+ imageFile: null,
+ });
+ setMapPickerOpen(false);
+ setLocationQuery('');
+ setLocationResults([]);
+ setSelectedLocation(null);
+ setSearchingLocation(false);
+ setMapError('');
+ }, [open, workOrder, cachedCraneSerial, targetCraneId]);
+
+ useEffect(() => {
+ if (!open || !targetCraneId) {
+ return;
+ }
+ let cancelled = false;
+ (async () => {
+ try {
+ const freshCrane = await fetchVehicleById(targetCraneId);
+ if (cancelled || !freshCrane) {
+ return;
+ }
+ const incomingSerial = String(freshCrane.crane_serial_number || '').trim();
+ if (incomingSerial) {
+ cacheCraneSerial(targetCraneId, incomingSerial);
+ setResolvedCraneSerialNumber(incomingSerial);
+ }
+ setForm((prev) => ({
+ ...prev,
+ crane_current_mileage: String(freshCrane.current_mileage ?? prev.crane_current_mileage ?? ''),
+ crane_superstructure_working_hours: String(freshCrane.superstructure_working_hours ?? prev.crane_superstructure_working_hours ?? ''),
+ crane_chassis_working_hours: String(freshCrane.chassis_working_hours ?? prev.crane_chassis_working_hours ?? ''),
+ }));
+ } catch {
+ // no-op: fallback remains workOrder.craneInfo values
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [open, workOrder?.id, targetCraneId]);
+
+ useEffect(() => {
+ if (!emailModalOpen) return;
+ let cancelled = false;
+ setLoadingTeamMembers(true);
+ (async () => {
+ try {
+ const members = await fetchTeamMembers();
+ if (!cancelled) {
+ setTeamMembers(members);
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setWidgetError(err?.message || 'Neuspješno dohvaćanje članova tima.');
+ }
+ } finally {
+ if (!cancelled) {
+ setLoadingTeamMembers(false);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [emailModalOpen]);
+
+ useEffect(() => {
+ if (!open || !workOrder?.id) {
+ setImageFiles([]);
+ return;
+ }
+
+ let cancelled = false;
+ setImageFiles([]);
+ (async () => {
+ try {
+ const files = await fetchWorkOrderImages(workOrder.id, { w: 1280, q: 75, fmt: 'webp' });
+ if (!cancelled) {
+ setImageFiles(
+ files
+ .map((item) => item.optimized_url || item.original_url)
+ .filter(Boolean)
+ );
+ }
+ } catch {
+ if (!cancelled) {
+ setImageFiles([]);
+ }
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [open, workOrder?.id]);
+
+ useEffect(() => {
+ if (!open || !workOrder?.id) {
+ setInvoices([]);
+ return;
+ }
+ let cancelled = false;
+ (async () => {
+ try {
+ const items = await fetchWorkOrderInvoices(workOrder.id);
+ if (!cancelled) {
+ setInvoices(items);
+ }
+ } catch {
+ if (!cancelled) {
+ setInvoices([]);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [open, workOrder?.id]);
+
+ const selectedMapEmbedUrl = (() => {
+ if (!selectedLocation) return null;
+ const lat = Number(selectedLocation.lat);
+ const lon = Number(selectedLocation.lon);
+ if (Number.isNaN(lat) || Number.isNaN(lon)) return null;
+ const delta = 0.01;
+ const bbox = `${lon - delta}%2C${lat - delta}%2C${lon + delta}%2C${lat + delta}`;
+ return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat}%2C${lon}`;
+ })();
+
+ if (!open || !workOrder) {
+ return null;
+ }
+
+ const searchLocation = async () => {
+ const query = locationQuery.trim();
+ if (!query) {
+ setMapError('Unesite naziv lokacije za pretragu.');
+ return;
+ }
+ setMapError('');
+ setSearchingLocation(true);
+ try {
+ const response = await fetch(
+ `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=8&q=${encodeURIComponent(query)}`
+ );
+ if (!response.ok) {
+ throw new Error('Pretraga lokacije nije uspjela.');
+ }
+ const payload = await response.json();
+ const results = Array.isArray(payload) ? payload : [];
+ setLocationResults(results);
+ if (results.length === 0) {
+ setMapError('Lokacija nije pronađena. Pokušajte s detaljnijim nazivom.');
+ }
+ } catch (error) {
+ setMapError(error?.message || 'Greška pri dohvaćanju karte.');
+ } finally {
+ setSearchingLocation(false);
+ }
+ };
+
+ const submit = async () => {
+ if (!isEditing) {
+ return;
+ }
+ setError('');
+
+ const startMileage = form.start_mileage === '' ? null : Number(form.start_mileage);
+ const endMileage = form.end_mileage === '' ? null : Number(form.end_mileage);
+ let travelStart = null;
+ let travelEnd = null;
+ const craneCurrentMileage = form.crane_current_mileage === '' ? null : Number(form.crane_current_mileage);
+ const craneSuperstructureHours = form.crane_superstructure_working_hours === '' ? null : Number(form.crane_superstructure_working_hours);
+ const craneChassisHours = form.crane_chassis_working_hours === '' ? null : Number(form.crane_chassis_working_hours);
+ const existingCraneCurrentMileage = Number(workOrder?.craneInfo?.current_mileage ?? 0);
+ const existingCraneSuperstructureHours = Number(workOrder?.craneInfo?.superstructure_working_hours ?? 0);
+ const existingCraneChassisHours = Number(workOrder?.craneInfo?.chassis_working_hours ?? 0);
+
+ if (startMileage != null && (Number.isNaN(startMileage) || startMileage < 0)) {
+ setError('Početna kilometraža mora biti valjan nenegativan broj.');
+ return;
+ }
+ if (endMileage != null && (Number.isNaN(endMileage) || endMileage < 0)) {
+ setError('Završna kilometraža mora biti valjan nenegativan broj.');
+ return;
+ }
+ if (endMileage != null && startMileage == null) {
+ setError('Početna kilometraža je obavezna ako je unesena završna.');
+ return;
+ }
+ if (startMileage != null && endMileage != null && endMileage < startMileage) {
+ setError('Završna kilometraža mora biti veća ili jednaka početnoj.');
+ return;
+ }
+ if (craneCurrentMileage != null && (Number.isNaN(craneCurrentMileage) || craneCurrentMileage < 0)) {
+ setError('Trenutna km dizalice mora biti valjan nenegativan broj.');
+ return;
+ }
+ if (craneSuperstructureHours != null && (Number.isNaN(craneSuperstructureHours) || craneSuperstructureHours < 0)) {
+ setError('Radni sati nadogradnje moraju biti valjan nenegativan broj.');
+ return;
+ }
+ if (craneChassisHours != null && (Number.isNaN(craneChassisHours) || craneChassisHours < 0)) {
+ setError('Radni sati podvozja moraju biti valjan nenegativan broj.');
+ return;
+ }
+ if ((form.travel_start_at && !form.travel_end_at) || (!form.travel_start_at && form.travel_end_at)) {
+ setError('Unesite i početak i kraj puta, ili ostavite oba prazna.');
+ return;
+ }
+ if (form.travel_start_at && form.travel_end_at) {
+ travelStart = new Date(form.travel_start_at);
+ travelEnd = new Date(form.travel_end_at);
+ if (Number.isNaN(travelStart.getTime()) || Number.isNaN(travelEnd.getTime())) {
+ setError('Vrijeme puta nije u ispravnom formatu.');
+ return;
+ }
+ if (travelEnd < travelStart) {
+ setError('Vrijeme kraja puta ne može biti prije početka puta.');
+ return;
+ }
+ if (!form.location.trim()) {
+ setError('Lokacija odredišta je obavezna kada je putovanje uneseno.');
+ return;
+ }
+ }
+ if (!form.purpose.trim()) {
+ setError('Svrha naloga je obavezna.');
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ const hasCraneChanges = (
+ craneCurrentMileage != null
+ && craneSuperstructureHours != null
+ && craneChassisHours != null
+ && (
+ craneCurrentMileage !== existingCraneCurrentMileage
+ || craneSuperstructureHours !== existingCraneSuperstructureHours
+ || craneChassisHours !== existingCraneChassisHours
+ )
+ );
+
+ if (hasCraneChanges && targetCraneId) {
+ let craneSerialNumber = String(resolvedCraneSerialNumber || '').trim();
+ if (!craneSerialNumber) {
+ craneSerialNumber = cachedCraneSerial;
+ }
+ if (!craneSerialNumber) {
+ const freshCrane = await fetchVehicleById(targetCraneId);
+ craneSerialNumber = String(freshCrane?.crane_serial_number || '').trim();
+ if (craneSerialNumber) {
+ cacheCraneSerial(targetCraneId, craneSerialNumber);
+ setResolvedCraneSerialNumber(craneSerialNumber);
+ }
+ }
+ if (!craneSerialNumber) {
+ // nastavi sa spremanjem sati i kilometraže za legacy dizalice bez SN
+ }
+ const cranePatch = {
+ current_mileage: craneCurrentMileage,
+ superstructure_working_hours: craneSuperstructureHours,
+ chassis_working_hours: craneChassisHours,
+ };
+ if (craneSerialNumber) {
+ cranePatch.crane_serial_number = craneSerialNumber;
+ }
+ await updateVehicle(targetCraneId, cranePatch);
+ }
+ await onSubmit(workOrder, {
+ status: form.status,
+ start_mileage: startMileage,
+ end_mileage: endMileage,
+ location: form.location.trim(),
+ travel_start_at: travelStart ? travelStart.toISOString() : null,
+ travel_end_at: travelEnd ? travelEnd.toISOString() : null,
+ purpose: form.purpose.trim(),
+ notes: form.notes.trim(),
+ });
+ onClose();
+ } catch (err) {
+ setError(err?.message || 'Spremanje putnog naloga nije uspjelo.');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleDownloadPdf = async () => {
+ setWidgetError('');
+ try {
+ openWorkOrderInvoicesPdfPage(workOrder.id);
+ } catch (err) {
+ setWidgetError(err?.message || 'Neuspješno preuzimanje PDF-a putnog naloga.');
+ }
+ };
+
+ const handleCreateInvoice = async () => {
+ if (!invoiceForm.naziv_racuna.trim()) {
+ setWidgetError('Polje naziv_računa je obavezno.');
+ return;
+ }
+ if (!invoiceForm.datum) {
+ setWidgetError('Polje datum je obavezno.');
+ return;
+ }
+
+ setWidgetError('');
+ setInvoiceSaving(true);
+ try {
+ await createWorkOrderInvoice(workOrder.id, invoiceForm);
+ const refreshed = await fetchWorkOrderInvoices(workOrder.id);
+ setInvoices(refreshed);
+ setInvoiceForm({
+ naziv_racuna: '',
+ lokacija: '',
+ datum: new Date().toISOString().slice(0, 10),
+ opis: '',
+ imageFile: null,
+ });
+ setInvoiceModalOpen(false);
+ } catch (err) {
+ setWidgetError(err?.message || 'Neuspješno spremanje računa.');
+ } finally {
+ setInvoiceSaving(false);
+ }
+ };
+
+ const handleSendEmail = async () => {
+ setWidgetError('');
+ setEmailSending(true);
+ try {
+ const recipients = sendToAllTeamMembers
+ ? teamMembers.map((member) => member.email).filter(Boolean)
+ : teamMembers
+ .filter((member) => selectedTeamMemberIds.includes(String(member.id)))
+ .map((member) => member.email)
+ .filter(Boolean);
+ if (recipients.length === 0) {
+ setWidgetError('Odaberite barem jednog člana tima ili uključite slanje svima.');
+ return;
+ }
+
+ let failed = 0;
+ for (const recipient of recipients) {
+ try {
+ await sendWorkOrderEmail(
+ workOrder.id,
+ {
+ recipient,
+ subject: emailPayload.subject.trim() || undefined,
+ message: emailPayload.message.trim() || undefined,
+ },
+ { toast: false }
+ );
+ showToast(`Email je poslan: ${recipient}`, 'success');
+ } catch {
+ failed += 1;
+ showToast(`Neuspješno slanje: ${recipient}`, 'error');
+ }
+ }
+ if (failed > 0) {
+ setWidgetError(`Slanje nije uspjelo za ${failed} primatelja.`);
+ return;
+ }
+ setEmailModalOpen(false);
+ } catch (err) {
+ setWidgetError(err?.message || 'Neuspješno slanje emaila za putni nalog.');
+ } finally {
+ setEmailSending(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
Uredi nalog {formatEntityCode('PN', workOrder.id)}
+
Dizalica: {craneLabel}
+
+
+
+
+
+
+
+ {mapPickerOpen && (
+ setMapPickerOpen(false)}
+ overlayClassName="z-[60] overflow-y-auto p-4 pt-2"
+ contentClassName="flex items-start justify-center min-h-full"
+ panelClassName="w-full max-w-3xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
+ >
+
+
+
Odabir lokacije na karti
+
+
+
+
+
+ setLocationQuery(event.currentTarget.value)}
+ className="flex-1 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
+ placeholder="Upišite adresu ili naziv lokacije"
+ />
+
+
+
+ {mapError && (
+
+ {mapError}
+
+ )}
+
+ {locationResults.length > 0 && (
+
+ {locationResults.map((result) => (
+
+ ))}
+
+ )}
+
+ {selectedLocation && selectedMapEmbedUrl && (
+
+
+ Odabrano: {selectedLocation.display_name}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ )}
+ {emailModalOpen && (
+ setEmailModalOpen(false)}
+ overlayClassName="z-[60] p-4"
+ contentClassName="flex items-center justify-center min-h-full"
+ panelClassName="w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
+ >
+
+
+
Pošalji email za putni nalog
+
+
+
+
+
+
+ {loadingTeamMembers
+ ? 'Učitavanje članova tima...'
+ : `Odabrano: ${sendToAllTeamMembers ? teamMembers.length : selectedTeamMemberIds.length}`}
+
+
setEmailPayload({ ...emailPayload, subject: event.currentTarget.value })}
+ placeholder="Naslov (opcionalno)"
+ className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
+ />
+
+
+
+
+
+
+
+ )}
+ {invoiceModalOpen && (
+ setInvoiceModalOpen(false)}
+ overlayClassName="z-[60] overflow-y-auto p-4 pt-16"
+ contentClassName="flex items-start justify-center min-h-full"
+ panelClassName="w-full max-w-2xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
+ >
+
+
+
Računi za putni nalog
+
+
+
+
+
+
+
+
+
+
+ {invoices.length === 0 ? (
+
Nema unesenih računa.
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx b/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx
new file mode 100644
index 0000000..db206ae
--- /dev/null
+++ b/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx
@@ -0,0 +1,152 @@
+import { useEffect, useMemo, useState } from 'preact/hooks';
+import { animated, useTransition } from '@react-spring/web';
+
+function getBrowserApiBase() {
+ const apiUrl = import.meta.env.PUBLIC_API_URL || '';
+ if (typeof window === 'undefined') {
+ return apiUrl || 'http://localhost:8001/api/';
+ }
+
+ try {
+ if (apiUrl) {
+ const parsed = new URL(apiUrl);
+ const internalHosts = new Set(['backend', 'localhost', '127.0.0.1']);
+ const isInternalDockerHost = parsed.hostname === 'backend';
+ if (!isInternalDockerHost) {
+ return parsed.toString();
+ }
+ }
+ } catch {
+ // fallback is handled below
+ }
+
+ return `${window.location.protocol}//${window.location.hostname}:8001/api/`;
+}
+
+export function resolveMediaUrl(pathOrUrl) {
+ if (!pathOrUrl || typeof window === 'undefined') return '';
+
+ let finalSrc = String(pathOrUrl);
+ const apiUrl = getBrowserApiBase();
+ const baseUrl = apiUrl.replace(/api\/?$/, '');
+
+ if (/^https?:\/\//i.test(finalSrc)) {
+ if (finalSrc.includes('backend:8000')) {
+ return finalSrc.replace('http://backend:8000/', baseUrl);
+ }
+ return finalSrc;
+ }
+
+ if (finalSrc.startsWith('/')) {
+ return `${baseUrl.replace(/\/$/, '')}${finalSrc}`;
+ }
+
+ return new URL(finalSrc, apiUrl).toString();
+}
+
+export default function WorkOrderImageCarousel({ images = [], editMode = false, emptyLabel = 'Nema priložene tehničke dokumentacije' }) {
+ const normalizedImages = useMemo(() => (
+ Array.isArray(images)
+ ? images.filter(Boolean)
+ : (typeof images === 'string' && images ? [images] : [])
+ ), [images]);
+ const [currentImgIdx, setCurrentImgIdx] = useState(0);
+
+ useEffect(() => {
+ setCurrentImgIdx(0);
+ }, [normalizedImages.length]);
+
+ useEffect(() => {
+ if (editMode || normalizedImages.length <= 1) return undefined;
+
+ const interval = window.setInterval(() => {
+ setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
+ }, 3000);
+
+ return () => window.clearInterval(interval);
+ }, [normalizedImages.length, editMode]);
+
+ if (normalizedImages.length === 0) {
+ return (
+
+ );
+ }
+
+ const prevSlide = (event) => {
+ event.preventDefault();
+ setCurrentImgIdx((prev) => (prev - 1 + normalizedImages.length) % normalizedImages.length);
+ };
+
+ const nextSlide = (event) => {
+ event.preventDefault();
+ setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
+ };
+
+ const finalSrc = resolveMediaUrl(normalizedImages[currentImgIdx] || '');
+ const imageTransitions = useTransition(currentImgIdx, {
+ from: { opacity: 0, transform: 'translate3d(8%,0,0) scale(1.01)' },
+ enter: { opacity: 1, transform: 'translate3d(0%,0,0) scale(1)' },
+ leave: { opacity: 0, transform: 'translate3d(-8%,0,0) scale(1.01)' },
+ config: { tension: 220, friction: 26 },
+ });
+
+ return (
+
+
+ {imageTransitions((style, idx) => (
+
+ ))}
+
+
+
+
+ {normalizedImages.length > 1 && (
+ <>
+
+
+
+
+ {normalizedImages.map((_, idx) => (
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx
new file mode 100644
index 0000000..3b16c92
--- /dev/null
+++ b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx
@@ -0,0 +1,258 @@
+import { useEffect, useMemo, useState } from 'preact/hooks';
+import { useStore } from '@nanostores/preact';
+import {
+ downloadWorkOrderPdf,
+ downloadWorkOrderInvoicesPdf,
+ downloadWorkOrderServiceRecordsPdf,
+ fetchWorkOrderById,
+ fetchWorkOrderInvoices,
+ fetchWorkOrderTaskServiceContext,
+} from '../../stores/fleetDashboardStore';
+import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
+import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
+import { formatEntityCode } from '../../lib/displayIds';
+import { resolveMediaUrl } from './WorkOrderImageCarousel';
+
+function readWorkOrderIdFromQuery() {
+ if (typeof window === 'undefined') {
+ return '';
+ }
+ const params = new URLSearchParams(window.location.search);
+ return params.get('workOrderId') || '';
+}
+
+function InvoiceImagePreview({ imageUrl, alt }) {
+ const [broken, setBroken] = useState(false);
+
+ if (!imageUrl || broken) {
+ return (
+
+ Bez slike
+
+ );
+ }
+
+ return (
+
+
setBroken(true)}
+ className="h-28 w-28 rounded-lg border border-border-hairline object-cover"
+ />
+
+ );
+}
+
+export default function WorkOrderInvoicesPdfPage() {
+ const authReady = useStore($authReady);
+ const token = useStore($accessToken);
+ const [workOrderId, setWorkOrderId] = useState('');
+ const [workOrder, setWorkOrder] = useState(null);
+ const [invoices, setInvoices] = useState([]);
+ const [taskContext, setTaskContext] = useState({ tasks: [] });
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ hydrateAuthFromStorage();
+ const id = readWorkOrderIdFromQuery();
+ setWorkOrderId(id);
+ }, []);
+
+ useEffect(() => {
+ if (authReady) {
+ fetchNotifications();
+ connectNotifications();
+ }
+ }, [authReady]);
+
+ useEffect(() => {
+ if (!workOrderId) {
+ setLoading(false);
+ setError('Nedostaje workOrderId u URL-u.');
+ return;
+ }
+ if (!authReady) {
+ return;
+ }
+ if (!token) {
+ setLoading(false);
+ setError('Sesija nije aktivna. Prijavite se ponovno.');
+ return;
+ }
+ let cancelled = false;
+ setLoading(true);
+ setError('');
+
+ (async () => {
+ try {
+ const [contextPayload, order, invoiceItems] = await Promise.all([
+ fetchWorkOrderTaskServiceContext(workOrderId),
+ fetchWorkOrderById(workOrderId),
+ fetchWorkOrderInvoices(workOrderId),
+ ]);
+ if (cancelled) {
+ return;
+ }
+ setTaskContext(contextPayload);
+ setWorkOrder(order);
+ setInvoices(invoiceItems);
+ } catch (err) {
+ if (!cancelled) {
+ setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.');
+ }
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [workOrderId, authReady, token]);
+
+ const title = useMemo(() => {
+ if (!workOrder) {
+ return 'Računi putnog naloga';
+ }
+ return `${formatEntityCode('PN', workOrder.id)}`;
+ }, [workOrder]);
+
+ return (
+
+
+
+
+
{title}
+
+ {workOrder ? `Dizalica: ${workOrder.crane_label || workOrder.crane || '-'}` : 'Pregled računa prije kreiranja PDF-a.'}
+
+
+
+
+
+
+
+
+
Task i povezani servisni zapisi
+
+
+ {loading &&
Učitavanje task konteksta...
}
+ {!loading && !error && (!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0) && (
+
Nema taskova povezanih s ovim putnim nalogom.
+ )}
+ {!loading && !error && Array.isArray(taskContext.tasks) && taskContext.tasks.length > 0 && (
+
+ {taskContext.tasks.map((task) => (
+
+
+
Radni zadatak: {task.title || '-'}
+
Status: {task.status || '-'}
+
Dodijeljeno: {task.assigned_to_name || '-'}
+
Dizalica: {task.vehicle_registration || '-'}
+
+
+ {!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
+
Nema povezanih servisnih zapisa.
+ ) : (
+ task.service_records.map((record) => (
+
+
+
Servis: {record.description || '-'}
+
Datum: {record.service_date || '-'}
+
Trošak: {record.cost || '-'} EUR
+
KM: {record.mileage ?? '-'}
+
+
+ {Array.isArray(record.photos) && record.photos.length > 0 ? (
+ record.photos.map((photo) => (
+
+
+
+ ))
+ ) : (
+
Bez slika
+ )}
+
+
+ ))
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+
+
Računi
+
+
+ {loading &&
Učitavanje računa...
}
+ {!loading && error && (
+
+ {error}
+
+ )}
+ {!loading && !error && invoices.length === 0 && (
+
Za ovaj putni nalog nema unesenih računa.
+ )}
+ {!loading && !error && invoices.length > 0 && (
+
+ {invoices.map((invoice) => (
+
+
+
+
naziv_računa: {invoice.naziv_racuna}
+
lokacija: {invoice.lokacija || '-'}
+
datum: {invoice.datum || '-'}
+
opis: {invoice.opis || '-'}
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/dashboard/WorkOrderModal.jsx b/frontend/src/components/dashboard/WorkOrderModal.jsx
new file mode 100644
index 0000000..691ecf2
--- /dev/null
+++ b/frontend/src/components/dashboard/WorkOrderModal.jsx
@@ -0,0 +1,592 @@
+import { useEffect, useState } from 'preact/hooks';
+import { useStore } from '@nanostores/preact';
+import ModalShell from '../ui/ModalShell';
+import { $user } from '../../stores/authStore';
+
+const PURPOSE_OPTIONS = [
+ { value: 'defektaza', label: 'Defektaža' },
+ { value: 'kontrola', label: 'Kontrola' },
+ { value: 'redovni_pregled', label: 'Redovni pregled' },
+];
+
+const INITIAL_FORM = {
+ has_travel_order: false,
+ start_mileage: '',
+ end_mileage: '',
+ servicer_vehicle_registration: '',
+ servicer_vehicle_make_model: '',
+ servicer_vehicle_start_mileage: '',
+ servicer_vehicle_end_mileage: '',
+ servicer_vehicle_fuel_cost: '',
+ location: '',
+ travel_start_at: '',
+ travel_end_at: '',
+ purpose: '',
+ notes: '',
+ status: 'open',
+};
+
+export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit }) {
+ const user = useStore($user);
+ const [form, setForm] = useState(INITIAL_FORM);
+ const [submitting, setSubmitting] = useState(false);
+ const [errorMessage, setErrorMessage] = useState('');
+ const [mapPickerOpen, setMapPickerOpen] = useState(false);
+ const [locationQuery, setLocationQuery] = useState('');
+ const [locationResults, setLocationResults] = useState([]);
+ const [selectedLocation, setSelectedLocation] = useState(null);
+ const [searchingLocation, setSearchingLocation] = useState(false);
+ const [mapError, setMapError] = useState('');
+
+ useEffect(() => {
+ if (!open) {
+ setForm(INITIAL_FORM);
+ setSubmitting(false);
+ setErrorMessage('');
+ setMapPickerOpen(false);
+ setLocationQuery('');
+ setLocationResults([]);
+ setSelectedLocation(null);
+ setSearchingLocation(false);
+ setMapError('');
+ return;
+ }
+
+ setForm((prev) => ({
+ ...prev,
+ start_mileage: selectedCrane ? String(selectedCrane.current_mileage ?? '') : prev.start_mileage,
+ servicer_vehicle_registration: user?.assigned_vehicle?.registration_number || '',
+ servicer_vehicle_make_model: `${user?.assigned_vehicle?.make || ''} ${user?.assigned_vehicle?.model || ''}`.trim(),
+ servicer_vehicle_start_mileage: user?.assigned_vehicle?.current_mileage == null ? '' : String(user.assigned_vehicle.current_mileage),
+ servicer_vehicle_end_mileage: '',
+ servicer_vehicle_fuel_cost: '0.00',
+ }));
+ }, [open, selectedCrane, user?.assigned_vehicle?.id]);
+
+ if (!open) return null;
+
+ const selectedMapEmbedUrl = (() => {
+ if (!selectedLocation) return null;
+ const lat = Number(selectedLocation.lat);
+ const lon = Number(selectedLocation.lon);
+ if (Number.isNaN(lat) || Number.isNaN(lon)) return null;
+ const delta = 0.01;
+ const bbox = `${lon - delta}%2C${lat - delta}%2C${lon + delta}%2C${lat + delta}`;
+ return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat}%2C${lon}`;
+ })();
+
+ const searchLocation = async () => {
+ const query = locationQuery.trim();
+ if (!query) {
+ setMapError('Unesite naziv lokacije za pretragu.');
+ return;
+ }
+ setMapError('');
+ setSearchingLocation(true);
+ try {
+ const response = await fetch(
+ `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=8&q=${encodeURIComponent(query)}`
+ );
+ if (!response.ok) {
+ throw new Error('Pretraga lokacije nije uspjela.');
+ }
+ const payload = await response.json();
+ const results = Array.isArray(payload) ? payload : [];
+ setLocationResults(results);
+ if (results.length === 0) {
+ setMapError('Lokacija nije pronađena. Pokušajte s detaljnijim nazivom.');
+ }
+ } catch (error) {
+ setMapError(error?.message || 'Greška pri dohvaćanju karte.');
+ } finally {
+ setSearchingLocation(false);
+ }
+ };
+
+ const submit = async (event) => {
+ event.preventDefault();
+ setErrorMessage('');
+
+ if (!selectedCrane) {
+ setErrorMessage('Odaberite dizalicu u Servisnom kontekstu prije kreiranja naloga.');
+ return;
+ }
+
+ if (!form.purpose.trim()) {
+ setErrorMessage('Svrha naloga je obavezna.');
+ return;
+ }
+
+ let startMileage = null;
+ let endMileage = null;
+ let servicerStartMileage = null;
+ let servicerEndMileage = null;
+ let servicerFuelCost = null;
+ let travelStart = null;
+ let travelEnd = null;
+
+ if (form.has_travel_order) {
+ if (!form.start_mileage || !form.location.trim()) {
+ setErrorMessage('Za putni nalog unesite početnu kilometražu i lokaciju.');
+ return;
+ }
+ startMileage = Number(form.start_mileage);
+ endMileage = form.end_mileage === '' ? null : Number(form.end_mileage);
+ if (Number.isNaN(startMileage) || startMileage < 0) {
+ setErrorMessage('Početna kilometraža mora biti valjan nenegativan broj.');
+ return;
+ }
+ if (endMileage != null && (Number.isNaN(endMileage) || endMileage < startMileage)) {
+ setErrorMessage('Završna kilometraža mora biti veća ili jednaka početnoj.');
+ return;
+ }
+ if (!form.travel_start_at || !form.travel_end_at) {
+ setErrorMessage('Unesite vrijeme početka i kraja puta.');
+ return;
+ }
+ travelStart = new Date(form.travel_start_at);
+ travelEnd = new Date(form.travel_end_at);
+ if (Number.isNaN(travelStart.getTime()) || Number.isNaN(travelEnd.getTime())) {
+ setErrorMessage('Vrijeme puta nije u ispravnom formatu.');
+ return;
+ }
+ if (travelEnd < travelStart) {
+ setErrorMessage('Vrijeme kraja puta ne može biti prije početka puta.');
+ return;
+ }
+ }
+
+ if (form.servicer_vehicle_start_mileage !== '') {
+ servicerStartMileage = Number(form.servicer_vehicle_start_mileage);
+ if (Number.isNaN(servicerStartMileage) || servicerStartMileage < 0) {
+ setErrorMessage('Početna kilometraža vozila servisera mora biti valjan nenegativan broj.');
+ return;
+ }
+ }
+ if (form.servicer_vehicle_end_mileage !== '') {
+ servicerEndMileage = Number(form.servicer_vehicle_end_mileage);
+ if (Number.isNaN(servicerEndMileage) || servicerEndMileage < 0) {
+ setErrorMessage('Završna kilometraža vozila servisera mora biti valjan nenegativan broj.');
+ return;
+ }
+ if (servicerStartMileage == null) {
+ setErrorMessage('Unesite početnu kilometražu vozila servisera ako unosite završnu.');
+ return;
+ }
+ if (servicerEndMileage <= servicerStartMileage) {
+ setErrorMessage('Završna kilometraža vozila servisera mora biti veća od početne.');
+ return;
+ }
+ }
+ if (form.servicer_vehicle_fuel_cost !== '') {
+ servicerFuelCost = Number(form.servicer_vehicle_fuel_cost);
+ if (Number.isNaN(servicerFuelCost) || servicerFuelCost < 0) {
+ setErrorMessage('Trošak goriva vozila servisera mora biti valjan nenegativan broj.');
+ return;
+ }
+ servicerFuelCost = Number(servicerFuelCost.toFixed(2));
+ }
+
+ setSubmitting(true);
+ try {
+ await onSubmit({
+ vehicle: selectedCrane.id,
+ start_mileage: startMileage,
+ end_mileage: endMileage,
+ servicer_vehicle_registration: form.servicer_vehicle_registration.trim(),
+ servicer_vehicle_make_model: form.servicer_vehicle_make_model.trim(),
+ servicer_vehicle_start_mileage: servicerStartMileage,
+ servicer_vehicle_end_mileage: servicerEndMileage,
+ servicer_vehicle_fuel_cost: servicerFuelCost,
+ location: form.has_travel_order ? form.location.trim() : '',
+ travel_start_at: travelStart ? travelStart.toISOString() : null,
+ travel_end_at: travelEnd ? travelEnd.toISOString() : null,
+ purpose: form.purpose.trim(),
+ notes: form.notes.trim(),
+ status: form.status,
+ });
+ onClose();
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
Novi radni/putni nalog
+
+
+
+
+
+
+
+ {mapPickerOpen && (
+ setMapPickerOpen(false)}
+ overlayClassName="z-[60] overflow-y-auto p-4 pt-2"
+ contentClassName="flex items-start justify-center min-h-full"
+ panelClassName="w-full max-w-3xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
+ >
+
+
+
Odabir lokacije na karti
+
+
+
+
+
+ setLocationQuery(event.currentTarget.value)}
+ className="flex-1 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
+ placeholder="Upišite adresu ili naziv lokacije"
+ />
+
+
+
+ {mapError && (
+
+ {mapError}
+
+ )}
+
+ {locationResults.length > 0 && (
+
+ {locationResults.map((result) => (
+
+ ))}
+
+ )}
+
+ {selectedLocation && selectedMapEmbedUrl && (
+
+
+ Odabrano: {selectedLocation.display_name}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/data/DataView.tsx b/frontend/src/components/data/DataView.tsx
new file mode 100644
index 0000000..a0896bd
--- /dev/null
+++ b/frontend/src/components/data/DataView.tsx
@@ -0,0 +1,114 @@
+import { useEffect } from 'preact/hooks';
+import { useSignal } from '@preact/signals';
+import { fetchJson } from '../../lib/api';
+import { getCachedData, setCachedData } from '../../lib/db';
+import { useToast } from '../../hooks/useToast';
+
+type LoadState = 'idle' | 'loading' | 'success' | 'error';
+
+export interface DataViewProps {
+ endpoint: string;
+ cacheKey: string;
+ initialData: unknown[] | null;
+ initialError?: string | null;
+}
+
+export default function DataView({
+ endpoint,
+ cacheKey,
+ initialData,
+ initialError = null,
+}: DataViewProps) {
+ const toast = useToast();
+ const itemsSignal = useSignal([]);
+ const stateSignal = useSignal('idle');
+ const sourceSignal = useSignal<'ssr' | 'network' | 'cache' | null>(null);
+ const errorSignal = useSignal(null);
+
+ useEffect(() => {
+ if (Array.isArray(initialData) && initialData.length) {
+ itemsSignal.value = initialData;
+ stateSignal.value = 'success';
+ sourceSignal.value = 'ssr';
+ } else if (initialError) {
+ errorSignal.value = initialError;
+ stateSignal.value = 'error';
+ } else {
+ stateSignal.value = 'loading';
+ }
+
+ const controller = new AbortController();
+
+ // SSR gives first paint; hydration then refreshes client data and persists it offline.
+ async function refreshData() {
+ try {
+ stateSignal.value = 'loading';
+ const payload = await fetchJson(endpoint, {
+ method: 'GET',
+ signal: controller.signal,
+ timeoutMs: 12000,
+ });
+
+ itemsSignal.value = Array.isArray(payload) ? payload : [];
+ await setCachedData(cacheKey, itemsSignal.value);
+ sourceSignal.value = 'network';
+ errorSignal.value = null;
+ stateSignal.value = 'success';
+ toast.success('Podaci su uspješno osvježeni.');
+ } catch (error) {
+ if (controller.signal.aborted) {
+ return;
+ }
+
+ const cached = await getCachedData(cacheKey);
+ if (cached?.payload) {
+ itemsSignal.value = Array.isArray(cached.payload) ? cached.payload : [];
+ sourceSignal.value = 'cache';
+ errorSignal.value = null;
+ stateSignal.value = 'success';
+ toast.warning('Prikazani su lokalno spremljeni podaci (offline fallback).');
+ return;
+ }
+
+ errorSignal.value = error instanceof Error ? error.message : 'Data loading failed.';
+ stateSignal.value = 'error';
+ toast.error('Neuspješno učitavanje podataka i nema lokalnog cachea.');
+ }
+ }
+
+ void refreshData();
+ return () => controller.abort();
+ }, [endpoint, cacheKey, initialData, initialError]);
+
+ return (
+
+
+ DataView
+
+ Source: {sourceSignal.value || 'unknown'} • State: {stateSignal.value}
+
+
+
+ {stateSignal.value === 'loading' && (
+ Učitavanje podataka...
+ )}
+
+ {stateSignal.value === 'error' && (
+
+ {errorSignal.value || 'Greška pri učitavanju.'}
+
+ )}
+
+ {stateSignal.value === 'success' && (
+
+
+ Ukupno zapisa: {itemsSignal.value.length}
+
+
+ {JSON.stringify(itemsSignal.value.slice(0, 20), null, 2)}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/fleet/ServicePhotoUpload.jsx b/frontend/src/components/fleet/ServicePhotoUpload.jsx
new file mode 100644
index 0000000..dde82da
--- /dev/null
+++ b/frontend/src/components/fleet/ServicePhotoUpload.jsx
@@ -0,0 +1,247 @@
+// src/components/fleet/ServicePhotoUpload.jsx
+// Forma za upload fotografija na servisni zapis (POST /api/fleet/service-photos/)
+// Kritično: AbortController prekida upload ako korisnik zatvori modal ili pokrene novi upload.
+// Backend prima multipart/form-data — apiClient detektira FormData i ne dodaje Content-Type header.
+
+import { useState, useEffect, useRef } from 'preact/hooks';
+import { useStore } from '@nanostores/preact';
+import ModalShell from '../ui/ModalShell';
+import { $isOffline } from '../../stores/networkStore.js';
+import { uploadServicePhoto } from '../../stores/fleetDashboardStore.js';
+import { showToast } from '../../stores/toastStore.js';
+
+const MAX_FILE_SIZE_MB = 10;
+const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
+const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'];
+
+export default function ServicePhotoUpload({ open, serviceRecordId, onClose, onSuccess }) {
+ const isOffline = useStore($isOffline);
+ const [images, setImages] = useState([]);
+ const [description, setDescription] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [progress, setProgress] = useState({ current: 0, total: 0 });
+ const [error, setError] = useState('');
+ const [isMounted, setIsMounted] = useState(false);
+
+ // AbortController ref — prekinuti tekući upload ako korisnik napusti ili pokrene novi
+ const abortRef = useRef(null);
+
+ useEffect(() => {
+ setIsMounted(true);
+ return () => {
+ // Prekid uploadova pri unmount-u (navigacija, hot reload)
+ abortRef.current?.abort();
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!open) {
+ abortRef.current?.abort();
+ abortRef.current = null;
+ setImages([]);
+ setDescription('');
+ setError('');
+ setSubmitting(false);
+ setProgress({ current: 0, total: 0 });
+ }
+ }, [open]);
+
+ if (!open || !isMounted) return null;
+
+ const handleFileChange = (e) => {
+ const files = Array.from(e.target.files ?? []);
+ const oversized = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES);
+ const invalidType = files.filter((f) => !ACCEPTED_TYPES.includes(f.type));
+
+ if (oversized.length > 0) {
+ setError(`Sljedeće datoteke prelaze ${MAX_FILE_SIZE_MB} MB: ${oversized.map((f) => f.name).join(', ')}`);
+ return;
+ }
+ if (invalidType.length > 0) {
+ setError(`Nepodržan format: ${invalidType.map((f) => f.name).join(', ')}. Koristite JPG, PNG, WEBP ili HEIC.`);
+ return;
+ }
+ setError('');
+ setImages(files);
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError('');
+
+ if (!serviceRecordId) {
+ setError('Nije poznat servisni zapis za koji se upload vrši.');
+ return;
+ }
+ if (images.length === 0) {
+ setError('Odaberite barem jednu fotografiju.');
+ return;
+ }
+ if (isOffline) {
+ showToast('Upload fotografija nije dostupan u offline modu.', 'error');
+ return;
+ }
+
+ // Prekid prethodnog aktivnog uploadovaja (dupli klik zaštita)
+ abortRef.current?.abort();
+ abortRef.current = new AbortController();
+ const { signal } = abortRef.current;
+
+ setSubmitting(true);
+ setProgress({ current: 0, total: images.length });
+
+ let successCount = 0;
+ try {
+ for (const [index, file] of images.entries()) {
+ if (signal.aborted) break;
+ setProgress({ current: index + 1, total: images.length });
+ await uploadServicePhoto(serviceRecordId, file, description, signal);
+ successCount++;
+ }
+
+ if (!signal.aborted) {
+ showToast(`${successCount} fotografija je uspješno uploadano.`, 'success');
+ e.target.reset();
+ setImages([]);
+ setDescription('');
+ onSuccess?.();
+ onClose?.();
+ }
+ } catch (err) {
+ if (err?.name === 'AbortError') {
+ // Tiho — korisnik je sam prekinuo, ne prikazujemo grešku
+ return;
+ }
+ setError(err?.message || 'Greška pri uploadu fotografija.');
+ if (successCount > 0) {
+ showToast(`${successCount} od ${images.length} fotografija uploadano.`, 'warning');
+ }
+ } finally {
+ if (!signal?.aborted) {
+ setSubmitting(false);
+ setProgress({ current: 0, total: 0 });
+ abortRef.current = null;
+ }
+ }
+ };
+
+ const handleCancel = () => {
+ abortRef.current?.abort();
+ abortRef.current = null;
+ onClose?.();
+ };
+
+ return (
+
+
+
+
+
Upload fotografija
+
+ Servisni zapis #{serviceRecordId}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/fleet/ServiceRecordCreateButton.jsx b/frontend/src/components/fleet/ServiceRecordCreateButton.jsx
new file mode 100644
index 0000000..62d08a3
--- /dev/null
+++ b/frontend/src/components/fleet/ServiceRecordCreateButton.jsx
@@ -0,0 +1,11 @@
+export default function ServiceRecordCreateButton({ onClick, className = '', label = '+ Servisni zapis' }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/fleet/ServiceRecordDetailModal.jsx b/frontend/src/components/fleet/ServiceRecordDetailModal.jsx
new file mode 100644
index 0000000..a1090af
--- /dev/null
+++ b/frontend/src/components/fleet/ServiceRecordDetailModal.jsx
@@ -0,0 +1,537 @@
+import { useEffect, useMemo, useState } from 'preact/hooks';
+import {
+ downloadServiceRecordPdf,
+ fetchTeamMembers,
+ fetchServiceRecordFiles,
+ sendServiceRecordEmail,
+ updateServiceRecord,
+} from '../../stores/fleetDashboardStore';
+import { showToast } from '../../stores/toastStore';
+import ModalShell from '../ui/ModalShell';
+import WorkOrderImageCarousel, { resolveMediaUrl } from '../dashboard/WorkOrderImageCarousel';
+import { formatEntityCode } from '../../lib/displayIds';
+
+function formatDate(value) {
+ if (!value) return '-';
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return value;
+ return parsed.toLocaleDateString('hr-HR');
+}
+
+function formatCost(value) {
+ const numeric = Number(value);
+ if (Number.isNaN(numeric)) return '-';
+ return `${numeric.toFixed(2)} EUR`;
+}
+
+function resolveFileUrl(pathOrUrl) {
+ if (!pathOrUrl) return '#';
+ return resolveMediaUrl(pathOrUrl);
+}
+
+export default function ServiceRecordDetailModal({
+ open,
+ serviceRecord,
+ onClose,
+ onUpdated,
+ onBack = null,
+ backLabel = 'Natrag',
+}) {
+ const [files, setFiles] = useState({ photos: [], attachments: [] });
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [emailSending, setEmailSending] = useState(false);
+ const [widgetError, setWidgetError] = useState('');
+ const [emailModalOpen, setEmailModalOpen] = useState(false);
+ const [teamMembers, setTeamMembers] = useState([]);
+ const [loadingTeamMembers, setLoadingTeamMembers] = useState(false);
+ const [selectedTeamMemberIds, setSelectedTeamMemberIds] = useState([]);
+ const [sendToAllTeamMembers, setSendToAllTeamMembers] = useState(false);
+ const [emailPayload, setEmailPayload] = useState({
+ subject: '',
+ message: '',
+ });
+ const [editMode, setEditMode] = useState(false);
+ const [editSaving, setEditSaving] = useState(false);
+ const [editForm, setEditForm] = useState({
+ service_title: '',
+ description: '',
+ parts: '',
+ cost: '',
+ });
+
+ useEffect(() => {
+ if (!open || typeof window === 'undefined') return;
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }, [open]);
+
+ useEffect(() => {
+ if (!open || !serviceRecord?.id) {
+ setFiles({ photos: [], attachments: [] });
+ setError('');
+ setLoading(false);
+ setEmailSending(false);
+ setWidgetError('');
+ setEmailModalOpen(false);
+ setTeamMembers([]);
+ setLoadingTeamMembers(false);
+ setSelectedTeamMemberIds([]);
+ setSendToAllTeamMembers(false);
+ setEmailPayload({ subject: '', message: '' });
+ setEditMode(false);
+ setEditSaving(false);
+ setEditForm({ service_title: '', description: '', parts: '', cost: '' });
+ return;
+ }
+
+ let cancelled = false;
+ setLoading(true);
+ setError('');
+ setEditMode(false);
+ setEditSaving(false);
+ setEditForm({
+ service_title: serviceRecord.service_title || '',
+ description: serviceRecord.description || '',
+ parts: serviceRecord.parts || '',
+ cost: serviceRecord.cost == null ? '' : String(serviceRecord.cost),
+ });
+
+ (async () => {
+ try {
+ const result = await fetchServiceRecordFiles(serviceRecord.id);
+ if (!cancelled) {
+ setFiles(result);
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setError(err?.message || 'Neuspješno dohvaćanje datoteka servisnog zapisa.');
+ }
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [open, serviceRecord?.id]);
+
+ useEffect(() => {
+ if (!emailModalOpen) return;
+ let cancelled = false;
+ setLoadingTeamMembers(true);
+ (async () => {
+ try {
+ const members = await fetchTeamMembers();
+ if (!cancelled) {
+ setTeamMembers(members);
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setWidgetError(err?.message || 'Neuspješno dohvaćanje članova tima.');
+ }
+ } finally {
+ if (!cancelled) {
+ setLoadingTeamMembers(false);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [emailModalOpen]);
+
+ const totalFiles = useMemo(
+ () => (files.photos?.length || 0) + (files.attachments?.length || 0),
+ [files]
+ );
+
+ const handleDownloadPdf = async () => {
+ setWidgetError('');
+ try {
+ await downloadServiceRecordPdf(serviceRecord.id);
+ } catch (err) {
+ setWidgetError(err?.message || 'Neuspješno preuzimanje PDF-a servisnog zapisa.');
+ }
+ };
+
+ const handleSendEmail = async () => {
+ setWidgetError('');
+ setEmailSending(true);
+ try {
+ const recipients = sendToAllTeamMembers
+ ? teamMembers.map((member) => member.email).filter(Boolean)
+ : teamMembers
+ .filter((member) => selectedTeamMemberIds.includes(String(member.id)))
+ .map((member) => member.email)
+ .filter(Boolean);
+ if (recipients.length === 0) {
+ setWidgetError('Odaberite barem jednog člana tima ili uključite slanje svima.');
+ return;
+ }
+
+ let failed = 0;
+ for (const recipient of recipients) {
+ try {
+ await sendServiceRecordEmail(
+ serviceRecord.id,
+ {
+ recipient,
+ subject: emailPayload.subject.trim() || undefined,
+ message: emailPayload.message.trim() || undefined,
+ },
+ { toast: false }
+ );
+ showToast(`Email je poslan: ${recipient}`, 'success');
+ } catch {
+ failed += 1;
+ showToast(`Neuspješno slanje: ${recipient}`, 'error');
+ }
+ }
+ if (failed > 0) {
+ setWidgetError(`Slanje nije uspjelo za ${failed} primatelja.`);
+ return;
+ }
+ setEmailModalOpen(false);
+ } catch (err) {
+ setWidgetError(err?.message || 'Neuspješno slanje emaila za servisni zapis.');
+ } finally {
+ setEmailSending(false);
+ }
+ };
+
+ const handleSaveEdit = async () => {
+ setWidgetError('');
+ setEditSaving(true);
+ try {
+ const costValue = editForm.cost === '' ? '0.00' : Number(editForm.cost).toFixed(2);
+ if (Number.isNaN(Number(costValue))) {
+ setWidgetError('Trošak mora biti valjan decimalni broj.');
+ return;
+ }
+
+ const updated = await updateServiceRecord(serviceRecord.id, {
+ service_title: editForm.service_title.trim(),
+ description: editForm.description.trim(),
+ parts: editForm.parts.trim(),
+ cost: costValue,
+ });
+ setEditMode(false);
+ onUpdated?.(updated);
+ } catch (err) {
+ setWidgetError(err?.message || 'Neuspješno ažuriranje servisnog zapisa.');
+ } finally {
+ setEditSaving(false);
+ }
+ };
+
+ if (!open || !serviceRecord) return null;
+
+ return (
+ <>
+
+
+
+
Detalji servisnog zapisa
+
+ ID: {formatEntityCode('SR', serviceRecord.id)} • Datum: {formatDate(serviceRecord.service_date)} • Datoteke: {totalFiles}
+
+
+
+ {onBack && (
+
+ )}
+
+
+
+
+
+
+
+
Naziv servisnog zapisa
+
{serviceRecord.service_title || '-'}
+
{serviceRecord.description || '-'}
+
+
+
Trošak / KM / Dizalica
+
+ {formatCost(serviceRecord.cost)} • {serviceRecord.mileage ?? '-'} km • {serviceRecord.crane_registration || serviceRecord.vehicle_registration || '-'}
+
+
+
+
+
+
Dokument i email
+
+
+
+
+ {widgetError && (
+
+ {widgetError}
+
+ )}
+
+
+ {loading && (
+
+ Učitavanje datoteka...
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {!loading && !error && (
+
+
+ item.optimized_url || item.image).filter(Boolean)}
+ emptyLabel="Nema slika za ovaj servisni zapis"
+ />
+
+
+
+
Dokumenti / prilozi
+ {files.attachments.length === 0 ? (
+
Nema priloga.
+ ) : (
+
+ )}
+
+ {editMode && (
+
+ )}
+
+ )}
+
+
+ {!editMode && (
+
+ )}
+ {editMode && (
+ <>
+
+
+ >
+ )}
+
+
+
+ {emailModalOpen && (
+ setEmailModalOpen(false)}
+ overlayClassName="z-[60] p-4"
+ contentClassName="flex items-center justify-center min-h-full"
+ panelClassName="w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
+ >
+
+
+
Pošalji email za servisni zapis
+
+
+
+
+
+
+ {loadingTeamMembers
+ ? 'Učitavanje članova tima...'
+ : `Odabrano: ${sendToAllTeamMembers ? teamMembers.length : selectedTeamMemberIds.length}`}
+
+
setEmailPayload({ ...emailPayload, subject: event.currentTarget.value })}
+ placeholder="Naslov (opcionalno)"
+ className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
+ />
+
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/fleet/ServiceRecordForm.jsx b/frontend/src/components/fleet/ServiceRecordForm.jsx
new file mode 100644
index 0000000..8eaec05
--- /dev/null
+++ b/frontend/src/components/fleet/ServiceRecordForm.jsx
@@ -0,0 +1,374 @@
+// src/components/fleet/ServiceRecordForm.jsx
+// Forma za kreiranje novog servisnog zapisa (POST /api/fleet/service-records/)
+// Koristi offline-first pattern iz fleetDashboardStore: createServiceRecord().
+
+import { useState, useEffect, useRef } from 'preact/hooks';
+import { useStore } from '@nanostores/preact';
+import ModalShell from '../ui/ModalShell';
+import { $isOffline } from '../../stores/networkStore.js';
+import { createServiceRecord, uploadServicePhoto, uploadServiceAttachment } from '../../stores/fleetDashboardStore.js';
+import { showToast } from '../../stores/toastStore.js';
+
+const INITIAL = {
+ task: '',
+ description: '',
+ parts: '',
+ cost: '',
+};
+
+const MAX_FILE_SIZE_MB = 10;
+const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
+const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'];
+const ACCEPTED_DOCUMENT_TYPES = [
+ 'application/pdf',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'text/plain',
+ 'application/zip',
+];
+const ACCEPTED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.heic', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.txt', '.zip'];
+
+export default function ServiceRecordForm({
+ open,
+ assignedCrane,
+ availableTasks = [],
+ onClose,
+ onSuccess,
+ onBack = null,
+ backLabel = 'Natrag',
+}) {
+ const isOffline = useStore($isOffline);
+ const [form, setForm] = useState(INITIAL);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState('');
+ const [isMounted, setIsMounted] = useState(false);
+ const [attachments, setAttachments] = useState([]);
+ const [attachmentDescription, setAttachmentDescription] = useState('');
+ const [uploadProgress, setUploadProgress] = useState({ current: 0, total: 0 });
+ const uploadAbortRef = useRef(null);
+
+ useEffect(() => {
+ setIsMounted(true);
+ }, []);
+
+ useEffect(() => {
+ if (!open || typeof window === 'undefined') return;
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) {
+ setForm(INITIAL);
+ setError('');
+ setSubmitting(false);
+ setAttachments([]);
+ setAttachmentDescription('');
+ setUploadProgress({ current: 0, total: 0 });
+ uploadAbortRef.current?.abort();
+ uploadAbortRef.current = null;
+ return;
+ }
+ // Predaberi jedini dostupni task ako postoji samo jedan
+ if (availableTasks.length === 1) {
+ setForm((prev) => ({ ...prev, task: String(availableTasks[0].id) }));
+ }
+ }, [open, assignedCrane, availableTasks]);
+
+ useEffect(() => {
+ if (!form.task) return;
+ const exists = availableTasks.some((task) => String(task.id) === String(form.task));
+ if (!exists) {
+ setForm((prev) => ({ ...prev, task: '' }));
+ }
+ }, [availableTasks, form.task]);
+
+ if (!open || !isMounted) return null;
+
+ const field = (name) => (e) => setForm((prev) => ({ ...prev, [name]: e.currentTarget.value }));
+
+ const handleFileChange = (event) => { const files = Array.from(event.currentTarget.files ?? []);
+ if (files.length === 0) {
+ setAttachments([]);
+ return;
+ }
+
+ const oversized = files.filter((file) => file.size > MAX_FILE_SIZE_BYTES);
+ if (oversized.length > 0) {
+ setError(`Datoteka prelazi ${MAX_FILE_SIZE_MB} MB: ${oversized[0].name}`);
+ return;
+ }
+
+ const invalidType = files.filter((file) => {
+ const lowerName = String(file.name || '').toLowerCase();
+ const byMime = ACCEPTED_IMAGE_TYPES.includes(file.type) || ACCEPTED_DOCUMENT_TYPES.includes(file.type);
+ const byExtension = ACCEPTED_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
+ return !(byMime || byExtension);
+ });
+ if (invalidType.length > 0) {
+ setError(`Nepodržan format: ${invalidType[0].name}. Podržano: JPG, PNG, WEBP, HEIC, PDF, DOC, DOCX, XLS, XLSX, TXT, ZIP.`);
+ return;
+ }
+
+ setError('');
+ setAttachments(files);
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const formElement = e.currentTarget;
+ setError('');
+
+ if (!assignedCrane) {
+ setError('Nije pronađena dodijeljena dizalica. Obratite se administratoru.');
+ return;
+ }
+
+ if (!form.task) {
+ setError('Za novi servisni zapis morate odabrati radni zadatak.');
+ return;
+ }
+ if (!form.description.trim()) {
+ setError('Opis posla je obavezan.');
+ return;
+ }
+
+ const cost = form.cost === '' ? '0.00' : parseFloat(form.cost).toFixed(2);
+ if (Number.isNaN(Number(cost))) {
+ setError('Trošak mora biti valjan decimalni broj.');
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ const payload = {
+ task: form.task,
+ description: form.description.trim(),
+ parts: form.parts.trim(),
+ cost,
+ };
+ const createdRecord = await createServiceRecord(payload);
+
+ if (attachments.length > 0) {
+ if (isOffline || String(createdRecord.id).startsWith('offline-')) {
+ setError('Upload datoteka nije dostupan u offline modu. Spremite zapis pa uploadajte datoteke kada ste online.');
+ setSubmitting(false);
+ return;
+ }
+
+ uploadAbortRef.current?.abort();
+ uploadAbortRef.current = new AbortController();
+ const signal = uploadAbortRef.current.signal;
+ setUploadProgress({ current: 0, total: attachments.length });
+
+ for (const [index, file] of attachments.entries()) {
+ setUploadProgress({ current: index + 1, total: attachments.length });
+ const isImage = ACCEPTED_IMAGE_TYPES.includes(file.type) || /\.(jpe?g|png|webp|heic)$/i.test(file.name || '');
+ if (isImage) {
+ await uploadServicePhoto(createdRecord.id, file, attachmentDescription, signal);
+ } else {
+ await uploadServiceAttachment(createdRecord.id, file, attachmentDescription, signal);
+ }
+ }
+ }
+
+ showToast('Servisni zapis je uspješno kreiran.', 'success');
+ formElement?.reset?.();
+ setAttachments([]);
+ setAttachmentDescription('');
+ setUploadProgress({ current: 0, total: 0 });
+ onSuccess?.();
+ onClose?.();
+ } catch (err) {
+ if (err?.name !== 'AbortError') {
+ setError(err?.message || 'Greška pri slanju servisnog zapisa.');
+ }
+ } finally {
+ setSubmitting(false);
+ uploadAbortRef.current = null;
+ }
+ };
+
+ const disabled = submitting || isOffline;
+
+ return (
+
+
+
+
+
Novi servisni zapis
+ {isOffline && (
+
+ ⚠ Offline mod — zapis će se sinkronizirati kad se veza vrati
+
+ )}
+
+
+ {onBack && (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/invoicing/InvoiceForm.jsx b/frontend/src/components/invoicing/InvoiceForm.jsx
new file mode 100644
index 0000000..fe83a05
--- /dev/null
+++ b/frontend/src/components/invoicing/InvoiceForm.jsx
@@ -0,0 +1,258 @@
+// src/components/invoicing/InvoiceForm.jsx
+// Forma za kreiranje fakture s dinamičkim stavkama (POST /api/invoicing/invoices/)
+// Backend prima JSON body s nested 'items' poljem: [{description, quantity, unit_price}]
+// NIJE offline-queued jer fakture zahtijevaju strogu konzistentnost s backendom.
+
+import { useState, useEffect } from 'preact/hooks';
+import { useStore } from '@nanostores/preact';
+import { $isOffline } from '../../stores/networkStore.js';
+import { $clients, $invoicesLoading, fetchClients, createInvoice } from '../../stores/invoiceStore.js';
+import { showToast } from '../../stores/toastStore.js';
+
+const EMPTY_ITEM = { description: '', quantity: '1', unit_price: '' };
+
+const today = () => {
+ const d = new Date();
+ d.setDate(d.getDate() + 30); // Default rok: 30 dana od danas
+ return d.toISOString().slice(0, 10);
+};
+
+export default function InvoiceForm({ onSuccess }) {
+ const isOffline = useStore($isOffline);
+ const clients = useStore($clients);
+ const loading = useStore($invoicesLoading);
+ const [isMounted, setIsMounted] = useState(false);
+
+ const [clientId, setClientId] = useState('');
+ const [dueDate, setDueDate] = useState(today());
+ const [items, setItems] = useState([{ ...EMPTY_ITEM }]);
+ const [submitting, setSubmitting] = useState(false);
+
+ useEffect(() => {
+ setIsMounted(true);
+ if ($clients.get().length === 0) {
+ fetchClients();
+ }
+ }, []);
+
+ if (!isMounted) {
+ return (
+
+ );
+ }
+
+ // ---- Item helpers ----
+
+ const setItemField = (index, field, value) => {
+ setItems((prev) =>
+ prev.map((item, i) => (i === index ? { ...item, [field]: value } : item))
+ );
+ };
+
+ const addItem = () => setItems((prev) => [...prev, { ...EMPTY_ITEM }]);
+
+ const removeItem = (index) => {
+ if (items.length === 1) return; // Minimalno jedna stavka
+ setItems((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ // Ukupni iznos — izračun u frontendu za prikaz
+ const total = items.reduce((sum, item) => {
+ const qty = parseFloat(item.quantity) || 0;
+ const price = parseFloat(item.unit_price) || 0;
+ return sum + qty * price;
+ }, 0);
+
+ // ---- Submit ----
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ if (isOffline) {
+ showToast('Faktura se ne može kreirati u offline modu.', 'error');
+ return;
+ }
+
+ if (!clientId) {
+ showToast('Odaberite klijenta.', 'error');
+ return;
+ }
+
+ const validItems = items.filter(
+ (item) => item.description.trim() && parseFloat(item.quantity) > 0 && parseFloat(item.unit_price) > 0
+ );
+ if (validItems.length === 0) {
+ showToast('Dodajte barem jednu stavku s opisom, količinom i cijenom.', 'error');
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ const payload = {
+ client: Number(clientId),
+ due_date: dueDate,
+ items: validItems.map((item) => ({
+ description: item.description.trim(),
+ quantity: parseFloat(item.quantity),
+ unit_price: parseFloat(item.unit_price),
+ })),
+ };
+ const invoice = await createInvoice(payload);
+ setClientId('');
+ setDueDate(today());
+ setItems([{ ...EMPTY_ITEM }]);
+ onSuccess?.(invoice);
+ } catch (err) {
+ if (err?.name !== 'AbortError') {
+ showToast(err?.message || 'Greška pri kreiranju fakture.', 'error');
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const disabled = submitting || isOffline;
+
+ return (
+
+
+ // NOVA_FAKTURA
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/notifications/NotificationDetailModal.jsx b/frontend/src/components/notifications/NotificationDetailModal.jsx
new file mode 100644
index 0000000..38e4120
--- /dev/null
+++ b/frontend/src/components/notifications/NotificationDetailModal.jsx
@@ -0,0 +1,239 @@
+import { useMemo } from 'preact/hooks';
+import ModalShell from '../ui/ModalShell';
+import { downloadWorkOrderInvoicesPdf, downloadWorkOrderPdf, downloadWorkOrderServiceRecordsPdf } from '../../stores/fleetDashboardStore';
+
+function formatTimestamp(value) {
+ if (!value) return '';
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return '';
+ return new Intl.DateTimeFormat('hr-HR', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(date);
+}
+
+function detectNotificationKind(notification) {
+ const title = String(notification?.title || '').toLowerCase();
+ if (title.includes('servisni kontekst')) return 'service_context';
+ if (title.includes('pdf')) return 'work_order_pdf';
+ if (title.includes('putni nalog kreiran')) return 'work_order_created';
+ if (title.includes('novi putni nalog')) return 'work_order_assigned';
+ if (title.includes('putni nalog zatvoren')) return 'work_order_closed';
+ if (title.includes('putni nalog obrisan')) return 'work_order_deleted';
+ if (title.includes('servis zabilježen')) return 'service_record_created';
+ if (title.includes('servis na vašem vozilu')) return 'service_record_vehicle_update';
+ if (title.includes('uskoro servis')) return 'service_due_warning';
+ return 'generic';
+}
+
+function getEntityAction(notification, meta) {
+ const metadata = notification?.metadata || {};
+ if (metadata.entity_type === 'service_context' && metadata.vehicle_id) {
+ return {
+ label: 'Otvori servisni kontekst',
+ run() {
+ window.dispatchEvent(new CustomEvent('notification:open-entity', {
+ detail: {
+ entityType: 'service_context',
+ vehicleId: metadata.vehicle_id,
+ section: metadata.section || meta.section,
+ },
+ }));
+ },
+ };
+ }
+ if (
+ metadata.entity_type === 'work_order_pdf' &&
+ metadata.stage === 'completed' &&
+ metadata.work_order_id
+ ) {
+ return {
+ label: metadata.pdf_type === 'invoices'
+ ? 'Preuzmi PDF računa'
+ : metadata.pdf_type === 'service_records'
+ ? 'Preuzmi PDF servisnih zapisa'
+ : 'Preuzmi PDF putnog naloga',
+ run() {
+ if (metadata.pdf_type === 'invoices') {
+ return downloadWorkOrderInvoicesPdf(metadata.work_order_id);
+ }
+ if (metadata.pdf_type === 'service_records') {
+ return downloadWorkOrderServiceRecordsPdf(metadata.work_order_id);
+ }
+ return downloadWorkOrderPdf(metadata.work_order_id);
+ },
+ };
+ }
+ if (metadata.entity_type === 'work_order' && metadata.work_order_id) {
+ return {
+ label: 'Otvori detalje naloga',
+ run() {
+ window.dispatchEvent(new CustomEvent('notification:open-entity', {
+ detail: {
+ entityType: 'work_order',
+ entityId: metadata.work_order_id,
+ vehicleId: metadata.vehicle_id || null,
+ section: metadata.section || meta.section,
+ },
+ }));
+ },
+ };
+ }
+ if (metadata.entity_type === 'service_record' && metadata.service_record_id) {
+ return {
+ label: 'Otvori detalje servisa',
+ run() {
+ window.dispatchEvent(new CustomEvent('notification:open-entity', {
+ detail: {
+ entityType: 'service_record',
+ entityId: metadata.service_record_id,
+ vehicleId: metadata.vehicle_id || null,
+ section: metadata.section || meta.section,
+ },
+ }));
+ },
+ };
+ }
+ return {
+ label: `Otvori ${meta.category}`,
+ run() {
+ window.dispatchEvent(new CustomEvent('navbar:navigate', { detail: meta.section }));
+ },
+ };
+}
+
+function getNotificationTypeMeta(kind) {
+ if (kind.startsWith('work_order_pdf')) {
+ return {
+ category: 'PDF dokumenti',
+ section: 'work-orders',
+ recommendation: 'Možete odmah preuzeti generirani PDF dokument.',
+ };
+ }
+ if (kind.startsWith('work_order')) {
+ return {
+ category: 'Putni nalozi',
+ section: 'work-orders',
+ recommendation: 'Provjerite status naloga i povezano vozilo.',
+ };
+ }
+ if (kind === 'service_context') {
+ return {
+ category: 'Servisni kontekst',
+ section: 'service-records',
+ recommendation: 'Kontekst je postavljen i spreman za rad na servisnim zapisima.',
+ };
+ }
+ if (kind.startsWith('service_')) {
+ return {
+ category: 'Servisni zapisi',
+ section: 'service-records',
+ recommendation: 'Provjerite detalje servisa i priloge.',
+ };
+ }
+ return {
+ category: 'Sustav',
+ section: 'dashboard',
+ recommendation: 'Pregledajte dashboard za dodatni kontekst.',
+ };
+}
+
+function levelClass(level) {
+ if (level === 'critical') return 'bg-red-100 text-red-700';
+ if (level === 'warning') return 'bg-amber-100 text-amber-700';
+ return 'bg-indigo-100 text-indigo-700';
+}
+
+export default function NotificationDetailModal({ open, notification, onClose }) {
+ const kind = useMemo(() => detectNotificationKind(notification), [notification]);
+ const meta = useMemo(() => getNotificationTypeMeta(kind), [kind]);
+ const action = useMemo(() => getEntityAction(notification, meta), [notification, meta]);
+ const showPdfLink = Boolean(
+ notification?.metadata?.entity_type === 'work_order_pdf' &&
+ notification?.metadata?.stage === 'completed' &&
+ notification?.metadata?.work_order_id
+ );
+
+ if (!open || !notification) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
Detalji notifikacije
+
{formatTimestamp(notification.created_at)}
+
+
+
+
+
+
+
+ {notification.level || 'info'}
+
+
+ {meta.category}
+
+
+
+
+
{notification.title || 'Obavijest'}
+
{notification.message || '-'}
+ {showPdfLink && (
+
+ )}
+
+
+
+
Preporuka
+
{meta.recommendation}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/tasks/TaskForm.jsx b/frontend/src/components/tasks/TaskForm.jsx
new file mode 100644
index 0000000..7c16481
--- /dev/null
+++ b/frontend/src/components/tasks/TaskForm.jsx
@@ -0,0 +1,143 @@
+// src/components/tasks/TaskForm.jsx
+// Forma za kreiranje novog zadatka (POST /api/tasks/tasks/)
+// Prati TodoForm.jsx arhitektonski obrazac.
+
+import { useState, useEffect } from 'preact/hooks';
+import { useStore } from '@nanostores/preact';
+import { $isOffline } from '../../stores/networkStore.js';
+import { $tasks, createTask, fetchTasks, getStatusLabel } from '../../stores/taskStore.js';
+import { showToast } from '../../stores/toastStore.js';
+
+const STATUS_OPTIONS = ['aktivan', 'servis', 'neaktivan'];
+
+const INITIAL = {
+ title: '',
+ description: '',
+ status: 'aktivan',
+};
+
+export default function TaskForm() {
+ const isOffline = useStore($isOffline);
+ const [form, setForm] = useState(INITIAL);
+ const [submitting, setSubmitting] = useState(false);
+ const [isMounted, setIsMounted] = useState(false);
+
+ useEffect(() => {
+ setIsMounted(true);
+ // Učitaj zadatke na mount ako su prazni
+ if ($tasks.get().length === 0 && !isOffline) {
+ fetchTasks();
+ }
+ }, []);
+
+ if (!isMounted) {
+ // SSR placeholder — iste dimenzije kao forma da se izbjegne layout shift
+ return (
+
+ );
+ }
+
+ const field = (name) => (e) => setForm((prev) => ({ ...prev, [name]: e.currentTarget.value }));
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ if (isOffline) {
+ showToast('Nije moguće kreirati zadatak u offline modu.', 'error');
+ return;
+ }
+
+ if (!form.title.trim()) {
+ showToast('Naslov zadatka je obavezan.', 'error');
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ await createTask({
+ title: form.title.trim(),
+ description: form.description.trim(),
+ status: form.status,
+ });
+ e.target.reset();
+ setForm(INITIAL);
+ } catch (err) {
+ if (err?.name !== 'AbortError') {
+ showToast(err?.message || 'Greška pri kreiranju zadatka.', 'error');
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const disabled = submitting || isOffline;
+
+ return (
+
+
+ // NOVI_ZADATAK
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/toast/ToastItem.tsx b/frontend/src/components/toast/ToastItem.tsx
new file mode 100644
index 0000000..70d7487
--- /dev/null
+++ b/frontend/src/components/toast/ToastItem.tsx
@@ -0,0 +1,35 @@
+import type { ToastMessage } from '../../hooks/useToast';
+
+const STYLES: Record = {
+ success: 'border-emerald-300 bg-emerald-500/15 text-emerald-100',
+ error: 'border-red-300 bg-red-500/15 text-red-100',
+ warning: 'border-amber-300 bg-amber-500/15 text-amber-100',
+ info: 'border-sky-300 bg-sky-500/15 text-sky-100',
+};
+
+interface ToastItemProps {
+ toast: ToastMessage;
+ onClose: (id: string) => void;
+}
+
+export default function ToastItem({ toast, onClose }: ToastItemProps) {
+ return (
+
+
+
{toast.message}
+
+
+
+ );
+}
diff --git a/frontend/src/components/toast/ToastProvider.tsx b/frontend/src/components/toast/ToastProvider.tsx
new file mode 100644
index 0000000..f9861a5
--- /dev/null
+++ b/frontend/src/components/toast/ToastProvider.tsx
@@ -0,0 +1,28 @@
+import { useEffect } from 'preact/hooks';
+import ToastItem from './ToastItem';
+import { removeToast, toastMessages } from '../../hooks/useToast';
+
+export default function ToastProvider() {
+ const toasts = Array.isArray(toastMessages.value) ? toastMessages.value : [];
+
+ useEffect(() => {
+ const timers = toasts.map((toast) =>
+ window.setTimeout(() => removeToast(toast.id), toast.durationMs)
+ );
+ return () => {
+ timers.forEach((timer) => window.clearTimeout(timer));
+ };
+ }, [toasts]);
+
+ if (!toasts.length) {
+ return null;
+ }
+
+ return (
+
+ {toasts.map((toast) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/ui/AnimatedDataTable.jsx b/frontend/src/components/ui/AnimatedDataTable.jsx
new file mode 100644
index 0000000..9f3d011
--- /dev/null
+++ b/frontend/src/components/ui/AnimatedDataTable.jsx
@@ -0,0 +1,82 @@
+import { animated, useTransition } from '@react-spring/web';
+import { useEffect, useState } from 'preact/hooks';
+
+export default function AnimatedDataTable({
+ columns = [],
+ rows = [],
+ rowKey = (row) => row?.id,
+ renderRow,
+ loading = false,
+ loadingMessage = 'Učitavanje...',
+ emptyMessage = 'Nema podataka.',
+ tableClassName = 'min-w-full text-sm',
+ headClassName = 'bg-canvas-deep text-left text-xs uppercase tracking-wide text-text-muted',
+ bodyClassName = 'divide-y divide-border-hairline',
+ rowClassName = 'hover:bg-canvas-deep',
+ wrapperClassName = 'overflow-x-auto',
+ trail = 35,
+}) {
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const transitions = useTransition(rows, {
+ keys: (row) => rowKey(row),
+ from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
+ enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
+ leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
+ trail,
+ config: { tension: 230, friction: 26 },
+ });
+
+ const colSpan = Math.max(1, columns.length);
+
+ return (
+
+
+
+
+ {columns.map((column) => (
+ |
+ {column.label}
+ |
+ ))}
+
+
+
+ {!mounted && (
+
+ |
+ {loadingMessage}
+ |
+
+ )}
+ {mounted && loading && (
+
+ |
+ {loadingMessage}
+ |
+
+ )}
+ {mounted && !loading && rows.length === 0 && (
+
+ |
+ {emptyMessage}
+ |
+
+ )}
+ {mounted && !loading && transitions((style, row) => (
+
+ {renderRow(row)}
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/ui/AnimatedPage.jsx b/frontend/src/components/ui/AnimatedPage.jsx
new file mode 100644
index 0000000..8777fd2
--- /dev/null
+++ b/frontend/src/components/ui/AnimatedPage.jsx
@@ -0,0 +1,15 @@
+import { animated, useSpring } from '@react-spring/web';
+
+export default function AnimatedPage({ children, className = '' }) {
+ const pageStyle = useSpring({
+ from: { opacity: 0, transform: 'translate3d(0,10px,0)' },
+ to: { opacity: 1, transform: 'translate3d(0,0,0)' },
+ config: { tension: 210, friction: 24 },
+ });
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/ui/ModalShell.jsx b/frontend/src/components/ui/ModalShell.jsx
new file mode 100644
index 0000000..0bdb4a4
--- /dev/null
+++ b/frontend/src/components/ui/ModalShell.jsx
@@ -0,0 +1,51 @@
+import { useEffect } from 'preact/hooks';
+import { createPortal } from 'preact/compat';
+
+function joinClasses(...classes) {
+ return classes.filter(Boolean).join(' ');
+}
+
+export default function ModalShell({
+ children,
+ onClose,
+ overlayClassName = '',
+ contentClassName = 'flex min-h-full items-center justify-center',
+ panelClassName = '',
+ closeOnBackdrop = true,
+}) {
+ useEffect(() => {
+ const body = document.body;
+ const count = parseInt(body.dataset.modalCount || '0', 10);
+ body.dataset.modalCount = count + 1;
+ body.classList.add('modal-open');
+ return () => {
+ const next = parseInt(body.dataset.modalCount || '0', 10) - 1;
+ body.dataset.modalCount = next;
+ if (next <= 0) {
+ body.classList.remove('modal-open');
+ delete body.dataset.modalCount;
+ }
+ };
+ }, []);
+
+ const handleOverlayClick = closeOnBackdrop
+ ? (event) => {
+ event.stopPropagation();
+ onClose?.(event);
+ }
+ : undefined;
+
+ return createPortal(
+
+
+
event.stopPropagation()}>
+ {children}
+
+
+
,
+ document.body
+ );
+}
diff --git a/frontend/src/components/ui/NetworkGuard.jsx b/frontend/src/components/ui/NetworkGuard.jsx
new file mode 100644
index 0000000..d19e6c0
--- /dev/null
+++ b/frontend/src/components/ui/NetworkGuard.jsx
@@ -0,0 +1,20 @@
+import { useStore } from '@nanostores/preact';
+import { $isOffline } from '../../stores/networkStore';
+
+export default function NetworkGuard() {
+ const isOffline = useStore($isOffline);
+ if (!isOffline) return null;
+ const isDev = import.meta.env.DEV;
+
+ return (
+
+ STATUS_OFFLINE:{' '}
+ {isDev
+ ? 'Veza s Django API-jem (8001) je prekinuta. Podaci se čitaju iz lokalnog cachea.'
+ : 'Veza je prekinuta. Podaci se čitaju iz lokalnog cachea.'}
+
+ );
+}
diff --git a/frontend/src/components/ui/ThemeToggle.jsx b/frontend/src/components/ui/ThemeToggle.jsx
new file mode 100644
index 0000000..3033b0b
--- /dev/null
+++ b/frontend/src/components/ui/ThemeToggle.jsx
@@ -0,0 +1,41 @@
+import { useState, useEffect } from 'preact/hooks';
+
+export default function ThemeToggle() {
+ const [isDark, setIsDark] = useState(false);
+
+ useEffect(() => {
+ const imaDarkKlasu = document.documentElement.classList.contains('dark');
+ setIsDark(imaDarkKlasu);
+ }, []);
+
+ const toggleTheme = () => {
+ if (document.documentElement.classList.contains('dark')) {
+ document.documentElement.classList.remove('dark');
+ localStorage.setItem('theme', 'light');
+ setIsDark(false);
+ } else {
+ document.documentElement.classList.add('dark');
+ localStorage.setItem('theme', 'dark');
+ setIsDark(true);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/ui/Toast.jsx b/frontend/src/components/ui/Toast.jsx
new file mode 100644
index 0000000..a9b9faa
--- /dev/null
+++ b/frontend/src/components/ui/Toast.jsx
@@ -0,0 +1,5 @@
+import ToastContainer from '../ToastContainer';
+
+export default function Toast() {
+ return ;
+}
diff --git a/frontend/src/components/ui/UserDisplay.jsx b/frontend/src/components/ui/UserDisplay.jsx
new file mode 100644
index 0000000..51d14cc
--- /dev/null
+++ b/frontend/src/components/ui/UserDisplay.jsx
@@ -0,0 +1,151 @@
+// src/components/ui/UserDisplay.jsx
+//
+// Prikazuje podatke o korisniku direktno iz $user nanostores atoma.
+//
+// ARHITEKTONSKA ODLUKA — zašto store, ne prop:
+// • $user je reaktivan atom — svaka promjena (login, refresh profila)
+// automatski re-renderira sve UserDisplay instance bez prop-drillinga
+// • Komponenta se poziva na više mjesta (Topbar, Sidebar, Profil) —
+// proslijeđivanje user objekta kao prop-a kroz sve te slojeve je anti-pattern
+//
+// USAGE:
+// — "Ivo Horvat"
+// — "Ivo"
+// — "Horvat"
+// — "ivo@example.com"
+// — "Serviser" | "Administrator" | "Korisnik"
+// — avatar krug + ime
+// — puna kartica s ulogom
+
+import { useStore } from '@nanostores/preact';
+import { useEffect, useState } from 'preact/hooks';
+import { $user, $authReady } from '../../stores/authStore.js';
+
+// ─── Mapa razrješivača polja ────────────────────────────────────────────────
+// Dodavanje novog polja = jedna nova linija ovdje.
+const FIELD_RESOLVER = {
+ ime_prezime: (u) =>
+ [`${u.first_name ?? ''}`.trim(), `${u.last_name ?? ''}`.trim()]
+ .filter(Boolean)
+ .join(' ') || u.email || '—',
+ ime: (u) => u.first_name?.trim() || u.email || '—',
+ prezime: (u) => u.last_name?.trim() || '—',
+ email: (u) => u.email || '—',
+ uloga: (u) => {
+ if (u.is_staff) return 'Administrator';
+ if (u.is_serviser) return 'Serviser';
+ return 'Korisnik';
+ },
+};
+
+// Dohvati inicijale za avatar krug (max 2 slova)
+function getInitials(user) {
+ if (!user) return '?';
+ const first = user.first_name?.trim()?.[0] ?? '';
+ const last = user.last_name?.trim()?.[0] ?? '';
+ if (first || last) return `${first}${last}`.toUpperCase();
+ return (user.email?.[0] ?? '?').toUpperCase();
+}
+
+// ─── Varijante prikaza ──────────────────────────────────────────────────────
+
+function TextVariant({ value, className }) {
+ return (
+
+ {value}
+
+ );
+}
+
+function BadgeVariant({ user, value }) {
+ // Kompaktni prikaz za navigacijske trake — krug s inicijalima + ime
+ return (
+
+
+ {getInitials(user)}
+
+
+ {value}
+
+
+ );
+}
+
+function CardVariant({ user, value }) {
+ // Prošireni prikaz — avatar + ime + uloga
+ const role = FIELD_RESOLVER.uloga(user);
+ return (
+
+
+ {getInitials(user)}
+
+
+
+ );
+}
+
+// Skeleton za loading stanje — iste dimenzije kao sadržaj, bez layout shifta
+function Skeleton({ variant }) {
+ if (variant === 'card') {
+ return (
+
+ );
+ }
+ if (variant === 'badge') {
+ return (
+
+ );
+ }
+ return ;
+}
+
+// ─── Glavna komponenta ──────────────────────────────────────────────────────
+
+export default function UserDisplay({
+ field = 'ime_prezime',
+ variant = 'text',
+ fallback = '—',
+ className,
+}) {
+ const user = useStore($user);
+ const authReady = useStore($authReady);
+ // SSR guard — na serveru uvijek renderira skeleton kako bi hydration bio miran
+ const [hasMounted, setHasMounted] = useState(false);
+ useEffect(() => { setHasMounted(true); }, []);
+
+ // Prije mount-a (SSR) i dok auth nije spreman — skeleton
+ if (!hasMounted || !authReady) {
+ return ;
+ }
+
+ // Auth spreman ali user nije učitan (nije prijavljen)
+ if (!user) {
+ return variant === 'text'
+ ? {fallback}
+ : ;
+ }
+
+ const resolver = FIELD_RESOLVER[field];
+ if (!resolver) {
+ console.warn(`[UserDisplay] Nepoznato polje: "${field}". Dostupna polja: ${Object.keys(FIELD_RESOLVER).join(', ')}`);
+ return ?{field};
+ }
+
+ const value = resolver(user);
+
+ if (variant === 'badge') return ;
+ if (variant === 'card') return ;
+ return ;
+}
diff --git a/frontend/src/config.css b/frontend/src/config.css
new file mode 100644
index 0000000..024d5bc
--- /dev/null
+++ b/frontend/src/config.css
@@ -0,0 +1,21 @@
+:root {
+ --app-canvas-base: #ffffff;
+ --app-canvas-elevated: #f9fafb;
+ --app-canvas-deep: #f3f4f6;
+ --app-brand-primary: #5c6bc0;
+ --app-brand-accent: #5e6ad2;
+ --app-brand-accent-bright: #4d59c2;
+ --app-text-main: #1f2937;
+ --app-text-muted: #4b5563;
+ --app-border-hairline: rgba(0, 0, 0, 0.09);
+}
+
+.dark {
+ --app-canvas-base: #1f2329;
+ --app-canvas-elevated: #2a2f36;
+ --app-canvas-deep: #343b44;
+ --app-brand-primary: #6f7de0;
+ --app-text-main: #f4f6fb;
+ --app-text-muted: #b3bcc8;
+ --app-border-hairline: rgba(255, 255, 255, 0.14);
+}
diff --git a/frontend/src/hooks/useToast.ts b/frontend/src/hooks/useToast.ts
new file mode 100644
index 0000000..028bb99
--- /dev/null
+++ b/frontend/src/hooks/useToast.ts
@@ -0,0 +1,44 @@
+import { signal } from '@preact/signals';
+
+export type ToastType = 'success' | 'error' | 'info' | 'warning';
+
+export interface ToastMessage {
+ id: string;
+ type: ToastType;
+ message: string;
+ durationMs: number;
+}
+
+export const toastMessages = signal([]);
+
+function getToastMessages(): ToastMessage[] {
+ return Array.isArray(toastMessages.value) ? toastMessages.value : [];
+}
+
+function nextId(): string {
+ return `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
+}
+
+export function removeToast(id: string): void {
+ toastMessages.value = getToastMessages().filter((toast) => toast.id !== id);
+}
+
+export function pushToast(
+ message: string,
+ type: ToastType = 'info',
+ durationMs = 3000
+): string {
+ const id = nextId();
+ toastMessages.value = [{ id, type, message, durationMs }, ...getToastMessages()].slice(0, 5);
+ return id;
+}
+
+export function useToast() {
+ return {
+ success: (message: string, durationMs?: number) => pushToast(message, 'success', durationMs),
+ error: (message: string, durationMs?: number) => pushToast(message, 'error', durationMs),
+ info: (message: string, durationMs?: number) => pushToast(message, 'info', durationMs),
+ warning: (message: string, durationMs?: number) => pushToast(message, 'warning', durationMs),
+ dismiss: removeToast,
+ };
+}
diff --git a/frontend/src/layouts/Layout.astro b/frontend/src/layouts/Layout.astro
new file mode 100644
index 0000000..55d48e9
--- /dev/null
+++ b/frontend/src/layouts/Layout.astro
@@ -0,0 +1,105 @@
+---
+import { ClientRouter } from 'astro:transitions';
+import '../config.css';
+import '../styles/global.css';
+import ThemeToggle from '../components/ui/ThemeToggle.jsx';
+import ButtonDisplayCounter from '../components/atom/ButtonDisplayCounter.jsx';
+import Toast from '../components/ui/Toast.jsx';
+import ToastTrigger from '../components/ToastTrigger.jsx';
+import NetworkGuard from '../components/ui/NetworkGuard.jsx';
+import Navbar from '../components/Navbar.jsx';
+import ToastProvider from '../components/toast/ToastProvider';
+
+interface Props {
+ title: string;
+ isAuthPage?: boolean;
+ minimalNav?: boolean;
+}
+
+const { title, isAuthPage = false, minimalNav = false } = Astro.props as Props;
+const isProd = import.meta.env.PROD;
+const isDev = import.meta.env.DEV;
+---
+
+
+
+
+
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+ {!isAuthPage && }
+
+
+ {!isAuthPage && isDev && }
+ {!isAuthPage && }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..36fc17e
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,112 @@
+export class ApiError extends Error {
+ status: number;
+ details?: unknown;
+
+ constructor(message: string, status: number, details?: unknown) {
+ super(message);
+ this.name = 'ApiError';
+ this.status = status;
+ this.details = details;
+ }
+}
+
+export interface FetchJsonOptions extends Omit {
+ body?: unknown;
+ timeoutMs?: number;
+}
+
+const API_BASE = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
+
+function buildUrl(endpoint: string): string {
+ return new URL(endpoint.replace(/^\/+/, ''), API_BASE).toString();
+}
+
+function normalizeHeaders(headers: HeadersInit | undefined): Record {
+ if (!headers) return {};
+ if (headers instanceof Headers) {
+ return Object.fromEntries(headers.entries());
+ }
+ if (Array.isArray(headers)) {
+ return Object.fromEntries(headers);
+ }
+ return { ...headers };
+}
+
+function createTimeoutSignal(signal: AbortSignal | null | undefined, timeoutMs: number): {
+ signal: AbortSignal;
+ clear: () => void;
+} {
+ const timeoutController = new AbortController();
+ const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
+
+ const clear = () => {
+ clearTimeout(timer);
+ };
+
+ if (signal) {
+ signal.addEventListener(
+ 'abort',
+ () => {
+ clear();
+ timeoutController.abort();
+ },
+ { once: true }
+ );
+ }
+
+ return { signal: timeoutController.signal, clear };
+}
+
+export async function fetchJson(endpoint: string, options: FetchJsonOptions = {}): Promise {
+ const {
+ body,
+ headers,
+ timeoutMs = 10000,
+ signal,
+ ...rest
+ } = options;
+
+ const requestHeaders: Record = {
+ Accept: 'application/json',
+ ...normalizeHeaders(headers),
+ };
+
+ let requestBody: BodyInit | undefined;
+ if (body !== undefined && body !== null) {
+ requestHeaders['Content-Type'] = 'application/json';
+ requestBody = JSON.stringify(body);
+ }
+
+ const timeout = createTimeoutSignal(signal, timeoutMs);
+ let response: Response;
+ try {
+ response = await fetch(buildUrl(endpoint), {
+ ...rest,
+ headers: requestHeaders,
+ body: requestBody,
+ signal: timeout.signal,
+ });
+ } finally {
+ timeout.clear();
+ }
+
+ if (!response.ok) {
+ let details: unknown = null;
+ try {
+ details = await response.json();
+ } catch {
+ details = await response.text().catch(() => null);
+ }
+ const message =
+ typeof details === 'object' && details && 'detail' in details
+ ? String((details as { detail: unknown }).detail)
+ : `API request failed (${response.status})`;
+ throw new ApiError(message, response.status, details);
+ }
+
+ if (response.status === 204) {
+ return null as T;
+ }
+
+ return (await response.json()) as T;
+}
diff --git a/frontend/src/lib/db.ts b/frontend/src/lib/db.ts
new file mode 100644
index 0000000..69df543
--- /dev/null
+++ b/frontend/src/lib/db.ts
@@ -0,0 +1,53 @@
+import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
+
+const DB_NAME = 'erp-cache-db';
+const DB_VERSION = 1;
+const CACHE_STORE = 'api_cache';
+
+interface CacheEntry {
+ key: string;
+ payload: T;
+ updatedAt: string;
+}
+
+interface ERPDBSchema extends DBSchema {
+ api_cache: {
+ key: string;
+ value: CacheEntry;
+ };
+}
+
+let dbPromise: Promise> | null = null;
+
+function getDB(): Promise> {
+ if (!dbPromise) {
+ dbPromise = openDB(DB_NAME, DB_VERSION, {
+ upgrade(db) {
+ if (!db.objectStoreNames.contains(CACHE_STORE)) {
+ db.createObjectStore(CACHE_STORE, { keyPath: 'key' });
+ }
+ },
+ });
+ }
+ return dbPromise;
+}
+
+export async function setCachedData(key: string, payload: T): Promise {
+ const db = await getDB();
+ await db.put(CACHE_STORE, {
+ key,
+ payload,
+ updatedAt: new Date().toISOString(),
+ });
+}
+
+export async function getCachedData(key: string): Promise | null> {
+ const db = await getDB();
+ const row = await db.get(CACHE_STORE, key);
+ return (row as CacheEntry | undefined) ?? null;
+}
+
+export async function deleteCachedData(key: string): Promise {
+ const db = await getDB();
+ await db.delete(CACHE_STORE, key);
+}
diff --git a/frontend/src/lib/displayIds.js b/frontend/src/lib/displayIds.js
new file mode 100644
index 0000000..406992f
--- /dev/null
+++ b/frontend/src/lib/displayIds.js
@@ -0,0 +1,22 @@
+export function shortUuid(id, size = 8) {
+ if (id == null) return '';
+ const raw = String(id).trim();
+ if (!raw) return '';
+ const compact = raw.split('-')[0] || raw;
+ return compact.slice(0, size).toUpperCase();
+}
+
+export function formatEntityCode(prefix, id, size = 8) {
+ const short = shortUuid(id, size);
+ if (!short) return `${prefix}-`;
+ return `${prefix}-${short}`;
+}
+
+export function formatPurposeLabel(value) {
+ const normalized = String(value || '').trim().toLowerCase();
+ if (!normalized) return '';
+ if (normalized === 'defektaza') return 'Defektaža';
+ if (normalized === 'kontrola') return 'Kontrola';
+ if (normalized === 'redovni_pregled') return 'Redovni pregled';
+ return value || '';
+}
diff --git a/frontend/src/pages/clients.astro b/frontend/src/pages/clients.astro
new file mode 100644
index 0000000..4b3e48b
--- /dev/null
+++ b/frontend/src/pages/clients.astro
@@ -0,0 +1,9 @@
+---
+export const prerender = false;
+import Layout from '../layouts/Layout.astro';
+import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
+---
+
+
+
+
diff --git a/frontend/src/pages/data/[...slug].astro b/frontend/src/pages/data/[...slug].astro
new file mode 100644
index 0000000..0e4725d
--- /dev/null
+++ b/frontend/src/pages/data/[...slug].astro
@@ -0,0 +1,37 @@
+---
+import Layout from '../../layouts/Layout.astro';
+import DataView from '../../components/data/DataView';
+import { fetchJson } from '../../lib/api';
+
+export const prerender = false;
+
+const slugParts = Astro.params.slug?.split('/').filter(Boolean) ?? [];
+const endpoint = slugParts.length ? `${slugParts.join('/')}/` : 'fleet/work-orders/';
+const cacheKey = `data:${endpoint}`;
+
+let initialData: unknown[] | null = null;
+let initialError: string | null = null;
+
+try {
+ // SSR fetch for first paint; client island revalidates and syncs IndexedDB after hydration.
+ const payload = await fetchJson(endpoint, { method: 'GET', timeoutMs: 8000 });
+ initialData = Array.isArray(payload) ? payload : [];
+} catch (error) {
+ initialError = error instanceof Error ? error.message : 'SSR data fetch failed.';
+}
+---
+
+
+
+
+ Endpoint: {endpoint}
+
+
+
+
diff --git a/frontend/src/pages/index.astro b/frontend/src/pages/index.astro
new file mode 100644
index 0000000..ad42261
--- /dev/null
+++ b/frontend/src/pages/index.astro
@@ -0,0 +1,9 @@
+---
+export const prerender = false;
+import Layout from '../layouts/Layout.astro';
+import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
+---
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/login.astro b/frontend/src/pages/login.astro
new file mode 100644
index 0000000..cd44211
--- /dev/null
+++ b/frontend/src/pages/login.astro
@@ -0,0 +1,10 @@
+---
+import Layout from '../layouts/Layout.astro';
+import LoginForm from '../components/auth/LoginForm.jsx';
+---
+
+
+
+
+
+
diff --git a/frontend/src/pages/putni-nalozi.astro b/frontend/src/pages/putni-nalozi.astro
new file mode 100644
index 0000000..71dd3dc
--- /dev/null
+++ b/frontend/src/pages/putni-nalozi.astro
@@ -0,0 +1,9 @@
+---
+export const prerender = false;
+import Layout from '../layouts/Layout.astro';
+import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
+---
+
+
+
+
diff --git a/frontend/src/pages/putni-nalozi/racuni.astro b/frontend/src/pages/putni-nalozi/racuni.astro
new file mode 100644
index 0000000..ee86315
--- /dev/null
+++ b/frontend/src/pages/putni-nalozi/racuni.astro
@@ -0,0 +1,9 @@
+---
+export const prerender = false;
+import Layout from '../../layouts/Layout.astro';
+import WorkOrderInvoicesPdfPage from '../../components/dashboard/WorkOrderInvoicesPdfPage.jsx';
+---
+
+
+
+
diff --git a/frontend/src/pages/service-recordsssss.astro b/frontend/src/pages/service-recordsssss.astro
new file mode 100644
index 0000000..d83636b
--- /dev/null
+++ b/frontend/src/pages/service-recordsssss.astro
@@ -0,0 +1,9 @@
+---
+export const prerender = false;
+import Layout from '../layouts/Layout.astro';
+import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
+---
+
+
+
+
diff --git a/frontend/src/pages/servisni-zapisi.astro b/frontend/src/pages/servisni-zapisi.astro
new file mode 100644
index 0000000..d83636b
--- /dev/null
+++ b/frontend/src/pages/servisni-zapisi.astro
@@ -0,0 +1,9 @@
+---
+export const prerender = false;
+import Layout from '../layouts/Layout.astro';
+import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
+---
+
+
+
+
diff --git a/frontend/src/pages/vehicles.astro b/frontend/src/pages/vehicles.astro
new file mode 100644
index 0000000..7e2850d
--- /dev/null
+++ b/frontend/src/pages/vehicles.astro
@@ -0,0 +1,9 @@
+---
+export const prerender = false;
+import Layout from '../layouts/Layout.astro';
+import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
+---
+
+
+
+
diff --git a/frontend/src/pages/work-orders.astro b/frontend/src/pages/work-orders.astro
new file mode 100644
index 0000000..71dd3dc
--- /dev/null
+++ b/frontend/src/pages/work-orders.astro
@@ -0,0 +1,9 @@
+---
+export const prerender = false;
+import Layout from '../layouts/Layout.astro';
+import FleetDashboardShell from '../components/dashboard/FleetDashboardShell.jsx';
+---
+
+
+
+
diff --git a/frontend/src/services/apiClient.js b/frontend/src/services/apiClient.js
new file mode 100644
index 0000000..f1e388c
--- /dev/null
+++ b/frontend/src/services/apiClient.js
@@ -0,0 +1,133 @@
+// src/services/apiClient.js
+
+/**
+ * 🌐 CENTRALIZIRANI API KLIJENT / MREŽNI POSREDNIK (API INTERCEPTOR WRAPPER)
+ * * * [Arhitektonska uloga]:
+ * Služi kao jedinstveni apstraktni sloj (Wrapper) oko nativne pregledničke `fetch` funkcije.
+ * Njegova je zadaća izolirati kompletnu logiku mrežne komunikacije, zaglavlja i serijalizacije
+ * iz Preact UI komponenti i NanoStores skladišta, osiguravajući stopostotni DRY princip.
+ * * * [Ključne tehničke funkcionalnosti]:
+ * 1. Dinamička konfiguracija okruženja: Automatski čita `PUBLIC_API_URL` preko Vite kompajlera.
+ * 2. Pametno upravljanje zaglavljima: Automatski presreće mrežne zahtjeve i injektira
+ * `Authorization: Bearer ` zaglavlje iz klijentovog memorijskog `authStore`-a.
+ * 3. Detekcija polimorfnih paketa: Prepoznaje razliku između običnog `application/json` unosa
+ * i `FormData` objekata (slike s terena), sprječavajući korupciju mrežnog paketa.
+ * 4. Robusni parser validacijskih grešaka: Izvlači i tekstualno formatira kompleksne DRF
+ * strukture grešaka (npr. pogreške po poljima ili nizove) kako bi Toast sustav stabilno radio.
+ */
+
+import { $accessToken, setToken } from '../stores/authStore';
+
+const KLIJENT_URL = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
+const POSLUZITELJ_URL = 'http://backend:8000/api/'; // 💡 Promijeni 'backend' u točan naziv tvog Django servisa iz docker-compose.yml!
+
+const BASE_URL = typeof window === 'undefined' ? POSLUZITELJ_URL : KLIJENT_URL;
+let refreshPromise = null;
+
+if (import.meta.env.DEV) {
+ console.log(`[API_CLIENT]: Pokrenut mod. Lokacija: ${typeof window === 'undefined' ? 'SERVER (Docker)' : 'CLIENT (Browser)'} -> Endpoint: ${BASE_URL}`);
+}
+
+async function request(endpoint, options = {}) {
+ const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
+ const url = new URL(endpoint.replace(/^\/+/, ''), BASE_URL).toString();
+ const headers = { ...options.headers };
+
+ const isFormData = options.body && (
+ options.body instanceof FormData ||
+ typeof options.body.append === 'function'
+ );
+
+ if (options.body && !isFormData) {
+ headers['Content-Type'] = 'application/json';
+ }
+
+ // 🔑 PAMETNA AUTORIZACIJA: Ako je proslijeđen eksplicitni serverToken (iz Astro.cookies), koristi njega.
+ // U suprotnom, povuci iz NanoStores (klijent mod).
+ let token = options.serverToken || $accessToken.get();
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+
+ let body = options.body;
+ if (body && !isFormData && typeof body !== 'string') {
+ body = JSON.stringify(body);
+ }
+
+ const config = { ...options, headers, body };
+
+ try {
+ let response = await fetch(url, config);
+
+ // Presretač za tihi refresh (Radi samo na klijentu, jer server ne radi automatski tihi refresh)
+ if (response.status === 401 && token && !cleanEndpoint.includes('token/refresh/') && typeof window !== 'undefined') {
+ console.warn("Access token istekao. Pokrećem tihi refresh...");
+ const refreshToken = localStorage.getItem('refresh_token');
+
+ if (refreshToken) {
+ if (!refreshPromise) {
+ refreshPromise = (async () => {
+ const refreshResponse = await fetch(`${BASE_URL}token/refresh/`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refresh: refreshToken }),
+ });
+
+ if (!refreshResponse.ok) {
+ throw new Error("Sesija istekla.");
+ }
+
+ const refreshData = await refreshResponse.json();
+ const newAccessToken = refreshData.access;
+ setToken(newAccessToken, refreshData.refresh || refreshToken);
+ return newAccessToken;
+ })();
+ }
+
+ try {
+ const newAccessToken = await refreshPromise;
+ config.headers['Authorization'] = `Bearer ${newAccessToken}`;
+ response = await fetch(url, config);
+ } catch (refreshError) {
+ setToken(null);
+ window.location.href = '/login?session=expired';
+ throw refreshError;
+ } finally {
+ refreshPromise = null;
+ }
+ }
+ }
+
+ if (!response.ok) {
+ const errData = await response.json().catch(() => ({}));
+ let errorMessage = `API Greška [${response.status}]`;
+ if (response.status === 400 && typeof errData === 'object') {
+ // Ako je greška DRF validacijska, flatten-iraj je u string ili objekt
+ throw { message: Object.values(errData).flat().join(', '), details: errData, status: 400 };
+ }
+ if (errData.detail) errorMessage = errData.detail;
+ throw { message: String(errorMessage), status: response.status };
+ }
+
+ if (options.responseType === 'blob') return await response.blob();
+ if (response.status === 204 || config.method === 'DELETE') return { success: true };
+
+ return await response.json();
+ } catch (error) {
+ // 401 obrađuje tihi refresh interceptor iznad (ne logiramo — nije bug).
+ // 404 na ručno pozvanim provjerama (heartbeat, itd.) nije pogreška arhitekture.
+ const silenced = error?.status === 401 || error?.status === 404;
+ if (!silenced) {
+ console.error(`Mrežni problem na ${endpoint}:`, error);
+ }
+ throw error;
+ }
+}
+
+export const api = {
+ get: (endpoint, options) => request(endpoint, { ...options, method: 'GET' }),
+ post: (endpoint, body, options) => request(endpoint, { ...options, method: 'POST', body }),
+ put: (endpoint, body, options) => request(endpoint, { ...options, method: 'PUT', body }),
+ patch: (endpoint, body, options) => request(endpoint, { ...options, method: 'PATCH', body }),
+ delete: (endpoint, options) => request(endpoint, { ...options, method: 'DELETE' }),
+};
\ No newline at end of file
diff --git a/frontend/src/stores/authStore.js b/frontend/src/stores/authStore.js
new file mode 100644
index 0000000..e9301d6
--- /dev/null
+++ b/frontend/src/stores/authStore.js
@@ -0,0 +1,116 @@
+import { atom } from 'nanostores';
+
+function getCookie(name) {
+ if (typeof window === 'undefined') return null;
+ const value = `; ${document.cookie}`;
+ const parts = value.split(`; ${name}=`);
+ if (parts.length === 2) return parts.pop().split(';').shift();
+ return null;
+}
+
+export const $accessToken = atom(null);
+export const $user = atom(null);
+export const $authReady = atom(false);
+let authHydrated = false;
+
+export function setToken(token, refreshToken = null) {
+ if (token) {
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('access_token', token);
+ document.cookie = `access_token=${token}; path=/; max-age=86400; SameSite=Lax;`;
+
+ if (refreshToken) {
+ localStorage.setItem('refresh_token', refreshToken);
+ document.cookie = `refresh_token=${refreshToken}; path=/; max-age=604800; SameSite=Lax;`;
+ }
+ }
+ $accessToken.set(token);
+
+ if (typeof window !== 'undefined' && $user.get()?.id) {
+ import('./notificationStore.js').then(({ connectNotifications }) => connectNotifications());
+ }
+ return;
+ }
+
+ if (typeof window !== 'undefined') {
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('refresh_token');
+ localStorage.removeItem('todo_cache');
+ document.cookie = 'access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
+ document.cookie = 'refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
+ }
+ if (typeof window !== 'undefined') {
+ import('./notificationStore.js').then(({ disconnectNotifications, clearNotifications }) => {
+ disconnectNotifications();
+ clearNotifications();
+ });
+ }
+ $accessToken.set(null);
+ $user.set(null);
+}
+
+export function isAuthenticated() {
+ return !!$accessToken.get();
+}
+
+export function hydrateAuthFromStorage() {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ if (authHydrated) {
+ return;
+ }
+ authHydrated = true;
+
+ const token = getCookie('access_token') || localStorage.getItem('access_token');
+ if (token) {
+ $accessToken.set(token);
+ }
+ $authReady.set(true);
+}
+
+export async function loadCurrentUser() {
+ const token = $accessToken.get();
+ if (!token) {
+ $user.set(null);
+ return null;
+ }
+
+ const { api } = await import('../services/apiClient.js');
+ const userData = await api.get('users/me/');
+ $user.set(userData);
+ return userData;
+}
+
+export async function validateToken() {
+ const token = $accessToken.get();
+ if (!token) return false;
+
+ try {
+ await loadCurrentUser();
+ const { fetchNotifications, connectNotifications } = await import('./notificationStore.js');
+ await fetchNotifications();
+ await connectNotifications();
+ return true;
+ } catch (err) {
+ if (err?.status === 401) {
+ setToken(null);
+ if (typeof window !== 'undefined') {
+ window.location.href = '/login?session=expired';
+ }
+ }
+ return false;
+ }
+}
+
+export async function loginWithCredentials(email, password) {
+ const { api } = await import('../services/apiClient.js');
+ const response = await api.post('token/', { email, password });
+ setToken(response.access, response.refresh);
+ await loadCurrentUser();
+ return response;
+}
+
+export function logout() {
+ setToken(null);
+}
diff --git a/frontend/src/stores/clientStore.js b/frontend/src/stores/clientStore.js
new file mode 100644
index 0000000..cbe03e7
--- /dev/null
+++ b/frontend/src/stores/clientStore.js
@@ -0,0 +1,39 @@
+// src/stores/clientStore.js
+// CRUD store za CRM klijente — endpoint: /api/crm/clients/
+
+import { atom } from 'nanostores';
+import { api } from '../services/apiClient.js';
+
+export const $clients = atom([]);
+export const $clientsLoading = atom(false);
+export const $clientsError = atom(null);
+
+export async function fetchClients(signal) {
+ $clientsLoading.set(true);
+ $clientsError.set(null);
+ try {
+ const data = await api.get('crm/clients/', { signal });
+ $clients.set(Array.isArray(data) ? data : (data.results ?? []));
+ } catch (err) {
+ if (err?.name !== 'AbortError') $clientsError.set(err.message ?? 'Greška');
+ } finally {
+ $clientsLoading.set(false);
+ }
+}
+
+export async function createClient(payload) {
+ const data = await api.post('crm/clients/', payload);
+ $clients.set([...$clients.get(), data]);
+ return data;
+}
+
+export async function updateClient(id, payload) {
+ const data = await api.patch(`crm/clients/${id}/`, payload);
+ $clients.set($clients.get().map((c) => (c.id === id ? data : c)));
+ return data;
+}
+
+export async function deleteClient(id) {
+ await api.delete(`crm/clients/${id}/`);
+ $clients.set($clients.get().filter((c) => c.id !== id));
+}
diff --git a/frontend/src/stores/collectionStore.js b/frontend/src/stores/collectionStore.js
new file mode 100644
index 0000000..c5d53b3
--- /dev/null
+++ b/frontend/src/stores/collectionStore.js
@@ -0,0 +1,8 @@
+import { atom } from 'nanostores';
+
+// Pohranjujemo cijeli objekt kolekcije (uključujući račune)
+export const $currentCollection = atom(null);
+
+export function updateCollection(data) {
+ $currentCollection.set(data);
+}
\ No newline at end of file
diff --git a/frontend/src/stores/counter.js b/frontend/src/stores/counter.js
new file mode 100644
index 0000000..6d0455b
--- /dev/null
+++ b/frontend/src/stores/counter.js
@@ -0,0 +1,3 @@
+import { atom } from 'nanostores';
+
+export const $counter = atom(0);
\ No newline at end of file
diff --git a/frontend/src/stores/craneSerialStore.js b/frontend/src/stores/craneSerialStore.js
new file mode 100644
index 0000000..07b5771
--- /dev/null
+++ b/frontend/src/stores/craneSerialStore.js
@@ -0,0 +1,57 @@
+import { atom } from 'nanostores';
+
+export const $craneSerialByVehicle = atom({});
+
+function normalizeVehicleId(vehicleId) {
+ if (vehicleId == null) {
+ return '';
+ }
+ return String(vehicleId);
+}
+
+function normalizeSerial(serial) {
+ return String(serial || '').trim();
+}
+
+export function cacheCraneSerial(vehicleId, serial) {
+ const key = normalizeVehicleId(vehicleId);
+ const value = normalizeSerial(serial);
+ if (!key || !value) {
+ return;
+ }
+ $craneSerialByVehicle.set({
+ ...$craneSerialByVehicle.get(),
+ [key]: value,
+ });
+}
+
+export function getCachedCraneSerial(vehicleId) {
+ const key = normalizeVehicleId(vehicleId);
+ if (!key) {
+ return '';
+ }
+ return normalizeSerial($craneSerialByVehicle.get()[key]);
+}
+
+export function cacheCraneSerialsFromVehicles(vehicles) {
+ if (!Array.isArray(vehicles) || vehicles.length === 0) {
+ return;
+ }
+ const current = $craneSerialByVehicle.get();
+ const next = { ...current };
+ let changed = false;
+ for (const vehicle of vehicles) {
+ const key = normalizeVehicleId(vehicle?.id);
+ const value = normalizeSerial(vehicle?.crane_serial_number);
+ if (!key || !value) {
+ continue;
+ }
+ if (next[key] !== value) {
+ next[key] = value;
+ changed = true;
+ }
+ }
+ if (changed) {
+ $craneSerialByVehicle.set(next);
+ }
+}
diff --git a/frontend/src/stores/fleetDashboardStore.js b/frontend/src/stores/fleetDashboardStore.js
new file mode 100644
index 0000000..b805caa
--- /dev/null
+++ b/frontend/src/stores/fleetDashboardStore.js
@@ -0,0 +1,830 @@
+import { atom, computed } from 'nanostores';
+import { api } from '../services/apiClient';
+import { $accessToken } from './authStore';
+import { showToast } from './toastStore';
+
+const DB_NAME = 'erp-offline-db';
+const DB_VERSION = 1;
+const STORE_KV = 'kv';
+const STORE_QUEUE = 'queue';
+const STORE_SYNC_LOG = 'sync_log';
+const CACHE_KEY = 'fleet_dashboard_cache';
+const MAX_SYNC_LOG_ITEMS = 150;
+
+export const $dashboardLoading = atom(false);
+export const $dashboardError = atom(null);
+export const $cranes = atom([]);
+export const $vehicles = $cranes;
+export const $workOrders = atom([]);
+export const $serviceRecords = atom([]);
+export const $offlineQueueCount = atom(0);
+export const $syncLog = atom([]);
+
+let dbPromise = null;
+let syncListenerStarted = false;
+let isSyncInProgress = false;
+
+function isBrowser() {
+ return typeof window !== 'undefined';
+}
+
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function createTempId(prefix) {
+ return `offline-${prefix}-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
+}
+
+function saveBlobToFile(blob, filename) {
+ if (!isBrowser()) {
+ return;
+ }
+ const href = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = href;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.URL.revokeObjectURL(href);
+}
+
+function normalizeCraneList(items) {
+ if (!Array.isArray(items)) {
+ return [];
+ }
+ return items.filter((item) => {
+ if (!item || typeof item !== 'object') return false;
+ return !item.asset_type || item.asset_type === 'crane';
+ });
+}
+
+function toSerializable(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+function openDB() {
+ if (!isBrowser()) {
+ return Promise.reject(new Error('IndexedDB is unavailable on server'));
+ }
+ if (dbPromise) {
+ return dbPromise;
+ }
+
+ dbPromise = new Promise((resolve, reject) => {
+ const request = window.indexedDB.open(DB_NAME, DB_VERSION);
+
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(STORE_KV)) {
+ db.createObjectStore(STORE_KV, { keyPath: 'key' });
+ }
+ if (!db.objectStoreNames.contains(STORE_QUEUE)) {
+ db.createObjectStore(STORE_QUEUE, { keyPath: 'id', autoIncrement: true });
+ }
+ if (!db.objectStoreNames.contains(STORE_SYNC_LOG)) {
+ db.createObjectStore(STORE_SYNC_LOG, { keyPath: 'id', autoIncrement: true });
+ }
+ };
+
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error || new Error('IndexedDB open failed'));
+ });
+
+ return dbPromise;
+}
+
+function runTx(storeName, mode, action) {
+ return openDB().then((db) => new Promise((resolve, reject) => {
+ const tx = db.transaction(storeName, mode);
+ const store = tx.objectStore(storeName);
+ const result = action(store);
+ tx.oncomplete = () => resolve(result);
+ tx.onerror = () => reject(tx.error || new Error('IndexedDB transaction failed'));
+ tx.onabort = () => reject(tx.error || new Error('IndexedDB transaction aborted'));
+ }));
+}
+
+function idbGetKV(key) {
+ return openDB().then((db) => new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_KV, 'readonly');
+ const store = tx.objectStore(STORE_KV);
+ const request = store.get(key);
+ request.onsuccess = () => resolve(request.result ? request.result.value : null);
+ request.onerror = () => reject(request.error || new Error('IndexedDB get failed'));
+ }));
+}
+
+function idbSetKV(key, value) {
+ return runTx(STORE_KV, 'readwrite', (store) => {
+ store.put({ key, value: toSerializable(value), updated_at: nowIso() });
+ });
+}
+
+function idbAddQueueItem(item) {
+ return openDB().then((db) => new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_QUEUE, 'readwrite');
+ const store = tx.objectStore(STORE_QUEUE);
+ const request = store.add(toSerializable(item));
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error || new Error('IndexedDB queue add failed'));
+ }));
+}
+
+function idbGetQueueItems() {
+ return openDB().then((db) => new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_QUEUE, 'readonly');
+ const store = tx.objectStore(STORE_QUEUE);
+ const request = store.getAll();
+ request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []);
+ request.onerror = () => reject(request.error || new Error('IndexedDB queue read failed'));
+ }));
+}
+
+function idbDeleteQueueItem(id) {
+ return runTx(STORE_QUEUE, 'readwrite', (store) => {
+ store.delete(id);
+ });
+}
+
+function idbPutQueueItem(item) {
+ return runTx(STORE_QUEUE, 'readwrite', (store) => {
+ store.put(toSerializable(item));
+ });
+}
+
+function idbAddSyncLog(entry) {
+ return openDB().then((db) => new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_SYNC_LOG, 'readwrite');
+ const store = tx.objectStore(STORE_SYNC_LOG);
+ store.add(toSerializable(entry));
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error || new Error('IndexedDB sync log add failed'));
+ }));
+}
+
+function idbGetSyncLog() {
+ return openDB().then((db) => new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_SYNC_LOG, 'readonly');
+ const store = tx.objectStore(STORE_SYNC_LOG);
+ const request = store.getAll();
+ request.onsuccess = () => {
+ const rows = Array.isArray(request.result) ? request.result : [];
+ rows.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
+ resolve(rows.slice(0, MAX_SYNC_LOG_ITEMS));
+ };
+ request.onerror = () => reject(request.error || new Error('IndexedDB sync log read failed'));
+ }));
+}
+
+async function trimSyncLogIfNeeded() {
+ const rows = await idbGetSyncLog();
+ if (rows.length <= MAX_SYNC_LOG_ITEMS) {
+ return;
+ }
+ const keepIds = new Set(rows.slice(0, MAX_SYNC_LOG_ITEMS).map((row) => row.id));
+ await runTx(STORE_SYNC_LOG, 'readwrite', (store) => {
+ const request = store.openCursor();
+ request.onsuccess = () => {
+ const cursor = request.result;
+ if (!cursor) return;
+ if (!keepIds.has(cursor.value.id)) {
+ cursor.delete();
+ }
+ cursor.continue();
+ };
+ });
+}
+
+async function refreshSyncLogStore() {
+ try {
+ $syncLog.set(await idbGetSyncLog());
+ } catch {
+ $syncLog.set([]);
+ }
+}
+
+async function updateQueueCount() {
+ try {
+ const queue = await idbGetQueueItems();
+ $offlineQueueCount.set(queue.length);
+ } catch {
+ $offlineQueueCount.set(0);
+ }
+}
+
+async function persistDashboardCache() {
+ await idbSetKV(CACHE_KEY, {
+ vehicles: $vehicles.get(),
+ workOrders: $workOrders.get(),
+ serviceRecords: $serviceRecords.get(),
+ cachedAt: nowIso(),
+ });
+}
+
+async function restoreDashboardCache() {
+ try {
+ const cached = await idbGetKV(CACHE_KEY);
+ if (!cached) return false;
+ $vehicles.set(Array.isArray(cached.vehicles) ? cached.vehicles : []);
+ $workOrders.set(Array.isArray(cached.workOrders) ? cached.workOrders : []);
+ $serviceRecords.set(Array.isArray(cached.serviceRecords) ? cached.serviceRecords : []);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function replaceTempItem(entity, tempId, serverItem) {
+ if (entity === 'work_orders') {
+ $workOrders.set($workOrders.get().map((item) => (item.id === tempId ? serverItem : item)));
+ } else if (entity === 'service_records') {
+ $serviceRecords.set($serviceRecords.get().map((item) => (item.id === tempId ? serverItem : item)));
+ } else if (entity === 'vehicles') {
+ $vehicles.set($vehicles.get().map((item) => (item.id === tempId ? serverItem : item)));
+ }
+}
+
+function replaceWorkOrderItem(workOrderId, nextValue) {
+ $workOrders.set(
+ $workOrders.get().map((item) => (String(item.id) === String(workOrderId) ? nextValue : item))
+ );
+}
+
+function appendLocalDraft(entity, payload, tempId) {
+ const base = {
+ ...payload,
+ id: tempId,
+ is_offline_draft: true,
+ created_at: payload.created_at || nowIso(),
+ };
+
+ if (entity === 'work_orders') {
+ $workOrders.set([base, ...$workOrders.get()]);
+ } else if (entity === 'service_records') {
+ $serviceRecords.set([base, ...$serviceRecords.get()]);
+ } else if (entity === 'vehicles') {
+ $vehicles.set([base, ...$vehicles.get()]);
+ }
+}
+
+function describeSyncAction(item) {
+ if (item.entity === 'work_orders') return 'Putni nalog';
+ if (item.entity === 'service_records') return 'Servisni zapis';
+ if (item.entity === 'vehicles' || item.entity === 'cranes') return 'Dizalica';
+ return 'Zapis';
+}
+
+async function logSync(item, status, detail = '') {
+ await idbAddSyncLog({
+ entity: item.entity,
+ endpoint: item.endpoint,
+ method: item.method,
+ status,
+ detail,
+ created_at: nowIso(),
+ });
+ await trimSyncLogIfNeeded();
+ await refreshSyncLogStore();
+}
+
+async function processOfflineQueue() {
+ if (!isBrowser() || isSyncInProgress || !navigator.onLine || !$accessToken.get()) {
+ return;
+ }
+
+ const queue = await idbGetQueueItems();
+ if (!queue.length) {
+ await updateQueueCount();
+ return;
+ }
+
+ isSyncInProgress = true;
+
+ for (const item of queue.sort((a, b) => a.id - b.id)) {
+ try {
+ let response = null;
+ if (item.method === 'POST') {
+ response = await api.post(item.endpoint, item.body);
+ } else if (item.method === 'PATCH') {
+ response = await api.patch(item.endpoint, item.body);
+ } else if (item.method === 'PUT') {
+ response = await api.put(item.endpoint, item.body);
+ }
+
+ if (response && item.tempId && item.entity) {
+ replaceTempItem(item.entity, item.tempId, response);
+ } else if (response && item.entity === 'work_orders' && item.targetId) {
+ replaceWorkOrderItem(item.targetId, response);
+ }
+ await idbDeleteQueueItem(item.id);
+ await logSync(item, 'success', `${describeSyncAction(item)} sinkroniziran.`);
+ } catch (error) {
+ if (error?.status === 401) {
+ await logSync(item, 'failed', 'Sinkronizacija zaustavljena: potrebna prijava.');
+ break;
+ }
+ const detail = error?.message || 'Nepoznata greška sinkronizacije.';
+ await logSync(item, 'failed', detail);
+ }
+ }
+
+ await persistDashboardCache();
+ await updateQueueCount();
+ isSyncInProgress = false;
+}
+
+function startOfflineSyncListeners() {
+ if (!isBrowser() || syncListenerStarted) return;
+ syncListenerStarted = true;
+ updateQueueCount();
+ refreshSyncLogStore();
+
+ window.addEventListener('online', async () => {
+ await processOfflineQueue();
+ await fetchFleetDashboardData({ silent: true });
+ });
+}
+
+async function createWithOfflineFallback(entity, endpoint, body) {
+ const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get();
+ if (canSendNow) {
+ try {
+ const response = await api.post(endpoint, body);
+ if (entity === 'service_records') {
+ try {
+ const { fetchNotifications } = await import('./notificationStore.js');
+ await fetchNotifications();
+ } catch (notificationError) {
+ console.warn('Neuspjelo osvježavanje notifikacija nakon kreiranja servisnog zapisa.', notificationError);
+ }
+ }
+ await logSync(
+ { entity, endpoint, method: 'POST' },
+ 'success',
+ `${describeSyncAction({ entity })} poslan odmah (online).`
+ );
+ return response;
+ } catch (error) {
+ if (error?.status && error.status !== 0 && error.status !== 503) {
+ await logSync(
+ { entity, endpoint, method: 'POST' },
+ 'failed',
+ `Online slanje nije uspjelo: ${error.message || 'greška'}.`
+ );
+ throw error;
+ }
+ }
+ }
+
+ const tempId = createTempId(entity);
+ appendLocalDraft(entity, body, tempId);
+ await idbAddQueueItem({
+ tempId,
+ entity,
+ endpoint,
+ method: 'POST',
+ body,
+ queued_at: nowIso(),
+ });
+ await updateQueueCount();
+ await persistDashboardCache();
+ await logSync(
+ { entity, endpoint, method: 'POST' },
+ 'queued',
+ `${describeSyncAction({ entity })} spremljen offline i čeka internet vezu.`
+ );
+ showToast('Podatak je spremljen offline i sinkronizirat će se kad se veza vrati.', 'warning');
+ return { id: tempId, ...body, is_offline_draft: true };
+}
+
+async function queueWorkOrderUpdate(workOrderId, body) {
+ const queue = await idbGetQueueItems();
+ const queuedCreate = queue.find(
+ (item) =>
+ item.entity === 'work_orders' &&
+ item.method === 'POST' &&
+ String(item.tempId) === String(workOrderId)
+ );
+
+ if (queuedCreate) {
+ queuedCreate.body = {
+ ...queuedCreate.body,
+ ...body,
+ };
+ await idbPutQueueItem(queuedCreate);
+ return;
+ }
+
+ await idbAddQueueItem({
+ entity: 'work_orders',
+ endpoint: `fleet/work-orders/${workOrderId}/`,
+ method: 'PATCH',
+ body,
+ targetId: workOrderId,
+ queued_at: nowIso(),
+ });
+}
+
+export const $workOrdersWithVehicle = computed(
+ [$workOrders, $vehicles],
+ (workOrders, vehicles) => {
+ const vehicleById = new Map(vehicles.map((vehicle) => [vehicle.id, vehicle]));
+ return workOrders.map((workOrder) => ({
+ ...workOrder,
+ vehicleInfo: vehicleById.get(workOrder.vehicle) || null,
+ craneInfo: vehicleById.get(workOrder.crane || workOrder.vehicle) || null,
+ }));
+ }
+);
+
+export const $dashboardStats = computed(
+ [$workOrders, $serviceRecords],
+ (workOrders, serviceRecords) => {
+ const today = new Date().toISOString().slice(0, 10);
+ const openWorkOrders = workOrders.filter((workOrder) => workOrder.status !== 'closed').length;
+ const closedWorkOrders = workOrders.filter((workOrder) => workOrder.status === 'closed').length;
+ const servicesToday = serviceRecords.filter((record) => record.service_date === today).length;
+ const warnings = serviceRecords.filter((record) => {
+ if (record.next_service_due_at == null || record.mileage == null) return false;
+ return Number(record.next_service_due_at) - Number(record.mileage) <= 1000;
+ }).length;
+
+ return {
+ openWorkOrders,
+ closedWorkOrders,
+ servicesToday,
+ warnings,
+ };
+ }
+);
+
+export async function ensureVehiclesCatalog() {
+ if (!$accessToken.get()) {
+ return $vehicles.get();
+ }
+
+ if ($vehicles.get().length > 0) {
+ return $vehicles.get();
+ }
+
+ try {
+ const vehicles = await api.get('fleet/cranes/');
+ $vehicles.set(normalizeCraneList(vehicles));
+ await persistDashboardCache();
+ return $vehicles.get();
+ } catch (error) {
+ if (error?.status === 401) {
+ $dashboardError.set('Sesija je istekla. Prijavite se ponovno.');
+ return $vehicles.get();
+ }
+ throw error;
+ }
+}
+
+export async function fetchFleetDashboardData(options = {}) {
+ const { silent = false } = options;
+ startOfflineSyncListeners();
+ if (!silent) $dashboardLoading.set(true);
+ $dashboardError.set(null);
+
+ const token = $accessToken.get();
+ if (!token) {
+ await restoreDashboardCache();
+ await updateQueueCount();
+ if (!silent) $dashboardLoading.set(false);
+ return;
+ }
+
+ try {
+ const [workOrders, serviceRecords, vehicles] = await Promise.all([
+ api.get('fleet/work-orders/'),
+ api.get('fleet/service-records/'),
+ api.get('fleet/cranes/'),
+ ]);
+
+ $workOrders.set(Array.isArray(workOrders) ? workOrders : []);
+ $serviceRecords.set(Array.isArray(serviceRecords) ? serviceRecords : []);
+ $vehicles.set(normalizeCraneList(vehicles));
+ await persistDashboardCache();
+ await processOfflineQueue();
+ } catch (error) {
+ const cacheLoaded = await restoreDashboardCache();
+ if (error?.status === 401) {
+ if (!cacheLoaded) {
+ $dashboardError.set('Prijavite se za prikaz podataka.');
+ }
+ } else if (!cacheLoaded) {
+ $dashboardError.set('Neuspješno dohvaćanje dashboard podataka.');
+ showToast('Trenutno nema mreže. Prikazani su lokalno spremljeni podaci ako postoje.', 'warning');
+ }
+ } finally {
+ await updateQueueCount();
+ await refreshSyncLogStore();
+ if (!silent) $dashboardLoading.set(false);
+ }
+}
+
+export async function createWorkOrder(data) {
+ return createWithOfflineFallback('work_orders', 'fleet/work-orders/', data);
+}
+
+export async function updateWorkOrder(workOrderId, changes) {
+ const existing = $workOrders.get().find((item) => String(item.id) === String(workOrderId));
+ if (!existing) {
+ throw new Error('Work order nije pronađen u lokalnom stanju.');
+ }
+
+ const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get();
+ if (canSendNow) {
+ try {
+ const response = await api.patch(`fleet/work-orders/${workOrderId}/`, changes);
+ replaceWorkOrderItem(workOrderId, response);
+ await persistDashboardCache();
+ await logSync(
+ { entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' },
+ 'success',
+ 'Ažuriranje naloga poslano odmah (online).'
+ );
+ return response;
+ } catch (error) {
+ if (error?.status && error.status !== 0 && error.status !== 503) {
+ await logSync(
+ { entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' },
+ 'failed',
+ `Online update nije uspio: ${error.message || 'greška'}.`
+ );
+ throw error;
+ }
+ }
+ }
+
+ const optimistic = {
+ ...existing,
+ ...changes,
+ is_offline_draft: true,
+ };
+ replaceWorkOrderItem(workOrderId, optimistic);
+ await queueWorkOrderUpdate(workOrderId, changes);
+ await updateQueueCount();
+ await persistDashboardCache();
+ await logSync(
+ { entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' },
+ 'queued',
+ 'Ažuriranje naloga spremljeno offline.'
+ );
+ showToast('Ažuriranje naloga spremljeno offline i čeka sinkronizaciju.', 'warning');
+ return optimistic;
+}
+
+export async function createServiceRecord(data) {
+ return createWithOfflineFallback('service_records', 'fleet/service-records/', data);
+}
+
+export async function updateServiceRecord(serviceRecordId, changes) {
+ const response = await api.patch(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/`, changes);
+ $serviceRecords.set(
+ $serviceRecords.get().map((item) => (
+ String(item.id) === String(serviceRecordId) ? response : item
+ ))
+ );
+ await persistDashboardCache();
+ showToast('Servisni zapis je uspješno ažuriran.', 'success');
+ return response;
+}
+
+export async function uploadServicePhoto(serviceRecordId, imageFile, description = '', signal = null) {
+ const formData = new FormData();
+ formData.append('service_record', serviceRecordId);
+ formData.append('image', imageFile);
+ if (description.trim()) {
+ formData.append('description', description.trim());
+ }
+ const options = signal ? { signal } : {};
+ const response = await api.post('fleet/service-photos/', formData, options);
+ showToast('Fotografija je uspješno učitana.', 'success');
+ return response;
+}
+
+export async function uploadServiceAttachment(serviceRecordId, file, description = '', signal = null) {
+ const formData = new FormData();
+ formData.append('service_record', serviceRecordId);
+ formData.append('file', file);
+ if (description.trim()) {
+ formData.append('description', description.trim());
+ }
+ const options = signal ? { signal } : {};
+ const response = await api.post('fleet/service-attachments/', formData, options);
+ showToast('Datoteka je uspješno učitana.', 'success');
+ return response;
+}
+
+export async function fetchServiceRecordFiles(serviceRecordId) {
+ if (!serviceRecordId) {
+ return { photos: [], attachments: [] };
+ }
+
+ const [photos, attachments] = await Promise.all([
+ api.get(`fleet/service-photos/?service_record_id=${encodeURIComponent(serviceRecordId)}`),
+ api.get(`fleet/service-attachments/?service_record_id=${encodeURIComponent(serviceRecordId)}`),
+ ]);
+
+ const normalizedPhotos = (Array.isArray(photos) ? photos : []).map((photo) => {
+ if (!photo?.id) {
+ return photo;
+ }
+ const query = new URLSearchParams({
+ w: '1280',
+ q: '75',
+ fmt: 'webp',
+ });
+ return {
+ ...photo,
+ optimized_url: `fleet/service-photos/${encodeURIComponent(photo.id)}/optimized/?${query.toString()}`,
+ };
+ });
+
+ return {
+ photos: normalizedPhotos,
+ attachments: Array.isArray(attachments) ? attachments : [],
+ };
+}
+
+export async function fetchWorkOrderImages(workOrderId, options = {}) {
+ if (!workOrderId) {
+ return [];
+ }
+
+ const width = Number(options.w) > 0 ? Number(options.w) : 1280;
+ const quality = Number(options.q) > 0 ? Number(options.q) : 75;
+ const format = (options.fmt || 'webp').toLowerCase();
+
+ const query = new URLSearchParams({
+ w: String(width),
+ q: String(quality),
+ fmt: format,
+ });
+ const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/images/?${query.toString()}`);
+ return Array.isArray(payload?.images) ? payload.images : [];
+}
+
+export async function fetchWorkOrderInvoices(workOrderId) {
+ if (!workOrderId) {
+ return [];
+ }
+ const payload = await api.get(`fleet/work-order-invoices/?work_order_id=${encodeURIComponent(workOrderId)}`);
+ return Array.isArray(payload) ? payload : [];
+}
+
+export async function fetchWorkOrderById(workOrderId) {
+ if (!workOrderId) {
+ return null;
+ }
+ return await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/`);
+}
+
+export async function fetchWorkOrderTaskServiceContext(workOrderId) {
+ if (!workOrderId) {
+ return { tasks: [] };
+ }
+ const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/task-service-context/`);
+ return payload && typeof payload === 'object' ? payload : { tasks: [] };
+}
+
+export async function createWorkOrderInvoice(workOrderId, data = {}, signal = null) {
+ if (!workOrderId) {
+ throw new Error('Work order ID je obavezan.');
+ }
+ const formData = new FormData();
+ formData.append('work_order', workOrderId);
+ formData.append('naziv_racuna', String(data.naziv_racuna || '').trim());
+ if (data.lokacija != null) {
+ formData.append('lokacija', String(data.lokacija).trim());
+ }
+ if (data.datum) {
+ formData.append('datum', data.datum);
+ }
+ if (data.opis != null) {
+ formData.append('opis', String(data.opis).trim());
+ }
+ if (data.imageFile instanceof File) {
+ formData.append('image', data.imageFile);
+ }
+ const options = signal ? { signal } : {};
+ const response = await api.post('fleet/work-order-invoices/', formData, options);
+ showToast('Račun je uspješno spremljen.', 'success');
+ return response;
+}
+
+export async function downloadWorkOrderPdf(workOrderId) {
+ if (!workOrderId) {
+ throw new Error('Work order ID je obavezan.');
+ }
+ showToast('Kreiran je zahtjev za PDF putnog naloga.', 'info');
+ const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/pdf/`, { responseType: 'blob' });
+ saveBlobToFile(blob, `${workOrderId}.work-order.pdf`);
+ showToast('PDF putnog naloga je preuzet.', 'success');
+}
+
+export async function downloadWorkOrderInvoicesPdf(workOrderId) {
+ if (!workOrderId) {
+ throw new Error('Work order ID je obavezan.');
+ }
+ showToast('Kreiran je zahtjev za PDF računa putnog naloga.', 'info');
+ const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-pdf/`, { responseType: 'blob' });
+ saveBlobToFile(blob, `${workOrderId}.work-order-invoices.pdf`);
+ showToast('PDF računa putnog naloga je preuzet.', 'success');
+}
+
+export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
+ if (!workOrderId) {
+ throw new Error('Work order ID je obavezan.');
+ }
+ showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info');
+ const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/`, { responseType: 'blob' });
+ saveBlobToFile(blob, `${workOrderId}.work-order-service-records.pdf`);
+ showToast('PDF servisnih zapisa putnog naloga je preuzet.', 'success');
+}
+
+export function openWorkOrderInvoicesPdfPage(workOrderId) {
+ if (!isBrowser() || !workOrderId) {
+ return;
+ }
+ const url = `/putni-nalozi/racuni?workOrderId=${encodeURIComponent(workOrderId)}`;
+ window.open(url, '_blank', 'noopener,noreferrer');
+}
+
+export async function fetchTeamMembers() {
+ const members = await api.get('users/team-members/');
+ return Array.isArray(members) ? members : [];
+}
+
+export async function sendWorkOrderEmail(workOrderId, payload = {}, options = {}) {
+ if (!workOrderId) {
+ throw new Error('Work order ID je obavezan.');
+ }
+ const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/send-email/`, payload);
+ if (options.toast !== false) {
+ showToast('Email za putni nalog je poslan.', 'success');
+ }
+ return response;
+}
+
+export async function downloadServiceRecordPdf(serviceRecordId) {
+ if (!serviceRecordId) {
+ throw new Error('Service record ID je obavezan.');
+ }
+ const blob = await api.get(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/pdf/`, { responseType: 'blob' });
+ saveBlobToFile(blob, `${serviceRecordId}.service-record.pdf`);
+ showToast('PDF servisnog zapisa je preuzet.', 'success');
+}
+
+export async function sendServiceRecordEmail(serviceRecordId, payload = {}, options = {}) {
+ if (!serviceRecordId) {
+ throw new Error('Service record ID je obavezan.');
+ }
+ const response = await api.post(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/send-email/`, payload);
+ if (options.toast !== false) {
+ showToast('Email za servisni zapis je poslan.', 'success');
+ }
+ return response;
+}
+
+export async function createVehicle(data) {
+ return createWithOfflineFallback('cranes', 'fleet/cranes/', data);
+}
+
+export async function fetchVehicleById(vehicleId) {
+ if (!vehicleId) {
+ return null;
+ }
+ return await api.get(`fleet/cranes/${encodeURIComponent(vehicleId)}/`);
+}
+
+export async function updateVehicle(vehicleId, changes) {
+ if (!vehicleId) {
+ throw new Error('ID dizalice je obavezan.');
+ }
+ const existing = $vehicles.get().find((item) => String(item.id) === String(vehicleId));
+ if (!existing) {
+ throw new Error('Dizalica nije pronađena u lokalnom stanju.');
+ }
+ const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get();
+ if (!canSendNow) {
+ throw new Error('Uređivanje podataka dizalice trenutno nije dostupno offline.');
+ }
+
+ const response = await api.patch(`fleet/cranes/${encodeURIComponent(vehicleId)}/`, changes);
+ $vehicles.set(
+ $vehicles.get().map((item) => (
+ String(item.id) === String(vehicleId) ? response : item
+ ))
+ );
+ await persistDashboardCache();
+ return response;
+}
+
+export async function triggerOfflineSync() {
+ await processOfflineQueue();
+}
diff --git a/frontend/src/stores/invoiceStore.js b/frontend/src/stores/invoiceStore.js
new file mode 100644
index 0000000..e32c16e
--- /dev/null
+++ b/frontend/src/stores/invoiceStore.js
@@ -0,0 +1,44 @@
+// src/stores/invoiceStore.js
+import { atom } from 'nanostores';
+import { api } from '../services/apiClient';
+import { showToast } from './toastStore';
+
+export const $invoices = atom([]);
+export const $clients = atom([]);
+export const $invoicesLoading = atom(false);
+
+export async function fetchClients() {
+ try {
+ const data = await api.get('invoicing/clients/');
+ $clients.set(Array.isArray(data) ? data : (data?.results ?? []));
+ } catch {
+ // Klijenti su opcionalni — ne blokiramo UI ako nisu dostupni
+ }
+}
+
+export async function fetchInvoices() {
+ $invoicesLoading.set(true);
+ try {
+ const data = await api.get('invoicing/invoices/');
+ $invoices.set(Array.isArray(data) ? data : (data?.results ?? []));
+ } catch (error) {
+ if (error?.status !== 401) {
+ showToast('Greška pri dohvatu faktura.', 'error');
+ }
+ } finally {
+ $invoicesLoading.set(false);
+ }
+}
+
+export async function createInvoice(data) {
+ const response = await api.post('invoicing/invoices/', data);
+ $invoices.set([response, ...$invoices.get()]);
+ showToast(`Faktura ${response.invoice_number} je uspješno kreirana.`, 'success');
+ return response;
+}
+
+export async function createTransaction(data) {
+ const response = await api.post('invoicing/transaction/', data);
+ showToast('Plaćanje je zabilježeno.', 'success');
+ return response;
+}
diff --git a/frontend/src/stores/networkStore.js b/frontend/src/stores/networkStore.js
new file mode 100644
index 0000000..5194772
--- /dev/null
+++ b/frontend/src/stores/networkStore.js
@@ -0,0 +1,41 @@
+import { atom } from 'nanostores';
+
+export const $isOffline = atom(false);
+
+const API_BASE = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
+
+// Heartbeat endpoint: OPTIONS /api/token/
+// DRF odgovara bez 405 greške i dovoljno je za provjeru dostupnosti backenda.
+const HEARTBEAT_URL = new URL('token/', API_BASE).toString();
+
+async function provjeriStvarnuVezu() {
+ try {
+ const response = await fetch(HEARTBEAT_URL, {
+ method: 'OPTIONS',
+ cache: 'no-store',
+ });
+
+ // Svaki HTTP odgovor (200, 204, 401...) = server živ.
+ // Samo 5xx ili network error = offline.
+ if (response.status >= 500) {
+ throw new Error('Server nedostupan');
+ }
+
+ $isOffline.set(false);
+ } catch {
+ $isOffline.set(true);
+ }
+}
+
+if (typeof window !== 'undefined') {
+ window.addEventListener('online', () => {
+ provjeriStvarnuVezu();
+ });
+
+ window.addEventListener('offline', () => {
+ $isOffline.set(true);
+ });
+
+ provjeriStvarnuVezu();
+ setInterval(provjeriStvarnuVezu, 10000);
+}
\ No newline at end of file
diff --git a/frontend/src/stores/notificationStore.js b/frontend/src/stores/notificationStore.js
new file mode 100644
index 0000000..40880f3
--- /dev/null
+++ b/frontend/src/stores/notificationStore.js
@@ -0,0 +1,149 @@
+import { atom, computed } from 'nanostores';
+import Pusher from 'pusher-js';
+import { api } from '../services/apiClient';
+import { $accessToken, $user } from './authStore';
+import { showToast } from './toastStore';
+
+export const $notifications = atom([]);
+export const $isRealtimeConnected = atom(false);
+export const $unreadNotifications = computed(
+ $notifications,
+ (notifications) => notifications.filter((notification) => !notification.is_read).length
+);
+
+let pusherClient = null;
+let userChannel = null;
+
+function getPusherConfig() {
+ const key = import.meta.env.PUBLIC_PUSHER_KEY || __PUSHER_KEY__;
+ const cluster = import.meta.env.PUBLIC_PUSHER_CLUSTER || __PUSHER_CLUSTER__;
+ const apiUrl = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
+ const authEndpoint = new URL('fleet/pusher-auth/', apiUrl).toString();
+
+ if (!key || !cluster) {
+ return null;
+ }
+
+ return { key, cluster, authEndpoint };
+}
+
+export async function fetchNotifications() {
+ try {
+ const data = await api.get('fleet/notifications/');
+ $notifications.set(Array.isArray(data) ? data : []);
+ } catch (error) {
+ console.error('Greška pri dohvaćanju notifikacija:', error);
+ }
+}
+
+export async function markNotificationAsRead(notificationId) {
+ try {
+ const updated = await api.post(`fleet/notifications/${notificationId}/mark-read/`);
+ $notifications.set(
+ $notifications.get().map((notification) =>
+ notification.id === notificationId ? { ...notification, ...updated } : notification
+ )
+ );
+ } catch (error) {
+ showToast('Neuspješno označavanje notifikacije kao pročitane.', 'error');
+ throw error;
+ }
+}
+
+export async function markAllNotificationsAsRead() {
+ try {
+ await api.post('fleet/notifications/mark-all-read/');
+ $notifications.set(
+ $notifications.get().map((notification) => ({
+ ...notification,
+ is_read: true,
+ }))
+ );
+ } catch (error) {
+ showToast('Neuspješno označavanje svih notifikacija kao pročitanih.', 'error');
+ throw error;
+ }
+}
+
+function attachChannelHandlers(channel) {
+ channel.bind('vehicle.notification.created', (payload) => {
+ const next = {
+ id: payload.id,
+ title: payload.title,
+ message: payload.message,
+ level: payload.level || 'info',
+ is_read: payload.is_read ?? false,
+ is_sent: false,
+ created_at: payload.created_at || new Date().toISOString(),
+ metadata: payload.metadata || null,
+ };
+
+ const existing = $notifications.get();
+ if (existing.some((notification) => notification.id === next.id)) {
+ return;
+ }
+
+ $notifications.set([next, ...existing]);
+ showToast(next.title, next.level === 'warning' || next.level === 'critical' ? 'error' : 'success');
+ });
+}
+
+export async function connectNotifications() {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const user = $user.get();
+ const token = $accessToken.get();
+ const pusherConfig = getPusherConfig();
+
+ if (!user?.id || !token || !pusherConfig) {
+ return;
+ }
+
+ if (userChannel) {
+ return;
+ }
+
+ pusherClient = new Pusher(pusherConfig.key, {
+ cluster: pusherConfig.cluster,
+ channelAuthorization: {
+ endpoint: pusherConfig.authEndpoint,
+ transport: 'ajax',
+ headersProvider: () => {
+ const currentToken = $accessToken.get();
+ return currentToken ? { Authorization: `Bearer ${currentToken}` } : {};
+ },
+ },
+ });
+
+ const channelName = `private-user-${user.id}`;
+ userChannel = pusherClient.subscribe(channelName);
+ attachChannelHandlers(userChannel);
+
+ pusherClient.connection.bind('connected', () => {
+ $isRealtimeConnected.set(true);
+ });
+ pusherClient.connection.bind('disconnected', () => {
+ $isRealtimeConnected.set(false);
+ });
+}
+
+export function disconnectNotifications() {
+ if (userChannel && pusherClient) {
+ pusherClient.unsubscribe(userChannel.name);
+ }
+
+ userChannel = null;
+
+ if (pusherClient) {
+ pusherClient.disconnect();
+ }
+
+ pusherClient = null;
+ $isRealtimeConnected.set(false);
+}
+
+export function clearNotifications() {
+ $notifications.set([]);
+}
diff --git a/frontend/src/stores/racunStore.js b/frontend/src/stores/racunStore.js
new file mode 100644
index 0000000..3f7d10f
--- /dev/null
+++ b/frontend/src/stores/racunStore.js
@@ -0,0 +1,20 @@
+// src/stores/racunStore.js
+// NAPOMENA: Stari endpoint 'todo/racuni/' je zamijenjen s 'invoicing/invoices/'
+import { atom } from 'nanostores';
+import { api } from '../services/apiClient';
+import { showToast } from './toastStore';
+
+export const $racuni = atom([]);
+export const $isLeapLoading = atom(false);
+
+export async function fetchRacuneZaKlijenta(clientId) {
+ if (!clientId) return;
+ try {
+ const data = await api.get(`invoicing/invoices/?client=${clientId}`);
+ $racuni.set(Array.isArray(data) ? data : (data?.results ?? []));
+ } catch (error) {
+ if (error?.status !== 401 && error?.status !== 404) {
+ showToast('Greška pri dohvatu faktura.', 'error');
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/stores/serviceContextStore.js b/frontend/src/stores/serviceContextStore.js
new file mode 100644
index 0000000..16ed715
--- /dev/null
+++ b/frontend/src/stores/serviceContextStore.js
@@ -0,0 +1,85 @@
+// src/stores/serviceContextStore.js
+//
+// Reaktivni store za "Servisni kontekst" — odabrani kupac + vozilo.
+// Atomi su inicijalno null; pozovi hydrateServiceContext() unutar useEffect.
+// ServiceContextSelector.jsx i FleetDashboardShell.jsx dijele isti izvor istine.
+
+import { atom } from 'nanostores';
+
+const LS_CLIENT = 'topbar_selected_client';
+const LS_VEHICLE = 'topbar_selected_vehicle';
+
+export const $selectedClientId = atom(null);
+export const $selectedVehicleId = atom(null);
+
+function emitServiceContextChanged(clientId, vehicleId) {
+ if (typeof window === 'undefined') return;
+ window.dispatchEvent(new CustomEvent('service-context:changed', {
+ detail: {
+ clientId: clientId || null,
+ vehicleId: vehicleId || null,
+ },
+ }));
+}
+
+/** Čita localStorage i puni atome. Idempotentno — sigurno pozvati više puta. */
+export function hydrateServiceContext() {
+ if (typeof window === 'undefined') return;
+ const client = localStorage.getItem(LS_CLIENT);
+ const vehicle = localStorage.getItem(LS_VEHICLE);
+ $selectedClientId.set(client || null);
+ $selectedVehicleId.set(vehicle || null);
+ emitServiceContextChanged(client || null, vehicle || null);
+}
+
+/** Postavi novog kupca i resetiraj odabrano vozilo. */
+export function setSelectedClient(id) {
+ $selectedClientId.set(id || null);
+ $selectedVehicleId.set(null);
+ if (id) {
+ localStorage.setItem(LS_CLIENT, id);
+ } else {
+ localStorage.removeItem(LS_CLIENT);
+ }
+ localStorage.removeItem(LS_VEHICLE);
+ emitServiceContextChanged(id || null, null);
+}
+
+/** Postavi odabrano vozilo. */
+export function setSelectedVehicle(id) {
+ $selectedVehicleId.set(id || null);
+ if (id) {
+ localStorage.setItem(LS_VEHICLE, id);
+ } else {
+ localStorage.removeItem(LS_VEHICLE);
+ }
+ emitServiceContextChanged($selectedClientId.get(), id || null);
+}
+
+/** Postavi cijeli servisni kontekst odjednom (kupac + vozilo). */
+export function setServiceContext(clientId, vehicleId) {
+ $selectedClientId.set(clientId || null);
+ $selectedVehicleId.set(vehicleId || null);
+
+ if (clientId) {
+ localStorage.setItem(LS_CLIENT, clientId);
+ } else {
+ localStorage.removeItem(LS_CLIENT);
+ }
+
+ if (vehicleId) {
+ localStorage.setItem(LS_VEHICLE, vehicleId);
+ } else {
+ localStorage.removeItem(LS_VEHICLE);
+ }
+ emitServiceContextChanged(clientId || null, vehicleId || null);
+}
+
+/** Poništi cijeli kontekst. */
+export function clearServiceContext() {
+ $selectedClientId.set(null);
+ $selectedVehicleId.set(null);
+ localStorage.removeItem(LS_CLIENT);
+ localStorage.removeItem(LS_VEHICLE);
+ emitServiceContextChanged(null, null);
+}
diff --git a/frontend/src/stores/taskStore.js b/frontend/src/stores/taskStore.js
new file mode 100644
index 0000000..8bbe41c
--- /dev/null
+++ b/frontend/src/stores/taskStore.js
@@ -0,0 +1,63 @@
+// src/stores/taskStore.js
+import { atom } from 'nanostores';
+import { api } from '../services/apiClient';
+import { showToast } from './toastStore';
+
+export const $tasks = atom([]);
+export const $tasksLoading = atom(false);
+
+const STATUS_LABELS = {
+ aktivan: 'Aktivan',
+ servis: 'U tijeku',
+ neaktivan: 'Otkazano',
+};
+
+export function getStatusLabel(status) {
+ return STATUS_LABELS[status] ?? status;
+}
+
+export function isTaskActive(task) {
+ const status = String(task?.status || '').toLowerCase();
+ return status === 'aktivan' || status === 'servis';
+}
+
+export async function fetchTasks() {
+ $tasksLoading.set(true);
+ try {
+ const data = await api.get('tasks/tasks/');
+ $tasks.set(Array.isArray(data) ? data : (data?.results ?? []));
+ } catch (error) {
+ if (error?.status !== 401) {
+ showToast('Greška pri dohvatu zadataka.', 'error');
+ }
+ } finally {
+ $tasksLoading.set(false);
+ }
+}
+
+export async function createTask(data) {
+ const response = await api.post('tasks/tasks/', data);
+ $tasks.set([response, ...$tasks.get()]);
+ showToast('Zadatak je uspješno kreiran.', 'success');
+ return response;
+}
+
+export async function updateTaskStatus(taskId, status) {
+ const response = await api.patch(`tasks/tasks/${taskId}/`, { status });
+ $tasks.set($tasks.get().map((t) => (String(t.id) === String(taskId) ? response : t)));
+ showToast(`Status zadatka promijenjen u: ${getStatusLabel(status)}`, 'success');
+ return response;
+}
+
+export async function updateTask(taskId, changes) {
+ const response = await api.patch(`tasks/tasks/${taskId}/`, changes);
+ $tasks.set($tasks.get().map((t) => (String(t.id) === String(taskId) ? response : t)));
+ showToast('Task je uspješno ažuriran.', 'success');
+ return response;
+}
+
+export async function deleteTask(taskId) {
+ await api.delete(`tasks/tasks/${taskId}/`);
+ $tasks.set($tasks.get().filter((t) => String(t.id) !== String(taskId)));
+ showToast('Zadatak je deaktiviran.', 'success');
+}
diff --git a/frontend/src/stores/toastStore.js b/frontend/src/stores/toastStore.js
new file mode 100644
index 0000000..a1a086e
--- /dev/null
+++ b/frontend/src/stores/toastStore.js
@@ -0,0 +1,41 @@
+// src/stores/toastStore.js
+import { atom } from 'nanostores';
+
+export const $toast = atom(null);
+export const $toasts = atom([]);
+
+function getToastList() {
+ const current = $toasts.get();
+ return Array.isArray(current) ? current : [];
+}
+
+export function showToast(message, type = 'error', timeout = 4500) {
+ const toast = { message, type, timeout, id: Date.now() + Math.random() };
+ $toast.set(toast);
+ $toasts.set([toast, ...getToastList()].slice(0, 5));
+ return toast.id;
+}
+
+export function clearToast(id = null) {
+ if (id == null) {
+ const items = getToastList();
+ if (items.length <= 1) {
+ $toast.set(null);
+ $toasts.set([]);
+ return;
+ }
+ const next = items.slice(1);
+ $toasts.set(next);
+ $toast.set(next[0] ?? null);
+ return;
+ }
+
+ const next = getToastList().filter((item) => item.id !== id);
+ $toasts.set(next);
+ $toast.set(next[0] ?? null);
+}
+
+export function clearAllToasts() {
+ $toast.set(null);
+ $toasts.set([]);
+}
\ No newline at end of file
diff --git a/frontend/src/stores/todoStore.js b/frontend/src/stores/todoStore.js
new file mode 100644
index 0000000..4dfebba
--- /dev/null
+++ b/frontend/src/stores/todoStore.js
@@ -0,0 +1,242 @@
+// src/stores/todoStore.js
+import { atom, computed } from 'nanostores';
+import { api } from '../services/apiClient';
+import { showToast } from './toastStore';
+
+export const $todos = atom([]);
+export const $collections = atom([]);
+export const $collectionIdFilter = atom('');
+export const $refreshSignal = atom(0);
+
+/**
+ * 📥 Dohvaća TODO stavke I kolekcije s backenda
+ */
+export async function fetchTodos() {
+ try {
+ // Dohvaćamo oboje paralelno
+ const [todosData, collectionsData] = await Promise.all([
+ api.get('todo/todos/'),
+ api.get('todo/collections/')
+ ]);
+
+ $todos.set(todosData);
+ $collections.set(collectionsData); // 💡 Puni store s kolekcijama
+ } catch (error) {
+ console.error('Greška pri dohvaćanju podataka:', error);
+ showToast('Neuspješno dohvaćanje podataka.', 'error');
+ }
+}
+
+/**
+ * ➕ Dodaje novu stavku (podržava FormData sa slikom)
+ */
+export async function addTodo(formData) {
+ try {
+ const newTodo = await api.post('todo/todos/', formData);
+
+ // 🚀 MEHATRONIČKA DIJAGNOSTIKA: Pogledajmo što točno Django isporučuje u konzolu
+ console.log("🚀 DJANGO_RESPONSE_RAW:", newTodo);
+
+ // Osiguravamo da radimo s čistim nizom
+ const currentTodos = Array.isArray($todos.get()) ? $todos.get() : [];
+
+ // Dodajemo na vrh niza i ažuriramo atom
+ $todos.set([newTodo, ...currentTodos]);
+
+ showToast('Zadatak uspješno dodan.', 'success');
+ } catch (error) {
+ console.error('Greška pri izradi:', error);
+ showToast('Greška prilikom izrade stavke.', 'error');
+ }
+}
+
+/**
+ * ❌ Pesimistično brisanje stavke s UI-ja nakon potvrde backenda
+ */
+export async function deleteTodo(id) {
+ try {
+ // 🔥 POPRAVLJENO: DRF ModelViewSet briše preko DELETE metode na 'todo/todos/[id]/'
+ await api.delete(`todo/todos/${id}/`);
+
+ // Tek ovdje vršimo mutaciju klijentskog stanja (Pesimistični UI update)
+ $todos.set($todos.get().filter(todo => todo.id !== id));
+ showToast('Stavka uspješno obrisana.', 'success');
+ } catch (error) {
+ console.error('Greška pri brisanju:', error);
+ showToast('Brisanje nije uspjelo na backendu.', 'error');
+ }
+}
+
+/**
+ * 🔄 Promjena statusa zadatka (Završeno / Aktivno)
+ */
+export async function toggleTodoStatus(id, currentStatus) {
+ try {
+ // 🔥 POPRAVLJENO: Putanja za ažuriranje u ruteru je 'todo/todos/[id]/'
+ const updatedTodo = await api.patch(`todo/todos/${id}/`, {
+ is_completed: !currentStatus
+ });
+
+ // Tek nakon uspješnog odgovora s backenda, ažuriramo klijentsko stanje s novim podacima
+ $todos.set(
+ $todos.get().map(todo => (todo.id === id ? updatedTodo : todo))
+ );
+ } catch (error) {
+ console.error('Greška pri ažuriranju statusa:', error);
+ showToast('Nije uspjelo ažuriranje statusa zadatka.', 'error');
+ }
+}
+
+/**
+ * 📝 Uređivanje postojećeg zadatka (podržava FormData sa slikama)
+ * Nakon uspješnog odgovora s backenda, ažuriramo objekt u klijentskoj memoriji
+ * što automatski i trenutno osvježava TodoList na Dashboardu bez F5 osvježavanja!
+ */
+export async function updateTodo(id, formData) {
+ try {
+ // DRF ModelViewSet prima izmjene na 'todo/todos/[id]/'
+ const updatedTodo = await api.patch(`todo/todos/${id}/`, formData);
+
+ // ⚡ REAKTIVNA SINKRONIZACIJA: Mapiramo kroz trenutno stanje u memoriji
+ $todos.set(
+ $todos.get().map(todo => (todo.id === id ? updatedTodo : todo))
+ );
+
+ showToast('✓ Servisni nalog uspješno ažuriran.', 'success');
+ return updatedTodo;
+ } catch (error) {
+ console.error('Greška pri uređivanju zadatka:', error);
+ showToast('Nije uspjelo spremanje izmjena na poslužitelj.', 'error');
+ throw error;
+ }
+}
+
+/**
+ * 💡 KREIRANJE IZVEDENOG STOREA
+ * $filteredTodos automatski sluša $todos i collectionId.
+ * Čim se $todos promijeni, ovaj se store automatski re-izračunava.
+ */
+export const $filteredTodos = computed(
+ [$todos, $collectionIdFilter],
+ (todos, collectionId) => {
+ // 1. Ako na Dashboardu uopće nije odabran filter (prikazuju se svi zadatci), vrati sve odmah!
+ if (!collectionId) return todos;
+
+ const targetId = parseInt(collectionId, 10);
+
+ return todos.filter(todo => {
+ // Ako zadatak uopće nema definiranu kolekciju, preskoči ga sigurno
+ if (!todo.collection && !todo.collection_id) return false;
+
+ // Scenarij A: Django je vratio ugniježđeni objekt (todo.collection.id)
+ if (todo.collection && typeof todo.collection === 'object') {
+ return todo.collection.id === targetId;
+ }
+
+ // Scenarij B: Django je vratio samo ravan broj ili string ID (todo.collection ili todo.collection_id)
+ const directId = todo.collection_id || todo.collection;
+ return parseInt(directId, 10) === targetId;
+ });
+ }
+);
+
+/**
+ * 🔄 Posebna funkcija za osvježavanje samo kolekcija
+ */
+export async function fetchCollections() {
+ try {
+ const data = await api.get('todo/collections/');
+ $collections.set(data);
+ } catch (error) {
+ showToast('Neuspješno osvježavanje kolekcija.', 'error');
+ }
+}
+
+export const triggerRefresh = async () => {
+ $refreshSignal.set($refreshSignal.get() + 1);
+
+ await fetchTodos();
+};
+
+
+/**
+ * ⚡ KLIJENTSKO SKUPNO ZATVARANJE NALOGA (Bulk Complete)
+ * Budući da backend nema namjenski bulk endpoint, filtriramo sve nezavršene zadatke
+ * unutar ove kolekcije na klijentu i asinkrono ih paralelno zatvaramo kroz postojeće PATCH rute.
+ */
+export async function bulkCompleteCollectionTodos(collectionId) {
+ try {
+ // 1. Pronalazimo sve zadatke koji pripadaju toj kolekciji i koji još nisu završeni
+ const nezavrseniTodos = $todos.get().filter(todo => {
+ const colId = todo.collection && typeof todo.collection === 'object'
+ ? todo.collection.id
+ : (todo.collection_id || todo.collection);
+ return parseInt(colId, 10) === parseInt(collectionId, 10) && !todo.is_completed;
+ });
+
+ if (nezavrseniTodos.length === 0) {
+ showToast('Svi nalozi za ovaj stroj su već zatvoreni.', 'info');
+ return;
+ }
+
+ showToast(`Pokrećem zatvaranje ${nezavrseniTodos.length} naloga...`, 'success');
+
+ // 2. Kreiramo niz asinkronih PATCH zahtjeva (Paralelno izvršavanje unutar Docker mreže)
+ const patchZahtjevi = nezavrseniTodos.map(todo =>
+ api.patch(`todo/todos/${todo.id}/`, { is_completed: true })
+ );
+
+ // 3. Čekamo da se svi zahtjevi izvrše na PostgreSQL bazi
+ const azuriraniRezultati = await Promise.all(patchZahtjevi);
+
+ // 4. Sinkroniziramo globalni atom sa svježim podatcima koje je vratio DRF update
+ const trenutniTodos = $todos.get();
+ const novaMrezaZadataka = trenutniTodos.map(todo => {
+ const ponovniZapis = azuriraniRezultati.find(r => r.id === todo.id);
+ return ponovniZapis ? ponovniZapis : todo;
+ });
+
+ $todos.set(novaMrezaZadataka);
+ showToast('✓ Svi servisni nalozi uspješno sinkronizirani i zatvoreni.', 'success');
+
+ } catch (error) {
+ console.error('Greška pri klijentskom skupnom zatvaranju:', error);
+ showToast('Nije uspjelo masovno zatvaranje svih naloga stroja.', 'error');
+ throw error;
+ }
+}
+
+
+/**
+ * Primjenjuje predložak na odabranu kolekciju.
+ * @param {number} collectionId - ID kolekcije (stroj/flota)
+ * @param {string} templateKey - Ključ predloška (npr. 'redovni_servis_1050')
+ */
+export async function primijeniServisniPredlozak(collectionId, templateKey) {
+ try {
+ const response = await fetch(`/api/todo/collections/${collectionId}/primijeni-predlozak/`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ // Ovdje ide tvoj auth header (JWT ili Session)
+ },
+ body: JSON.stringify({ template_key: templateKey })
+ });
+
+ if (!response.ok) {
+ throw new Error('Greška pri primjeni predloška.');
+ }
+
+ const data = await response.json();
+
+ // Ovdje možeš ažurirati lokalni store s novim zadacima
+ // $kolekcije.set([...$collections.get(), ...data.todos]);
+ $collections.set([...trenutno, ...data.todos]);
+
+
+ return data; // Vraća listu novokreiranih zadataka
+ } catch (error) {
+ console.error("Kritična greška u servisu:", error);
+ throw error;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/stores/uiStore.js b/frontend/src/stores/uiStore.js
new file mode 100644
index 0000000..daac8fb
--- /dev/null
+++ b/frontend/src/stores/uiStore.js
@@ -0,0 +1,8 @@
+// src/stores/uiStore.js
+import { atom } from 'nanostores';
+
+// Pohranjujemo ID zadatka koji je trenutno otvoren. Ako je null, modal je zatvoren.
+export const $activeTodoId = atom(null);
+
+export const openTodoModal = (id) => $activeTodoId.set(id);
+export const closeTodoModal = () => $activeTodoId.set(null);
\ No newline at end of file
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
new file mode 100644
index 0000000..624c868
--- /dev/null
+++ b/frontend/src/styles/global.css
@@ -0,0 +1,61 @@
+/* src/styles/global.css */
+@import "tailwindcss";
+
+/* 1. Reci Tailwindu v4 da skenira Flowbite komponente unutar node_modules */
+@source "../../node_modules/flowbite/**/*.js";
+
+/* 2. Učitaj Flowbite plugin koristeći v4 sintaksu */
+@plugin "flowbite/plugin";
+
+@theme {
+ --color-canvas-base: var(--app-canvas-base);
+ --color-canvas-elevated: var(--app-canvas-elevated);
+ --color-canvas-deep: var(--app-canvas-deep);
+
+ --color-brand-primary: var(--app-brand-primary);
+ --color-brand-accent: var(--app-brand-accent);
+ --color-brand-accent-bright: var(--app-brand-accent-bright);
+
+ --color-text-main: var(--app-text-main);
+ --color-text-muted: var(--app-text-muted);
+ --color-border-hairline: var(--app-border-hairline);
+
+ /* Registracija globalne animacije */
+ --animate-blob-float: blobFloat 10s ease-in-out infinite;
+
+ @keyframes blobFloat {
+ 0%, 100% { transform: translateY(0px) scale(1) rotate(0deg); }
+ 50% { transform: translateY(-20px) scale(1.02) rotate(1deg); }
+ }
+}
+
+/* 🌙 DARK MOD: Kada 'html' element dobije klasu .dark, varijable poprimaju Blade Runner estetiku */
+@variant dark (&:where(.dark, .dark *));
+
+/* ==============================================================================
+ 🚀 DRY UTILITY KOMPONENTE (Tailwind v4 Sintaksa)
+ ============================================================================== */
+
+/* Stvaramo unificiranu bazu gumba koja radi kroz Astro i Preact */
+@utility btn-erp {
+ @apply inline-flex items-center justify-center font-sans font-bold uppercase tracking-wider text-xs px-4 py-2.5 rounded-md shadow-sm transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
+}
+
+/* Primarni gumb (Popravljen s ispravnom v4 prozirnošću na hoveru) */
+@utility btn-erp-primary {
+ /* ✅ POPRAVLJENO: bg-brand-primary/90 postavlja prozirnost izravno na boju tijekom hover stanja */
+ @apply btn-erp bg-brand-primary text-white hover:bg-brand-primary/90 active:scale-[0.98];
+}
+
+/* Opcionalni Outline gumb */
+@utility btn-erp-outline {
+ @apply btn-erp border border-border-hairline text-text-muted hover:bg-canvas-deep;
+}
+
+/* Modal blur efekt — blurira main content, ali ne i Navbar */
+#main-content {
+ transition: filter 0.2s ease;
+}
+body.modal-open #main-content {
+ filter: blur(4px);
+}
\ No newline at end of file
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..8bf91d3
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "astro/tsconfigs/strict",
+ "include": [".astro/types.d.ts", "**/*"],
+ "exclude": ["dist"]
+}