25 Commits

Author SHA1 Message Date
mariomitte
cbcbf0b3af fix: sortiranje putnih naloga i upload modal u detaljima
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Dodaje sortiranje stupca Putni nalog (↑/↓) u tablicama gdje je prikazan work order kod i uspoređuje kodove u formatu MTDDMMYY po godini, mjesecu i danu.

Popravlja tok uređivanja servisnog zapisa tako da upload fotografija ostaje u višem overlayju bez zatvaranja parent modala te nakon uspješnog uploada osvježava listu datoteka u detaljima.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 19:16:19 +02:00
mariomitte
ad7b293f54 fix: filtriraj putne naloge i prosiri servisni zapis modal
Ogranicava prikaz 'Dohvati sve putne naloge' na naloge trenutnog servisera, dok supervisor bez serviser uloge i dalje vidi sve.

U detaljima servisnog zapisa dodaje gumb za upload datoteka i povezuje ga s postojecim ServicePhotoUpload modalom.

U modalu Novi servisni zapis dodaje polje Naziv servisnog zapisa iznad opisa i salje ga kao service_title.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 19:02:05 +02:00
mariomitte
8485431180 fix: zamijeni auth polling health endpointom
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Dodaje backend /api/health/ endpoint i prebacuje frontend heartbeat sa /api/token/ OPTIONS na /api/health/ GET kako bi se izbjeglo periodicko gadanje auth rute.

Dodaje TTL cache za clientStore (lastFetchedAt + force opcija) kako bi se smanjili redundantni crm/clients pozivi pri navigaciji.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 16:27:20 +02:00
mariomitte
bbdd02c9e9 fix: stabiliziraj navbar tranziciju i ukloni white splash
Zadrzava Navbar i ostale UI otoke kroz Astro tranzicije te sinkronizira aktivni path preko astro:page-load kako bi react-spring indikator animirao iz trenutne pozicije.

Uklanja bijeli splash pri promjeni stranice i prelasku teme postavljanjem html background boje inline, prilagodbom ThemeToggle logike i uklanjanjem body color tranzicije.

Dodano je i TTL kesiranje za dashboard/task dohvat kako bi se smanjili redundantni API pozivi pri navigaciji.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 16:18:15 +02:00
mariomitte
fe7edf5643 fix: premjesti kalendar widget u Layout, popravi PDF toast i Servisi danas
- TaskCalendarPortal u Layout.astro (van AnimatedPage transforma) pa fixed
  pozicija uvijek prati viewport rub bez probijanja sadržaja
- Kalendar widgeta panel koristi clipping wrapper (overflow:hidden + animirana
  width) umjesto translate3d — potpuno skriva sjenu i border kad je zatvoren
- Serviser vidi samo svoje taskove, supervisor vidi sve; neprijavljeni korisnik
  ne vidi widget (return null)
- FleetDashboardShell sluša calendar:open-task event i otvara TaskServiceRecordsModal
- Servisi danas broji Taskove (scheduled_date=danas, status aktivan/servis)
  umjesto Servisnih zapisa
- downloadGeneratedPdfByUrl hvata grešku i prikazuje Toast (npr. PDF nije
  dostupan ili je istekao)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 15:27:20 +02:00
mariomitte
0c9e1b7d50 feat: dodaj scheduled_date na Task i kalendar widget
- Novo polje Task.scheduled_date (DateField, nullable, db_index) za planiranje
- Migracija 0009_task_scheduled_date
- Celery task notify_upcoming_tasks: dnevno u 08:00 šalje Pusher + email za sutrašnje zadatke
- TaskCalendarWidget: react-spring slide panel s lijeve strane, grid prikaz mjeseca, dot indikatori, klik otvara TaskServiceRecordsModal
- FleetDashboardShell: import + render kalendara, Planirano stupac u tablici zadataka
- TaskCreateModal + TaskServiceRecordsModal: scheduled_date input i prikaz
- Admin + serializer + views: scheduled_date filter i prikaz
- 47 backend testova prolaze (uključuje 8 novih za notify_upcoming_tasks)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 14:52:10 +02:00
mariomitte
bf8e25efe7 feat: dodaj display_code polje za putne naloge (MT070626 format)
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
- Dodano polje 'display_code' na WorkOrder model (format: 2 inicijala + ddmmyy)
- Migracije za kreiranje polja i backfill postojećih naloga
- Ažuriran WorkOrderSerializer za validaciju i serijalizaciju display_code
- Backend tasks za auto-generiranje display_code pri kreiranju
- Frontend komponente ažurirane za prikaz display_code umjesto UUID:
  * FleetDashboardShell tablica putnih naloga
  * WorkOrderDetailModal
  * TaskCreateModal
  * Sve PDF stranice
- displayIds.js utility za formatiranjeDisplay ID-eva
- Admin interface prikazuje display_code
2026-07-17 23:53:47 +02:00
mariomitte
ef8718a4cf fix: ukloni dupli toast provider u layoutu
Uklonjen je paralelni ToastProvider iz Layout.astro kako bi ostao samo postojeći ToastContainer (nanostore), čime se sprječava hydration crash i null.map greška na /putni-nalozi.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 19:39:04 +02:00
mariomitte
75257525c4 fix: osvjezi taskove nakon azuriranja putnog naloga
Nakon spremanja izmjena u Uredi putni nalog, frontend sada uz fleet podatke ponovno dohvaća i taskove, pa se status taska odmah prikaže kao 'spreman_za_zavrsetak' kada backend zatvori povezani putni nalog.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 19:32:50 +02:00
mariomitte
7acaef941f feat: integritet zatvaranja taska i putnog naloga
Dodaje status 'spreman_za_zavrsetak' i backend provjeru koja blokira zatvaranje taska kada je povezani putni nalog otvoren, uz 409 odgovor i opcionalni auto-close flow.\n\nPri zatvaranju putnog naloga taskovi u statusu 'servis' automatski prelaze u 'spreman_za_zavrsetak'. Frontend modal sada podržava potvrdu i retry s auto_close_work_order=true.\n\nUključeni su i ciljani testovi za novi behavior, te ažuriran prikaz statusa u dashboardu.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 19:20:33 +02:00
mariomitte
e14665d8ec fix: dev proxy za frontend API pozive
U dev modu frontend sada koristi /api preko Astro/Vite proxy sloja prema backend servisu, umjesto direktnog gađanja localhost:8001 iz browsera.\n\nTime se uklanja ERR_CONNECTION_REFUSED u lokalnom Docker development okruženju.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 18:58:09 +02:00
mariomitte
ef51036bc7 fix: refresh notifikacija nakon PDF zahtjeva (cache hit i async polling)
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Dodan _refreshNotificationsAsync() koji odmah osvjezi notifikacijski feed nakon cache hit PDF downloada, te _schedulePdfNotificationPolling() koji pollingom na 5s/20s/60s hvata Celery completion kad Pusher nije dostupan ili worker kasni.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 17:21:46 +02:00
mariomitte
13de9e113c fix: ispravljen dvostruki /api/ prefiks u download_url za generirani PDF
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Backend je vraćao download_url s /api/ prefiksom, a apiClient.js rezolvira URL relativno na BASE_URL koji vec sadrži /api/ - rezultat je bio /api/api/ i 404. Uklonjen /api/ prefiks na svim mjestima (views.py x2, tasks.py x1).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 17:03:11 +02:00
mariomitte
e8330473cd feat: async PDF cache i API media putanje za putne naloge
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Uvodi background request tok za generiranje PDF-a s 24h cache-om i cleanup taskom preko Celery beat-a. Dodaje notification metadata download_url za ponovno preuzimanje bez regeneracije, API image endpointe za putne naloge i račune, te frontend integraciju za async download i produkcijski prikaz slika.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 16:42:02 +02:00
mariomitte
5e7ec72a4e fix: ispravljen prikaz Vozilo servisera u WorkOrderDetailModal
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
- Vozilo servisera sada prikazuje servicer_vehicle_* polja (registracija,
  marka/model, km start/end, trosak goriva) umjesto craneInfo podataka
- start_mileage/end_mileage (km dizalice na nalogu) premjesteni u Podaci dizalice
- servicer_vehicle_* polja dodana u form state, useEffect init i submit payload
- Trošak goriva (EUR) dodan kao novo polje u sekciji Vozilo servisera

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 11:12:14 +02:00
mariomitte
346682b172 config: disable CommonPasswordValidator za interni ERP
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-07-16 21:56:11 +02:00
mariomitte
8e4b3f8aea feat: WorkOrderPhoto izolacija, upload modal i SN stupac u tablici putnih naloga
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
- Dodan WorkOrderPhoto model (FK na WorkOrder, odvojen od VehicleServicePhoto)
- Migracija 0022_workorderphoto
- WorkOrderViewSet.images action refaktoriran: GET/POST koristi WorkOrderPhoto
- WorkOrderPhotoSerializer dodan u serializers.py
- WorkOrderPhotoInline dodan u admin.py
- Novi WorkOrderPhotoUpload.jsx modal s podrskom za onBack prop i z-[60]
- WorkOrderDetailModal: gumb Dodaj fotografije u editMode, photoUploadOpen state,
  ispravljen onSuccess callback (map na URL stringove), reset pri zatvaranju
- FleetDashboardShell: stupac SN u tablici putnih naloga (craneInfo.crane_serial_number)
- fleetDashboardStore: uklonjen dupli toast iz uploadWorkOrderPhoto
- Dodan test test_work_order_images_endpoint.py

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-16 06:33:08 +02:00
mariomitte
1ab2bfb3c3 patch prikaži stranicu servisnih zapisa sa SN dizalice
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-07-14 23:09:00 +02:00
mariomitte
3ad536a23e interaktivna Tablica radnih sati, db
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-07-13 00:36:28 +02:00
70151f1225 production with django admin template
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-07-12 22:44:03 +02:00
mariomitte
ac5f25a99c patch ne otkriva entrypoint.sh na windows 2026-07-12 15:25:57 +02:00
24cd9ae11c WIP
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-07-12 03:59:56 +02:00
3268e53642 WIP: local production settings updates 2026-07-12 03:56:32 +02:00
mariomitte
4e3bf6dab0 patch oko teksta PDFa i UI tablica
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-07-12 03:25:03 +02:00
mariomitte
3b54499b17 patch astro frontend config file 2026-07-10 23:34:29 +02:00
96 changed files with 7051 additions and 999 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

View File

@@ -1,5 +1,6 @@
import os
from pathlib import Path
from celery.schedules import crontab
from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -11,6 +12,7 @@ SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-fk#ywtdyi8=t6^vdbo!=b
DEBUG = os.environ.get('DEBUG', 'True') == 'True'
INSTALLED_APPS = [
'unfold',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@@ -27,6 +29,14 @@ INSTALLED_APPS = [
'modules.crm',
'modules.fleet'
]
UNFOLD = {
'SITE_TITLE': 'ERP Admin',
'SITE_HEADER': 'ERP Administration',
'SITE_SYMBOL': 'dashboard',
'SHOW_HISTORY': True,
}
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # Mora biti prvi
'django.middleware.security.SecurityMiddleware',
@@ -61,7 +71,7 @@ WSGI_APPLICATION = 'core.wsgi.application'
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.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
@@ -160,12 +170,22 @@ CACHES = {
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_BEAT_SCHEDULE = {
'cleanup-expired-generated-pdfs-hourly': {
'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task',
'schedule': crontab(minute=0),
},
'notify-upcoming-tasks-daily': {
'task': 'modules.task_management.tasks.notify_upcoming_tasks',
'schedule': crontab(hour=8, minute=0),
},
}
# 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'
# STATIC_ROOT = BASE_DIR / '/app/staticfiles'
# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente)
MEDIA_URL = '/media/'
@@ -211,4 +231,4 @@ CACHES = {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/1',
}
}
}

View File

@@ -46,4 +46,14 @@ INSTALLED_APPS += ['debug_toolbar']
MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
INTERNAL_IPS = ['127.0.0.1']
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_ALWAYS_EAGER = True
# 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 / '/tmp/staticfiles'
# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente)
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

View File

@@ -16,9 +16,11 @@ DATABASES = {
}
# Sigurnosne postavke
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
USE_X_FORWARDED_HOST = True
# Produkcijski CORS (samo domena na kojoj vrti Astro)
CORS_ALLOWED_ORIGINS = os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',')
@@ -34,5 +36,10 @@ CSRF_TRUSTED_ORIGINS = [
]
# Statika i Mediji
STATIC_URL = '/static/'
STATIC_ROOT = '/app/staticfiles'
MEDIA_ROOT = '/app/media'
MEDIA_URL = '/media/'
MEDIA_ROOT = '/app/media'
# Privremeni fallback dok se media ne servira preko reverse proxy-ja.
SERVE_MEDIA_FILES = os.environ.get('SERVE_MEDIA_FILES', '1').strip().lower() in ('1', 'true', 'yes', 'on')

View File

@@ -2,14 +2,19 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.http import JsonResponse
from django.urls import path, include
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
def api_health(_request):
return JsonResponse({'status': 'ok'})
urlpatterns = [
path('admin/', admin.site.urls),
path('api/health/', api_health, name='api_health'),
# Uključujemo rute iz modula (svaki modul ima svoj urls.py)
path('api/crm/', include('modules.crm.urls')),
@@ -30,4 +35,6 @@ if settings.DEBUG:
urlpatterns += [
path('__debug__/', include(debug_toolbar.urls)),
]
if settings.DEBUG or getattr(settings, 'SERVE_MEDIA_FILES', False):
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -0,0 +1,8 @@
email,first_name,last_name,password,is_serviser,is_kupac,is_team_member,is_staff,is_superuser,is_active,telefon,oib,occupation,residence,work_position,licence_number,is_verified,client_id,client_tax_id
serviser1@example.com,Ime1,Prezime1,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
serviser2@example.com,Ime2,Prezime2,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
serviser3@example.com,Ime3,Prezime3,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
admin1@example.com,Admin1,Prezime1,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
admin2@example.com,Admin2,Prezime2,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
ramo@knezljubo.hr,Ramo,Husanović,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
domagoj@knezljubo.hr,Domagoj,Knez,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
1 email first_name last_name password is_serviser is_kupac is_team_member is_staff is_superuser is_active telefon oib occupation residence work_position licence_number is_verified client_id client_tax_id
2 serviser1@example.com Ime1 Prezime1 password123 true false true false false true +38591111222 12345678901 Serviser Zagreb Serviser dizalice LIC-001 true
3 serviser2@example.com Ime2 Prezime2 password123 true false true false false true +38591111222 12345678901 Serviser Zagreb Serviser dizalice LIC-001 true
4 serviser3@example.com Ime3 Prezime3 password123 true false true false false true +38591111222 12345678901 Serviser Zagreb Serviser dizalice LIC-001 true
5 admin1@example.com Admin1 Prezime1 password123 false false true true true true +38591111222 12345678901 Admin Zagreb Direktor LIC-001 true
6 admin2@example.com Admin2 Prezime2 password123 false false true true true true +38591111222 12345678901 Admin Zagreb Direktor LIC-001 true
7 ramo@knezljubo.hr Ramo Husanović password123 true false true false false true +38591111222 12345678901 Serviser Zagreb Serviser dizalice LIC-001 true
8 domagoj@knezljubo.hr Domagoj Knez password123 false false true true true true +38591111222 12345678901 Admin Zagreb Direktor LIC-001 true

View File

@@ -0,0 +1,202 @@
import csv
from pathlib import Path
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from modules.crm.models import Client
def _to_bool(value, field_name):
if value is None or value == "":
return None
normalized = str(value).strip().lower()
if normalized in {"1", "true", "t", "yes", "y", "da"}:
return True
if normalized in {"0", "false", "f", "no", "n", "ne"}:
return False
raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}")
def _to_int(value, field_name):
if value is None or value == "":
return None
try:
return int(str(value).strip())
except (TypeError, ValueError) as exc:
raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc
class Command(BaseCommand):
help = "Uvozi korisnike (radnike) iz CSV datoteke u CustomUser model."
expected_columns = [
"email",
"first_name",
"last_name",
"password",
"is_serviser",
"is_kupac",
"is_team_member",
"is_staff",
"is_superuser",
"is_active",
"telefon",
"oib",
"occupation",
"residence",
"work_position",
"licence_number",
"is_verified",
"client_id",
"client_tax_id",
]
def add_arguments(self, parser):
parser.add_argument("csv_path", type=str, help="Putanja do CSV datoteke.")
parser.add_argument(
"--dry-run",
action="store_true",
help="Validira i simulira import bez spremanja u bazu.",
)
parser.add_argument(
"--create-only",
action="store_true",
help="Kreira samo nove korisnike; postojeće preskače.",
)
parser.add_argument(
"--default-password",
type=str,
default="",
help="Fallback lozinka za nove korisnike ako polje 'password' nije popunjeno.",
)
def handle(self, *args, **options):
csv_path = Path(options["csv_path"]).expanduser().resolve()
dry_run = options["dry_run"]
create_only = options["create_only"]
default_password = options["default_password"]
if not csv_path.exists():
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
User = get_user_model()
created_count = 0
updated_count = 0
skipped_count = 0
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise CommandError("CSV nema zaglavlje (header).")
normalized_headers = [str(h).strip() for h in reader.fieldnames]
missing = [column for column in self.expected_columns if column not in normalized_headers]
if missing:
raise CommandError("CSV nema obavezna polja: " + ", ".join(missing))
row_number = 1
with transaction.atomic():
for row in reader:
row_number += 1
if not any((value or "").strip() for value in row.values()):
continue
email = (row.get("email") or "").strip().lower()
if not email:
raise CommandError(f"Red {row_number}: email je obavezan.")
user = User.objects.filter(email__iexact=email).first()
if user and create_only:
skipped_count += 1
self.stdout.write(
self.style.WARNING(
f"Red {row_number}: preskočeno (korisnik već postoji) {email}"
)
)
continue
is_create = user is None
if is_create:
password = (row.get("password") or "").strip() or default_password
if not password:
raise CommandError(
f"Red {row_number}: password je obavezan za nove korisnike "
"(ili koristite --default-password)."
)
user = User(email=email)
user.set_password(password)
else:
password = (row.get("password") or "").strip()
if password:
user.set_password(password)
client = self._resolve_client(row=row, row_number=row_number)
user.first_name = (row.get("first_name") or "").strip()
user.last_name = (row.get("last_name") or "").strip()
user.telefon = (row.get("telefon") or "").strip()
user.oib = (row.get("oib") or "").strip() or None
user.occupation = (row.get("occupation") or "").strip()
user.residence = (row.get("residence") or "").strip()
user.work_position = (row.get("work_position") or "").strip()
user.licence_number = (row.get("licence_number") or "").strip() or None
for bool_field in [
"is_serviser",
"is_kupac",
"is_team_member",
"is_staff",
"is_superuser",
"is_active",
"is_verified",
]:
parsed = _to_bool(row.get(bool_field), bool_field)
if parsed is not None:
setattr(user, bool_field, parsed)
user.client_profile = client
user.username = None
user.full_clean()
if not dry_run:
user.save()
if is_create:
created_count += 1
else:
updated_count += 1
self.stdout.write(
self.style.SUCCESS(
f"Red {row_number}: {'kreiran' if is_create else 'ažuriran'} korisnik {email}"
)
)
if dry_run:
transaction.set_rollback(True)
summary = (
f"Import korisnika završen. created={created_count}, updated={updated_count}, "
f"skipped={skipped_count}, dry_run={dry_run}"
)
self.stdout.write(self.style.SUCCESS(summary))
def _resolve_client(self, *, row, row_number):
client_id = _to_int(row.get("client_id"), "client_id")
client_tax_id = (row.get("client_tax_id") or "").strip()
if client_id:
client = Client.objects.filter(id=client_id).first()
if not client:
raise CommandError(f"Red {row_number}: klijent s ID={client_id} ne postoji.")
return client
if client_tax_id:
client = Client.objects.filter(tax_id=client_tax_id).first()
if not client:
raise CommandError(f"Red {row_number}: klijent s tax_id={client_tax_id} ne postoji.")
return client
return None

View File

@@ -1,5 +1,4 @@
from rest_framework import serializers
from django.core.exceptions import ObjectDoesNotExist
from .models import CustomUser
class UserSerializer(serializers.ModelSerializer):
@@ -14,11 +13,8 @@ class UserSerializer(serializers.ModelSerializer):
'first_name': {'required': True, 'allow_blank': False}
}
def _get_assigned_asset(self, obj):
try:
return obj.assigned_vehicles
except ObjectDoesNotExist:
return None
def _get_assigned_assets(self, obj):
return obj.assigned_vehicles.all()
def _serialize_asset(self, vehicle):
if not vehicle:
@@ -34,10 +30,14 @@ class UserSerializer(serializers.ModelSerializer):
}
def get_assigned_vehicle(self, obj):
return self._serialize_asset(self._get_assigned_asset(obj))
assigned = self._get_assigned_assets(obj)
vehicle = assigned.filter(asset_type='vehicle').first() or assigned.first()
return self._serialize_asset(vehicle)
def get_assigned_crane(self, obj):
return self._serialize_asset(self._get_assigned_asset(obj))
assigned = self._get_assigned_assets(obj)
crane = assigned.filter(asset_type='crane').first() or assigned.first()
return self._serialize_asset(crane)
def validate(self, data):
"""
@@ -66,7 +66,7 @@ class TeamMemberSerializer(serializers.ModelSerializer):
class Meta:
model = CustomUser
fields = ['id', 'email', 'full_name', 'is_team_member']
fields = ['id', 'email', 'full_name', 'is_team_member', 'is_serviser']
read_only_fields = fields
def get_full_name(self, obj):

View File

@@ -1,4 +1,5 @@
import pytest
from django.contrib.auth.models import Group
from django.contrib.auth import get_user_model
User = get_user_model()
@@ -22,4 +23,20 @@ def test_create_superuser():
password="password123"
)
assert admin.is_superuser is True
assert admin.is_staff is True
assert admin.is_staff is True
@pytest.mark.django_db
def test_user_groups_many_to_many_relation():
user = User.objects.create_user(
email="m2m@example.com",
password="password123",
)
group_a = Group.objects.create(name="Serviseri")
group_b = Group.objects.create(name="Dispeceri")
user.groups.add(group_a, group_b)
assert user.groups.count() == 2
assert group_a.user_set.filter(id=user.id).exists()
assert group_b.user_set.filter(id=user.id).exists()

103
backend/entrypoint.sh Normal file → Executable file
View File

@@ -1,101 +1,22 @@
#!/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 "--> Pokrećem migracije..."
python manage.py migrate --noinput
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..."
python manage.py collectstatic --noinput
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
echo "--> Pokrećem Gunicorn..."
exec 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 "$@"
echo "--> Pokrećem Celery..."
shift
exec celery "$@"
else
exec "$@"
fi
exec "$@"
fi

View File

View File

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

View File

@@ -0,0 +1,130 @@
import csv
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.core.exceptions import ValidationError
from django.db import transaction
from modules.crm.models import Client
class Command(BaseCommand):
help = "Uvozi kupce (vlasnike dizalica) iz CSV datoteke u Client model."
expected_columns = [
"name",
"client_type",
"tax_id",
"email",
"phone",
"address",
"city",
"postal_code",
]
def add_arguments(self, parser):
parser.add_argument("csv_path", type=str, help="Putanja do CSV datoteke.")
parser.add_argument(
"--dry-run",
action="store_true",
help="Validira i simulira import bez spremanja u bazu.",
)
parser.add_argument(
"--create-only",
action="store_true",
help="Kreira samo nove kupce; postojeće preskače.",
)
def handle(self, *args, **options):
csv_path = Path(options["csv_path"]).expanduser().resolve()
dry_run = options["dry_run"]
create_only = options["create_only"]
if not csv_path.exists():
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
created_count = 0
updated_count = 0
skipped_count = 0
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise CommandError("CSV nema zaglavlje (header).")
normalized_headers = [str(h).strip() for h in reader.fieldnames]
missing = [column for column in self.expected_columns if column not in normalized_headers]
if missing:
raise CommandError("CSV nema obavezna polja: " + ", ".join(missing))
row_number = 1
with transaction.atomic():
for row in reader:
row_number += 1
if not any((value or "").strip() for value in row.values()):
continue
tax_id = (row.get("tax_id") or "").strip()
email = (row.get("email") or "").strip().lower()
if not tax_id:
raise CommandError(f"Red {row_number}: tax_id je obavezan.")
if not email:
raise CommandError(f"Red {row_number}: email je obavezan.")
client = Client.objects.filter(tax_id=tax_id).first()
if client and create_only:
skipped_count += 1
self.stdout.write(
self.style.WARNING(
f"Red {row_number}: preskočeno (kupac već postoji) tax_id={tax_id}"
)
)
continue
is_create = client is None
if is_create:
client = Client(tax_id=tax_id)
client_type = (row.get("client_type") or "legal").strip()
if client_type not in {"legal", "individual"}:
raise CommandError(
f"Red {row_number}: client_type mora biti 'legal' ili 'individual'."
)
client.name = (row.get("name") or "").strip()
client.client_type = client_type
client.email = email
client.phone = (row.get("phone") or "").strip() or None
client.address = (row.get("address") or "").strip()
client.city = (row.get("city") or "").strip()
client.postal_code = (row.get("postal_code") or "").strip()
try:
client.full_clean()
except ValidationError as exc:
raise CommandError(
f"Red {row_number}: validacija nije prošla -> {exc.message_dict}"
) from exc
if not dry_run:
client.save()
if is_create:
created_count += 1
else:
updated_count += 1
self.stdout.write(
self.style.SUCCESS(
f"Red {row_number}: {'kreiran' if is_create else 'ažuriran'} kupac {client.name}"
)
)
if dry_run:
transaction.set_rollback(True)
summary = (
f"Import kupaca završen. created={created_count}, updated={updated_count}, "
f"skipped={skipped_count}, dry_run={dry_run}"
)
self.stdout.write(self.style.SUCCESS(summary))

View File

@@ -5,7 +5,9 @@ from .models import (
Crane,
Vehicle,
WorkOrder,
WorkOrderPhoto,
WorkOrderInvoice,
GeneratedWorkOrderPdf,
VehicleServiceRecord,
VehicleServicePhoto,
VehicleServiceAttachment,
@@ -42,7 +44,7 @@ class ServiceAttachmentInline(admin.TabularInline):
class WorkOrderInline(admin.TabularInline):
model = WorkOrder
extra = 0
fields = ('creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
fields = ('display_code', 'creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
readonly_fields = ('date',)
@@ -60,6 +62,26 @@ class WorkOrderInvoiceInline(admin.TabularInline):
readonly_fields = ('created_at',)
class WorkOrderPhotoInline(admin.TabularInline):
model = WorkOrderPhoto
extra = 0
fields = ('thumbnail_preview', 'image', 'description', 'uploaded_by', 'created_at', 'is_active')
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(
'<a href="{}" target="_blank">'
'<img src="{}" style="height:64px;width:auto;border-radius:4px;object-fit:cover;" />'
'</a>',
obj.image.url,
obj.image.url,
)
return ""
@admin.register(Vehicle)
class VehicleAdmin(admin.ModelAdmin):
list_display = (
@@ -154,12 +176,12 @@ class CraneAdmin(VehicleAdmin):
@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_display = ('display_code', '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')
search_fields = ('display_code', 'vehicle__registration_number', 'creator__username', 'purpose')
readonly_fields = ('date',)
ordering = ('-date',)
inlines = (WorkOrderInvoiceInline,)
inlines = (WorkOrderInvoiceInline, WorkOrderPhotoInline)
def distance_display(self, obj):
return obj.distance
@@ -173,6 +195,15 @@ class WorkOrderInvoiceAdmin(admin.ModelAdmin):
search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id')
ordering = ('-datum', '-created_at')
@admin.register(GeneratedWorkOrderPdf)
class GeneratedWorkOrderPdfAdmin(admin.ModelAdmin):
list_display = ('id', 'work_order', 'pdf_type', 'status', 'requested_by', 'expires_at', 'is_active', 'created_at')
list_filter = ('pdf_type', 'status', 'is_active', 'expires_at')
search_fields = ('work_order__id', 'requested_by__username', 'requested_by__email', 'filename')
readonly_fields = ('created_at', 'updated_at')
ordering = ('-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')

View File

@@ -0,0 +1,2 @@
registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,assigned_servicer_email,is_active
ZG-CRN-001,123456,Liebherr,LTM 1050,2022,WLH1234567890ABCD,1200,800,45200,15000,true,,serviser1@example.com,true
1 registration_number crane_serial_number make model year vin superstructure_working_hours chassis_working_hours current_mileage service_interval_km is_company_vehicle client_id assigned_servicer_email is_active
2 ZG-CRN-001 123456 Liebherr LTM 1050 2022 WLH1234567890ABCD 1200 800 45200 15000 true serviser1@example.com true

View File

@@ -0,0 +1,2 @@
registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,client_name,client_type,client_tax_id,client_email,client_phone,client_address,client_city,client_postal_code,assigned_servicer_email,is_active
ZG-CRN-001,123456,Liebherr,LTM 1050,2022,WLH1234567890ABCD,1200,800,45200,15000,false,,Kupac Primjer d.o.o.,legal,12345678901,kupac.primjer@example.com,+38591111222,Ulica 1,Zagreb,10000,serviser1@example.com,true
1 registration_number crane_serial_number make model year vin superstructure_working_hours chassis_working_hours current_mileage service_interval_km is_company_vehicle client_id client_name client_type client_tax_id client_email client_phone client_address client_city client_postal_code assigned_servicer_email is_active
2 ZG-CRN-001 123456 Liebherr LTM 1050 2022 WLH1234567890ABCD 1200 800 45200 15000 false Kupac Primjer d.o.o. legal 12345678901 kupac.primjer@example.com +38591111222 Ulica 1 Zagreb 10000 serviser1@example.com true

View File

@@ -0,0 +1,12 @@
registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,client_name,client_type,client_tax_id,client_email,client_phone,client_address,client_city,client_postal_code,assigned_servicer_email,is_active
ZG-LTM-001,210501,Liebherr,LTM 1350,2021,WLHZGLTM0012021AA,3400,2900,128450,15000,false,,Adriacink transport d.o.o.,legal,10000000001,adriacink.transport@example.com,+38591111001,Ulica 1,Zagreb,10000,serviser1@example.com,true
ZG-LTM-002,210502,Liebherr,LTM 1100,2022,WLHZGLTM0022022BB,2850,2410,96420,15000,false,,PAKLOG,legal,10000000004,paklog@example.com,+38591111004,Ulica 4,Zagreb,10000,serviser2@example.com,true
ZG-LTM-003,210503,Liebherr,LTM 1110,2023,WLHZGLTM0032023CC,1620,1395,54780,15000,false,,Palace,legal,10000000005,palace@example.com,+38591111005,Ulica 5,Zagreb,10000,serviser3@example.com,true
ZG-LTM-004,210504,Liebherr,LTM 1750,2021,WLHZGLTM0042021UA,3400,2900,128450,15000,false,,Adriacink transport d.o.o.,legal,10000000001,adriacink.transport@example.com,+38591111001,Ulica 1,Zagreb,10000,serviser1@example.com,true
ZG-LTM-005,210505,Liebherr,LTM 1500,2021,WLHZGLTM0052023AA,3400,2900,128450,15000,false,,BOSMAN,legal,10000000002,bosman@example.com,+38591111002,Ulica 2,Zagreb,10000,serviser1@example.com,true
ZG-LTM-006,210506,Liebherr,LTM 1060,2022,WLHZGLTM0062022BB,2850,2410,96420,15000,false,,BOSSIL,legal,10000000003,bossil@example.com,+38591111003,Ulica 3,Zagreb,10000,serviser2@example.com,true
ZG-LTM-007,210507,Liebherr,LTM 1060,2022,WLHZGLTM0072022BB,2850,2410,96420,15000,false,,Petrokemija Kutina,legal,10000000006,petrokemija.kutina@example.com,+38591111006,Ulica 6,Kutina,44320,serviser3@example.com,true
ZG-LTM-008,210508,Liebherr,LTM 1040,2022,WLHZGLTM0082022BB,2850,2410,96420,15000,false,,Paron,legal,10000000007,paron@example.com,+38591111007,Ulica 7,Zagreb,10000,serviser3@example.com,true
ZG-LTM-009,210509,Liebherr,LTM 1040,2022,WLHZGLTM0092022BB,2850,2410,96420,15000,false,,Petrokemija Kutina,legal,10000000006,petrokemija.kutina@example.com,+38591111006,Ulica 6,Kutina,44320,serviser3@example.com,true
ZG-LTM-010,210510,Liebherr,LTM 1250,2022,WLHZGLTM0102022BB,2850,2410,96420,15000,false,,PRANGL,legal,10000000008,prangl@example.com,+38591111008,Ulica 8,Zagreb,10000,serviser3@example.com,true
ZG-LTM-011,210511,Liebherr,LTM 1090,2022,WLHZGLTM0112022BB,2850,2410,96420,15000,false,,Salona MONT,legal,10000000009,salona.mont@example.com,+38591111009,Ulica 9,Split,21000,serviser3@example.com,true
1 registration_number crane_serial_number make model year vin superstructure_working_hours chassis_working_hours current_mileage service_interval_km is_company_vehicle client_id client_name client_type client_tax_id client_email client_phone client_address client_city client_postal_code assigned_servicer_email is_active
2 ZG-LTM-001 210501 Liebherr LTM 1350 2021 WLHZGLTM0012021AA 3400 2900 128450 15000 false Adriacink transport d.o.o. legal 10000000001 adriacink.transport@example.com +38591111001 Ulica 1 Zagreb 10000 serviser1@example.com true
3 ZG-LTM-002 210502 Liebherr LTM 1100 2022 WLHZGLTM0022022BB 2850 2410 96420 15000 false PAKLOG legal 10000000004 paklog@example.com +38591111004 Ulica 4 Zagreb 10000 serviser2@example.com true
4 ZG-LTM-003 210503 Liebherr LTM 1110 2023 WLHZGLTM0032023CC 1620 1395 54780 15000 false Palace legal 10000000005 palace@example.com +38591111005 Ulica 5 Zagreb 10000 serviser3@example.com true
5 ZG-LTM-004 210504 Liebherr LTM 1750 2021 WLHZGLTM0042021UA 3400 2900 128450 15000 false Adriacink transport d.o.o. legal 10000000001 adriacink.transport@example.com +38591111001 Ulica 1 Zagreb 10000 serviser1@example.com true
6 ZG-LTM-005 210505 Liebherr LTM 1500 2021 WLHZGLTM0052023AA 3400 2900 128450 15000 false BOSMAN legal 10000000002 bosman@example.com +38591111002 Ulica 2 Zagreb 10000 serviser1@example.com true
7 ZG-LTM-006 210506 Liebherr LTM 1060 2022 WLHZGLTM0062022BB 2850 2410 96420 15000 false BOSSIL legal 10000000003 bossil@example.com +38591111003 Ulica 3 Zagreb 10000 serviser2@example.com true
8 ZG-LTM-007 210507 Liebherr LTM 1060 2022 WLHZGLTM0072022BB 2850 2410 96420 15000 false Petrokemija Kutina legal 10000000006 petrokemija.kutina@example.com +38591111006 Ulica 6 Kutina 44320 serviser3@example.com true
9 ZG-LTM-008 210508 Liebherr LTM 1040 2022 WLHZGLTM0082022BB 2850 2410 96420 15000 false Paron legal 10000000007 paron@example.com +38591111007 Ulica 7 Zagreb 10000 serviser3@example.com true
10 ZG-LTM-009 210509 Liebherr LTM 1040 2022 WLHZGLTM0092022BB 2850 2410 96420 15000 false Petrokemija Kutina legal 10000000006 petrokemija.kutina@example.com +38591111006 Ulica 6 Kutina 44320 serviser3@example.com true
11 ZG-LTM-010 210510 Liebherr LTM 1250 2022 WLHZGLTM0102022BB 2850 2410 96420 15000 false PRANGL legal 10000000008 prangl@example.com +38591111008 Ulica 8 Zagreb 10000 serviser3@example.com true
12 ZG-LTM-011 210511 Liebherr LTM 1090 2022 WLHZGLTM0112022BB 2850 2410 96420 15000 false Salona MONT legal 10000000009 salona.mont@example.com +38591111009 Ulica 9 Split 21000 serviser3@example.com true

View File

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

View File

@@ -0,0 +1,283 @@
import csv
from pathlib import Path
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.core.exceptions import ValidationError
from django.db import transaction
from modules.crm.models import Client
from modules.fleet.models import Vehicle
def _to_bool(value, field_name):
if value is None or value == "":
return None
normalized = str(value).strip().lower()
if normalized in {"1", "true", "t", "yes", "y", "da"}:
return True
if normalized in {"0", "false", "f", "no", "n", "ne"}:
return False
raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}")
def _to_int(value, field_name):
if value is None or value == "":
return None
try:
return int(str(value).strip())
except (TypeError, ValueError) as exc:
raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc
class Command(BaseCommand):
help = "Uvozi dizalice iz CSV datoteke i sprema ih u Vehicle model (asset_type=crane)."
expected_columns = [
"registration_number",
"crane_serial_number",
"make",
"model",
"year",
"vin",
"superstructure_working_hours",
"chassis_working_hours",
"current_mileage",
"service_interval_km",
"is_company_vehicle",
"client_id",
"assigned_servicer_email",
"is_active",
]
optional_client_columns = [
"client_name",
"client_type",
"client_tax_id",
"client_email",
"client_phone",
"client_address",
"client_city",
"client_postal_code",
]
def add_arguments(self, parser):
parser.add_argument(
"csv_path",
type=str,
help="Apsolutna ili relativna putanja do CSV datoteke.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Validira CSV i ispisuje rezultat bez spremanja u bazu.",
)
parser.add_argument(
"--create-only",
action="store_true",
help="Kreira samo nove zapise; postojeće registracije se preskaču.",
)
def handle(self, *args, **options):
csv_path = Path(options["csv_path"]).expanduser().resolve()
dry_run = options["dry_run"]
create_only = options["create_only"]
if not csv_path.exists():
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
created_count = 0
updated_count = 0
skipped_count = 0
User = get_user_model()
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise CommandError("CSV nema zaglavlje (header).")
normalized_headers = [str(h).strip() for h in reader.fieldnames]
missing = [column for column in self.expected_columns if column not in normalized_headers]
if missing:
raise CommandError(
"CSV nema obavezna polja: " + ", ".join(missing)
)
row_number = 1
with transaction.atomic():
for row in reader:
row_number += 1
if not any((value or "").strip() for value in row.values()):
continue
registration_number = (row.get("registration_number") or "").strip()
crane_serial_number = (row.get("crane_serial_number") or "").strip()
if not registration_number:
raise CommandError(f"Red {row_number}: registration_number je obavezan.")
if not crane_serial_number:
raise CommandError(f"Red {row_number}: crane_serial_number je obavezan.")
vehicle = Vehicle.objects.filter(registration_number=registration_number).first()
if vehicle and create_only:
skipped_count += 1
self.stdout.write(
self.style.WARNING(
f"Red {row_number}: preskočeno (već postoji) {registration_number}"
)
)
continue
if vehicle is None:
vehicle = Vehicle(registration_number=registration_number)
was_created = True
else:
was_created = False
assigned_servicer = None
assigned_servicer_email = (row.get("assigned_servicer_email") or "").strip()
if assigned_servicer_email:
assigned_servicer = User.objects.filter(email=assigned_servicer_email).first()
if not assigned_servicer:
raise CommandError(
f"Red {row_number}: korisnik s emailom '{assigned_servicer_email}' ne postoji."
)
is_company_vehicle = _to_bool(row.get("is_company_vehicle"), "is_company_vehicle")
is_active = _to_bool(row.get("is_active"), "is_active")
if is_company_vehicle is None:
is_company_vehicle = True
client = self._resolve_or_create_client(
row=row,
row_number=row_number,
is_company_vehicle=is_company_vehicle,
)
vehicle.asset_type = "crane"
vehicle.crane_serial_number = crane_serial_number
vehicle.make = (row.get("make") or "").strip()
vehicle.model = (row.get("model") or "").strip()
vehicle.year = _to_int(row.get("year"), "year")
vehicle.vin = (row.get("vin") or "").strip() or None
vehicle.superstructure_working_hours = _to_int(
row.get("superstructure_working_hours"),
"superstructure_working_hours",
) or 0
vehicle.chassis_working_hours = _to_int(
row.get("chassis_working_hours"),
"chassis_working_hours",
) or 0
vehicle.current_mileage = _to_int(row.get("current_mileage"), "current_mileage") or 0
vehicle.service_interval_km = _to_int(
row.get("service_interval_km"),
"service_interval_km",
) or 15000
if is_company_vehicle is not None:
vehicle.is_company_vehicle = is_company_vehicle
vehicle.client = client
vehicle.assigned_servicer = assigned_servicer
if is_active is not None:
vehicle.is_active = is_active
try:
vehicle.full_clean()
except ValidationError as exc:
raise CommandError(f"Red {row_number}: validacija nije prošla -> {exc.message_dict}") from exc
if not dry_run:
vehicle.save()
if was_created:
created_count += 1
else:
updated_count += 1
self.stdout.write(
self.style.SUCCESS(
f"Red {row_number}: {'kreirano' if was_created else 'ažurirano'} {registration_number}"
)
)
if dry_run:
transaction.set_rollback(True)
summary = (
f"Import završen. created={created_count}, updated={updated_count}, "
f"skipped={skipped_count}, dry_run={dry_run}"
)
self.stdout.write(self.style.SUCCESS(summary))
def _resolve_or_create_client(self, *, row, row_number, is_company_vehicle):
client_id = _to_int(row.get("client_id"), "client_id")
if client_id:
client = Client.objects.filter(id=client_id).first()
if not client:
raise CommandError(
f"Red {row_number}: klijent s ID={client_id} ne postoji."
)
return client
client_tax_id = (row.get("client_tax_id") or "").strip()
client_email = (row.get("client_email") or "").strip().lower()
client_name = (row.get("client_name") or "").strip()
# Reuse pattern iz postojećih seed komandi: pokušaj pronaći postojeći zapis prije kreiranja.
if client_tax_id:
existing = Client.objects.filter(tax_id=client_tax_id).first()
if existing:
return existing
if client_email:
existing = Client.objects.filter(email__iexact=client_email).first()
if existing:
return existing
if client_name:
existing = Client.objects.filter(name=client_name).first()
if existing:
return existing
if is_company_vehicle:
return None
if not any([client_tax_id, client_email, client_name]):
raise CommandError(
f"Red {row_number}: za klijentsku dizalicu (is_company_vehicle=false) "
"morate navesti client_id ili client podatke za kreiranje kupca."
)
missing_for_create = []
create_fields = {
"name": client_name,
"client_type": (row.get("client_type") or "legal").strip() or "legal",
"tax_id": client_tax_id,
"email": client_email,
"phone": (row.get("client_phone") or "").strip() or None,
"address": (row.get("client_address") or "").strip(),
"city": (row.get("client_city") or "").strip(),
"postal_code": (row.get("client_postal_code") or "").strip(),
}
for required_key in ["name", "tax_id", "email", "address", "city", "postal_code"]:
if not create_fields[required_key]:
missing_for_create.append(required_key)
if missing_for_create:
raise CommandError(
f"Red {row_number}: nedostaju podaci za kreiranje kupca: {', '.join(missing_for_create)}"
)
if create_fields["client_type"] not in {"legal", "individual"}:
raise CommandError(
f"Red {row_number}: client_type mora biti 'legal' ili 'individual'."
)
client, was_created = Client.objects.get_or_create(
tax_id=create_fields["tax_id"],
defaults=create_fields,
)
if was_created:
self.stdout.write(
self.style.SUCCESS(
f"Red {row_number}: kreiran kupac '{client.name}' (OIB/VAT: {client.tax_id})"
)
)
return client

View File

@@ -0,0 +1,267 @@
import csv
from pathlib import Path
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from modules.fleet.models import Vehicle
def _to_bool(value, field_name):
if value is None or value == "":
return None
normalized = str(value).strip().lower()
if normalized in {"1", "true", "t", "yes", "y", "da"}:
return True
if normalized in {"0", "false", "f", "no", "n", "ne"}:
return False
raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}")
def _to_int(value, field_name):
if value is None or value == "":
return None
try:
return int(str(value).strip())
except (TypeError, ValueError) as exc:
raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc
class Command(BaseCommand):
help = (
"Uvozi vozila servisera iz CSV datoteke i sprema ih u Vehicle model "
"(asset_type=vehicle, is_company_vehicle=true)."
)
expected_columns = [
"registration_number",
"make",
"model",
"year",
"vin",
"current_mileage",
"service_interval_km",
"assigned_servicer_email",
"is_active",
]
users_template_columns = [
"email",
"is_serviser",
]
def add_arguments(self, parser):
parser.add_argument(
"csv_path",
type=str,
help="Apsolutna ili relativna putanja do CSV datoteke vozila.",
)
parser.add_argument(
"--users-csv-path",
type=str,
default="",
help=(
"Putanja do users_import_template.csv (fallback je "
"backend/core/users/import_templates/users_import_template.csv)."
),
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Validira CSV i ispisuje rezultat bez spremanja u bazu.",
)
parser.add_argument(
"--create-only",
action="store_true",
help="Kreira samo nove zapise; postojeće registracije se preskaču.",
)
def handle(self, *args, **options):
csv_path = self._resolve_input_csv_path(options["csv_path"])
users_csv_path = self._resolve_users_csv_path(options.get("users_csv_path") or "")
dry_run = options["dry_run"]
create_only = options["create_only"]
if not csv_path.exists():
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
if not users_csv_path.exists():
raise CommandError(f"users_import_template.csv ne postoji: {users_csv_path}")
allowed_servicer_emails = self._load_servicer_emails_from_template(users_csv_path)
if not allowed_servicer_emails:
raise CommandError(
"users_import_template.csv ne sadrži nijednog servisera (is_serviser=true)."
)
User = get_user_model()
created_count = 0
updated_count = 0
skipped_count = 0
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise CommandError("CSV nema zaglavlje (header).")
normalized_headers = [str(h).strip() for h in reader.fieldnames]
missing = [column for column in self.expected_columns if column not in normalized_headers]
if missing:
raise CommandError("CSV nema obavezna polja: " + ", ".join(missing))
row_number = 1
with transaction.atomic():
for row in reader:
row_number += 1
if not any((value or "").strip() for value in row.values()):
continue
registration_number = (row.get("registration_number") or "").strip()
if not registration_number:
raise CommandError(f"Red {row_number}: registration_number je obavezan.")
assigned_servicer_email = (row.get("assigned_servicer_email") or "").strip().lower()
if not assigned_servicer_email:
raise CommandError(f"Red {row_number}: assigned_servicer_email je obavezan.")
if assigned_servicer_email not in allowed_servicer_emails:
raise CommandError(
f"Red {row_number}: '{assigned_servicer_email}' nije valjan serviser "
"u users_import_template.csv."
)
assigned_servicer = User.objects.filter(email__iexact=assigned_servicer_email).first()
if not assigned_servicer:
raise CommandError(
f"Red {row_number}: korisnik s emailom '{assigned_servicer_email}' ne postoji u bazi."
)
if not assigned_servicer.is_serviser:
raise CommandError(
f"Red {row_number}: korisnik '{assigned_servicer_email}' nije označen kao serviser."
)
vehicle = Vehicle.objects.filter(registration_number=registration_number).first()
if vehicle and create_only:
skipped_count += 1
self.stdout.write(
self.style.WARNING(
f"Red {row_number}: preskočeno (već postoji) {registration_number}"
)
)
continue
if vehicle is None:
vehicle = Vehicle(registration_number=registration_number)
was_created = True
else:
was_created = False
if vehicle.asset_type and vehicle.asset_type != "vehicle":
raise CommandError(
f"Red {row_number}: zapis {registration_number} već postoji kao "
f"asset_type='{vehicle.asset_type}', ne može se prepisati u 'vehicle'."
)
is_active = _to_bool(row.get("is_active"), "is_active")
vehicle.asset_type = "vehicle"
vehicle.make = (row.get("make") or "").strip()
vehicle.model = (row.get("model") or "").strip()
vehicle.year = _to_int(row.get("year"), "year")
vehicle.vin = (row.get("vin") or "").strip() or None
vehicle.current_mileage = _to_int(row.get("current_mileage"), "current_mileage") or 0
vehicle.service_interval_km = _to_int(
row.get("service_interval_km"),
"service_interval_km",
) or 15000
vehicle.is_company_vehicle = True
vehicle.client = None
vehicle.assigned_servicer = assigned_servicer
if is_active is not None:
vehicle.is_active = is_active
try:
vehicle.full_clean()
except ValidationError as exc:
raise CommandError(f"Red {row_number}: validacija nije prošla -> {exc.message_dict}") from exc
if not dry_run:
vehicle.save()
if was_created:
created_count += 1
else:
updated_count += 1
self.stdout.write(
self.style.SUCCESS(
f"Red {row_number}: {'kreirano' if was_created else 'ažurirano'} "
f"{registration_number} -> {assigned_servicer_email}"
)
)
if dry_run:
transaction.set_rollback(True)
summary = (
f"Import vozila servisera završen. created={created_count}, updated={updated_count}, "
f"skipped={skipped_count}, dry_run={dry_run}"
)
self.stdout.write(self.style.SUCCESS(summary))
def _resolve_input_csv_path(self, csv_path):
resolved = Path(csv_path).expanduser().resolve()
if resolved.exists():
return resolved
raw = str(csv_path).strip()
candidates = []
if "/backend/" in raw:
candidates.append(raw.replace("/backend/", "/", 1))
if "\\backend\\" in raw:
candidates.append(raw.replace("\\backend\\", "\\", 1))
if raw.startswith("backend/"):
candidates.append(raw.replace("backend/", "", 1))
if raw.startswith("backend\\"):
candidates.append(raw.replace("backend\\", "", 1))
for candidate in candidates:
candidate_path = Path(candidate).expanduser().resolve()
if candidate_path.exists():
self.stdout.write(
self.style.WARNING(
f"CSV nije pronađen na '{resolved}', koristim '{candidate_path}'."
)
)
return candidate_path
return resolved
def _resolve_users_csv_path(self, users_csv_path):
if users_csv_path:
return Path(users_csv_path).expanduser().resolve()
backend_root = Path(__file__).resolve().parents[4]
return backend_root / "core" / "users" / "import_templates" / "users_import_template.csv"
def _load_servicer_emails_from_template(self, users_csv_path):
with users_csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise CommandError("users_import_template.csv nema zaglavlje (header).")
normalized_headers = [str(h).strip() for h in reader.fieldnames]
missing = [column for column in self.users_template_columns if column not in normalized_headers]
if missing:
raise CommandError(
"users_import_template.csv nema obavezna polja: " + ", ".join(missing)
)
emails = set()
row_number = 1
for row in reader:
row_number += 1
if not any((value or "").strip() for value in row.values()):
continue
is_serviser = _to_bool(row.get("is_serviser"), f"is_serviser (users CSV, red {row_number})")
email = (row.get("email") or "").strip().lower()
if is_serviser and email:
emails.add(email)
return emails

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.15 on 2026-07-11
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("fleet", "0020_alter_workorderinvoice_created_at_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name="vehicle",
name="assigned_servicer",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="assigned_vehicles",
to=settings.AUTH_USER_MODEL,
verbose_name="Serviser",
),
),
]

View File

@@ -0,0 +1,43 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
def work_order_photo_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_photos/{unique_name}"
class Migration(migrations.Migration):
dependencies = [
('fleet', '0021_alter_vehicle_assigned_servicer_many_to_one'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WorkOrderPhoto',
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)),
('image', models.ImageField(upload_to=work_order_photo_upload_to, verbose_name='Fotografija putnog naloga')),
('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_work_order_photos', to=settings.AUTH_USER_MODEL, verbose_name='Postavio')),
('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Fotografija putnog naloga',
'verbose_name_plural': 'Fotografije putnog naloga',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,39 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import modules.fleet.models
import uuid
class Migration(migrations.Migration):
dependencies = [
('fleet', '0022_workorderphoto'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='GeneratedWorkOrderPdf',
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')),
('pdf_type', models.CharField(choices=[('work_order', 'Putni nalog'), ('service_records', 'Servisni zapisi'), ('invoices', 'Računi')], db_index=True, max_length=32, verbose_name='Tip PDF-a')),
('status', models.CharField(choices=[('pending', 'U obradi'), ('ready', 'Spremno'), ('failed', 'Neuspješno')], db_index=True, default='pending', max_length=16, verbose_name='Status')),
('file', models.FileField(blank=True, null=True, upload_to=modules.fleet.models._generated_work_order_pdf_upload_to, verbose_name='PDF datoteka')),
('filename', models.CharField(blank=True, max_length=255, verbose_name='Naziv datoteke')),
('expires_at', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Vrijedi do')),
('error_message', models.TextField(blank=True, verbose_name='Poruka greške')),
('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_generated_work_order_pdfs', to=settings.AUTH_USER_MODEL, verbose_name='Zatražio')),
('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='generated_pdfs', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Generirani PDF putnog naloga',
'verbose_name_plural': 'Generirani PDF-ovi putnih naloga',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.2.15 on 2026-07-17 14:32
import modules.fleet.models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0023_generatedworkorderpdf'),
]
operations = [
migrations.AlterField(
model_name='workorderphoto',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
),
migrations.AlterField(
model_name='workorderphoto',
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='workorderphoto',
name='image',
field=models.ImageField(upload_to=modules.fleet.models._work_order_photo_upload_to, verbose_name='Fotografija putnog naloga'),
),
migrations.AlterField(
model_name='workorderphoto',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.15 on 2026-07-17 21:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0024_alter_workorderphoto_created_at_and_more'),
]
operations = [
migrations.AddField(
model_name='workorder',
name='display_code',
field=models.CharField(
blank=True,
db_index=True,
help_text='Format: MT070626 (2 slova + 6 znamenki).',
max_length=8,
verbose_name='Prikaz putnog naloga',
),
),
]

View File

@@ -0,0 +1,30 @@
from django.db import migrations
def backfill_workorder_display_code(apps, schema_editor):
WorkOrder = apps.get_model('fleet', 'WorkOrder')
for work_order in WorkOrder.objects.select_related('creator').all():
current_code = str(getattr(work_order, 'display_code', '') or '').strip().upper()
if current_code:
continue
creator = getattr(work_order, 'creator', None)
first_name = str(getattr(creator, 'first_name', '') or '').strip()
last_name = str(getattr(creator, 'last_name', '') or '').strip()
if not first_name or not last_name:
continue
order_date = getattr(work_order, 'date', None)
if not order_date:
continue
display_code = f"{first_name[0].upper()}{last_name[0].upper()}{order_date.strftime('%d%m%y')}"
WorkOrder.objects.filter(pk=work_order.pk).update(display_code=display_code)
class Migration(migrations.Migration):
dependencies = [
('fleet', '0025_workorder_display_code'),
]
operations = [
migrations.RunPython(backfill_workorder_display_code, migrations.RunPython.noop),
]

View File

@@ -45,6 +45,24 @@ def _work_order_invoice_upload_to(instance, filename):
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/work_order_invoices/{unique_name}"
def _work_order_photo_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_photos/{unique_name}"
def _generated_work_order_pdf_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}/generated_pdfs/{unique_name}"
class VehicleServicePhoto(BaseModel):
"""
@@ -149,7 +167,7 @@ class Vehicle(BaseModel):
)
current_mileage = models.PositiveIntegerField(default=0, verbose_name=_("Trenutna kilometraža (km)"))
service_interval_km = models.PositiveIntegerField(default=15000, verbose_name=_("Interval servisa (km)"))
assigned_servicer = models.OneToOneField(
assigned_servicer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name='assigned_vehicles',
@@ -232,6 +250,7 @@ class WorkOrder(BaseModel):
('defektaza', _("Defektaža")),
('kontrola', _("Kontrola")),
('redovni_pregled', _("Redovni pregled")),
('popravak', _("Popravak")),
]
vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='work_orders', verbose_name=_("Vozilo"))
@@ -269,6 +288,13 @@ class WorkOrder(BaseModel):
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"))
display_code = models.CharField(
max_length=8,
blank=True,
db_index=True,
verbose_name=_("Prikaz putnog naloga"),
help_text=_("Format: MT070626 (2 slova + 6 znamenki)."),
)
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"))
@@ -304,6 +330,12 @@ class WorkOrder(BaseModel):
def clean(self):
super().clean()
if self.display_code:
self.display_code = str(self.display_code).strip().upper()
if not re.fullmatch(r"[A-Z]{2}[0-9]{6}", self.display_code):
raise ValidationError({
"display_code": _("Prikaz putnog naloga mora biti u formatu MT070626 (2 slova + 6 znamenki).")
})
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.")})
@@ -344,6 +376,79 @@ class WorkOrderInvoice(BaseModel):
return f"{self.naziv_racuna} ({self.work_order_id})"
class WorkOrderPhoto(BaseModel):
work_order = models.ForeignKey(
WorkOrder,
on_delete=models.CASCADE,
related_name='photos',
verbose_name=_("Putni nalog"),
)
image = models.ImageField(
upload_to=_work_order_photo_upload_to,
verbose_name=_("Fotografija putnog naloga"),
)
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_work_order_photos',
verbose_name=_("Postavio"),
)
is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
class Meta:
verbose_name = _("Fotografija putnog naloga")
verbose_name_plural = _("Fotografije putnog naloga")
ordering = ['-created_at']
def __str__(self):
return f"WorkOrderPhoto {self.pk} ({self.work_order_id})"
class GeneratedWorkOrderPdf(BaseModel):
PDF_TYPE_CHOICES = [
('work_order', _("Putni nalog")),
('service_records', _("Servisni zapisi")),
('invoices', _("Računi")),
]
STATUS_CHOICES = [
('pending', _("U obradi")),
('ready', _("Spremno")),
('failed', _("Neuspješno")),
]
work_order = models.ForeignKey(
WorkOrder,
on_delete=models.CASCADE,
related_name='generated_pdfs',
verbose_name=_("Putni nalog"),
)
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='requested_generated_work_order_pdfs',
verbose_name=_("Zatražio"),
)
pdf_type = models.CharField(max_length=32, choices=PDF_TYPE_CHOICES, db_index=True, verbose_name=_("Tip PDF-a"))
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='pending', db_index=True, verbose_name=_("Status"))
file = models.FileField(upload_to=_generated_work_order_pdf_upload_to, null=True, blank=True, verbose_name=_("PDF datoteka"))
filename = models.CharField(max_length=255, blank=True, verbose_name=_("Naziv datoteke"))
expires_at = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name=_("Vrijedi do"))
error_message = models.TextField(blank=True, verbose_name=_("Poruka greške"))
class Meta:
verbose_name = _("Generirani PDF putnog naloga")
verbose_name_plural = _("Generirani PDF-ovi putnih naloga")
ordering = ['-created_at']
def __str__(self):
return f"{self.pdf_type}:{self.work_order_id}:{self.status}"
class VehicleServiceRecord(BaseModel):
"""
Zapisi o servisima/popravcima na vozilu.

View File

@@ -0,0 +1,128 @@
import os
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
DEFAULT_MARGIN = 28
DEFAULT_HEADER_H = 86
DEFAULT_FOOTER_H = 72
def register_unicode_fonts():
if "Vera" in pdfmetrics.getRegisteredFontNames():
return
fonts_dir = os.path.join(os.path.dirname(__import__("reportlab").__file__), "fonts")
pdfmetrics.registerFont(TTFont("Vera", os.path.join(fonts_dir, "Vera.ttf")))
pdfmetrics.registerFont(TTFont("Vera-Bold", os.path.join(fonts_dir, "VeraBd.ttf")))
pdfmetrics.registerFont(TTFont("Vera-Italic", os.path.join(fonts_dir, "VeraIt.ttf")))
def draw_standard_header_footer(
pdf,
*,
page_num,
client_name,
manufacturer,
model,
serial,
upgrade_hours,
chassis_hours,
mileage,
work_order_number,
generated_date,
report_title="Izvještaj servisera",
page_size=A4,
margin=DEFAULT_MARGIN,
header_h=DEFAULT_HEADER_H,
footer_h=DEFAULT_FOOTER_H,
):
w, h = page_size
pdf.setStrokeColor(colors.black)
pdf.setLineWidth(0.7)
pdf.rect(margin, h - header_h, w - 2 * margin, header_h - 4, stroke=1, fill=0)
pdf.setFont("Vera-Bold", 11)
pdf.drawString(margin + 6, h - 22, str(client_name or "-")[:44])
pdf.drawCentredString(w / 2, h - 22, report_title)
pdf.setFont("Vera", 9)
pdf.drawRightString(w - margin - 6, h - 22, f"Stranica {page_num}")
pdf.setLineWidth(0.5)
pdf.line(margin, h - 36, w - margin, h - 36)
labels = [
"Model:",
"Serijski broj:",
"Radni sati nadogradnje:",
"Radni sati podvozja:",
"Km:",
"Broj naloga:",
]
values = [
(manufacturer, model),
serial,
upgrade_hours,
chassis_hours,
mileage,
work_order_number,
]
col_w = (w - 2 * margin) / len(labels)
pdf.setFont("Vera", 7.2)
for i, (lbl, val) in enumerate(zip(labels, values)):
x = margin + i * col_w + 3
pdf.drawString(x, h - 49, lbl)
value_text = str(val or "-")
value_font_size = 9
if i == 0 and isinstance(val, tuple):
manufacturer_text = str(val[0] or "-")[:24]
model_text = str(val[1] or "-")[:24]
pdf.setFont("Vera-Bold", 8.2)
pdf.drawString(x, h - 59, manufacturer_text)
pdf.setFont("Vera", 7.6)
pdf.drawString(x, h - 68, model_text)
elif i == len(labels) - 1:
value_font_size = 7
max_value_width = col_w - 8
while (
pdf.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width
and value_font_size > 5.5
):
value_font_size -= 0.3
if pdf.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width:
trimmed = value_text
while (
len(trimmed) > 3
and pdf.stringWidth(f"{trimmed}...", "Vera-Bold", value_font_size) > max_value_width
):
trimmed = trimmed[:-1]
value_text = f"{trimmed}..."
else:
value_text = value_text[:24]
if not (i == 0 and isinstance(val, tuple)):
pdf.setFont("Vera-Bold", value_font_size)
pdf.drawString(x, h - 61, value_text)
pdf.setFont("Vera", 7.2)
if i > 0:
pdf.line(margin + i * col_w, h - 36, margin + i * col_w, h - header_h + 4)
footer_y = footer_h
pdf.setLineWidth(0.5)
pdf.rect(margin, footer_y + 42, w - 2 * margin, 18, stroke=1, fill=0)
sig_labels = [f"Datum: {generated_date}", "Potpis servisera", "Pečat i potpis klijenta *1", "Pregledao"]
sig_w = (w - 2 * margin) / 4
pdf.setFont("Vera", 7.2)
for i, lbl in enumerate(sig_labels):
x = margin + i * sig_w
pdf.drawString(x + 4, footer_y + 45, lbl)
if i > 0:
pdf.line(x, footer_y + 42, x, footer_y + 60)
pdf.setFont("Vera", 6.5)
pdf.drawString(margin, footer_y + 26, "*1- potpisom klijent potvrđuje da je suglasan s podacima u radnom nalogu")
pdf.drawCentredString(
w / 2,
footer_y + 14,
"Ovlašteni servis LIEBHERR Werk-Ehingen GmbH, LIEBHERR-Werk Nenzing GmbH, Liebherr-MCCTech Rostock GmbH",
)

View File

@@ -1,10 +1,13 @@
from rest_framework import serializers
import re
from django.urls import reverse
from django.utils import timezone
from .models import (
Crane,
Vehicle,
VehicleNotification,
WorkOrder,
WorkOrderPhoto,
WorkOrderInvoice,
VehicleServiceRecord,
VehicleServicePhoto,
@@ -96,7 +99,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
class Meta:
model = WorkOrder
fields = [
'id', 'vehicle', 'crane', 'crane_label', 'creator', 'date',
'id', 'display_code', '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',
@@ -110,6 +113,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
'creator': {'required': False},
'start_mileage': {'required': False, 'allow_null': True},
'end_mileage': {'required': False, 'allow_null': True},
'display_code': {'required': False, 'allow_blank': False},
'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},
@@ -144,6 +148,14 @@ class WorkOrderSerializer(serializers.ModelSerializer):
if user and 'creator' not in validated_data:
validated_data['creator'] = user
if not validated_data.get('display_code'):
creator = validated_data.get('creator')
generated_display_code = self._generate_display_code(creator, timezone.localdate())
if not generated_display_code:
raise serializers.ValidationError({
'display_code': 'Serviser mora imati ime i prezime za automatsko generiranje oznake. Unesite oznaku ručno u formatu MT070626.'
})
validated_data['display_code'] = generated_display_code
return services.VehicleService.create_work_order(validated_data)
@@ -180,8 +192,26 @@ class WorkOrderSerializer(serializers.ModelSerializer):
raise serializers.ValidationError({'servicer_vehicle_fuel_cost': 'Trošak goriva ne može biti negativan.'})
return attrs
def validate_display_code(self, value):
normalized = str(value or '').strip().upper()
if not re.fullmatch(r"[A-Z]{2}[0-9]{6}", normalized):
raise serializers.ValidationError('Prikaz putnog naloga mora biti u formatu MT070626 (2 slova + 6 znamenki).')
return normalized
@staticmethod
def _generate_display_code(user, target_date):
if user is None:
return None
first_name = str(getattr(user, 'first_name', '') or '').strip()
last_name = str(getattr(user, 'last_name', '') or '').strip()
if not first_name or not last_name:
return None
return f"{first_name[0].upper()}{last_name[0].upper()}{target_date.strftime('%d%m%y')}"
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField()
class Meta:
model = WorkOrderInvoice
fields = [
@@ -192,11 +222,32 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
'datum',
'opis',
'image',
'image_url',
'created_by',
'created_at',
]
read_only_fields = ['id', 'created_by', 'created_at']
def get_image_url(self, obj):
request = self.context.get('request')
if not obj.image:
return None
if request is None:
return None
return request.build_absolute_uri(
reverse('work-order-invoice-image', kwargs={'pk': obj.pk})
)
class WorkOrderPhotoSerializer(serializers.ModelSerializer):
class Meta:
model = WorkOrderPhoto
fields = ['id', 'work_order', 'image', 'description', 'uploaded_by', 'created_at']
read_only_fields = ['id', 'uploaded_by', 'created_at']
extra_kwargs = {
'work_order': {'required': False},
}
class VehicleServiceRecordSerializer(serializers.ModelSerializer):
crane = serializers.SerializerMethodField()
@@ -207,12 +258,13 @@ class VehicleServiceRecordSerializer(serializers.ModelSerializer):
performed_by_name = serializers.SerializerMethodField()
vehicle_registration = serializers.SerializerMethodField()
task_label = serializers.SerializerMethodField()
crane_serial_number = serializers.SerializerMethodField()
class Meta:
model = VehicleServiceRecord
fields = [
'id', 'vehicle', 'crane', 'vehicle_registration', 'crane_registration',
'task', 'task_label',
'task', 'task_label', 'crane_serial_number',
'performed_by', 'performed_by_name',
'service_title', 'description', 'parts', 'cost',
'service_date', 'mileage', 'next_service_due_at',
@@ -261,6 +313,12 @@ class VehicleServiceRecordSerializer(serializers.ModelSerializer):
return None
return obj.task.title
def get_crane_serial_number(self, obj):
# Provjeri je li povezano vozilo/dizalica postoji i vrati serijski broj
if hasattr(obj, 'vehicle') and obj.vehicle:
return getattr(obj.vehicle, 'crane_serial_number', None)
return None
def create(self, validated_data):
request = self.context.get('request')
user = getattr(request, 'user', None)

View File

@@ -126,6 +126,7 @@ class VehicleService:
@staticmethod
def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder:
previous_status = instance.status
for k, v in data.items():
setattr(instance, k, v)
try:
@@ -134,8 +135,17 @@ class VehicleService:
raise ValidationError({"detail": str(e)})
with transaction.atomic():
instance.save()
# Ako je nalog zatvoren, obavijestimo kreatora/servisera
if instance.status == 'closed':
transitioned_to_closed = previous_status != 'closed' and instance.status == 'closed'
if transitioned_to_closed:
from modules.task_management.models import Task
Task.objects.filter(
is_active=True,
work_order=instance,
status__in=('aktivan', 'servis'),
).update(status='spreman_za_zavrsetak')
# Ako je nalog upravo zatvoren, obavijestimo kreatora/servisera
if transitioned_to_closed:
try:
NotificationService.create_notification(
recipient=instance.creator,

View File

@@ -2,6 +2,8 @@
from celery import shared_task
from django.core.mail import send_mail
from django.conf import settings
from django.utils import timezone
from django.core.files.base import ContentFile
import logging
from io import BytesIO
import base64
@@ -9,9 +11,18 @@ 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
from .models import VehicleNotification, GeneratedWorkOrderPdf
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
logger = logging.getLogger(__name__)
register_unicode_fonts()
def _work_order_display_code(work_order):
normalized = str(getattr(work_order, 'display_code', '') or '').strip().upper()
if normalized:
return normalized
return 'NALOG'
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
@@ -78,53 +89,94 @@ def _build_work_order_invoices_pdf(work_order):
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
y = height - 50
margin = 28
header_h = 86
footer_h = 72
content_top = height - header_h - 10
content_bottom = footer_h + 52
client_name = getattr(getattr(work_order.vehicle, "client", None), "name", None) or "-"
manufacturer = str(work_order.vehicle.make or "-")
model_line = str(work_order.vehicle.model or "-")
serial_line = str(getattr(work_order.vehicle, "crane_serial_number", None) or "-")
upgrade_hours = str(getattr(work_order.vehicle, "superstructure_working_hours", "-") or "-")
chassis_hours = str(getattr(work_order.vehicle, "chassis_working_hours", "-") or "-")
mileage = str(getattr(work_order.vehicle, "current_mileage", "-") or "-")
generated_date = timezone.localtime(timezone.now()).strftime("%d.%m.%Y")
display_code = _work_order_display_code(work_order)
pdf.setFont("Helvetica-Bold", 14)
pdf.drawString(40, y, f"Računi putnog naloga WO-{work_order.pk}")
y -= 22
pdf.setFont("Helvetica", 10)
pdf.drawString(40, y, f"Dizalica: {work_order.vehicle.registration_number}")
y -= 16
pdf.drawString(40, y, f"Datum naloga: {work_order.date}")
def draw_header_footer(page_num):
draw_standard_header_footer(
pdf,
page_num=page_num,
client_name=client_name,
manufacturer=manufacturer,
model=model_line,
serial=serial_line,
upgrade_hours=upgrade_hours,
chassis_hours=chassis_hours,
mileage=mileage,
work_order_number=display_code,
generated_date=generated_date,
report_title="Računi putnog naloga",
page_size=A4,
margin=margin,
header_h=header_h,
footer_h=footer_h,
)
page_num = 1
draw_header_footer(page_num)
y = content_top
y -= 8
pdf.setFont("Vera-Bold", 13)
pdf.drawString(margin, y, f"Računi putnog naloga {display_code}")
y -= 20
pdf.setFont("Vera", 10)
pdf.drawString(margin, y, f"Datum naloga: {work_order.date}")
invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')
if not invoices.exists():
y -= 24
pdf.drawString(40, y, "Nema računa za ovaj putni nalog.")
y -= 20
pdf.drawString(margin, y, "Nema računa za ovaj putni nalog.")
pdf.save()
return buffer.getvalue()
for index, invoice in enumerate(invoices, start=1):
pdf.showPage()
y = height - 50
pdf.setFont("Helvetica-Bold", 13)
pdf.drawString(40, y, f"Račun #{index}")
page_num += 1
draw_header_footer(page_num)
y = content_top
y -= 8
pdf.setFont("Vera-Bold", 13)
pdf.drawString(margin, y, f"Račun #{index}")
y -= 22
pdf.setFont("Helvetica", 10)
pdf.drawString(40, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}")
pdf.setFont("Vera", 10)
pdf.drawString(margin, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}")
y -= 16
pdf.drawString(40, y, f"lokacija: {invoice.lokacija or '-'}")
pdf.drawString(margin, y, f"lokacija: {invoice.lokacija or '-'}")
y -= 16
pdf.drawString(40, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}")
pdf.drawString(margin, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}")
y -= 16
pdf.drawString(40, y, f"opis: {(invoice.opis or '-')[:140]}")
pdf.drawString(margin, y, f"opis: {(invoice.opis or '-')[:140]}")
y -= 20
if not invoice.image:
pdf.drawString(40, y, "slika: Nema slike.")
pdf.drawString(margin, y, "slika: Nema slike.")
continue
try:
invoice.image.open('rb')
with Image.open(invoice.image) as source:
image = source.convert('RGB')
max_width = width - 80
max_height = y - 60
max_width = width - (2 * margin)
max_height = y - content_bottom
if max_height < 120:
pdf.showPage()
y = height - 60
max_height = y - 60
page_num += 1
draw_header_footer(page_num)
y = content_top
max_height = y - content_bottom
ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0)
draw_width = max(1, int(image.width * ratio))
@@ -132,7 +184,7 @@ def _build_work_order_invoices_pdf(work_order):
image_reader = ImageReader(image)
pdf.drawImage(
image_reader,
40,
margin,
y - draw_height,
width=draw_width,
height=draw_height,
@@ -140,7 +192,7 @@ def _build_work_order_invoices_pdf(work_order):
mask='auto',
)
except (UnidentifiedImageError, OSError):
pdf.drawString(40, y, "slika: Slika nije dostupna ili je oštećena.")
pdf.drawString(margin, y, "slika: Slika nije dostupna ili je oštećena.")
finally:
invoice.image.close()
@@ -158,8 +210,110 @@ def build_work_order_invoices_pdf_task(work_order_id):
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"
filename = f"{_work_order_display_code(work_order)}.work-order-invoices.pdf"
return {
"filename": filename,
"pdf_base64": pdf_b64,
}
}
@shared_task
def cleanup_expired_generated_pdfs_task():
now = timezone.now()
expired = GeneratedWorkOrderPdf.objects.filter(
is_active=True,
expires_at__isnull=False,
expires_at__lte=now,
)
deleted = 0
for item in expired:
if item.file:
item.file.delete(save=False)
item.is_active = False
item.status = 'failed'
item.error_message = 'PDF cache istekao.'
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
deleted += 1
return {"deleted": deleted}
@shared_task
def build_work_order_pdf_cached_task(generated_pdf_id):
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf
from .services import NotificationService
generated = (
GeneratedWorkOrderPdf.objects
.select_related('work_order', 'work_order__vehicle', 'requested_by')
.filter(pk=generated_pdf_id, is_active=True)
.first()
)
if generated is None:
return {"error": "Generated PDF zapis nije pronađen."}
work_order = generated.work_order
try:
if generated.pdf_type == 'invoices':
payload = build_work_order_invoices_pdf_task(str(work_order.pk))
if not isinstance(payload, dict) or payload.get('error'):
raise ValueError(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 ValueError('PDF sadržaj nije dostupan.')
pdf_bytes = base64.b64decode(pdf_b64)
elif generated.pdf_type == 'service_records':
pdf_bytes = _build_work_order_service_records_pdf(work_order)
else:
pdf_bytes = _build_work_order_pdf(work_order)
filename = generated.filename or f"{_work_order_display_code(work_order)}.{generated.pdf_type}.pdf"
generated.file.save(filename, ContentFile(pdf_bytes), save=False)
generated.status = 'ready'
generated.error_message = ''
generated.save(update_fields=['file', 'status', 'error_message', 'updated_at'])
if generated.requested_by:
NotificationService.create_notification(
recipient=generated.requested_by,
title="PDF spreman",
message=f"PDF dokument je uspješno generiran za putni nalog {_work_order_display_code(work_order)}.",
level="success",
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": generated.pdf_type,
"stage": "completed",
"generated_pdf_id": str(generated.pk),
"download_url": f"fleet/work-orders/{work_order.pk}/generated-pdfs/{generated.pk}/download/",
"filename": generated.filename or filename,
"expires_at": generated.expires_at.isoformat() if generated.expires_at else None,
},
)
return {"status": "ready", "generated_pdf_id": str(generated.pk)}
except Exception as exc:
generated.status = 'failed'
generated.error_message = str(exc)
generated.save(update_fields=['status', 'error_message', 'updated_at'])
if generated.requested_by:
NotificationService.create_notification(
recipient=generated.requested_by,
title="Greška kod PDF-a",
message=f"Generiranje PDF dokumenta nije uspjelo za putni nalog {_work_order_display_code(work_order)}.",
level="warning",
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": generated.pdf_type,
"stage": "failed",
"generated_pdf_id": str(generated.pk),
},
)
logger.exception("Greška kod build_work_order_pdf_cached_task: %s", exc)
return {"status": "failed", "error": str(exc)}

View File

@@ -4,14 +4,12 @@ from modules.fleet.models import Vehicle
from django.contrib.auth import get_user_model
@pytest.mark.django_db
def test_vehicle_servicer_one_to_one_constraint(test_user):
# Prvo vozilo prolazi
def test_vehicle_servicer_many_to_one_allows_multiple_vehicles(test_user):
Vehicle.objects.create(registration_number="ZG-111-AA", assigned_servicer=test_user)
# Drugo vozilo za istog servisera mora baciti IntegrityError
with pytest.raises(Exception): # Django IntegrityError
Vehicle.objects.create(registration_number="ZG-222-BB", assigned_servicer=test_user)
Vehicle.objects.create(registration_number="ZG-222-BB", assigned_servicer=test_user)
assert Vehicle.objects.filter(assigned_servicer=test_user).count() == 2
@pytest.mark.django_db
def test_vehicle_next_service_logic():

View File

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

View File

@@ -3,6 +3,9 @@ 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
from modules.fleet.serializers import WorkOrderSerializer
from modules.task_management.models import Task
from rest_framework.test import APIRequestFactory
class FleetServiceTests(TestCase):
@@ -19,7 +22,7 @@ class FleetServiceTests(TestCase):
)
def test_create_work_order_via_service(self):
data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "Delivery"}
data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "kontrola"}
wo = VehicleService.create_work_order(data)
self.vehicle.refresh_from_db()
self.assertEqual(self.vehicle.current_mileage, 5200)
@@ -49,4 +52,80 @@ class FleetServiceTests(TestCase):
# Nalog sa završnom kilometražom
wo.end_mileage = 1100
assert wo.distance == 100
assert wo.distance == 100
def test_closing_work_order_moves_task_to_ready_for_completion(self):
work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
purpose="kontrola",
status="open",
start_mileage=5000,
end_mileage=5200,
)
task = Task.objects.create(
title="Servis",
description="",
status="servis",
assigned_to=self.user,
vehicle=self.vehicle,
work_order=work_order,
)
VehicleService.update_work_order(work_order, {"status": "closed"})
task.refresh_from_db()
self.assertEqual(task.status, "spreman_za_zavrsetak")
def test_closing_work_order_moves_active_task_to_ready_for_completion(self):
work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
purpose="kontrola",
status="open",
start_mileage=5000,
end_mileage=5200,
)
task = Task.objects.create(
title="Preventiva",
description="",
status="aktivan",
assigned_to=self.user,
vehicle=self.vehicle,
work_order=work_order,
)
VehicleService.update_work_order(work_order, {"status": "closed"})
task.refresh_from_db()
self.assertEqual(task.status, "spreman_za_zavrsetak")
def test_work_order_serializer_autogenerates_display_code(self):
self.user.first_name = "Mario"
self.user.last_name = "Tkalac"
self.user.save(update_fields=["first_name", "last_name"])
request = APIRequestFactory().post('/api/fleet/work-orders/', {})
request.user = self.user
serializer = WorkOrderSerializer(
data={
"vehicle": str(self.vehicle.id),
"purpose": "kontrola",
},
context={"request": request},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
work_order = serializer.save()
self.assertRegex(work_order.display_code, r'^MT[0-9]{6}$')
def test_work_order_serializer_rejects_invalid_display_code(self):
request = APIRequestFactory().post('/api/fleet/work-orders/', {})
request.user = self.user
serializer = WorkOrderSerializer(
data={
"vehicle": str(self.vehicle.id),
"purpose": "kontrola",
"display_code": "BAD-CODE",
},
context={"request": request},
)
self.assertFalse(serializer.is_valid())
self.assertIn("display_code", serializer.errors)

View File

@@ -0,0 +1,140 @@
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
import uuid
from io import BytesIO
from PIL import Image
from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto
def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
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 WorkOrderImagesEndpointTests(TestCase):
def setUp(self):
user_model = get_user_model()
suffix = uuid.uuid4().hex[:8]
self.user = user_model.objects.create_user(
username=f'wo-img-user-{suffix}',
email=f'wo-img-user-{suffix}@example.test',
password='test1234',
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.vehicle = Vehicle.objects.create(
asset_type='crane',
registration_number='WO-IMG-001',
crane_serial_number='123456',
)
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
purpose='kontrola',
)
self.service_record = VehicleServiceRecord.objects.create(
vehicle=self.vehicle,
performed_by=self.user,
description='Servisni zapis za test izolacije fotografija.',
service_title='Test servis',
)
def test_work_order_images_endpoint_isolated_from_service_record_photos(self):
# Existing service photo must NOT appear in work-order images response
VehicleServicePhoto.objects.create(
service_record=self.service_record,
image=create_test_image('service.jpg'),
description='Service photo',
uploaded_by=self.user,
)
upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
response = self.client.post(
upload_url,
data={
'image': create_test_image('workorder.jpg'),
'description': 'Work order photo',
},
format='multipart',
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(str(response.data.get('work_order')), str(self.work_order.pk))
list_response = self.client.get(upload_url)
self.assertEqual(list_response.status_code, 200)
images = list_response.data.get('images', [])
self.assertEqual(len(images), 1)
self.assertEqual(str(images[0].get('work_order_id')), str(self.work_order.pk))
def test_service_record_files_endpoint_does_not_return_work_order_images(self):
upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
self.client.post(
upload_url,
data={
'image': create_test_image('workorder-2.jpg'),
'description': 'WO isolated',
},
format='multipart',
)
service_photos_url = f"{reverse('service-photo-list')}?service_record_id={self.service_record.pk}"
response = self.client.get(service_photos_url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 0)
def test_work_order_images_endpoint_returns_api_file_urls(self):
upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
create_response = self.client.post(
upload_url,
data={
'image': create_test_image('workorder-url.jpg'),
'description': 'URL test',
},
format='multipart',
)
self.assertEqual(create_response.status_code, 201, create_response.data)
list_response = self.client.get(upload_url)
self.assertEqual(list_response.status_code, 200)
images = list_response.data.get('images', [])
self.assertEqual(len(images), 1)
photo_id = images[0]['id']
self.assertIn('/api/fleet/work-orders/', images[0]['original_url'])
self.assertIn(f'/images/{photo_id}/file', images[0]['original_url'])
file_response = self.client.get(images[0]['original_url'])
self.assertEqual(file_response.status_code, 200)
self.assertTrue(file_response['Content-Type'].startswith('image/'))
def test_work_order_invoice_serializer_exposes_image_api_url(self):
invoice = WorkOrderInvoice.objects.create(
work_order=self.work_order,
naziv_racuna='Test račun',
image=create_test_image('invoice.jpg'),
created_by=self.user,
)
list_url = f"{reverse('work-order-invoice-list')}?work_order_id={self.work_order.pk}"
list_response = self.client.get(list_url)
self.assertEqual(list_response.status_code, 200)
self.assertEqual(len(list_response.data), 1)
image_url = list_response.data[0].get('image_url')
self.assertIsNotNone(image_url)
self.assertIn(f'/api/fleet/work-order-invoices/{invoice.pk}/image/', image_url)
image_response = self.client.get(image_url)
self.assertEqual(image_response.status_code, 200)
self.assertTrue(image_response['Content-Type'].startswith('image/'))

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,13 @@ from modules.crm.models import Client
logger = logging.getLogger(__name__)
class InvoiceService:
@staticmethod
def create_full_invoice(*args, **kwargs):
from modules.invoicing.services import InvoiceService as _InvoiceService
return _InvoiceService.create_full_invoice(*args, **kwargs)
@shared_task(bind=True, max_retries=3)
def process_paperless_document_task(self, document_id):
"""
@@ -57,8 +64,6 @@ def process_paperless_document_task(self, document_id):
"created_at": raw_created,
}
from modules.invoicing.services import InvoiceService
lookup_field = User.USERNAME_FIELD
system_user = User.objects.filter(**{lookup_field: "bot@erp.hr"}).first()

View File

@@ -1,21 +1,56 @@
# backend/modules/task_management/admin.py
# backend/modules/task_management/admin.py
from django.contrib import admin
from .models import Task
from .models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry, TaskWorkHoursTable
class TaskWorkEntryInline(admin.TabularInline):
model = TaskWorkEntry
extra = 0
fields = ('sort_order', 'title', 'work_description', 'active_faults', 'explanation_hr', 'template_entry')
readonly_fields = ('template_entry',)
class TaskWorkHoursTableInline(admin.StackedInline):
model = TaskWorkHoursTable
extra = 0
fields = ('data', 'is_active')
class TaskTemplateEntryInline(admin.TabularInline):
model = TaskTemplateEntry
extra = 0
fields = ('sort_order', 'title', 'work_description', 'active_faults', 'explanation_hr', 'is_active')
@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
list_display = ('title', 'status', 'scheduled_date', 'assigned_to', 'vehicle', 'work_order', 'created_at')
list_filter = ('status', 'scheduled_date', 'created_at', 'work_order')
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')
readonly_fields = ('id', 'created_at', 'updated_at')
inlines = (TaskWorkHoursTableInline, TaskWorkEntryInline)
@admin.register(TaskTemplate)
class TaskTemplateAdmin(admin.ModelAdmin):
list_display = ('code', 'title', 'status', 'is_active', 'created_at')
list_filter = ('status', 'is_active', 'created_at')
search_fields = ('code', 'title', 'description')
inlines = (TaskTemplateEntryInline,)
@admin.register(TaskWorkEntry)
class TaskWorkEntryAdmin(admin.ModelAdmin):
list_display = ('task', 'sort_order', 'title', 'template_entry', 'created_at')
list_filter = ('created_at',)
search_fields = ('task__title', 'title', 'active_faults', 'explanation_hr')
readonly_fields = ('id', 'created_at', 'updated_at')
@admin.register(TaskWorkHoursTable)
class TaskWorkHoursTableAdmin(admin.ModelAdmin):
list_display = ('task', 'updated_at', 'is_active')
search_fields = ('task__title', 'task__vehicle__registration_number')
readonly_fields = ('id', 'created_at', 'updated_at')

View File

@@ -0,0 +1,7 @@
from rest_framework.exceptions import APIException
class OpenWorkOrderBlockingTaskClose(APIException):
status_code = 409
default_code = 'open_work_order_blocking_task_close'
default_detail = 'Povezani putni nalog je još otvoren.'

View File

@@ -0,0 +1,72 @@
# Generated by Django 5.2.15 on 2026-07-12
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0003_task_vehicle'),
]
operations = [
migrations.CreateModel(
name='TaskTemplate',
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')),
('code', models.SlugField(max_length=64, unique=True)),
('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)),
],
options={
'ordering': ['title'],
},
),
migrations.CreateModel(
name='TaskTemplateEntry',
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')),
('sort_order', models.PositiveSmallIntegerField(default=1)),
('title', models.CharField(max_length=255)),
('work_description', models.TextField(blank=True)),
('active_faults', models.TextField(blank=True, help_text='Aktivne greške.')),
('explanation_hr', models.TextField(blank=True, help_text='Obrazloženje na hrvatskom jeziku.')),
('task_template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='task_management.tasktemplate')),
],
options={
'ordering': ['sort_order', 'created_at'],
},
),
migrations.CreateModel(
name='TaskWorkEntry',
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')),
('sort_order', models.PositiveSmallIntegerField(default=1)),
('title', models.CharField(max_length=255)),
('work_description', models.TextField(blank=True)),
('active_faults', models.TextField(blank=True)),
('explanation_hr', models.TextField(blank=True)),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_entries', to='task_management.task')),
('template_entry', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='task_work_entries', to='task_management.tasktemplateentry')),
],
options={
'ordering': ['sort_order', 'created_at'],
},
),
migrations.AddConstraint(
model_name='tasktemplateentry',
constraint=models.UniqueConstraint(fields=('task_template', 'sort_order'), name='task_template_entry_sort_order_unique'),
),
]

View File

@@ -0,0 +1,100 @@
# Generated by Django 5.2.15 on 2026-07-12
from django.db import migrations
def seed_task_templates(apps, schema_editor):
TaskTemplate = apps.get_model('task_management', 'TaskTemplate')
TaskTemplateEntry = apps.get_model('task_management', 'TaskTemplateEntry')
templates = [
{
'code': 'zamjena-ulja-500h',
'title': 'Zamjena ulja nakon 500h',
'description': 'Redovni servis uljnog sustava nakon 500 radnih sati.',
'status': 'aktivan',
'entries': [
('Provjera razine i stanja ulja', 'Izvršena kontrola razine i vizualna kontrola kontaminacije ulja.', 'Nema aktivnih grešaka.', 'Sustav uredan, bez upozorenja.'),
('Ispuštanje starog ulja', 'Sigurno ispušteno staro ulje prema servisnoj proceduri.', 'Nema aktivnih grešaka.', 'Ulje zamijenjeno prema uputama proizvođača.'),
('Zamjena filtera ulja', 'Demontiran stari i ugrađen novi filter ulja.', 'Nema aktivnih grešaka.', 'Filter promijenjen i sustav pripremljen za punjenje.'),
('Punjenje i test sustava', 'Sustav napunjen novim uljem i odrađen probni rad.', 'Nema aktivnih grešaka.', 'Nakon testa nema curenja ni grešaka.'),
],
},
{
'code': 'zamjena-ulja-1000h',
'title': 'Zamjena ulja nakon 1000h',
'description': 'Veliki servis uljnog sustava nakon 1000 radnih sati.',
'status': 'aktivan',
'entries': [
('Dijagnostika sustava prije servisa', 'Očitani parametri rada i zabilježeno početno stanje.', 'Provjeriti povremeni pad tlaka.', 'Prijevod poruke greške i potvrda simptoma na hrvatskom.'),
('Zamjena ulja i glavnih filtera', 'Zamijenjeno ulje i svi pripadajući filter elementi.', 'Nema kritičnih aktivnih grešaka.', 'Servisni interval resetiran.'),
('Kontrola brtvi i spojeva', 'Pregledani spojevi i brtve na mogućnost curenja.', 'Nema aktivnih grešaka.', 'Spojevi su suhi i ispravni.'),
('Test pod opterećenjem', 'Izvršen funkcionalni test pod radnim opterećenjem.', 'Nema aktivnih grešaka.', 'Sustav stabilan pri opterećenju.'),
],
},
{
'code': 'promjena-senzora',
'title': 'Promjena senzora',
'description': 'Zamjena neispravnog senzora i verifikacija rada.',
'status': 'aktivan',
'entries': [
('Identifikacija neispravnog senzora', 'Dijagnostikom potvrđena neispravnost senzora.', 'Aktivna greška senzora.', 'Greška prevedena s engleskog i objašnjena korisniku.'),
('Demontaža i montaža novog senzora', 'Neispravan senzor uklonjen i ugrađen novi originalni dio.', 'Nema novih aktivnih grešaka.', 'Montaža izvedena prema specifikaciji.'),
('Kalibracija senzora', 'Izvršena kalibracija i sinkronizacija s upravljačkom jedinicom.', 'Nema aktivnih grešaka.', 'Kalibracija uspješna.'),
('Završno testiranje', 'Provjeren odziv sustava tijekom rada.', 'Nema aktivnih grešaka.', 'Senzor radi unutar tolerancije.'),
],
},
{
'code': 'aktivna-osovina-ukoso',
'title': 'Aktivna osovina ide ukoso',
'description': 'Dijagnostika i otklanjanje kvara aktivne osovine.',
'status': 'aktivan',
'entries': [
('Dijagnostika geometrije osovine', 'Provjereni kutovi i odstupanja geometrije osovine.', 'Aktivna greška upravljanja osovinom.', 'Opis greške preveden i pojašnjen na hrvatskom.'),
('Pregled hidrauličkih komponenti', 'Provjeren tlak i stanje hidrauličkih vodova.', 'Mogući pad tlaka na upravljačkom krugu.', 'Predložena zamjena kritičnog ventila.'),
('Korekcija i podešavanje', 'Izvršeno mehaničko/hidrauličko podešavanje osovine.', 'Nema aktivnih grešaka nakon podešavanja.', 'Osovina vraćena u radne tolerancije.'),
('Probna vožnja i završna potvrda', 'Izvršena probna vožnja i kontrola stabilnosti pravca.', 'Nema aktivnih grešaka.', 'Kvar otklonjen, vozilo spremno za rad.'),
],
},
]
for template_data in templates:
entries = template_data.pop('entries')
template, _ = TaskTemplate.objects.update_or_create(
code=template_data['code'],
defaults=template_data,
)
TaskTemplateEntry.objects.filter(task_template=template).delete()
for idx, entry in enumerate(entries, start=1):
title, work_description, active_faults, explanation_hr = entry
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=idx,
title=title,
work_description=work_description,
active_faults=active_faults,
explanation_hr=explanation_hr,
)
def reverse_seed_task_templates(apps, schema_editor):
TaskTemplate = apps.get_model('task_management', 'TaskTemplate')
TaskTemplate.objects.filter(
code__in=[
'zamjena-ulja-500h',
'zamjena-ulja-1000h',
'promjena-senzora',
'aktivna-osovina-ukoso',
]
).delete()
class Migration(migrations.Migration):
dependencies = [
('task_management', '0004_tasktemplate_taskworkentry'),
]
operations = [
migrations.RunPython(seed_task_templates, reverse_seed_task_templates),
]

View File

@@ -0,0 +1,39 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0005_seed_task_templates'),
]
operations = [
migrations.AlterField(
model_name='task',
name='status',
field=models.CharField(
choices=[
('aktivan', 'Aktivan'),
('servis', 'U tijeku'),
('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'),
],
default='aktivan',
max_length=20,
),
),
migrations.AlterField(
model_name='tasktemplate',
name='status',
field=models.CharField(
choices=[
('aktivan', 'Aktivan'),
('servis', 'U tijeku'),
('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'),
],
default='aktivan',
max_length=20,
),
),
]

View File

@@ -0,0 +1,29 @@
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0006_add_finished_task_status'),
]
operations = [
migrations.CreateModel(
name='TaskWorkHoursTable',
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')),
('data', models.JSONField(blank=True, default=None, null=True, verbose_name='Podaci tablice radnih sati')),
('task', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='work_hours_table', to='task_management.task', verbose_name='Radni zadatak')),
],
options={
'verbose_name': 'Tablica radnih sati',
'verbose_name_plural': 'Tablice radnih sati',
'ordering': ['-updated_at'],
},
),
]

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0007_taskworkhourstable'),
]
operations = [
migrations.AlterField(
model_name='task',
name='status',
field=models.CharField(
choices=[
('aktivan', 'Aktivan'),
('servis', 'U tijeku'),
('spreman_za_zavrsetak', 'Spreman za završetak'),
('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'),
],
default='aktivan',
max_length=20,
),
),
]

View File

@@ -0,0 +1,22 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0008_task_add_ready_for_completion_status'),
]
operations = [
migrations.AddField(
model_name='task',
name='scheduled_date',
field=models.DateField(
null=True,
blank=True,
db_index=True,
verbose_name='Planirani datum izvršenja',
help_text='Datum kada je planirano izvršenje radnog zadatka.',
),
),
]

View File

@@ -1,10 +1,11 @@
# backend/modules/task_management/models.py
# 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
from core.base_models import BaseModel
class Task(BaseModel):
"""
@@ -13,6 +14,8 @@ class Task(BaseModel):
STATUS_CHOICES = [
('aktivan', 'Aktivan'),
('servis', 'U tijeku'),
('spreman_za_zavrsetak', 'Spreman za završetak'),
('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'),
]
title = models.CharField(max_length=255)
@@ -23,9 +26,9 @@ class Task(BaseModel):
default='aktivan'
)
assigned_to = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='tasks'
)
vehicle = models.ForeignKey(
@@ -44,6 +47,13 @@ class Task(BaseModel):
related_name='work_order_tasks',
verbose_name=_("Putni nalog"),
)
scheduled_date = models.DateField(
null=True,
blank=True,
db_index=True,
verbose_name=_("Planirani datum izvršenja"),
help_text=_("Datum kada je planirano izvršenje radnog zadatka."),
)
class Meta:
ordering = ['-created_at']
@@ -53,7 +63,102 @@ class Task(BaseModel):
def clean(self):
super().clean()
if self.status == 'neaktivan' and not self.work_order_id:
if self.status in {'neaktivan', 'zavrsen'} and not self.work_order_id:
raise ValidationError({
'work_order': "Putni nalog je obavezan prije zatvaranja zadatka."
})
})
class TaskWorkHoursTable(BaseModel):
"""Interaktivna tablica radnih sati povezana s radnim zadatkom."""
task = models.OneToOneField(
Task,
on_delete=models.CASCADE,
related_name='work_hours_table',
verbose_name=_("Radni zadatak"),
)
data = models.JSONField(
null=True,
blank=True,
default=None,
verbose_name=_("Podaci tablice radnih sati"),
)
class Meta:
ordering = ['-updated_at']
verbose_name = _("Tablica radnih sati")
verbose_name_plural = _("Tablice radnih sati")
def __str__(self):
return f"Work hours table for task {self.task_id}"
class TaskTemplate(BaseModel):
"""Predložak za brzo kreiranje servisnog radnog zadatka."""
code = models.SlugField(max_length=64, unique=True)
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
status = models.CharField(max_length=20, choices=Task.STATUS_CHOICES, default='aktivan')
class Meta:
ordering = ['title']
def __str__(self):
return f"{self.title} ({self.code})"
class TaskTemplateEntry(BaseModel):
"""Predlošci radnih unosa koji se generiraju prilikom kreiranja Taska."""
task_template = models.ForeignKey(
TaskTemplate,
on_delete=models.CASCADE,
related_name='entries',
)
sort_order = models.PositiveSmallIntegerField(default=1)
title = models.CharField(max_length=255)
work_description = models.TextField(blank=True)
active_faults = models.TextField(blank=True, help_text=_("Aktivne greške."))
explanation_hr = models.TextField(blank=True, help_text=_("Obrazloženje na hrvatskom jeziku."))
class Meta:
ordering = ['sort_order', 'created_at']
constraints = [
models.UniqueConstraint(
fields=['task_template', 'sort_order'],
name='task_template_entry_sort_order_unique',
)
]
def __str__(self):
return f"{self.task_template.code} #{self.sort_order} - {self.title}"
class TaskWorkEntry(BaseModel):
"""Radni unos na konkretnom Tasku (generiran iz templatea ili ručno)."""
task = models.ForeignKey(
Task,
on_delete=models.CASCADE,
related_name='work_entries',
)
template_entry = models.ForeignKey(
TaskTemplateEntry,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='task_work_entries',
)
sort_order = models.PositiveSmallIntegerField(default=1)
title = models.CharField(max_length=255)
work_description = models.TextField(blank=True)
active_faults = models.TextField(blank=True)
explanation_hr = models.TextField(blank=True)
class Meta:
ordering = ['sort_order', 'created_at']
def __str__(self):
return f"{self.task_id} #{self.sort_order} - {self.title}"

View File

@@ -1,12 +1,93 @@
# backend/modules/task_management/serializers.py
# backend/modules/task_management/serializers.py
from rest_framework import serializers
from .models import Task
from .models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry, TaskWorkHoursTable
WORK_HOURS_ROW_FIELDS = [
'day',
'date',
'work_time_from',
'work_time_to',
'travel_time_from',
'travel_time_to',
'break_hours',
'work_hours',
'travel_hours',
'departure_place',
'arrival_place',
'vehicle_km',
]
class TaskWorkHoursTableSerializer(serializers.ModelSerializer):
class Meta:
model = TaskWorkHoursTable
fields = ['id', 'task', 'data', 'created_at', 'updated_at']
read_only_fields = ['id', 'created_at', 'updated_at']
def _normalize_row(self, row):
normalized = {}
for field in WORK_HOURS_ROW_FIELDS:
value = row.get(field, '')
normalized[field] = '' if value is None else str(value).strip()
return normalized
def validate_data(self, value):
if value is None:
return None
if not isinstance(value, dict):
raise serializers.ValidationError("Podaci tablice moraju biti JSON objekt ili null.")
rows = value.get('rows', [])
if not isinstance(rows, list):
raise serializers.ValidationError({"rows": "Polje 'rows' mora biti lista redaka."})
normalized_rows = []
for row in rows[:30]:
if not isinstance(row, dict):
raise serializers.ValidationError({"rows": "Svaki redak mora biti JSON objekt."})
normalized_rows.append(self._normalize_row(row))
return {'rows': normalized_rows}
class TaskWorkEntrySerializer(serializers.ModelSerializer):
class Meta:
model = TaskWorkEntry
fields = [
'id', 'task', 'template_entry', 'sort_order', 'title',
'work_description', 'active_faults', 'explanation_hr',
'created_at',
]
read_only_fields = ['id', 'created_at']
class TaskTemplateEntrySerializer(serializers.ModelSerializer):
class Meta:
model = TaskTemplateEntry
fields = [
'id', 'sort_order', 'title', 'work_description',
'active_faults', 'explanation_hr',
]
read_only_fields = ['id']
class TaskTemplateSerializer(serializers.ModelSerializer):
entries = TaskTemplateEntrySerializer(many=True, read_only=True)
class Meta:
model = TaskTemplate
fields = ['id', 'code', 'title', 'description', 'status', 'entries']
read_only_fields = ['id']
class TaskSerializer(serializers.ModelSerializer):
assigned_to_name = serializers.SerializerMethodField()
work_order_label = serializers.SerializerMethodField()
vehicle_registration = serializers.SerializerMethodField()
template_id = serializers.UUIDField(write_only=True, required=False, allow_null=True)
auto_close_work_order = serializers.BooleanField(write_only=True, required=False)
class Meta:
model = Task
@@ -15,27 +96,50 @@ class TaskSerializer(serializers.ModelSerializer):
'assigned_to', 'assigned_to_name',
'vehicle', 'vehicle_registration',
'work_order', 'work_order_label',
'scheduled_date',
'template_id', 'auto_close_work_order',
'created_at',
]
read_only_fields = ['id', 'created_at']
extra_kwargs = {
'title': {'required': False, 'allow_blank': True},
'description': {'required': False, 'allow_blank': True},
'status': {'required': False},
'work_order': {'required': False, 'allow_null': True},
'vehicle': {'required': False, 'allow_null': True},
'scheduled_date': {'required': False, 'allow_null': True},
}
def validate_title(self, value):
"""Primjer custom validacije: naziv ne smije biti prekratak."""
if not value:
return value
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)
template_id = attrs.get('template_id')
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))
title = (attrs.get('title', getattr(current, 'title', '')) or '').strip()
if template_id:
template = TaskTemplate.objects.filter(id=template_id, is_active=True).first()
if not template:
raise serializers.ValidationError({'template_id': 'Odabrani template ne postoji ili nije aktivan.'})
if current is None and not vehicle:
raise serializers.ValidationError({
'vehicle': "Dizalica je obavezna pri kreiranju taska."
})
if current is None and not template_id and not title:
raise serializers.ValidationError({
'title': "Naslov zadatka je obavezan ako se task ne kreira iz templatea."
})
if work_order and not vehicle:
vehicle = work_order.vehicle
attrs['vehicle'] = vehicle
@@ -44,7 +148,7 @@ class TaskSerializer(serializers.ModelSerializer):
'work_order': "Putni nalog mora pripadati istoj dizalici kao task."
})
if status_value == 'neaktivan' and not work_order:
if status_value in {'neaktivan', 'zavrsen'} and not work_order:
raise serializers.ValidationError({
'work_order': "Putni nalog je obavezan prije zatvaranja zadatka."
})
@@ -53,7 +157,10 @@ class TaskSerializer(serializers.ModelSerializer):
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()}"
display_code = str(getattr(obj.work_order, 'display_code', '') or '').strip().upper()
if display_code:
return display_code
return None
def get_assigned_to_name(self, obj):
if not obj.assigned_to_id:
@@ -72,4 +179,4 @@ class TaskSerializer(serializers.ModelSerializer):
def get_vehicle_registration(self, obj):
if not obj.vehicle_id:
return None
return obj.vehicle.registration_number
return obj.vehicle.registration_number

View File

@@ -1,23 +1,46 @@
# backend/modules/task_management/services.py
# backend/modules/task_management/services.py
from .models import Task
from django.db import transaction
from rest_framework.exceptions import ValidationError
from django.core.exceptions import ValidationError as DjangoValidationError
import logging
from .models import Task, TaskTemplate, TaskWorkEntry
from .exceptions import OpenWorkOrderBlockingTaskClose
from modules.fleet.models import VehicleServiceRecord
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):
payload = dict(data)
template_id = payload.pop('template_id', None)
if payload.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)
template = None
if template_id:
template = TaskTemplate.objects.filter(id=template_id, is_active=True).prefetch_related('entries').first()
if not template:
raise ValidationError({"template_id": "Odabrani template nije pronađen."})
payload['title'] = template.title
payload['description'] = template.description
payload['status'] = 'aktivan'
payload.setdefault('work_order', None)
title = (payload.get('title') or '').strip()
if title and len(title) < 3:
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
if not title:
raise ValidationError({"title": "Naslov zadatka je obavezan."})
if user and 'assigned_to' not in payload:
payload['assigned_to'] = user
@@ -27,22 +50,100 @@ class TaskService:
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()
with transaction.atomic():
entry.save()
if template:
TaskService._create_work_entries_from_template(task=entry, template=template)
TaskService._create_service_records_from_template(task=entry, template=template, user=user)
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)
def _create_work_entries_from_template(*, task: Task, template: TaskTemplate):
template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10])
for idx, source in enumerate(template_entries, start=1):
TaskWorkEntry.objects.create(
task=task,
template_entry=source,
sort_order=idx,
title=source.title,
work_description=source.work_description,
active_faults=source.active_faults,
explanation_hr=source.explanation_hr,
)
@staticmethod
def _create_service_records_from_template(*, task: Task, template: TaskTemplate, user):
if not user:
raise ValidationError({
"template_id": "Kreiranje taska iz templatea zahtijeva prijavljenog korisnika za servisne zapise."
})
if not task.vehicle_id:
raise ValidationError({
"vehicle": "Task iz templatea mora imati dodijeljenu dizalicu za generiranje servisnih zapisa."
})
template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10])
for source in template_entries:
description_parts = []
if source.work_description:
description_parts.append(source.work_description.strip())
if source.explanation_hr:
description_parts.append(f"Obrazloženje (HR): {source.explanation_hr.strip()}")
if source.active_faults:
description_parts.append(f"Aktivne greške: {source.active_faults.strip()}")
description = "\n\n".join([part for part in description_parts if part]).strip() or source.title
VehicleServiceRecord.objects.create(
vehicle=task.vehicle,
task=task,
performed_by=user,
service_title=source.title,
description=description,
parts=(source.active_faults or '').strip(),
cost=0,
)
@staticmethod
def update_task_entry(instance, data, user, auto_close_work_order=False):
if not user.is_staff and instance.assigned_to != user:
raise ValidationError("Nemate dozvolu za izmjenu ovog zadatka.")
# Spremanje
target_status = data.get('status', instance.status)
target_work_order = data.get('work_order', instance.work_order)
closing_statuses = {'neaktivan', 'zavrsen'}
if target_status in closing_statuses:
if not target_work_order:
raise ValidationError({"work_order": "Putni nalog je obavezan prije zatvaranja zadatka."})
if target_work_order.status == 'open':
if not auto_close_work_order:
work_order_label = f"WO-{str(target_work_order.id).split('-')[0].upper()}"
raise OpenWorkOrderBlockingTaskClose(detail={
"code": "OPEN_WORK_ORDER_BLOCKING_TASK_CLOSE",
"message": f"Putni nalog {work_order_label} je još otvoren. Želite li ga automatski zatvoriti?",
"work_order_id": str(target_work_order.id),
"work_order_label": work_order_label,
})
if target_work_order.end_mileage is None:
raise ValidationError({
"work_order": "Automatsko zatvaranje putnog naloga nije moguće bez završne kilometraže."
})
from modules.fleet.services import VehicleService
target_work_order = VehicleService.update_work_order(target_work_order, {"status": "closed"})
data['work_order'] = target_work_order
for attr, value in data.items():
if attr == 'template_id':
continue
setattr(instance, attr, value)
if 'title' in data and len(str(data.get('title') or '').strip()) < 3:
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
try:
instance.full_clean()
except DjangoValidationError as exc:
raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
instance.save()
return instance
return instance

View File

@@ -1,10 +1,64 @@
# backend/modules/task_management/tasks.py
import logging
from celery import shared_task
from .models import Task
from django.contrib.auth import get_user_model
from modules.fleet.models import Vehicle, WorkOrder
logger = logging.getLogger(__name__)
@shared_task
def notify_upcoming_tasks():
"""
Dnevna Celery periodik task koji šalje Pusher + email notifikacije
za radne zadatke čiji je planirani datum izvršenja sutra.
"""
from datetime import date, timedelta
from modules.fleet.services import NotificationService
tomorrow = date.today() + timedelta(days=1)
upcoming = Task.objects.filter(
is_active=True,
scheduled_date=tomorrow,
status__in=('aktivan', 'servis'),
).select_related('assigned_to', 'vehicle')
notified = 0
for task in upcoming:
recipient = task.assigned_to
if not recipient:
continue
vehicle_label = (
task.vehicle.registration_number if task.vehicle else 'nepoznato'
)
try:
NotificationService.create_notification(
recipient=recipient,
title=f"Sutra planirani zadatak: {task.title}",
message=(
f"Radni zadatak '{task.title}' na dizalici {vehicle_label} "
f"je planiran za sutra ({tomorrow.strftime('%d.%m.%Y')})."
),
level="info",
send_email=True,
metadata={
"entity_type": "task",
"task_id": str(task.id),
"vehicle_id": str(task.vehicle_id) if task.vehicle_id else None,
"section": "dashboard",
"scheduled_date": tomorrow.isoformat(),
},
)
notified += 1
except Exception:
logger.exception("Greška pri slanju notifikacije za task %s", task.id)
logger.info("notify_upcoming_tasks: obaviješteno %d servisera za datum %s", notified, tomorrow)
return {"notified": notified, "date": tomorrow.isoformat()}
@shared_task
def create_task_from_invoice(invoice_data):
"""
@@ -25,7 +79,7 @@ def create_task_from_invoice(invoice_data):
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']}",
@@ -34,4 +88,4 @@ def create_task_from_invoice(invoice_data):
status='aktivan',
vehicle=vehicle,
work_order=work_order,
)
)

View File

@@ -1,5 +1,6 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
import datetime
from modules.task_management.models import Task
@@ -9,4 +10,25 @@ class TaskModelTests(TestCase):
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
assert "aktivan" in str(task) or "(" in str(task)
def test_scheduled_date_defaults_to_null(self):
user = get_user_model().objects.create_user(username="u2", email="u2@example.test", password="pass")
task = Task.objects.create(title="Task bez datuma", assigned_to=user)
assert task.scheduled_date is None
def test_scheduled_date_is_saved_and_retrieved(self):
user = get_user_model().objects.create_user(username="u3", email="u3@example.test", password="pass")
target = datetime.date(2026, 8, 15)
task = Task.objects.create(title="Planirani task", assigned_to=user, scheduled_date=target)
task.refresh_from_db()
assert task.scheduled_date == target
def test_scheduled_date_can_be_updated_to_null(self):
user = get_user_model().objects.create_user(username="u4", email="u4@example.test", password="pass")
target = datetime.date(2026, 8, 15)
task = Task.objects.create(title="Task s datumom", assigned_to=user, scheduled_date=target)
task.scheduled_date = None
task.save()
task.refresh_from_db()
assert task.scheduled_date is None

View File

@@ -7,26 +7,77 @@ from modules.fleet.models import Vehicle, WorkOrder
class TaskSerializerTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username="task-user-base", email="task-user-base@example.test", password="pass")
self.vehicle = Vehicle.objects.create(registration_number="ZG-TSK-01", asset_type='crane')
def test_validate_title_too_short_raises(self):
serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan"})
serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)})
with self.assertRaises(ValidationError):
serializer.is_valid(raise_exception=True)
def test_validate_title_ok(self):
serializer = TaskSerializer(data={"title": "Valid title", "description": "", "status": "aktivan"})
serializer = TaskSerializer(data={"title": "Valid title", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)})
assert serializer.is_valid(), serializer.errors
def test_cannot_close_without_work_order(self):
serializer = TaskSerializer(data={"title": "Task 1", "description": "", "status": "neaktivan"})
serializer = TaskSerializer(data={"title": "Task 1", "description": "", "status": "neaktivan", "vehicle": str(self.vehicle.id)})
with self.assertRaises(ValidationError):
serializer.is_valid(raise_exception=True)
def test_can_close_with_work_order(self):
User = get_user_model()
user = User.objects.create_user(username="task-user", email="task-user@example.test", password="pass")
vehicle = Vehicle.objects.create(registration_number="ZG-TSK-02", asset_type='crane')
work_order = WorkOrder.objects.create(vehicle=vehicle, creator=user, purpose='defektaza')
task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=user)
work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='defektaza')
task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True)
assert serializer.is_valid(), serializer.errors
assert serializer.is_valid(), serializer.errors
def test_cannot_mark_finished_without_work_order(self):
serializer = TaskSerializer(data={"title": "Task 3", "description": "", "status": "zavrsen", "vehicle": str(self.vehicle.id)})
with self.assertRaises(ValidationError):
serializer.is_valid(raise_exception=True)
def test_can_mark_finished_with_work_order(self):
work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='kontrola')
task = Task.objects.create(title="Task 4", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(instance=task, data={"status": "zavrsen", "work_order": str(work_order.id)}, partial=True)
assert serializer.is_valid(), serializer.errors
def test_scheduled_date_is_optional_on_create(self):
serializer = TaskSerializer(data={"title": "Task planned", "status": "aktivan", "vehicle": str(self.vehicle.id)})
assert serializer.is_valid(), serializer.errors
assert 'scheduled_date' not in serializer.errors
def test_scheduled_date_is_included_in_serialized_output(self):
import datetime
task = Task.objects.create(
title="Task with date",
assigned_to=self.user,
vehicle=self.vehicle,
scheduled_date=datetime.date(2026, 9, 1),
)
serializer = TaskSerializer(instance=task)
assert 'scheduled_date' in serializer.data
assert str(serializer.data['scheduled_date']) == '2026-09-01'
def test_scheduled_date_can_be_null_in_serialized_output(self):
task = Task.objects.create(title="Task no date", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(instance=task)
assert serializer.data['scheduled_date'] is None
def test_scheduled_date_accepts_valid_date(self):
serializer = TaskSerializer(
data={"title": "Task mit datum", "status": "aktivan",
"vehicle": str(self.vehicle.id), "scheduled_date": "2026-12-25"}
)
assert serializer.is_valid(), serializer.errors
def test_scheduled_date_rejects_invalid_date_string(self):
serializer = TaskSerializer(
data={"title": "Task bad date", "status": "aktivan",
"vehicle": str(self.vehicle.id), "scheduled_date": "not-a-date"}
)
assert not serializer.is_valid()
assert 'scheduled_date' in serializer.errors

View File

@@ -2,8 +2,9 @@ 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
from modules.task_management.exceptions import OpenWorkOrderBlockingTaskClose
from modules.task_management.models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
class TaskServiceTests(TestCase):
@@ -42,7 +43,14 @@ class TaskServiceTests(TestCase):
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')
work_order = WorkOrder.objects.create(
vehicle=vehicle,
creator=self.user,
purpose='defektaza',
status='closed',
start_mileage=100,
end_mileage=120,
)
task = Task.objects.create(title="Za zatvaranje", description="", assigned_to=self.user, status='servis')
updated = TaskService.update_task_entry(
instance=task,
@@ -50,4 +58,138 @@ class TaskServiceTests(TestCase):
user=self.user,
)
assert updated.status == "neaktivan"
assert updated.work_order == work_order
assert updated.work_order == work_order
def test_update_task_entry_rejects_finished_without_work_order(self):
task = Task.objects.create(title="Za završetak", description="", assigned_to=self.user, status='servis')
with self.assertRaises(ValidationError):
TaskService.update_task_entry(instance=task, data={"status": "zavrsen"}, user=self.user)
def test_update_task_entry_allows_finished_with_work_order(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01A", asset_type='crane')
work_order = WorkOrder.objects.create(
vehicle=vehicle,
creator=self.user,
purpose='kontrola',
status='closed',
start_mileage=100,
end_mileage=150,
)
task = Task.objects.create(title="Za završetak", description="", assigned_to=self.user, status='servis', vehicle=vehicle)
updated = TaskService.update_task_entry(
instance=task,
data={"status": "zavrsen", "work_order": work_order},
user=self.user,
)
assert updated.status == "zavrsen"
assert updated.work_order == work_order
def test_update_task_entry_blocks_closing_when_work_order_open(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01B", asset_type='crane')
work_order = WorkOrder.objects.create(vehicle=vehicle, creator=self.user, purpose='kontrola', status='open')
task = Task.objects.create(
title="Integritet",
description="",
assigned_to=self.user,
status='servis',
vehicle=vehicle,
work_order=work_order,
)
with self.assertRaises(OpenWorkOrderBlockingTaskClose):
TaskService.update_task_entry(
instance=task,
data={"status": "zavrsen"},
user=self.user,
)
def test_update_task_entry_can_auto_close_open_work_order(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01C", asset_type='crane')
work_order = WorkOrder.objects.create(
vehicle=vehicle,
creator=self.user,
purpose='kontrola',
status='open',
start_mileage=100,
end_mileage=150,
)
task = Task.objects.create(
title="Auto close",
description="",
assigned_to=self.user,
status='servis',
vehicle=vehicle,
work_order=work_order,
)
updated = TaskService.update_task_entry(
instance=task,
data={"status": "zavrsen"},
user=self.user,
auto_close_work_order=True,
)
work_order.refresh_from_db()
self.assertEqual(updated.status, "zavrsen")
self.assertEqual(work_order.status, "closed")
def test_create_task_entry_from_template_creates_service_records(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-02", asset_type='crane')
template = TaskTemplate.objects.create(
code='template-servis-zapisi',
title='Template servis',
description='Opis templatea',
status='aktivan',
)
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=1,
title='Pregled sustava',
work_description='Provjera hidraulike',
active_faults='Greška E101',
explanation_hr='Prevedeno i objašnjeno.',
)
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=2,
title='Zamjena senzora',
work_description='Zamijenjen senzor kuta',
active_faults='',
explanation_hr='Senzor kalibriran.',
)
created = TaskService.create_task_entry(
data={'template_id': template.id, 'vehicle': vehicle},
user=self.user,
)
self.assertEqual(created.vehicle_id, vehicle.id)
self.assertEqual(TaskWorkEntry.objects.filter(task=created).count(), 2)
records = VehicleServiceRecord.objects.filter(task=created).order_by('created_at')
self.assertEqual(records.count(), 2)
self.assertEqual(records.first().service_title, 'Pregled sustava')
self.assertIn('Obrazloženje (HR): Prevedeno i objašnjeno.', records.first().description)
self.assertIn('Aktivne greške: Greška E101', records.first().description)
self.assertEqual(records.first().performed_by_id, self.user.id)
def test_create_task_entry_from_template_limits_service_records_to_ten(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-03", asset_type='crane')
template = TaskTemplate.objects.create(
code='template-limit-10',
title='Template limit',
description='Opis',
status='aktivan',
)
for index in range(1, 13):
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=index,
title=f'Korak {index}',
work_description=f'Opis {index}',
)
created = TaskService.create_task_entry(
data={'template_id': template.id, 'vehicle': vehicle},
user=self.user,
)
self.assertEqual(TaskWorkEntry.objects.filter(task=created).count(), 10)
self.assertEqual(VehicleServiceRecord.objects.filter(task=created).count(), 10)

View File

@@ -0,0 +1,133 @@
import datetime
from unittest.mock import patch
from django.test import TestCase
from django.contrib.auth import get_user_model
from modules.task_management.tasks import notify_upcoming_tasks
from modules.task_management.models import Task
from modules.fleet.models import Vehicle, WorkOrder
class NotifyUpcomingTasksTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="serviser-notif",
email="serviser@example.test",
password="pass",
)
self.vehicle = Vehicle.objects.create(
registration_number="ZG-NOTIF-01",
asset_type='crane',
)
@patch('modules.fleet.services.NotificationService.create_notification')
def test_notifies_for_task_scheduled_tomorrow(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
Task.objects.create(
title="Sutrašnji zadatak",
assigned_to=self.user,
vehicle=self.vehicle,
status='aktivan',
scheduled_date=tomorrow,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 1)
mock_notify.assert_called_once()
call_kwargs = mock_notify.call_args.kwargs
self.assertEqual(call_kwargs['recipient'], self.user)
self.assertIn('Sutrašnji zadatak', call_kwargs['title'])
self.assertEqual(call_kwargs['level'], 'info')
self.assertTrue(call_kwargs.get('send_email'))
@patch('modules.fleet.services.NotificationService.create_notification')
def test_notifies_multiple_upcoming_tasks(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
Task.objects.create(title="Task A", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=tomorrow)
Task.objects.create(title="Task B", assigned_to=self.user, vehicle=self.vehicle,
status='servis', scheduled_date=tomorrow)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 2)
self.assertEqual(mock_notify.call_count, 2)
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_without_scheduled_date(self, mock_notify):
Task.objects.create(
title="Bez datuma", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=None,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_scheduled_today(self, mock_notify):
today = datetime.date.today()
Task.objects.create(
title="Danas", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=today,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_scheduled_in_past(self, mock_notify):
yesterday = datetime.date.today() - datetime.timedelta(days=1)
Task.objects.create(
title="Jučer", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=yesterday,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_finished_task(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
vehicle2 = Vehicle.objects.create(registration_number="ZG-NOTIF-02", asset_type='crane')
work_order = WorkOrder.objects.create(
vehicle=vehicle2, creator=self.user, purpose='defektaza',
status='closed', start_mileage=100, end_mileage=200,
)
Task.objects.create(
title="Završeni", assigned_to=self.user, vehicle=vehicle2,
status='zavrsen', scheduled_date=tomorrow, work_order=work_order,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_cancelled_task(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
vehicle3 = Vehicle.objects.create(registration_number="ZG-NOTIF-03", asset_type='crane')
work_order = WorkOrder.objects.create(
vehicle=vehicle3, creator=self.user, purpose='kontrola',
status='closed', start_mileage=50, end_mileage=100,
)
Task.objects.create(
title="Otkazani", assigned_to=self.user, vehicle=vehicle3,
status='neaktivan', scheduled_date=tomorrow, work_order=work_order,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_without_assigned_user(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
Task.objects.create(
title="Bez servisera", assigned_to=None, vehicle=self.vehicle,
status='aktivan', scheduled_date=tomorrow,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_result_includes_date_string(self, mock_notify):
result = notify_upcoming_tasks()
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
self.assertEqual(result['date'], tomorrow.isoformat())
self.assertEqual(result['notified'], 0)

View File

@@ -1,10 +1,12 @@
from django.test import TestCase, RequestFactory
from django.contrib.auth import get_user_model
from unittest.mock import patch
from rest_framework.test import APIClient
from modules.task_management.views import TaskViewSet
from modules.task_management.serializers import TaskSerializer
from modules.task_management.models import Task
from modules.task_management.models import Task, TaskTemplate, TaskTemplateEntry
from modules.fleet.models import Vehicle
class TaskViewSetTests(TestCase):
@@ -15,10 +17,13 @@ class TaskViewSetTests(TestCase):
self.view = TaskViewSet()
self.view.request = self.factory.post("/")
self.view.request.user = self.user
self.vehicle = Vehicle.objects.create(registration_number="ZG-TV-01", asset_type='crane')
self.api_client = APIClient()
self.api_client.force_authenticate(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"}
data = {"title": "Task from view", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)}
serializer = TaskSerializer(data=data, context={"request": self.view.request})
assert serializer.is_valid(), serializer.errors
@@ -42,7 +47,12 @@ class TaskViewSetTests(TestCase):
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)
mock_update.assert_called_once_with(
instance=task,
data=serializer.validated_data,
user=self.view.request.user,
auto_close_work_order=False,
)
# confirm instance saved with new title
task.refresh_from_db()
assert task.title == "Updated"
@@ -51,4 +61,32 @@ class TaskViewSetTests(TestCase):
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
assert task.is_active is False
def test_template_created_task_is_visible_in_service_records_endpoint(self):
template = TaskTemplate.objects.create(
code='template-api-service-records',
title='Template API',
description='Opis',
status='aktivan',
)
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=1,
title='Korak API',
work_description='Opis API koraka',
)
response = self.api_client.post(
"/api/tasks/tasks/",
data={"template_id": str(template.id), "vehicle": str(self.vehicle.id)},
format="json",
)
self.assertEqual(response.status_code, 201, response.content)
created_task_id = str(response.json()["id"])
service_records_response = self.api_client.get("/api/fleet/service-records/")
self.assertEqual(service_records_response.status_code, 200, service_records_response.content)
payload = service_records_response.json()
rows = payload if isinstance(payload, list) else payload.get("results", [])
self.assertTrue(any(str(row.get("task") or "") == created_task_id for row in rows))

View File

@@ -0,0 +1,97 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
from modules.task_management.models import Task, TaskWorkHoursTable
class TaskWorkHoursTableTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username="wh-user", email="wh-user@example.test", password="Pass123!abc")
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.vehicle = Vehicle.objects.create(registration_number="ZG-WH-01", asset_type='crane')
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
purpose='kontrola',
location='Zagreb',
)
self.task = Task.objects.create(
title="Task tablica sati",
description="",
status="aktivan",
assigned_to=self.user,
vehicle=self.vehicle,
work_order=self.work_order,
)
def test_put_work_hours_table_accepts_null_data(self):
response = self.client.put(
f"/api/tasks/tasks/{self.task.id}/work-hours-table/",
data=None,
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
self.assertIsNone(response.json().get('data'))
def test_put_work_hours_table_accepts_partial_row_and_normalizes(self):
response = self.client.put(
f"/api/tasks/tasks/{self.task.id}/work-hours-table/",
data={"rows": [{"day": "NED", "travel_hours": "2,5", "departure_place": "Zagreb"}]},
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
payload = response.json()
rows = payload.get('data', {}).get('rows', [])
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].get('day'), 'NED')
self.assertEqual(rows[0].get('travel_hours'), '2,5')
self.assertEqual(rows[0].get('departure_place'), 'Zagreb')
self.assertIn('arrival_place', rows[0])
self.assertEqual(rows[0].get('arrival_place'), '')
def test_put_work_hours_table_rejects_invalid_json_shape(self):
response = self.client.put(
f"/api/tasks/tasks/{self.task.id}/work-hours-table/",
data="invalid-shape",
format="json",
)
self.assertEqual(response.status_code, 400, response.content)
def test_task_service_context_includes_work_hours_table_data(self):
TaskWorkHoursTable.objects.create(
task=self.task,
data={"rows": [{"day": "NED", "date": "12.07.2026"}]},
)
VehicleServiceRecord.objects.create(
vehicle=self.vehicle,
task=self.task,
performed_by=self.user,
service_title="Servis zapis",
description="Opis",
cost=0,
)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.id}/task-service-context/")
self.assertEqual(response.status_code, 200, response.content)
payload = response.json()
tasks = payload.get('tasks', [])
self.assertEqual(len(tasks), 1)
self.assertEqual(tasks[0].get('work_hours_table'), {"rows": [{"day": "NED", "date": "12.07.2026"}]})
def test_service_records_pdf_works_with_empty_work_hours_table(self):
VehicleServiceRecord.objects.create(
vehicle=self.vehicle,
task=self.task,
performed_by=self.user,
service_title="Servis zapis",
description="Opis",
cost=0,
)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.id}/service-records-pdf/")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response["Content-Type"], "application/pdf")

View File

@@ -1,15 +1,18 @@
# backend/modules/task_management/views.py
# backend/modules/task_management/views.py
import logging
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.exceptions import ValidationError
from .models import Task
from .serializers import TaskSerializer
from .models import Task, TaskTemplate
from .serializers import TaskSerializer, TaskTemplateSerializer, TaskWorkHoursTableSerializer
from .services import TaskService
# Postavljanje loggera za modul
logger = logging.getLogger(__name__)
class TaskViewSet(viewsets.ModelViewSet):
"""
ViewSet koji koristi TaskService za rukovanje operacijama.
@@ -19,46 +22,73 @@ class TaskViewSet(viewsets.ModelViewSet):
def get_queryset(self):
qs = Task.objects.filter(is_active=True).select_related(
'assigned_to', 'vehicle', 'work_order', 'work_order__vehicle'
'assigned_to', 'vehicle', 'work_order', 'work_order__vehicle', 'work_hours_table'
)
vehicle_id = self.request.query_params.get('vehicle')
if vehicle_id:
qs = qs.filter(vehicle_id=vehicle_id)
scheduled_date = self.request.query_params.get('scheduled_date')
if scheduled_date:
qs = qs.filter(scheduled_date=scheduled_date)
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)
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
auto_close_work_order = serializer.validated_data.pop('auto_close_work_order', False)
TaskService.update_task_entry(
instance=serializer.instance,
data=serializer.validated_data,
user=self.request.user
user=self.request.user,
auto_close_work_order=auto_close_work_order,
)
# 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}")
@action(detail=False, methods=['get'], url_path='templates')
def templates(self, request):
templates = TaskTemplate.objects.filter(is_active=True).prefetch_related('entries').order_by('title')
serializer = TaskTemplateSerializer(templates, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get', 'put', 'patch'], url_path='work-hours-table')
def work_hours_table(self, request, pk=None):
task = self.get_object()
table = getattr(task, 'work_hours_table', None)
if request.method.lower() == 'get':
if table:
serializer = TaskWorkHoursTableSerializer(table)
return Response(serializer.data)
return Response({'task': str(task.id), 'data': None})
if isinstance(request.data, dict) and 'data' in request.data:
payload_data = request.data.get('data')
else:
payload_data = request.data
if payload_data == {}:
payload_data = None
serializer = TaskWorkHoursTableSerializer(
table,
data={
'task': str(task.id),
'data': payload_data,
},
partial=bool(table),
)
serializer.is_valid(raise_exception=True)
instance = serializer.save(task=task)
return Response(TaskWorkHoursTableSerializer(instance).data)

View File

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

View File

@@ -29,8 +29,13 @@ services:
- ./frontend:/app
- /app/node_modules
environment:
- PUBLIC_API_URL=${PUBLIC_API_URL}
- NODE_ENV=development
- PUBLIC_ALLOWED_HOSTS=localhost,127.0.0.1
- PUBLIC_HMR_HOST=localhost
- PUBLIC_HMR_PROTOCOL=ws
- PUBLIC_HMR_CLIENT_PORT=4321
- PUBLIC_API_URL=http://localhost:4321/api/
- PUBLIC_API_PROXY_TARGET=http://backend:8000
flower:
container_name: 004erpflower
@@ -46,6 +51,22 @@ services:
- redis
- worker
beat:
container_name: 004erpbeat
build:
context: ./backend
dockerfile: Dockerfile
command: celery -A core beat --loglevel=info
user: "1000:1000"
volumes:
- ./backend:/app
- media_volume:/app/media
env_file:
- .env
depends_on:
- redis
- worker
volumes:
media_volume:
external: true # Ako je definiran u glavnom file-u, ovo ga povezuje
external: true # Ako je definiran u glavnom file-u, ovo ga povezuje

121
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,121 @@
services:
db:
restart: unless-stopped
redis:
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: 004erpbackend
restart: unless-stopped
env_file:
- .env
environment:
DJANGO_SETTINGS_MODULE: core.settings.production
command: gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120
volumes:
- media_volume:/app/media
ports:
- "8001:8000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
worker:
build:
context: ./backend
dockerfile: Dockerfile
container_name: 004erpworker
restart: unless-stopped
env_file:
- .env
environment:
DEBUG: "False"
DJANGO_SETTINGS_MODULE: core.settings.production
command: celery -A core worker --loglevel=info
volumes:
- media_volume:/app/media
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
beat:
build:
context: ./backend
dockerfile: Dockerfile
container_name: 004erpbeat
restart: unless-stopped
env_file:
- .env
environment:
DEBUG: "False"
DJANGO_SETTINGS_MODULE: core.settings.production
command: celery -A core beat --loglevel=info
volumes:
- media_volume:/app/media
depends_on:
redis:
condition: service_started
worker:
condition: service_started
flower:
build:
context: ./backend
dockerfile: Dockerfile
container_name: 004erpflower
restart: unless-stopped
env_file:
- .env
environment:
DEBUG: "False"
DJANGO_SETTINGS_MODULE: core.settings.production
command: celery -A core flower --port=5555
ports:
- "5555:5555"
depends_on:
redis:
condition: service_started
worker:
condition: service_started
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: 004erpfrontend
restart: unless-stopped
environment:
NODE_ENV: production
PUBLIC_API_URL: ${PUBLIC_API_URL}
command: sh -c "npm run build && node ./dist/server/entry.mjs"
ports:
- "4321:4321"
depends_on:
backend:
condition: service_started
webhook:
build: /opt/erp/deploy-webhook
container_name: 004erpwebhook
restart: unless-stopped
environment:
WEBHOOK_SECRET: ${GITEA_WEBHOOK_SECRET}
ports:
- "9001:9001"
volumes:
- /opt/erp/app:/opt/erp/app
- /var/run/docker.sock:/var/run/docker.sock
volumes:
postgres_data:
redis_data:
media_volume:
external: true

View File

@@ -27,7 +27,7 @@ services:
context: ./backend
dockerfile: Dockerfile
command: celery -A core worker --loglevel=info
user: "1000:1000"
user: "1001:1001"
volumes:
- ./backend:/app
- media_volume:/app/media
@@ -44,4 +44,4 @@ services:
volumes:
postgres_data:
redis_data:
media_volume: # Ovo je ključno za tvoj ERP
media_volume: # Ovo je ključno za tvoj ERP

View File

@@ -13,12 +13,13 @@ const envHosts = (process.env.PUBLIC_ALLOWED_HOSTS || '')
.filter(Boolean);
const allowedHosts = Array.from(
new Set(['localhost', '127.0.0.1', ...envHosts])
new Set(['localhost', '127.0.0.1', 'serviseri-004.captain.mitteworkspace.cloud', ...envHosts])
);
const hmrHost = process.env.PUBLIC_HMR_HOST || 'localhost';
const hmrProtocol = process.env.PUBLIC_HMR_PROTOCOL || 'ws';
const hmrClientPort = Number(process.env.PUBLIC_HMR_CLIENT_PORT || 4321);
const apiProxyTarget = process.env.PUBLIC_API_PROXY_TARGET || 'http://localhost:8001';
export default defineConfig({
output: 'server',
@@ -59,6 +60,12 @@ export default defineConfig({
host: hmrHost,
clientPort: hmrClientPort,
},
proxy: {
'/api': {
target: apiProxyTarget,
changeOrigin: true,
},
},
watch: {
usePolling: true,
interval: 100,
@@ -70,4 +77,4 @@ export default defineConfig({
},
integrations: [preact()],
});
});

View File

@@ -1,7 +1,147 @@
const CACHE_NAME = 'erp-shell-v1';
const CACHE_NAME = 'erp-shell-v2';
const API_CACHE_NAME = 'erp-api-v1';
const WRITE_QUEUE_DB_NAME = 'erp-write-queue-db';
const WRITE_QUEUE_STORE = 'requests';
const WRITE_QUEUE_SYNC_TAG = 'erp-write-queue-sync';
const OFFLINE_URL = '/offline.html';
const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL];
const API_CACHE_BLOCKLIST = [
'/api/token/',
'/api/token/refresh/',
'/api/fleet/pusher-auth/',
];
function isCacheableApiGet(request, url) {
if (request.method !== 'GET') return false;
if (url.origin !== self.location.origin) return false;
if (!url.pathname.startsWith('/api/')) return false;
return !API_CACHE_BLOCKLIST.some((blocked) => url.pathname.startsWith(blocked));
}
function isQueueableApiWrite(request, url) {
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) return false;
if (url.origin !== self.location.origin) return false;
if (!url.pathname.startsWith('/api/')) return false;
if (API_CACHE_BLOCKLIST.some((blocked) => url.pathname.startsWith(blocked))) return false;
const contentType = (request.headers.get('content-type') || '').toLowerCase();
if (contentType.includes('multipart/form-data')) return false;
return true;
}
function openWriteQueueDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(WRITE_QUEUE_DB_NAME, 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(WRITE_QUEUE_STORE)) {
db.createObjectStore(WRITE_QUEUE_STORE, { keyPath: 'id', autoIncrement: true });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function serializeHeaders(headers) {
const serialized = {};
headers.forEach((value, key) => {
serialized[key] = value;
});
return serialized;
}
function getAllQueuedRequests(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction(WRITE_QUEUE_STORE, 'readonly');
const store = tx.objectStore(WRITE_QUEUE_STORE);
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
function addQueuedRequest(db, queuedRequest) {
return new Promise((resolve, reject) => {
const tx = db.transaction(WRITE_QUEUE_STORE, 'readwrite');
const store = tx.objectStore(WRITE_QUEUE_STORE);
const request = store.add(queuedRequest);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function deleteQueuedRequest(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction(WRITE_QUEUE_STORE, 'readwrite');
const store = tx.objectStore(WRITE_QUEUE_STORE);
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async function queueWriteRequest(request) {
const body = request.method === 'DELETE' ? null : await request.clone().text();
const db = await openWriteQueueDb();
const payload = {
url: request.url,
method: request.method,
headers: serializeHeaders(request.headers),
body,
createdAt: Date.now(),
};
await addQueuedRequest(db, payload);
}
function createQueuedResponse() {
return new Response(
JSON.stringify({
queued: true,
offline: true,
detail: 'Zahtjev je spremljen i bit će sinkroniziran kad veza bude dostupna.',
}),
{
status: 202,
headers: { 'Content-Type': 'application/json' },
}
);
}
async function scheduleWriteQueueSync() {
if (!self.registration || !self.registration.sync) return;
await self.registration.sync.register(WRITE_QUEUE_SYNC_TAG);
}
async function replayQueuedRequests() {
const db = await openWriteQueueDb();
const queuedRequests = await getAllQueuedRequests(db);
for (const queued of queuedRequests) {
const headers = new Headers(queued.headers || {});
const init = {
method: queued.method,
headers,
credentials: 'same-origin',
};
if (queued.body != null && queued.method !== 'DELETE') {
init.body = queued.body;
}
try {
const response = await fetch(queued.url, init);
if (response.ok) {
await deleteQueuedRequest(db, queued.id);
continue;
}
if (response.status >= 500) {
throw new Error(`Replay failed with status ${response.status}`);
}
await deleteQueuedRequest(db, queued.id);
} catch (error) {
throw error;
}
}
}
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
@@ -11,22 +151,76 @@ self.addEventListener('install', (event) => {
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
)
Promise.all([
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== API_CACHE_NAME)
.map((name) => caches.delete(name))
)
),
replayQueuedRequests().catch((error) => {
console.error('Initial write-queue replay failed.', error);
}),
])
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (isQueueableApiWrite(request, url)) {
event.respondWith(
fetch(request.clone()).catch(async () => {
try {
await queueWriteRequest(request);
} catch (error) {
console.error('Failed to queue offline write request.', error);
return new Response(
JSON.stringify({
queued: false,
offline: true,
detail: 'Zahtjev nije moguće spremiti offline.',
}),
{
status: 503,
headers: { 'Content-Type': 'application/json' },
}
);
}
try {
await scheduleWriteQueueSync();
} catch (error) {
console.warn('Background sync registration failed.', error);
}
return createQueuedResponse();
})
);
return;
}
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (isCacheableApiGet(request, url)) {
event.respondWith(
caches.open(API_CACHE_NAME).then(async (cache) => {
const cached = await cache.match(request);
const networkFetch = fetch(request)
.then((response) => {
if (response && response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => cached);
return cached || networkFetch;
})
);
return;
}
if (url.origin !== self.location.origin) return;
if (request.mode === 'navigate') {
@@ -60,3 +254,13 @@ self.addEventListener('fetch', (event) => {
);
}
});
self.addEventListener('sync', (event) => {
if (event.tag !== WRITE_QUEUE_SYNC_TAG) return;
event.waitUntil(replayQueuedRequests());
});
self.addEventListener('message', (event) => {
if (event.data !== 'FLUSH_WRITE_QUEUE') return;
event.waitUntil(replayQueuedRequests());
});

View File

@@ -2,7 +2,8 @@ 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 { useEffect, useRef, useState } from 'preact/hooks';
import { useSpring, animated } from '@react-spring/web';
import { hydrateAuthFromStorage } from '../stores/authStore';
const ITEMS = [
@@ -13,27 +14,82 @@ const ITEMS = [
{ 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]);
function normalizePath(p) {
const value = String(p || '/');
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
return value;
}
function getActiveIndex(path) {
const idx = ITEMS.findIndex((item) => item.href === path);
return idx >= 0 ? idx : 0;
}
export default function Navbar({ minimal = false }) {
// Navbar ostaje montiran (transition:persist) — pratimo promjene pathname preko astro:page-load
const [pathname, setPathname] = useState(() =>
typeof window !== 'undefined' ? normalizePath(window.location.pathname) : '/'
);
const itemRefs = useRef([]);
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
// Na prvom renderu preskočimo animaciju (pill se pojavljuje na ispravnoj poziciji bez slide-a od 0)
const [skipAnimation, setSkipAnimation] = useState(true);
const activeIndex = getActiveIndex(pathname);
const measureIndicator = (currentIndex) => {
const idx = currentIndex ?? activeIndex;
const el = itemRefs.current[idx];
if (!el) return;
const parent = el.closest('ul');
if (!parent) return;
const parentRect = parent.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
setIndicatorStyle({
left: elRect.left - parentRect.left,
width: elRect.width,
});
};
// Mount: izmjeri početnu poziciju bez animacije, pa uključi animaciju za buduće navigacije
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 timer = setTimeout(() => {
measureIndicator();
setSkipAnimation(false);
}, 30);
// Sluša Astro view-transition navigaciju (Navbar ostaje montiran zahvaljujući transition:persist)
const handlePageLoad = () => {
setPathname(normalizePath(window.location.pathname));
};
document.addEventListener('astro:page-load', handlePageLoad);
return () => {
clearTimeout(timer);
document.removeEventListener('astro:page-load', handlePageLoad);
};
}, []);
const currentItem = useMemo(
() => ITEMS.find((item) => item.href === normalizedPath) || ITEMS[0],
[normalizedPath]
);
// Kad se promijeni activeIndex (nova stranica), animiraj pill na novu poziciju
useEffect(() => {
if (!skipAnimation) {
measureIndicator();
}
}, [activeIndex]);
// react-spring: animirani klizač
// immediate=true na prvom renderu → pill se ne animira od lijevog ruba, već skače na pravo mjesto
const springStyle = useSpring({
left: indicatorStyle.left,
width: indicatorStyle.width,
immediate: skipAnimation,
config: { tension: 340, friction: 28 },
});
const currentItem = ITEMS[activeIndex];
return (
<nav className="sticky top-0 z-30 mb-4 rounded-lg border border-border-hairline bg-canvas-elevated/95 px-4 py-3 backdrop-blur">
@@ -44,16 +100,29 @@ export default function Navbar({ minimal = false }) {
{!minimal && <UserDisplay field="ime_prezime" className="text-xs text-text-muted" />}
</a>
{/* Navigacijski linkovi */}
<ul className="hidden items-center gap-1 text-sm sm:flex">
{ITEMS.map((item) => (
<li key={item.id}>
{/* Navigacijski linkovi s react-spring klizačem */}
<ul className="relative hidden items-center gap-1 text-sm sm:flex">
{/* Animirani pozadinski klizač */}
<animated.li
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
bottom: 0,
borderRadius: '0.5rem',
backgroundColor: 'rgb(238 242 255)',
pointerEvents: 'none',
...springStyle,
}}
/>
{ITEMS.map((item, idx) => (
<li key={item.id} ref={(el) => (itemRefs.current[idx] = el)}>
<a
href={item.href}
className={
normalizedPath === item.href
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700'
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep'
pathname === item.href
? 'relative z-10 block rounded-lg px-3 py-2 font-medium text-indigo-700'
: 'relative z-10 block rounded-lg px-3 py-2 text-text-main hover:text-indigo-600'
}
>
{item.label}
@@ -64,6 +133,7 @@ export default function Navbar({ minimal = false }) {
{/* Desna strana */}
<div className="flex items-center gap-2">
{/* Mobilni dropdown */}
<details className="relative sm:hidden">
<summary className="list-none cursor-pointer rounded-lg border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-deep">
{currentItem?.label || 'Izbornik'}
@@ -78,7 +148,7 @@ export default function Navbar({ minimal = false }) {
<a
href={item.href}
className={
normalizedPath === item.href
pathname === item.href
? 'block bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700'
: 'block px-3 py-2 text-sm text-text-main hover:bg-canvas-deep'
}

View File

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

View File

@@ -1,6 +1,6 @@
import { useStore } from '@nanostores/preact';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { animated, useSpring } from '@react-spring/web';
import { animated, useSpring, useTransition } from '@react-spring/web';
import DashboardTopbar from './DashboardTopbar';
import WorkOrderModal from './WorkOrderModal';
import TaskCreateModal from './TaskCreateModal';
@@ -9,6 +9,7 @@ import WorkOrderDetailModal from './WorkOrderDetailModal';
import SyncLogPanel from './SyncLogPanel';
import ServiceRecordForm from '../fleet/ServiceRecordForm';
import ServicePhotoUpload from '../fleet/ServicePhotoUpload';
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
import ServiceRecordDetailModal from '../fleet/ServiceRecordDetailModal';
import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
import ClientsSection from '../crm/ClientsSection';
@@ -24,6 +25,7 @@ import {
$serviceRecords,
createWorkOrder,
fetchFleetDashboardData,
fetchTeamMembers,
ensureVehiclesCatalog,
triggerOfflineSync,
updateWorkOrder,
@@ -31,8 +33,8 @@ import {
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 { formatWorkOrderDisplayCode, parseWorkOrderDisplayCode } from '../../lib/displayIds';
import { $tasks, fetchTasks, getStatusLabel, isTaskActive, updateTask } from '../../stores/taskStore';
import AnimatedDataTable from '../ui/AnimatedDataTable';
import AnimatedPage from '../ui/AnimatedPage';
@@ -65,12 +67,75 @@ function formatDate(value) {
return parsed.toLocaleDateString('hr-HR');
}
function formatTaskCraneLabel(task, cranes) {
const crane = cranes.find((item) => String(item.id) === String(task?.vehicle || ''));
const serial = String(crane?.crane_serial_number || '').trim();
const model = [crane?.make, crane?.model].filter(Boolean).join(' ').trim();
const registration = String(task?.vehicle_registration || crane?.registration_number || '').trim();
const primary = serial ? `SN ${serial}` : (registration || '-');
return model ? `${primary} / ${model}` : primary;
}
function formatServiceRecordCraneLabel(record, cranes) {
const crane = cranes.find((item) => String(item.id) === String(record?.vehicle || ''));
const serial = String(record?.crane_serial_number || crane?.crane_serial_number || '').trim();
const model = [crane?.make, crane?.model].filter(Boolean).join(' ').trim();
const registration = String(record?.vehicle_registration || crane?.registration_number || '').trim();
const primary = serial ? `SN ${serial}` : (registration || '-');
return model ? `${primary} / ${model}` : primary;
}
function formatCost(value) {
const numeric = Number(value);
if (Number.isNaN(numeric)) return '-';
return `${numeric.toFixed(2)} EUR`;
}
function normalizeComparableId(value) {
if (value && typeof value === 'object') {
return value.id ?? value.pk ?? '';
}
return value ?? '';
}
function normalizeWorkOrderCodeForSort(value) {
return String(value || '').trim().toUpperCase().split(/\s+/)[0] || '';
}
function compareWorkOrderCodesByDate(leftCode, rightCode, direction = 'asc') {
const left = parseWorkOrderDisplayCode(normalizeWorkOrderCodeForSort(leftCode));
const right = parseWorkOrderDisplayCode(normalizeWorkOrderCodeForSort(rightCode));
if (left.valid && right.valid) {
if (left.sortKey !== right.sortKey) {
return direction === 'asc' ? left.sortKey - right.sortKey : right.sortKey - left.sortKey;
}
return left.normalized.localeCompare(right.normalized);
}
if (left.valid !== right.valid) {
return left.valid ? -1 : 1;
}
return String(leftCode || '').localeCompare(String(rightCode || ''));
}
function nextSortDirection(direction) {
return direction === 'asc' ? 'desc' : 'asc';
}
function SortableCodeLabel({ title, direction, onToggle }) {
return (
<button
type="button"
onClick={onToggle}
className="inline-flex items-center gap-1 rounded px-1 py-0.5 text-left hover:bg-canvas-base"
title="Sortiraj po datumu iz koda (godina, mjesec, dan)"
>
<span>{title}</span>
<span className={direction === 'asc' ? 'text-indigo-700' : 'text-text-muted/50'}></span>
<span className={direction === 'desc' ? 'text-indigo-700' : 'text-text-muted/50'}></span>
</button>
);
}
const PAGE_MODE_TO_SECTION = {
dashboard: 'dashboard',
'work-orders': 'work-orders',
@@ -93,6 +158,8 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const [selectedServiceRecord, setSelectedServiceRecord] = useState(null);
// photoUpload: { open: bool, serviceRecordId: string|null }
const [photoUpload, setPhotoUpload] = useState({ open: false, serviceRecordId: null });
const [serviceRecordFilesRefreshToken, setServiceRecordFilesRefreshToken] = useState(0);
const [workOrderPhotoUpload, setWorkOrderPhotoUpload] = useState({ open: false, workOrderId: null });
const loading = useStore($dashboardLoading);
const error = useStore($dashboardError);
const stats = useStore($dashboardStats);
@@ -110,8 +177,13 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const [currentPage, setCurrentPage] = useState(1);
const [taskFilter, setTaskFilter] = useState('active');
const [taskScope, setTaskScope] = useState('all');
const [supervisorServicers, setSupervisorServicers] = useState([]);
const [selectedServicerId, setSelectedServicerId] = useState('all');
const [workOrderScope, setWorkOrderScope] = useState('context');
const [workOrderCodeSortDirection, setWorkOrderCodeSortDirection] = useState('desc');
const [taskWorkOrderSortDirection, setTaskWorkOrderSortDirection] = useState('desc');
const [selectedCraneRowId, setSelectedCraneRowId] = useState(null);
const [expandedServiceTaskId, setExpandedServiceTaskId] = useState(null);
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
@@ -121,9 +193,13 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
}, []);
useEffect(() => {
const nextSection = String(resolvedInitialSection || 'dashboard');
setActiveSection(nextSection);
}, [resolvedInitialSection]);
function onCalendarOpenTask(event) {
const task = event?.detail?.task;
if (task) setSelectedTask(task);
}
window.addEventListener('calendar:open-task', onCalendarOpenTask);
return () => window.removeEventListener('calendar:open-task', onCalendarOpenTask);
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -270,22 +346,85 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|| contextCrane?.client_name
|| null
), [clients, selectedClientId, contextCrane]);
const contextCraneLabel = useMemo(() => {
if (!hydratedContextCrane) return null;
const serial = String(hydratedContextCrane.crane_serial_number || '').trim();
if (serial) return `SN ${serial}`;
return hydratedContextCrane.registration_number || '-';
}, [hydratedContextCrane]);
const serviceRecordsForContext = useMemo(() => {
if (!selectedVehicleId) return [];
return [...serviceRecords]
.filter((record) => String(record.vehicle) === String(selectedVehicleId))
.sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || '')));
}, [serviceRecords, selectedVehicleId]);
const serviceTaskGroups = useMemo(() => {
if (!selectedVehicleId) return [];
const contextTasks = [...tasks]
.filter((task) => String(task.vehicle || '') === String(selectedVehicleId))
.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
const groups = contextTasks.map((task) => ({
id: String(task.id),
title: task.title || 'Radni zadatak',
status: task.status || '-',
task,
records: serviceRecordsForContext.filter((record) => String(record.task || '') === String(task.id)),
}));
const unassignedRecords = serviceRecordsForContext.filter((record) => !record.task);
if (unassignedRecords.length > 0) {
groups.push({
id: 'unassigned-task',
title: 'Bez radnog zadatka',
status: '-',
task: null,
records: unassignedRecords,
});
}
return groups;
}, [selectedVehicleId, tasks, serviceRecordsForContext]);
const serviceTaskTransitions = useTransition(serviceTaskGroups, {
keys: (group) => group.id,
from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
trail: 30,
config: { tension: 230, friction: 26 },
});
const scopedWorkOrders = useMemo(() => {
const sorted = [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
const isServiserUser = Boolean(user?.is_serviser);
const isSupervisorUser = Boolean(user?.is_team_member) && !Boolean(user?.is_serviser);
const currentUserId = normalizeComparableId(user?.id);
const roleScoped = sorted.filter((order) => {
if (!isServiserUser || isSupervisorUser) {
return true;
}
if (!currentUserId) {
return false;
}
const creatorCandidates = [order?.creator, order?.creator_id];
return creatorCandidates.some((candidate) => (
String(normalizeComparableId(candidate)) === String(currentUserId)
));
});
const codeSorted = [...roleScoped].sort((left, right) => (
compareWorkOrderCodesByDate(
formatWorkOrderDisplayCode(left),
formatWorkOrderDisplayCode(right),
workOrderCodeSortDirection
)
));
if (workOrderScope === 'all') {
return sorted;
return codeSorted;
}
if (!hydratedSelectedVehicleId) {
return [];
}
return sorted.filter((order) => String(order.vehicle) === String(hydratedSelectedVehicleId));
}, [workOrders, workOrderScope, hydratedSelectedVehicleId]);
return codeSorted.filter((order) => String(order.vehicle) === String(hydratedSelectedVehicleId));
}, [workOrders, workOrderScope, hydratedSelectedVehicleId, user?.id, user?.is_serviser, user?.is_team_member, workOrderCodeSortDirection]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(scopedWorkOrders.length / pageSize));
const latestWorkOrders = useMemo(
@@ -310,6 +449,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const isTeamMember = Boolean(user?.is_team_member);
const isServiser = Boolean(user?.is_serviser);
const isCustomer = Boolean(user?.is_kupac);
const isSupervisorNotServiser = isTeamMember && !isServiser;
const canViewAllTasks = isTeamMember && (isServiser || !isCustomer);
const canToggleTaskScope = isTeamMember && isServiser;
const isTaskAssignedToCurrentUser = (task) => (
@@ -320,6 +460,42 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
setTaskScope(canViewAllTasks ? 'all' : 'mine');
}, [canViewAllTasks]);
useEffect(() => {
if (!authReady || !isSupervisorNotServiser) {
setSupervisorServicers([]);
setSelectedServicerId('all');
return;
}
let isActive = true;
(async () => {
try {
const members = await fetchTeamMembers();
if (!isActive) return;
const servicers = members.filter((member) => Boolean(member?.is_serviser));
setSupervisorServicers(servicers);
} catch {
if (isActive) {
setSupervisorServicers([]);
}
}
})();
return () => {
isActive = false;
};
}, [authReady, isSupervisorNotServiser]);
useEffect(() => {
if (selectedServicerId === 'all') {
return;
}
const servicerExists = supervisorServicers.some(
(member) => String(member?.id || '') === String(selectedServicerId)
);
if (!servicerExists) {
setSelectedServicerId('all');
}
}, [selectedServicerId, supervisorServicers]);
const dashboardTasks = useMemo(() => {
const sorted = [...tasks].sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
const scoped = (() => {
@@ -331,14 +507,32 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
}
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 filteredByServicer = (() => {
if (!isSupervisorNotServiser || selectedServicerId === 'all') {
return scoped;
}
return scoped.filter(
(task) => String(task?.assigned_to || '') === String(selectedServicerId)
);
})();
const filteredByStatus = (() => {
if (taskFilter === 'active') {
return filteredByServicer.filter((task) => isTaskActive(task));
}
if (taskFilter === 'inactive') {
return filteredByServicer.filter((task) => !isTaskActive(task));
}
return filteredByServicer;
})();
return [...filteredByStatus].sort((left, right) => (
compareWorkOrderCodesByDate(
left?.work_order_label || '',
right?.work_order_label || '',
taskWorkOrderSortDirection
)
));
}, [tasks, taskFilter, canViewAllTasks, canToggleTaskScope, taskScope, user?.id, isSupervisorNotServiser, selectedServicerId, taskWorkOrderSortDirection]);
const selectedTaskServiceRecords = useMemo(() => {
if (!selectedTask?.id) return [];
return [...serviceRecords]
@@ -363,6 +557,14 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
setCurrentPage(1);
}, [workOrderScope, hydratedSelectedVehicleId]);
useEffect(() => {
if (!expandedServiceTaskId) return;
const exists = serviceTaskGroups.some((group) => String(group.id) === String(expandedServiceTaskId));
if (!exists) {
setExpandedServiceTaskId(null);
}
}, [expandedServiceTaskId, serviceTaskGroups]);
useEffect(() => {
if (!selectedCraneRowId) return;
const exists = cranes.some((item) => String(item.id) === String(selectedCraneRowId));
@@ -377,7 +579,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
showToast('Putni nalog je spremljen.', 'success');
await fetchFleetDashboardData({ silent: true });
} catch (error) {
showToast('Kreiranje naloga nije uspjelo.', 'error');
showToast(error?.message || 'Kreiranje naloga nije uspjelo.', 'error');
}
}
@@ -386,8 +588,9 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
await updateWorkOrder(workOrder.id, changes);
showToast('Nalog je uspješno ažuriran.', 'success');
await fetchFleetDashboardData({ silent: true });
await fetchTasks();
} catch (error) {
showToast('Ažuriranje naloga nije uspjelo.', 'error');
showToast(error?.message || 'Ažuriranje naloga nije uspjelo.', 'error');
}
}
@@ -421,9 +624,41 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
showToast('Servisni kontekst je postavljen.', 'success');
}
async function openTaskModalFromContext() {
if (!selectedClientId || !selectedVehicleId) {
showToast('Odaberite kupca i dizalicu u Servisnom kontekstu prije kreiranja radnog zadatka.', 'warning');
return;
}
try {
const availableCranes = await ensureVehiclesCatalog();
const selectedFromContext = availableCranes.find(
(item) => String(item.id) === String(selectedVehicleId)
);
if (!selectedFromContext) {
showToast('Odabrana dizalica nije dostupna u trenutnom kontekstu.', 'warning');
return;
}
} catch {
showToast('Ne mogu dohvatiti dizalice. Provjerite mrežu ili prijavu.', 'error');
return;
}
setIsTaskModalOpen(true);
}
const workOrderColumns = [
{ key: 'code', label: 'Putni nalog', className: 'px-4 py-3' },
{
key: 'code',
label: (
<SortableCodeLabel
title="Putni nalog"
direction={workOrderCodeSortDirection}
onToggle={() => setWorkOrderCodeSortDirection((prev) => nextSortDirection(prev))}
/>
),
className: 'px-4 py-3',
},
{ key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' },
{ key: 'serialNumber', label: 'SN', 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' },
@@ -440,9 +675,20 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const taskColumns = [
{ key: 'title', label: 'Radni zadatak', className: 'px-4 py-3' },
{ key: 'status', label: 'Status', className: 'px-4 py-3' },
{ key: 'scheduledDate', label: 'Planirano', 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: 'workOrder',
label: (
<SortableCodeLabel
title="Putni nalog"
direction={taskWorkOrderSortDirection}
onToggle={() => setTaskWorkOrderSortDirection((prev) => nextSortDirection(prev))}
/>
),
className: 'px-4 py-3',
},
{ key: 'assigned', label: 'Dodijeljeno', className: 'px-4 py-3' },
];
const isDashboardPage = pageMode === 'dashboard';
@@ -474,26 +720,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
}
setIsWorkOrderModalOpen(true);
}}
onOpenTaskModal={async () => {
if (!selectedClientId || !selectedVehicleId) {
showToast('Odaberite kupca i dizalicu u Servisnom kontekstu prije kreiranja radnog zadatka.', 'warning');
return;
}
try {
const availableCranes = await ensureVehiclesCatalog();
const selectedFromContext = availableCranes.find(
(item) => String(item.id) === String(selectedVehicleId)
);
if (!selectedFromContext) {
showToast('Odabrana dizalica nije dostupna u trenutnom kontekstu.', 'warning');
return;
}
} catch {
showToast('Ne mogu dohvatiti dizalice. Provjerite mrežu ili prijavu.', 'error');
return;
}
setIsTaskModalOpen(true);
}}
onOpenTaskModal={openTaskModalFromContext}
onNewServiceRecord={({ craneId }) => {
const crane = cranes.find((item) => String(item.id) === String(craneId));
openServiceRecordForCrane(crane ?? null);
@@ -533,6 +760,23 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
<h3 className="font-semibold text-text-main">Radni zadaci</h3>
<div className="flex items-center gap-2 text-xs">
{hasMounted && isSupervisorNotServiser && (
<label className="flex items-center gap-2 text-text-main">
<span>Serviser</span>
<select
value={selectedServicerId}
onChange={(event) => setSelectedServicerId(event.target.value)}
className="rounded-md border border-border-hairline bg-canvas-elevated px-2 py-1 text-xs text-text-main focus:border-indigo-400 focus:outline-none"
>
<option value="all">Svi serviseri</option>
{supervisorServicers.map((servicer) => (
<option key={servicer.id} value={servicer.id}>
{servicer.full_name || servicer.email}
</option>
))}
</select>
</label>
)}
{canToggleTaskScope && (
<>
<button
@@ -611,14 +855,26 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
</td>
<td className="px-4 py-3">
<span className={isTaskActive(task) ? 'text-emerald-700' : 'text-red-700'}>
{task.status || '-'}
{getStatusLabel(task.status || '-')}
</span>
</td>
<td className="px-4 py-3">{formatDateTime(task.created_at)}</td>
<td className="px-4 py-3">
{task.vehicle_registration
|| cranes.find((item) => String(item.id) === String(task.vehicle))?.registration_number
|| '-'}
{task.scheduled_date ? (
<span className="font-medium text-indigo-700">
{formatDate(task.scheduled_date)}
</span>
) : (
<span className="text-text-muted">-</span>
)}
</td>
<td
className="px-4 py-3"
title={formatDateTime(task.created_at) !== '-' ? formatDateTime(task.created_at) : undefined}
>
<span className="cursor-help">{formatDate(task.created_at)}</span>
</td>
<td className="px-4 py-3">
{formatTaskCraneLabel(task, cranes)}
</td>
<td className="px-4 py-3">{task.work_order_label || '-'}</td>
<td className="px-4 py-3">{task.assigned_to_name || '-'}</td>
@@ -640,8 +896,8 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
<h3 className="font-semibold text-text-main">Putni nalozi dizalica</h3>
<p className="text-xs text-text-muted">
{workOrderScope === 'context'
? (selectedContextClientName && hydratedContextCrane?.registration_number
? `Kontekst: ${selectedContextClientName} / ${hydratedContextCrane.registration_number}`
? (selectedContextClientName && contextCraneLabel
? `Kontekst: ${selectedContextClientName} / ${contextCraneLabel}`
: 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.')
: 'Prikaz svih putnih naloga.'}
</p>
@@ -700,13 +956,16 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
className="cursor-pointer px-4 py-3 font-medium text-text-main"
onClick={() => openWorkOrderDetails(workOrder)}
>
{formatEntityCode('PN', workOrder.id)}
{formatWorkOrderDisplayCode(workOrder)}
</td>
<td
className="px-4 py-3"
>
{[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ') || workOrder.craneLabel || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`}
</td>
<td className="px-4 py-3 font-mono text-xs text-text-muted">
{workOrder.craneInfo?.crane_serial_number || '-'}
</td>
<td className="px-4 py-3">
{formatDate(workOrder.date)}
</td>
@@ -724,15 +983,10 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
<button
type="button"
onClick={() => {
const related = serviceRecords.find((record) => String(record.vehicle) === String(workOrder.vehicle));
if (!related) {
showToast('Nema servisnog zapisa za ovaj putni nalog. Kreirajte servisni zapis prvo.', 'warning');
return;
}
setPhotoUpload({ open: true, serviceRecordId: related.id });
setWorkOrderPhotoUpload({ open: true, workOrderId: workOrder.id });
}}
className="rounded px-2 py-1 text-xs font-medium text-text-muted hover:bg-canvas-deep hover:text-text-main"
title="Upload fotografija"
title="Upload fotografija putnog naloga"
>
📷
</button>
@@ -777,12 +1031,19 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
<div>
<h3 className="font-semibold text-text-main">Servisni zapisi</h3>
<p className="text-xs text-text-muted">
{selectedContextClientName && hydratedContextCrane?.registration_number
? `Kontekst: ${selectedContextClientName} / ${hydratedContextCrane.registration_number}`
{selectedContextClientName && contextCraneLabel
? `Kontekst: ${selectedContextClientName} / ${contextCraneLabel}`
: 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.'}
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={openTaskModalFromContext}
className="rounded-md bg-indigo-600 px-3 py-1 text-xs font-semibold text-white hover:bg-indigo-700"
>
+ Novi radni zadatak
</button>
{hydratedSelectedVehicleId && hydratedContextCrane && (
<ServiceRecordCreateButton onClick={() => openServiceRecordForCrane(hydratedContextCrane)} />
)}
@@ -795,55 +1056,104 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
</button>
</div>
</div>
<AnimatedDataTable
columns={[
{ key: 'date', label: 'Datum', className: 'px-4 py-3' },
{ key: 'crane', label: 'Dizalica', className: 'px-4 py-3' },
{ key: 'task', label: 'Radni zadatak', className: 'px-4 py-3' },
{ key: 'description', label: 'Opis', className: 'px-4 py-3' },
{ key: 'cost', label: 'Trošak', className: 'px-4 py-3' },
{ key: 'files', label: 'Datoteke', className: 'px-4 py-3' },
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
]}
rows={serviceRecordsForContext}
rowKey={(record) => record.id}
loading={loading}
loadingMessage="Učitavanje..."
emptyMessage={!hydratedSelectedVehicleId
? 'Odaberite dizalicu za prikaz servisnih zapisa.'
: 'Nema aktivnih servisnih zapisa za odabranu dizalicu.'}
renderRow={(record) => (
<>
<td className="px-4 py-3">{formatDate(record.service_date)}</td>
<td className="px-4 py-3">{record.crane_registration || record.vehicle_registration || '-'}</td>
<td className="px-4 py-3">{record.task_label || '-'}</td>
<td className="max-w-md truncate px-4 py-3" title={record.description || ''}>
{record.description || '-'}
</td>
<td className="px-4 py-3">{formatCost(record.cost)}</td>
<td className="px-4 py-3">{record.files_count ?? (record.photos_count ?? 0)}</td>
<td className="space-x-2 px-4 py-3">
<button
type="button"
onClick={() => {
setServiceRecordBackTask(null);
setSelectedServiceRecord(record);
}}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Detalji
</button>
<button
type="button"
onClick={() => setPhotoUpload({ open: true, serviceRecordId: record.id })}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
📎 Dodaj datoteke
</button>
</td>
</>
)}
/>
{!hydratedSelectedVehicleId && (
<p className="px-4 py-6 text-center text-sm text-text-muted">
Odaberite dizalicu za prikaz servisnih zapisa.
</p>
)}
{hydratedSelectedVehicleId && loading && (
<p className="px-4 py-6 text-center text-sm text-text-muted">
Učitavanje...
</p>
)}
{hydratedSelectedVehicleId && !loading && serviceTaskGroups.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-text-muted">
Nema grupiranih radnih zadataka za odabranu dizalicu.
</p>
)}
{hydratedSelectedVehicleId && !loading && serviceTaskGroups.length > 0 && (
<div className="divide-y divide-border-hairline">
{serviceTaskTransitions((style, group) => {
const isOpen = String(expandedServiceTaskId || '') === String(group.id);
return (
<animated.div key={group.id} style={style}>
<button
type="button"
onClick={() => setExpandedServiceTaskId(isOpen ? null : group.id)}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-canvas-deep"
>
<div>
<span className="font-medium text-text-main">{group.title}</span>
<span className="ml-2 text-xs text-text-muted">{group.status}</span>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700">
{group.records.length} servisnih zapisa
</span>
<span className="text-text-muted">{isOpen ? '▲' : '▼'}</span>
</div>
</button>
{isOpen && (
<div className="border-t border-border-hairline bg-canvas-base/50 px-4 py-3">
{group.records.length === 0 ? (
<p className="text-sm text-text-muted">
Nema servisnih zapisa za odabrani radni zadatak.
</p>
) : (
<AnimatedDataTable
columns={[
{ key: 'date', label: 'Datum', className: 'px-4 py-3' },
{ key: 'crane', label: 'DIZALICA', className: 'px-4 py-3' },
{ key: 'description', label: 'Opis', className: 'px-4 py-3' },
{ key: 'cost', label: 'Trošak', className: 'px-4 py-3' },
{ key: 'files', label: 'Datoteke', className: 'px-4 py-3' },
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
]}
rows={group.records}
rowKey={(record) => record.id}
rowClassName="hover:bg-canvas-deep"
renderRow={(record) => (
<>
{/* <pre className="text-xs bg-gray-100 p-2">
{JSON.stringify(group, null, 2)}
</pre> */}
<td className="px-4 py-3">{formatDate(record.service_date)}</td>
<td className="px-4 py-3">{formatServiceRecordCraneLabel(record, cranes)}</td>
<td className="max-w-md truncate px-4 py-3" title={record.description || ''}>
{record.description || '-'}
</td>
<td className="px-4 py-3">{formatCost(record.cost)}</td>
<td className="px-4 py-3">{record.files_count ?? (record.photos_count ?? 0)}</td>
<td className="space-x-2 px-4 py-3">
<button
type="button"
onClick={() => {
setServiceRecordBackTask(group.task);
setSelectedServiceRecord(record);
}}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Detalji
</button>
<button
type="button"
onClick={() => setPhotoUpload({ open: true, serviceRecordId: record.id })}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
📎 Dodaj datoteke
</button>
</td>
</>
)}
/>
)}
</div>
)}
</animated.div>
);
})}
</div>
)}
</article>
)}
@@ -884,43 +1194,45 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
columns={vehicleColumns}
rows={cranes}
rowKey={(vehicle) => vehicle.id}
rowClassName="hover:bg-canvas-deep"
isRowSelected={(vehicle) => String(vehicle.id) === String(selectedCraneRowId)}
loading={loading}
loadingMessage="Učitavanje vozila..."
emptyMessage="Nema dostupnih vozila."
renderRow={(vehicle) => (
<>
<td
className={`px-4 py-3 font-medium ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50 text-emerald-800' : 'text-text-main'}`}
className="px-4 py-3 font-medium text-text-main"
onClick={() => setSelectedCraneRowId(vehicle.id)}
>
{vehicle.registration_number || '-'}
</td>
<td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)}
>
{vehicle.crane_serial_number || '-'}
</td>
<td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)}
>
{vehicle.make || '-'} {vehicle.model || ''}
</td>
<td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)}
>
{vehicle.client_name || '-'}
</td>
<td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)}
>
{vehicle.current_mileage ?? '-'}
</td>
<td
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
className="px-4 py-3"
onClick={() => setSelectedCraneRowId(vehicle.id)}
>
<span className={vehicle.is_active ? 'text-emerald-700' : 'text-red-700'}>
@@ -984,6 +1296,16 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
open={photoUpload.open}
serviceRecordId={photoUpload.serviceRecordId}
onClose={() => setPhotoUpload({ open: false, serviceRecordId: null })}
onSuccess={() => {
setServiceRecordFilesRefreshToken((prev) => prev + 1);
fetchFleetDashboardData({ silent: true });
}}
/>
<WorkOrderPhotoUpload
open={workOrderPhotoUpload.open}
workOrderId={workOrderPhotoUpload.workOrderId}
onClose={() => setWorkOrderPhotoUpload({ open: false, workOrderId: null })}
onSuccess={() => fetchFleetDashboardData({ silent: true })}
/>
@@ -998,6 +1320,12 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
setSelectedServiceRecord(updatedRecord);
fetchFleetDashboardData({ silent: true });
}}
onOpenPhotoUpload={(record) => {
const targetId = record?.id ?? selectedServiceRecord?.id;
if (!targetId) return;
setPhotoUpload({ open: true, serviceRecordId: targetId });
}}
filesRefreshToken={serviceRecordFilesRefreshToken}
onBack={serviceRecordBackTask
? () => {
setSelectedServiceRecord(null);
@@ -1023,6 +1351,12 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
setSelectedTask(updatedTask);
return updatedTask;
}}
onUpdateTask={async (task, changes) => {
const updatedTask = await updateTask(task.id, changes);
setSelectedTask(updatedTask);
await fetchFleetDashboardData({ silent: true });
return updatedTask;
}}
onCreateServiceRecord={(task) => {
const taskCrane = cranes.find((item) => String(item.id) === String(task?.vehicle));
setSelectedTask(null);

View File

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

View File

@@ -0,0 +1,276 @@
import { useMemo, useState } from 'preact/hooks';
import { animated, useSpring } from '@react-spring/web';
import { getStatusLabel } from '../../stores/taskStore';
const WEEKDAY_LABELS = ['Po', 'Ut', 'Sr', 'Če', 'Pe', 'Su', 'Ne'];
const MONTH_NAMES = [
'Siječanj', 'Veljača', 'Ožujak', 'Travanj', 'Svibanj', 'Lipanj',
'Srpanj', 'Kolovoz', 'Rujan', 'Listopad', 'Studeni', 'Prosinac',
];
const PANEL_WIDTH = 300;
function toIsoDateKey(dateStr) {
if (!dateStr) return null;
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function formatDayLabel(year, month, day) {
return `${String(day).padStart(2, '0')}.${String(month + 1).padStart(2, '0')}.${year}`;
}
export default function TaskCalendarWidget({ tasks = [], onTaskClick }) {
const [open, setOpen] = useState(false);
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
const [selectedDay, setSelectedDay] = useState(null);
const panelSpring = useSpring({
left: open ? 0 : -PANEL_WIDTH,
config: { tension: 260, friction: 28 },
});
const buttonSpring = useSpring({
left: open ? PANEL_WIDTH : 0,
config: { tension: 260, friction: 28 },
});
// Map: "YYYY-MM-DD" -> task[]
const tasksByDate = useMemo(() => {
const map = {};
for (const task of tasks) {
const key = toIsoDateKey(task.scheduled_date);
if (!key) continue;
if (!map[key]) map[key] = [];
map[key].push(task);
}
return map;
}, [tasks]);
// Calendar grid cells for current month
const calendarDays = useMemo(() => {
const firstDay = new Date(viewYear, viewMonth, 1);
let startDow = firstDay.getDay(); // 0=Sun
startDow = (startDow + 6) % 7; // shift to Mon=0
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const cells = [];
for (let i = 0; i < startDow; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
return cells;
}, [viewYear, viewMonth]);
const selectedDayKey = selectedDay
? `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(selectedDay).padStart(2, '0')}`
: null;
const selectedDayTasks = selectedDayKey ? (tasksByDate[selectedDayKey] ?? []) : [];
const today = new Date();
function prevMonth() {
setSelectedDay(null);
if (viewMonth === 0) { setViewYear((y) => y - 1); setViewMonth(11); }
else setViewMonth((m) => m - 1);
}
function nextMonth() {
setSelectedDay(null);
if (viewMonth === 11) { setViewYear((y) => y + 1); setViewMonth(0); }
else setViewMonth((m) => m + 1);
}
function handleDayClick(day) {
setSelectedDay((prev) => (prev === day ? null : day));
}
function handleTaskClick(task) {
onTaskClick?.(task);
setOpen(false);
}
const totalScheduled = Object.values(tasksByDate).flat().length;
return (
<>
{/* Backdrop */}
{open && (
<div
role="presentation"
className="fixed inset-0 z-[45] bg-black/20 backdrop-blur-[1px]"
onClick={() => setOpen(false)}
/>
)}
{/* Clipping wrapper — animates width 0→300, clips shadow+border completely */}
<animated.div
style={{
width: panelSpring.left.to((v) => `${Math.max(0, v + PANEL_WIDTH)}px`),
pointerEvents: open ? 'auto' : 'none',
}}
className="fixed left-0 top-0 z-[46] h-screen overflow-hidden"
>
{/* Inner panel — always 300px, anchored to right edge of wrapper */}
<div
style={{ width: `${PANEL_WIDTH}px` }}
className="absolute right-0 top-0 flex h-full flex-col border-r border-border-hairline bg-canvas-elevated shadow-2xl"
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
<h2 className="text-sm font-semibold text-text-main">📅 Kalendar zadataka</h2>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md p-1 text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori kalendar"
>
</button>
</div>
{/* Month navigation */}
<div className="flex items-center justify-between px-4 py-2">
<button
type="button"
onClick={prevMonth}
className="rounded p-1 text-sm text-text-main hover:bg-canvas-deep"
aria-label="Prethodni mjesec"
>
</button>
<span className="text-sm font-medium text-text-main">
{MONTH_NAMES[viewMonth]} {viewYear}
</span>
<button
type="button"
onClick={nextMonth}
className="rounded p-1 text-sm text-text-main hover:bg-canvas-deep"
aria-label="Sljedeći mjesec"
>
</button>
</div>
{/* Calendar grid */}
<div className="px-3">
<div className="grid grid-cols-7 text-center">
{WEEKDAY_LABELS.map((label) => (
<div key={label} className="py-1 text-[11px] font-medium text-text-muted">
{label}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-y-0.5 text-center">
{calendarDays.map((day, idx) => {
if (!day) return <div key={`e-${idx}`} />;
const key = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const hasTasks = Boolean(tasksByDate[key]?.length);
const taskCount = tasksByDate[key]?.length ?? 0;
const isToday =
today.getFullYear() === viewYear &&
today.getMonth() === viewMonth &&
today.getDate() === day;
const isSelected = selectedDay === day;
return (
<button
key={key}
type="button"
onClick={() => handleDayClick(day)}
title={hasTasks ? `${taskCount} zadatak/a` : undefined}
className={[
'relative mx-auto flex h-8 w-8 items-center justify-center rounded-full text-xs transition-colors',
isSelected
? 'bg-indigo-600 font-semibold text-white'
: isToday
? 'bg-indigo-50 font-semibold text-indigo-700'
: hasTasks
? 'font-medium text-text-main hover:bg-canvas-deep'
: 'text-text-main hover:bg-canvas-deep',
].join(' ')}
>
{day}
{hasTasks && (
<span
className={[
'absolute bottom-0.5 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full',
isSelected ? 'bg-white' : 'bg-indigo-500',
].join(' ')}
/>
)}
</button>
);
})}
</div>
</div>
{/* Divider */}
<div className="mt-2 border-t border-border-hairline" />
{/* Task list for selected day OR hint */}
<div className="flex-1 overflow-y-auto px-4 py-3">
{selectedDay ? (
<>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-text-muted">
{formatDayLabel(viewYear, viewMonth, selectedDay)}
</p>
{selectedDayTasks.length === 0 ? (
<p className="text-sm text-text-muted">Nema zadataka za odabrani datum.</p>
) : (
<ul className="space-y-1.5">
{selectedDayTasks.map((task) => (
<li key={task.id}>
<button
type="button"
onClick={() => handleTaskClick(task)}
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-left text-sm hover:bg-indigo-50 hover:border-indigo-200 transition-colors"
>
<span className="block truncate font-medium text-text-main">
{task.title || '-'}
</span>
<span className="mt-0.5 block text-[11px] text-text-muted">
{task.assigned_to_name || '-'} {' '}
{getStatusLabel(task.status || '-')}
</span>
</button>
</li>
))}
</ul>
)}
</>
) : (
<>
<p className="text-xs text-text-muted">
Odaberite datum u kalendaru za prikaz zadataka.
</p>
{totalScheduled > 0 && (
<p className="mt-1.5 text-xs font-medium text-indigo-600">
{totalScheduled} zadatak/a s planiranim datumom.
</p>
)}
{totalScheduled === 0 && (
<p className="mt-1.5 text-[11px] text-text-muted">
Nijedan zadatak nema postavljen planirani datum.
</p>
)}
</>
)}
</div>
</div>
</animated.div>
{/* Toggle tab button — slides right as panel opens */}
<animated.button
type="button"
style={{ left: buttonSpring.left.to((v) => `${v}px`) }}
onClick={() => setOpen((o) => !o)}
className="fixed top-1/2 z-[47] -translate-y-1/2 rounded-r-xl border border-l-0 border-border-hairline bg-canvas-elevated px-1.5 py-4 shadow-md hover:bg-indigo-50 focus:outline-none"
aria-label={open ? 'Zatvori kalendar' : 'Otvori kalendar'}
title="Kalendar zadataka"
>
<span
style={{ writingMode: 'vertical-rl', textOrientation: 'mixed' }}
className="select-none text-[11px] font-medium text-text-main"
>
📅 Kalendar
</span>
</animated.button>
</>
);
}

View File

@@ -1,62 +1,130 @@
import { useEffect, useState } from 'preact/hooks';
import { createTask, getStatusLabel } from '../../stores/taskStore';
import { formatEntityCode, formatPurposeLabel } from '../../lib/displayIds';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { createTask, fetchTaskTemplates, getStatusLabel } from '../../stores/taskStore';
import { formatPurposeLabel, formatWorkOrderDisplayCode } from '../../lib/displayIds';
import ModalShell from '../ui/ModalShell';
const STATUS_OPTIONS = ['aktivan', 'servis', 'neaktivan'];
const STATUS_OPTIONS = ['aktivan', 'servis', 'zavrsen', 'neaktivan'];
const INITIAL_FORM = {
title: '',
description: '',
status: 'aktivan',
work_order: '',
scheduled_date: '',
};
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('');
const [templateMode, setTemplateMode] = useState(false);
const [templateLoading, setTemplateLoading] = useState(false);
const [templateId, setTemplateId] = useState('');
const [templates, setTemplates] = useState([]);
const selectedTemplate = useMemo(
() => templates.find((template) => String(template.id) === String(templateId)) || null,
[templates, templateId]
);
const availableWorkOrders = useMemo(
() => workOrders.filter((workOrder) => String(workOrder?.status || '').toLowerCase() !== 'closed'),
[workOrders]
);
useEffect(() => {
if (!open) {
setForm(INITIAL_FORM);
setSubmitting(false);
setError('');
setTemplateMode(false);
setTemplateId('');
setTemplates([]);
setTemplateLoading(false);
}
}, [open]);
useEffect(() => {
if (!selectedTemplate) return;
setForm((prev) => ({
...prev,
title: selectedTemplate.title || '',
description: selectedTemplate.description || '',
status: 'aktivan',
work_order: '',
// scheduled_date is kept so user can set it in template mode too
}));
}, [selectedTemplate]);
if (!open) return null;
function setField(name, value) {
setForm((prev) => ({ ...prev, [name]: value }));
}
async function enableTemplateMode() {
if (templateMode) {
setTemplateMode(false);
setTemplateId('');
setForm(INITIAL_FORM);
return;
}
setError('');
setTemplateLoading(true);
try {
const fetched = await fetchTaskTemplates();
setTemplates(fetched);
setTemplateMode(true);
} catch (err) {
setError(err?.message || 'Neuspješno učitavanje templatea.');
} finally {
setTemplateLoading(false);
}
}
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({
let payload;
if (templateMode) {
if (!templateId) {
setError('Odaberite template za unos zadatka.');
return;
}
payload = {
template_id: templateId,
vehicle: contextCrane.id,
work_order: null,
scheduled_date: form.scheduled_date || null,
};
} else {
const title = String(form.title || '').trim();
if (!title) {
setError('Naslov zadatka je obavezan.');
return;
}
if ((form.status === 'neaktivan' || form.status === 'zavrsen') && !form.work_order) {
setError('Putni nalog je obavezan prije zatvaranja zadatka.');
return;
}
payload = {
title,
description: String(form.description || '').trim(),
status: form.status || 'aktivan',
vehicle: contextCrane.id,
work_order: form.work_order || null,
});
scheduled_date: form.scheduled_date || null,
};
}
setSubmitting(true);
try {
await createTask(payload);
await onSuccess?.();
onClose?.();
} catch (err) {
@@ -67,15 +135,13 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
}
return (
<div
className="fixed inset-0 z-50 overflow-y-auto bg-slate-950/15 p-4 pt-20 backdrop-blur-[2px]"
onClick={onClose}
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto p-4 pt-20"
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 overflow-hidden max-h-[calc(100vh-2rem)]"
>
<div className="flex min-h-full items-start justify-center">
<div
className="w-full max-w-xl max-h-[calc(100vh-2rem)] overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="w-full flex flex-col">
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Novi radni zadatak</h3>
@@ -83,26 +149,60 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
Kontekst dizalice: {contextCrane?.registration_number || 'Nije odabrana'}
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori modal"
>
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={enableTemplateMode}
disabled={templateLoading || submitting}
className="rounded-md border border-border-hairline px-3 py-1 text-xs font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
{templateMode ? 'Ručni unos' : 'Unos iz templatea'}
</button>
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori modal"
>
</button>
</div>
</div>
<form onSubmit={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
{templateMode && (
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Template zadatka *</span>
<select
value={templateId}
onChange={(event) => setTemplateId(event.currentTarget.value)}
disabled={submitting || templateLoading}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
<option value=""> Odaberite template </option>
{templates.map((template) => (
<option key={template.id} value={String(template.id)}>
{template.title}
</option>
))}
</select>
{selectedTemplate && (
<span className="text-[11px] text-text-muted">
Generirat će se do 10 servisnih zapisa iz predloška (vidljivo u tablici Servisni zapisi).
</span>
)}
</label>
)}
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Naslov zadatka *</span>
<input
type="text"
value={form.title}
onInput={(event) => setField('title', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
required
required={!templateMode}
/>
</label>
@@ -112,7 +212,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
rows={3}
value={form.description}
onInput={(event) => setField('description', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="resize-none rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
/>
</label>
@@ -123,7 +223,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
<select
value={form.status}
onChange={(event) => setField('status', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
{STATUS_OPTIONS.map((status) => (
@@ -135,21 +235,37 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Planirani datum izvršenja</span>
<input
type="date"
value={form.scheduled_date}
onInput={(event) => setField('scheduled_date', event.currentTarget.value)}
disabled={submitting}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
/>
</label>
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
<span className="font-medium text-text-main">Povezani putni nalog</span>
<select
value={form.work_order}
onChange={(event) => setField('work_order', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
<option value=""> Bez povezanog naloga </option>
{workOrders.map((workOrder) => (
{availableWorkOrders.map((workOrder) => (
<option key={workOrder.id} value={String(workOrder.id)}>
{formatEntityCode('PN', workOrder.id)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {formatPurposeLabel(workOrder.purpose) || '-'}
{formatWorkOrderDisplayCode(workOrder)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {formatPurposeLabel(workOrder.purpose) || '-'}
</option>
))}
</select>
{workOrders.length === 0 && (
{templateMode && (
<span className="text-[11px] text-text-muted">
Kod template unosa nalog nije obavezan i backend ga neće automatski povezati.
</span>
)}
{availableWorkOrders.length === 0 && (
<span className="text-[11px] text-amber-600">
Nema dostupnih naloga za odabranu dizalicu.
</span>
@@ -175,14 +291,13 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
<button
type="submit"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
disabled={submitting}
disabled={submitting || templateLoading}
>
{submitting ? 'Spremanje...' : 'Kreiraj radni zadatak'}
</button>
</div>
</form>
</div>
</div>
</div>
</ModalShell>
);
}

View File

@@ -1,6 +1,9 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
import { formatEntityCode } from '../../lib/displayIds';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { getStatusLabel } from '../../stores/taskStore';
const STATUS_OPTIONS = ['aktivan', 'servis', 'spreman_za_zavrsetak', 'zavrsen', 'neaktivan'];
function formatDate(value) {
if (!value) return '-';
@@ -9,6 +12,13 @@ function formatDate(value) {
return parsed.toLocaleDateString('hr-HR');
}
function formatDateTime(value) {
if (!value) return '-';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '-';
return parsed.toLocaleString('hr-HR');
}
function formatCost(value) {
const numeric = Number(value);
if (Number.isNaN(numeric)) return '-';
@@ -23,12 +33,22 @@ export default function TaskServiceRecordsModal({
onClose,
onOpenServiceRecord,
onAssignWorkOrder,
onUpdateTask,
onCreateServiceRecord,
}) {
const [showWorkOrderEditor, setShowWorkOrderEditor] = useState(false);
const [selectedWorkOrderId, setSelectedWorkOrderId] = useState('');
const [assigningWorkOrder, setAssigningWorkOrder] = useState(false);
const [assignError, setAssignError] = useState('');
const [showTaskEditor, setShowTaskEditor] = useState(false);
const [updatingTask, setUpdatingTask] = useState(false);
const [taskError, setTaskError] = useState('');
const [taskForm, setTaskForm] = useState({
title: '',
description: '',
status: 'aktivan',
scheduled_date: '',
});
useEffect(() => {
if (!open || typeof window === 'undefined') return;
@@ -41,6 +61,15 @@ export default function TaskServiceRecordsModal({
setShowWorkOrderEditor(!task.work_order);
setAssignError('');
setAssigningWorkOrder(false);
setShowTaskEditor(false);
setTaskError('');
setUpdatingTask(false);
setTaskForm({
title: String(task.title || ''),
description: String(task.description || ''),
status: String(task.status || 'aktivan'),
scheduled_date: String(task.scheduled_date || ''),
});
}, [task?.id, task?.work_order]);
const currentWorkOrder = useMemo(
@@ -53,6 +82,8 @@ export default function TaskServiceRecordsModal({
[workOrders]
);
const effectiveWorkOrderId = task?.work_order ? String(task.work_order) : String(selectedWorkOrderId || '');
const handleWorkOrderChange = async (event) => {
const nextValue = String(event.currentTarget.value || '');
setSelectedWorkOrderId(nextValue);
@@ -68,6 +99,59 @@ export default function TaskServiceRecordsModal({
}
};
const handleTaskUpdate = async (event) => {
event.preventDefault();
const title = String(taskForm.title || '').trim();
const status = String(taskForm.status || 'aktivan');
const description = String(taskForm.description || '').trim();
if (!title) {
setTaskError('Naslov zadatka je obavezan.');
return;
}
if ((status === 'zavrsen' || status === 'neaktivan') && !effectiveWorkOrderId) {
setTaskError('Putni nalog je obavezan prije zatvaranja zadatka.');
return;
}
setTaskError('');
setUpdatingTask(true);
try {
await onUpdateTask?.(task, {
title,
description,
status,
scheduled_date: taskForm.scheduled_date || null,
});
setShowTaskEditor(false);
} catch (error) {
const conflictCode = error?.details?.code;
if (error?.status === 409 && conflictCode === 'OPEN_WORK_ORDER_BLOCKING_TASK_CLOSE') {
const confirmText = error?.details?.message || error?.message || 'Povezani putni nalog je još otvoren. Želite li ga automatski zatvoriti?';
const approved = typeof window !== 'undefined' ? window.confirm(confirmText) : false;
if (approved) {
try {
await onUpdateTask?.(task, {
title,
description,
status,
scheduled_date: taskForm.scheduled_date || null,
auto_close_work_order: true,
});
setShowTaskEditor(false);
return;
} catch (retryError) {
setTaskError(retryError?.message || 'Neuspješno automatsko zatvaranje putnog naloga i završetak zadatka.');
return;
}
}
setTaskError(confirmText);
return;
}
setTaskError(error?.message || 'Neuspješno spremanje izmjena radnog zadatka.');
} finally {
setUpdatingTask(false);
}
};
if (!open || !task) return null;
return (
@@ -82,7 +166,7 @@ export default function TaskServiceRecordsModal({
<div>
<h3 className="text-lg font-semibold text-text-main">Radni zadatak detalji</h3>
<p className="text-xs text-text-muted">
Radni zadatak: {task.title || '-'} Status: {task.status || '-'} Zapisa: {records.length}
Radni zadatak: {task.title || '-'} Status: {getStatusLabel(task.status || '-')} Zapisa: {records.length}
</p>
</div>
<button
@@ -97,12 +181,146 @@ export default function TaskServiceRecordsModal({
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto p-4">
<div className="space-y-4">
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<h4 className="text-sm font-semibold text-text-main">Podaci radnog zadatka</h4>
{!showTaskEditor ? (
<button
type="button"
onClick={() => {
setTaskError('');
setShowTaskEditor(true);
}}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Izmijeni radni zadatak
</button>
) : null}
</div>
{!showTaskEditor ? (
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<dt className="text-xs text-text-muted">Naslov</dt>
<dd className="text-text-main">{task.title || '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Status</dt>
<dd className="text-text-main">{getStatusLabel(task.status || '-')}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Dodijeljeno</dt>
<dd className="text-text-main">{task.assigned_to_name || '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Dizalica</dt>
<dd className="text-text-main">{task.vehicle_registration || '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Putni nalog</dt>
<dd className="text-text-main">{task.work_order_label || '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Planirani datum</dt>
<dd className="text-text-main">{task.scheduled_date ? formatDate(task.scheduled_date) : '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Vrijeme izrade</dt>
<dd className="text-text-main">{formatDateTime(task.created_at)}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-xs text-text-muted">Opis</dt>
<dd className="whitespace-pre-wrap text-text-main">{task.description || '-'}</dd>
</div>
</dl>
) : (
<form onSubmit={handleTaskUpdate} className="space-y-3">
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Naslov *</span>
<input
type="text"
value={taskForm.title}
onInput={(event) => setTaskForm((prev) => ({ ...prev, title: event.currentTarget.value }))}
disabled={updatingTask}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
required
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Opis</span>
<textarea
rows={3}
value={taskForm.description}
onInput={(event) => setTaskForm((prev) => ({ ...prev, description: event.currentTarget.value }))}
disabled={updatingTask}
className="resize-none rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Status</span>
<select
value={taskForm.status}
onChange={(event) => setTaskForm((prev) => ({ ...prev, status: event.currentTarget.value }))}
disabled={updatingTask}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
>
{STATUS_OPTIONS.map((status) => (
<option key={status} value={status}>
{getStatusLabel(status)}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Planirani datum izvršenja</span>
<input
type="date"
value={taskForm.scheduled_date || ''}
onInput={(event) => setTaskForm((prev) => ({ ...prev, scheduled_date: event.currentTarget.value }))}
disabled={updatingTask}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
/>
</label>
{taskError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{taskError}
</div>
) : null}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setTaskError('');
setShowTaskEditor(false);
setTaskForm({
title: String(task.title || ''),
description: String(task.description || ''),
status: String(task.status || 'aktivan'),
scheduled_date: String(task.scheduled_date || ''),
});
}}
disabled={updatingTask}
className="rounded border border-border-hairline px-3 py-1.5 text-xs text-text-main hover:bg-canvas-elevated disabled:opacity-60"
>
Odustani
</button>
<button
type="submit"
disabled={updatingTask}
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{updatingTask ? 'Spremanje...' : 'Spremi izmjene'}
</button>
</div>
</form>
)}
</div>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Putni nalozi</h4>
{currentWorkOrder && !showWorkOrderEditor ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded border border-border-hairline bg-canvas-elevated px-3 py-2">
<p className="text-sm text-text-main">
{formatEntityCode('PN', currentWorkOrder.id)} {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} {currentWorkOrder.status || '-'}
{formatWorkOrderDisplayCode(currentWorkOrder)} {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} {currentWorkOrder.status || '-'}
</p>
<button
type="button"
@@ -123,7 +341,7 @@ export default function TaskServiceRecordsModal({
<option value=""> Odaberi putni nalog </option>
{sortedWorkOrders.map((workOrder) => (
<option key={workOrder.id} value={String(workOrder.id)}>
{formatEntityCode('PN', workOrder.id)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {workOrder.status || '-'}
{formatWorkOrderDisplayCode(workOrder)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {workOrder.status || '-'}
</option>
))}
</select>

View File

@@ -0,0 +1,476 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
// ─── helpers ────────────────────────────────────────────────────────────────
function parseTime(str) {
if (!str || typeof str !== 'string') return null;
const match = str.trim().match(/^(\d{1,2}):(\d{2})$/);
if (!match) return null;
const h = parseInt(match[1], 10);
const m = parseInt(match[2], 10);
if (h > 23 || m > 59) return null;
return h + m / 60;
}
function calcHours(fromStr, toStr) {
const from = parseTime(fromStr);
const to = parseTime(toStr);
if (from === null || to === null) return null;
let diff = to - from;
if (diff < 0) diff += 24; // overnight
return Math.max(0, diff);
}
function parseNum(value) {
if (value == null || value === '') return 0;
const n = Number(String(value).replace(',', '.'));
return Number.isFinite(n) ? n : 0;
}
function fmtH(h) { return h.toFixed(1).replace('.', ','); }
/** Converts decimal hours back to "HH:MM" string. */
function formatTime(decimalHours) {
if (!Number.isFinite(decimalHours)) return '';
let h = ((decimalHours % 24) + 24) % 24;
const hours = Math.floor(h);
let mins = Math.round((h - hours) * 60);
if (mins === 60) return `${String(hours + 1).padStart(2, '0')}:00`;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
/** Returns 'travel' | 'work' | 'empty' for a given row. */
function rowMode(row) {
if (row?.travel_time_from?.trim() || row?.travel_time_to?.trim()) return 'travel';
if (row?.work_time_from?.trim() || row?.work_time_to?.trim()) return 'work';
return 'empty';
}
/**
* Applies mutual-exclusion and auto-calculation to a single row.
* Travel mode → clears work fields, computes travel_hours from times break.
* Work mode → clears travel fields + vehicle_km, computes work_hours.
* Empty mode → clears both calculated totals.
*/
function computeRow(row) {
const r = { ...row };
const mode = rowMode(r);
if (mode === 'travel') {
r.work_time_from = '';
r.work_time_to = '';
r.work_hours = '';
const raw = calcHours(r.travel_time_from, r.travel_time_to);
r.travel_hours = raw !== null ? fmtH(Math.max(0, raw - parseNum(r.break_hours))) : '';
} else if (mode === 'work') {
r.travel_time_from = '';
r.travel_time_to = '';
r.travel_hours = '';
r.vehicle_km = '';
const raw = calcHours(r.work_time_from, r.work_time_to);
r.work_hours = raw !== null ? fmtH(Math.max(0, raw - parseNum(r.break_hours))) : '';
} else {
r.work_hours = '';
r.travel_hours = '';
}
return r;
}
const INTERNAL = ['_groupId', '_role'];
function stripInternal(row) { const r = { ...row }; for (const f of INTERNAL) delete r[f]; return r; }
let _gidSeq = 0;
function newGroupId() { _gidSeq += 1; return `g${_gidSeq}`; }
// ─── constants ───────────────────────────────────────────────────────────────
const EMPTY_ROW = {
day: '', date: '',
work_time_from: '', work_time_to: '',
travel_time_from: '', travel_time_to: '',
break_hours: '',
work_hours: '', travel_hours: '',
departure_place: '', arrival_place: '',
vehicle_km: '',
};
/**
* computed: true → value is auto-calculated, never editable directly.
* travelOnly: true → disabled when mode === 'work'.
* workOnly: true → disabled when mode === 'travel'.
*/
const FIELD_CONFIGS = [
{ key: 'day', label: 'Dan', type: 'select' },
{ key: 'date', label: 'Datum', type: 'text', placeholder: 'dd.mm.gggg' },
{ key: 'work_time_from', label: 'Vr. rada od', type: 'time', workOnly: true },
{ key: 'work_time_to', label: 'Vr. rada do', type: 'time', workOnly: true },
{ key: 'travel_time_from', label: 'Put od', type: 'time', travelOnly: true },
{ key: 'travel_time_to', label: 'Put do', type: 'time', travelOnly: true },
{ key: 'break_hours', label: 'Pauza h', type: 'number', step: '0.5', min: '0', placeholder: '0.0' },
{ key: 'work_hours', label: 'Sati rada', type: 'number', step: '0.1', min: '0', placeholder: '0.0', computed: true },
{ key: 'travel_hours', label: 'Sati puta', type: 'number', step: '0.1', min: '0', placeholder: '0.0', computed: true },
{ key: 'departure_place', label: 'Mj. polaska', type: 'text', placeholder: 'Zagreb', travelOnly: true },
{ key: 'arrival_place', label: 'Mj. dolaska', type: 'text', placeholder: 'npr. Split', travelOnly: true },
{ key: 'vehicle_km', label: 'Km vozila', type: 'number', step: '1', min: '0', placeholder: '0', travelOnly: true },
];
const DAY_OPTIONS = ['', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB', 'NED'];
const FB_INPUT = [
'block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs',
'text-gray-900 placeholder-gray-400',
'focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none',
'dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400',
'dark:focus:border-blue-500 dark:focus:ring-blue-500',
'transition-colors duration-150',
].join(' ');
const FB_MUTED = [
'block w-full rounded-lg border border-gray-200 bg-gray-100 px-2.5 py-2 text-xs',
'text-gray-400 cursor-default select-none',
'dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500',
].join(' ');
// ─── component ───────────────────────────────────────────────────────────────
export default function TaskWorkHoursTableModal({
open,
task,
defaultLocation = '',
saving = false,
onClose,
onSave,
}) {
const [rows, setRows] = useState([]);
useEffect(() => {
if (!open) { setRows([]); return; }
const incoming = Array.isArray(task?.work_hours_table?.rows) ? task.work_hours_table.rows : [];
setRows(
incoming.length > 0
? incoming.map((r) => ({ ...EMPTY_ROW, ...(r || {}) }))
: [{ ...EMPTY_ROW }, { ...EMPTY_ROW }, { ...EMPTY_ROW }],
);
}, [open, task?.id, task?.work_hours_table]);
if (!open || !task) return null;
// ── derived totals ─────────────────────────────────────────────────────
const totals = useMemo(() => {
const travel = rows.reduce((s, r) => s + parseNum(r.travel_hours), 0);
const work = rows.reduce((s, r) => s + parseNum(r.work_hours), 0);
const km = rows.reduce((s, r) => s + parseNum(r.vehicle_km), 0);
return {
travel: fmtH(travel),
work: fmtH(work),
km: Number.isInteger(km) ? String(km) : km.toFixed(1),
};
}, [rows]);
// ── row mutation ───────────────────────────────────────────────────────
const updateRowField = (index, field, value) => {
setRows((prev) => {
// Step 1: update the changed row with full mutual-exclusion + calc
let updated = prev.map((row, i) => {
if (i !== index) return row;
return computeRow({ ...row, [field]: value });
});
const changed = updated[index];
const gid = changed?._groupId;
const role = changed?._role;
if (!gid) return updated;
// Step 2: departure row changes → sync return row
if (role === 'departure') {
updated = updated.map((row, i) => {
if (i === index || row._groupId !== gid || row._role !== 'return') return row;
let returnRow = { ...row };
// Mirror day/date always
if (field === 'day' || field === 'date') {
returnRow[field] = value;
}
// Mirror break_hours
if (field === 'break_hours') {
returnRow.break_hours = value;
}
// Mirror vehicle_km
if (field === 'vehicle_km') {
returnRow.vehicle_km = value;
}
// Mirror travel times + recalculate return travel_to from duration
if (field === 'travel_time_from' || field === 'travel_time_to') {
returnRow.travel_time_from = changed.travel_time_from;
// Recalculate return's travel_to: return departs when work ends (travel_time_from stays)
// and duration equals departure trip duration
const depDuration = calcHours(changed.travel_time_from, changed.travel_time_to);
if (depDuration !== null && returnRow.travel_time_from) {
const retFrom = parseTime(returnRow.travel_time_from);
if (retFrom !== null) returnRow.travel_time_to = formatTime(retFrom + depDuration);
}
}
// Mirror places (swapped): arrival of departure → departure of return
if (field === 'arrival_place') {
returnRow.departure_place = value;
}
if (field === 'departure_place') {
returnRow.arrival_place = value;
}
return computeRow(returnRow);
});
}
// Step 3: work row's work_time_to → fill return row's travel_time_from
// and recalculate return row's travel_time_to using departure duration
if (role === 'work' && field === 'work_time_to') {
const depRow = updated.find(r => r._groupId === gid && r._role === 'departure');
const depDuration = depRow ? calcHours(depRow.travel_time_from, depRow.travel_time_to) : null;
updated = updated.map((row, i) => {
if (i === index || row._groupId !== gid || row._role !== 'return') return row;
const newFrom = value; // Put od = when work ends
const newTo = (depDuration !== null && parseTime(newFrom) !== null)
? formatTime(parseTime(newFrom) + depDuration)
: row.travel_time_to;
return computeRow({ ...row, travel_time_from: newFrom, travel_time_to: newTo });
});
}
return updated;
});
};
const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]);
const addThreeRows = () => {
const today = new Date();
const dd = String(today.getDate()).padStart(2, '0');
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dateStr = `${dd}.${mm}.${today.getFullYear()}`;
const dayLabels = ['NED', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB'];
const dayLabel = dayLabels[today.getDay()];
const gid = newGroupId();
const loc = defaultLocation || '';
setRows((prev) => [
...prev,
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: 'Zagreb', arrival_place: loc, _groupId: gid, _role: 'departure' },
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, _groupId: gid, _role: 'work' },
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: loc, arrival_place: 'Zagreb', _groupId: gid, _role: 'return' },
]);
};
const removeRow = (i) => setRows((prev) => prev.filter((_, idx) => idx !== i));
const handleSave = async () => {
await onSave?.({ rows: rows.map(stripInternal) });
};
// ── field rendering ────────────────────────────────────────────────────
const isDisabled = (row, f) => {
const mode = rowMode(row);
if (f.computed) return true;
if (f.travelOnly && mode === 'work') return true;
if (f.workOnly && mode === 'travel') return true;
return false;
};
const renderCell = (row, idx, f) => {
const disabled = isDisabled(row, f);
const cls = disabled ? FB_MUTED : FB_INPUT;
if (f.type === 'select') {
return (
<select
value={row[f.key] || ''}
onChange={(e) => !disabled && updateRowField(idx, f.key, e.currentTarget.value)}
disabled={disabled}
className={cls}
>
{DAY_OPTIONS.map((o) => <option key={o || '_'} value={o}>{o || '-'}</option>)}
</select>
);
}
return (
<input
type={f.type}
value={row[f.key] || ''}
onInput={(e) => !disabled && updateRowField(idx, f.key, e.currentTarget.value)}
readOnly={f.computed}
disabled={disabled}
className={cls}
placeholder={f.placeholder || ''}
step={f.step}
min={f.min}
/>
);
};
// ── render ─────────────────────────────────────────────────────────────
return (
<ModalShell
onClose={onClose}
overlayClassName="z-[70] overflow-y-auto p-4 pt-16"
contentClassName="flex min-h-full items-start justify-center"
panelClassName="w-full max-w-[96vw] rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-2rem)]"
>
<div className="flex w-full flex-col">
{/* ── header ── */}
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-3">
<div>
<h3 className="text-base font-semibold text-text-main">Tablica radnih sati</h3>
<p className="text-xs text-text-muted">{task.title || '-'}</p>
</div>
<button type="button" onClick={onClose} aria-label="Zatvori"
className="ml-4 rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700"></button>
</div>
<div className="space-y-4 overflow-y-auto px-5 py-4">
{/* ── info banner ── */}
<div className="flex flex-wrap gap-x-4 gap-y-1 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-300">
<span><span className="font-semibold">Put (oddo):</span> unesi Put od/do Sati puta se računaju automatski; Km vozila se popunjava ručno; polja Vr. rada su blokirana.</span>
<span><span className="font-semibold">Rad (oddo):</span> unesi Vr. rada od/do Sati rada se računaju automatski; Km, Put, Mj. polaska/dolaska su blokirani.</span>
<span><span className="font-semibold">Pauza</span> umanjuje izračunate sate u oba slučaja.</span>
</div>
{/* ── toolbar ── */}
<div className="flex flex-wrap gap-2">
<button type="button" onClick={addRow}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">
+ Dodaj redak
</button>
<button type="button" onClick={addThreeRows}
className="inline-flex items-center gap-1 rounded-lg border border-indigo-300 bg-indigo-50 px-3 py-1.5 text-xs font-medium text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 dark:hover:bg-indigo-900/50">
+ Dodaj standardna 3 retka (Put / Rad / Povratak)
</button>
</div>
{/* ── mobile cards (< xl) ── */}
<div className="space-y-3 xl:hidden">
{rows.length === 0 && (
<p className="text-sm text-gray-400 dark:text-gray-500">Nema unesenih redaka.</p>
)}
{rows.map((row, idx) => (
<div key={idx} className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-bold text-gray-500 dark:text-gray-400">#{idx + 1}</span>
<button type="button" onClick={() => removeRow(idx)}
className="rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20">
Obriši
</button>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{FIELD_CONFIGS.map((f) => (
<label key={f.key} className="flex flex-col gap-0.5">
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">{f.label}</span>
{renderCell(row, idx, f)}
</label>
))}
</div>
</div>
))}
{rows.length > 0 && (
<div className="rounded-lg border border-gray-300 bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200">
UKUPNO
<span className="ml-2">Sati rada: <span className="text-green-700 dark:text-green-400">{totals.work} h</span></span>
<span className="mx-2 text-gray-300 dark:text-gray-600">·</span>
<span>Sati puta: <span className="text-blue-700 dark:text-blue-400">{totals.travel} h</span></span>
<span className="mx-2 text-gray-300 dark:text-gray-600">·</span>
<span>Km vozila: <span className="text-gray-800 dark:text-gray-100">{totals.km} km</span></span>
</div>
)}
</div>
{/* ── desktop table (≥ xl) ── */}
<div className="hidden xl:block">
<div className="overflow-auto rounded-xl border border-gray-200 shadow-sm dark:border-gray-700"> <table className="min-w-[1580px] w-full bg-white text-xs dark:bg-gray-900">
<thead>
<tr className="border-b border-gray-200 bg-gray-50 text-left dark:border-gray-700 dark:bg-gray-800">
<th className="w-8 px-2 py-2.5 text-center text-gray-400 dark:text-gray-500">#</th>
{FIELD_CONFIGS.map((f) => (
<th key={f.key} className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200 whitespace-nowrap">
{f.label}
{f.computed && <span className="ml-1 text-[9px] font-normal text-indigo-400">(auto)</span>}
</th>
))}
<th className="px-2 py-2.5 text-gray-400 dark:text-gray-500">Akcija</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={FIELD_CONFIGS.length + 2}
className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
Nema unesenih redaka.
</td>
</tr>
)}
{rows.map((row, idx) => (
<tr key={idx}
className="border-t border-gray-100 odd:bg-white even:bg-gray-50/60 hover:bg-blue-50/30 dark:border-gray-700 dark:odd:bg-gray-900 dark:even:bg-gray-800/60 dark:hover:bg-blue-900/10">
<td className="px-2 py-1.5 text-center text-[11px] text-gray-400 dark:text-gray-500">{idx + 1}</td>
{FIELD_CONFIGS.map((f) => (
<td key={f.key} className="px-1 py-1 align-middle">
{renderCell(row, idx, f)}
</td>
))}
<td className="px-1 py-1 align-middle">
<button type="button" onClick={() => removeRow(idx)}
className="whitespace-nowrap rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20">
Obriši
</button>
</td>
</tr>
))}
{rows.length > 0 && (
/* cols: # day date work_from work_to travel_from travel_to break → 8 cols
then: work_hours travel_hours departure arrival vehicle_km action */
<tr className="border-t-2 border-gray-300 bg-gray-100 text-xs font-bold dark:border-gray-600 dark:bg-gray-800">
<td colSpan={8} className="px-3 py-2 text-gray-700 dark:text-gray-200">UKUPNO</td>
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Sati rada:</span>{totals.work} h
</td>
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Sati puta:</span>{totals.travel} h
</td>
<td colSpan={2} className="px-2 py-2" />
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Km:</span>{totals.km}
</td>
<td className="px-2 py-2" />
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* ── footer ── */}
<div className="flex items-center justify-end gap-3 border-t border-border-hairline px-5 py-3">
<button type="button" onClick={onClose} disabled={saving}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">
Odustani
</button>
<button type="button" onClick={handleSave} disabled={saving}
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-60 dark:bg-blue-700 dark:hover:bg-blue-600">
{saving ? 'Spremam…' : 'Spremi tablicu radnih sati'}
</button>
</div>
</div>
</ModalShell>
);
}

View File

@@ -1,20 +1,17 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
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';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
const PURPOSE_OPTIONS = [
{ value: 'defektaza', label: 'Defektaža' },
@@ -23,21 +20,23 @@ const PURPOSE_OPTIONS = [
];
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,
display_code: '',
status: 'open',
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: '',
crane_current_mileage: '',
crane_superstructure_working_hours: '',
crane_chassis_working_hours: '',
});
const [imageFiles, setImageFiles] = useState([]);
const [submitting, setSubmitting] = useState(false);
@@ -55,6 +54,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
});
const [invoices, setInvoices] = useState([]);
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
const [photoUploadOpen, setPhotoUploadOpen] = useState(false);
const [invoiceSaving, setInvoiceSaving] = useState(false);
const [invoiceForm, setInvoiceForm] = useState({
naziv_racuna: '',
@@ -63,7 +63,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
opis: '',
imageFile: null,
});
const [resolvedCraneSerialNumber, setResolvedCraneSerialNumber] = useState('');
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [locationQuery, setLocationQuery] = useState('');
const [locationResults, setLocationResults] = useState([]);
@@ -71,15 +70,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
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}`;
@@ -101,11 +91,9 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
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),
display_code: workOrder.display_code || '',
status: workOrder.status || 'open',
start_mileage: String(workOrder.start_mileage ?? ''),
end_mileage: workOrder.end_mileage == null ? '' : String(workOrder.end_mileage),
@@ -114,9 +102,11 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
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 ?? ''),
servicer_vehicle_registration: workOrder.servicer_vehicle_registration || '',
servicer_vehicle_make_model: workOrder.servicer_vehicle_make_model || '',
servicer_vehicle_start_mileage: workOrder.servicer_vehicle_start_mileage == null ? '' : String(workOrder.servicer_vehicle_start_mileage),
servicer_vehicle_end_mileage: workOrder.servicer_vehicle_end_mileage == null ? '' : String(workOrder.servicer_vehicle_end_mileage),
servicer_vehicle_fuel_cost: workOrder.servicer_vehicle_fuel_cost == null ? '' : String(workOrder.servicer_vehicle_fuel_cost),
});
setSubmitting(false);
setError('');
@@ -133,6 +123,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
});
setInvoices([]);
setInvoiceModalOpen(false);
setPhotoUploadOpen(false);
setInvoiceSaving(false);
setInvoiceForm({
naziv_racuna: '',
@@ -147,38 +138,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
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]);
}, [open, workOrder]);
useEffect(() => {
if (!emailModalOpen) return;
@@ -310,13 +270,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
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;
@@ -333,18 +286,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
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;
@@ -369,50 +310,28 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setError('Svrha naloga je obavezna.');
return;
}
const displayCode = String(form.display_code || '').trim().toUpperCase();
if (!displayCode) {
setError('Prikaz putnog naloga je obavezan.');
return;
}
if (!/^[A-Z]{2}[0-9]{6}$/.test(displayCode)) {
setError('Prikaz putnog naloga mora biti u formatu MT070626.');
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, {
display_code: displayCode,
status: form.status,
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: form.servicer_vehicle_start_mileage === '' ? null : Number(form.servicer_vehicle_start_mileage),
servicer_vehicle_end_mileage: form.servicer_vehicle_end_mileage === '' ? null : Number(form.servicer_vehicle_end_mileage),
servicer_vehicle_fuel_cost: form.servicer_vehicle_fuel_cost === '' ? null : Number(form.servicer_vehicle_fuel_cost),
location: form.location.trim(),
travel_start_at: travelStart ? travelStart.toISOString() : null,
travel_end_at: travelEnd ? travelEnd.toISOString() : null,
@@ -523,7 +442,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Uredi nalog {formatEntityCode('PN', workOrder.id)}</h3>
<h3 className="text-lg font-semibold text-text-main">Uredi putni nalog {formatWorkOrderDisplayCode(workOrder)}</h3>
<p className="text-sm text-text-muted">Dizalica: {craneLabel}</p>
</div>
<button
@@ -584,6 +503,20 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
<div className="rounded-xl border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Status</h4>
<label className="mb-3 flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Prikaz putnog naloga *</span>
<input
type="text"
value={form.display_code}
onInput={(event) => setForm({ ...form, display_code: event.currentTarget.value.toUpperCase() })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
pattern="[A-Za-z]{2}[0-9]{6}"
maxLength={8}
placeholder="MT070626"
disabled={!isEditing}
required
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Status *</span>
<select
@@ -607,85 +540,59 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
<h4 className="mb-2 text-sm font-semibold text-text-main">Vozilo servisera</h4>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Registracija</span>
<span className="font-medium text-text-main">Registracija vozila servisera</span>
<input
type="text"
value={workOrder?.craneInfo?.registration_number || ''}
className="rounded-lg border border-border-hairline bg-canvas-deep px-3 py-2 text-text-muted"
disabled
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Marka/Model</span>
<input
type="text"
value={workOrder?.craneInfo?.display_name || ''}
className="rounded-lg border border-border-hairline bg-canvas-deep px-3 py-2 text-text-muted"
disabled
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Početna kilometraža {form.has_travel_order ? '*' : ''}</span>
<input
type="number"
min="0"
value={form.start_mileage}
onInput={(event) => setForm({ ...form, start_mileage: event.currentTarget.value })}
value={form.servicer_vehicle_registration}
onInput={(event) => setForm({ ...form, servicer_vehicle_registration: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Završna kilometraža</span>
<span className="font-medium text-text-main">Marka/Model vozila servisera</span>
<input
type="number"
min="0"
value={form.end_mileage}
onInput={(event) => setForm({ ...form, end_mileage: event.currentTarget.value })}
type="text"
value={form.servicer_vehicle_make_model}
onInput={(event) => setForm({ ...form, servicer_vehicle_make_model: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Početna km vozila servisera</span>
<input
type="number"
min="0"
value={form.servicer_vehicle_start_mileage}
onInput={(event) => setForm({ ...form, servicer_vehicle_start_mileage: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Završna km vozila servisera</span>
<input
type="number"
min="0"
value={form.servicer_vehicle_end_mileage}
onInput={(event) => setForm({ ...form, servicer_vehicle_end_mileage: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Trošak goriva (EUR)</span>
<input
type="number"
min="0"
step="0.01"
value={form.servicer_vehicle_fuel_cost}
onInput={(event) => setForm({ ...form, servicer_vehicle_fuel_cost: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
</div>
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Podaci dizalice</h4>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Trenutna km dizalice</span>
<input
type="number"
min="0"
value={form.crane_current_mileage}
onInput={(event) => setForm({ ...form, crane_current_mileage: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Radni sati nadogradnje</span>
<input
type="number"
min="0"
value={form.crane_superstructure_working_hours}
onInput={(event) => setForm({ ...form, crane_superstructure_working_hours: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Radni sati podvozja</span>
<input
type="number"
min="0"
value={form.crane_chassis_working_hours}
onInput={(event) => setForm({ ...form, crane_chassis_working_hours: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
</div>
</div>
@@ -811,14 +718,23 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
Izmijeni
</button>
) : (
<button
type="button"
onClick={submit}
disabled={submitting}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{submitting ? 'Spremam...' : 'Spremi izmjene'}
</button>
<>
<button
type="button"
onClick={() => setPhotoUploadOpen(true)}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
📷 Dodaj fotografije
</button>
<button
type="button"
onClick={submit}
disabled={submitting}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{submitting ? 'Spremam...' : 'Spremi izmjene'}
</button>
</>
)}
</div>
</form>
@@ -1110,6 +1026,20 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
</div>
</ModalShell>
)}
<WorkOrderPhotoUpload
open={photoUploadOpen}
workOrderId={workOrder?.id}
onClose={() => setPhotoUploadOpen(false)}
onBack={() => setPhotoUploadOpen(false)}
onSuccess={() => {
setPhotoUploadOpen(false);
if (workOrder?.id) {
fetchWorkOrderImages(workOrder.id, { w: 1280, q: 75, fmt: 'webp' })
.then((files) => setImageFiles(files.map((item) => item.optimized_url || item.original_url).filter(Boolean)))
.catch(() => {});
}
}}
/>
</>
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { animated, useTransition } from '@react-spring/web';
import { api } from '../../services/apiClient';
function getBrowserApiBase() {
const apiUrl = import.meta.env.PUBLIC_API_URL || '';
@@ -44,6 +45,109 @@ export function resolveMediaUrl(pathOrUrl) {
return new URL(finalSrc, apiUrl).toString();
}
function isProtectedMediaUrl(url) {
return typeof url === 'string' && /\/api\/fleet\//i.test(url);
}
function revokeObjectUrls(entries = {}) {
Object.values(entries).forEach((url) => {
if (typeof url === 'string' && url.startsWith('blob:')) {
window.URL.revokeObjectURL(url);
}
});
}
export function useAuthenticatedMediaSources(mediaPaths = []) {
const [authenticatedSources, setAuthenticatedSources] = useState({});
const authenticatedSourcesRef = useRef({});
const resolvedMediaPaths = useMemo(() => {
const unique = new Set();
if (!Array.isArray(mediaPaths)) {
return [];
}
mediaPaths.forEach((item) => {
const resolved = resolveMediaUrl(item);
if (resolved) {
unique.add(resolved);
}
});
return Array.from(unique);
}, [mediaPaths]);
const resolvedMediaPathsKey = useMemo(() => resolvedMediaPaths.join('|'), [resolvedMediaPaths]);
useEffect(() => {
authenticatedSourcesRef.current = authenticatedSources;
}, [authenticatedSources]);
useEffect(() => {
let cancelled = false;
const protectedPaths = resolvedMediaPaths.filter((url) => isProtectedMediaUrl(url));
if (protectedPaths.length === 0) {
setAuthenticatedSources((previous) => {
revokeObjectUrls(previous);
return {};
});
return undefined;
}
(async () => {
const nextSources = {};
const createdObjectUrls = [];
const fetched = await Promise.all(
protectedPaths.map(async (url) => {
try {
const blob = await api.get(url, { responseType: 'blob' });
const blobUrl = window.URL.createObjectURL(blob);
createdObjectUrls.push(blobUrl);
return [url, blobUrl];
} catch {
return null;
}
})
);
fetched.forEach((entry) => {
if (!entry) return;
const [url, blobUrl] = entry;
nextSources[url] = blobUrl;
});
if (cancelled) {
createdObjectUrls.forEach((blobUrl) => window.URL.revokeObjectURL(blobUrl));
return;
}
setAuthenticatedSources((previous) => {
revokeObjectUrls(previous);
return nextSources;
});
})();
return () => {
cancelled = true;
};
}, [resolvedMediaPathsKey]);
useEffect(() => () => {
revokeObjectUrls(authenticatedSourcesRef.current);
}, []);
const getAuthenticatedMediaSrc = (pathOrUrl) => {
const resolved = resolveMediaUrl(pathOrUrl);
if (!resolved) {
return '';
}
return authenticatedSources[resolved] || resolved;
};
return {
getAuthenticatedMediaSrc,
};
}
export default function WorkOrderImageCarousel({ images = [], editMode = false, emptyLabel = 'Nema priložene tehničke dokumentacije' }) {
const normalizedImages = useMemo(() => (
Array.isArray(images)
@@ -51,6 +155,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
: (typeof images === 'string' && images ? [images] : [])
), [images]);
const [currentImgIdx, setCurrentImgIdx] = useState(0);
const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(normalizedImages);
useEffect(() => {
setCurrentImgIdx(0);
@@ -87,7 +192,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
};
const finalSrc = resolveMediaUrl(normalizedImages[currentImgIdx] || '');
const finalSrc = getAuthenticatedMediaSrc(normalizedImages[currentImgIdx] || '');
const imageTransitions = useTransition(currentImgIdx, {
from: { opacity: 0, transform: 'translate3d(8%,0,0) scale(1.01)' },
enter: { opacity: 1, transform: 'translate3d(0%,0,0) scale(1)' },
@@ -101,7 +206,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
{imageTransitions((style, idx) => (
<animated.img
key={idx}
src={resolveMediaUrl(normalizedImages[idx] || finalSrc)}
src={getAuthenticatedMediaSrc(normalizedImages[idx] || finalSrc)}
alt={`Dokumentacija s terena ${idx + 1}`}
style={style}
className="absolute inset-0 h-full w-full object-cover will-change-transform"

View File

@@ -7,11 +7,13 @@ import {
fetchWorkOrderById,
fetchWorkOrderInvoices,
fetchWorkOrderTaskServiceContext,
updateTaskWorkHoursTable,
} 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';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
function readWorkOrderIdFromQuery() {
if (typeof window === 'undefined') {
@@ -53,6 +55,8 @@ export default function WorkOrderInvoicesPdfPage() {
const [taskContext, setTaskContext] = useState({ tasks: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingTask, setEditingTask] = useState(null);
const [savingWorkHours, setSavingWorkHours] = useState(false);
useEffect(() => {
hydrateAuthFromStorage();
@@ -118,9 +122,57 @@ export default function WorkOrderInvoicesPdfPage() {
if (!workOrder) {
return 'Računi putnog naloga';
}
return `${formatEntityCode('PN', workOrder.id)}`;
return formatWorkOrderDisplayCode(workOrder);
}, [workOrder]);
const mediaPaths = useMemo(() => {
const paths = [];
if (Array.isArray(taskContext.tasks)) {
taskContext.tasks.forEach((task) => {
if (!Array.isArray(task?.service_records)) return;
task.service_records.forEach((record) => {
if (!Array.isArray(record?.photos)) return;
record.photos.forEach((photo) => {
const candidate = photo?.optimized_url || photo?.image_url;
if (candidate) {
paths.push(candidate);
}
});
});
});
}
if (Array.isArray(invoices)) {
invoices.forEach((invoice) => {
const invoiceImage = invoice?.image_url || invoice?.image;
if (invoiceImage) {
paths.push(invoiceImage);
}
});
}
return paths;
}, [taskContext.tasks, invoices]);
const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(mediaPaths);
const handleSaveWorkHoursTable = async (tableData) => {
if (!editingTask?.id) return;
setSavingWorkHours(true);
try {
const response = await updateTaskWorkHoursTable(editingTask.id, tableData);
setTaskContext((prev) => ({
...prev,
tasks: (Array.isArray(prev.tasks) ? prev.tasks : []).map((task) => (
String(task.id) === String(editingTask.id)
? { ...task, work_hours_table: response?.data ?? null }
: task
)),
}));
setEditingTask(null);
} finally {
setSavingWorkHours(false);
}
};
return (
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
@@ -144,7 +196,7 @@ export default function WorkOrderInvoicesPdfPage() {
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-text-main">Task i povezani servisni zapisi</h2>
<h2 className="text-lg font-semibold text-text-main">Servisni zapisi</h2>
<button
type="button"
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
@@ -168,6 +220,15 @@ export default function WorkOrderInvoicesPdfPage() {
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
</div>
<div className="mt-2">
<button
type="button"
onClick={() => setEditingTask(task)}
className="rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Uredi tablicu radnih sati
</button>
</div>
<div className="mt-3 space-y-3">
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
<p className="text-xs text-text-muted">Nema povezanih servisnih zapisa.</p>
@@ -185,13 +246,13 @@ export default function WorkOrderInvoicesPdfPage() {
record.photos.map((photo) => (
<a
key={photo.id}
href={resolveMediaUrl(photo.optimized_url || photo.image_url)}
href={getAuthenticatedMediaSrc(photo.optimized_url || photo.image_url)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex"
>
<img
src={resolveMediaUrl(photo.optimized_url || photo.image_url)}
src={getAuthenticatedMediaSrc(photo.optimized_url || photo.image_url)}
alt={`Servisna slika ${photo.id}`}
className="h-20 w-20 rounded border border-border-hairline object-cover"
/>
@@ -244,7 +305,7 @@ export default function WorkOrderInvoicesPdfPage() {
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
</div>
<InvoiceImagePreview
imageUrl={resolveMediaUrl(invoice.image)}
imageUrl={getAuthenticatedMediaSrc(invoice.image_url || invoice.image) || resolveMediaUrl(invoice.image_url || invoice.image)}
alt={`Račun ${invoice.naziv_racuna}`}
/>
</div>
@@ -253,6 +314,14 @@ export default function WorkOrderInvoicesPdfPage() {
</div>
)}
</div>
<TaskWorkHoursTableModal
open={!!editingTask}
task={editingTask}
defaultLocation={workOrder?.location || ''}
saving={savingWorkHours}
onClose={() => setEditingTask(null)}
onSave={handleSaveWorkHoursTable}
/>
</section>
);
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import ModalShell from '../ui/ModalShell';
import { $user } from '../../stores/authStore';
import { generateWorkOrderDisplayCode } from '../../lib/displayIds';
const PURPOSE_OPTIONS = [
{ value: 'defektaza', label: 'Defektaža' },
@@ -11,6 +12,7 @@ const PURPOSE_OPTIONS = [
const INITIAL_FORM = {
has_travel_order: false,
display_code: '',
start_mileage: '',
end_mileage: '',
servicer_vehicle_registration: '',
@@ -54,6 +56,7 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
setForm((prev) => ({
...prev,
display_code: generateWorkOrderDisplayCode(user?.first_name, user?.last_name, new Date()),
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(),
@@ -116,6 +119,15 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
setErrorMessage('Svrha naloga je obavezna.');
return;
}
const displayCode = String(form.display_code || '').trim().toUpperCase();
if (!displayCode) {
setErrorMessage('Prikaz putnog naloga je obavezan. Provjerite da serviser ima ime i prezime ili unesite oznaku ručno.');
return;
}
if (!/^[A-Z]{2}[0-9]{6}$/.test(displayCode)) {
setErrorMessage('Prikaz putnog naloga mora biti u formatu MT070626.');
return;
}
let startMileage = null;
let endMileage = null;
@@ -191,6 +203,7 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
try {
await onSubmit({
vehicle: selectedCrane.id,
display_code: displayCode,
start_mileage: startMileage,
end_mileage: endMileage,
servicer_vehicle_registration: form.servicer_vehicle_registration.trim(),
@@ -233,6 +246,20 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
</div>
<form onSubmit={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Prikaz putnog naloga *</span>
<input
type="text"
value={form.display_code}
onInput={(event) => setForm({ ...form, display_code: event.currentTarget.value.toUpperCase() })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
placeholder="MT070626"
pattern="[A-Za-z]{2}[0-9]{6}"
maxLength={8}
required
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Status *</span>
<select

View File

@@ -134,7 +134,7 @@ export default function ServicePhotoUpload({ open, serviceRecordId, onClose, onS
return (
<ModalShell
onClose={handleCancel}
overlayClassName="z-50 p-4"
overlayClassName="z-[70] 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 ${isOffline ? 'pointer-events-none opacity-50' : ''}`}
>

View File

@@ -34,6 +34,8 @@ export default function ServiceRecordDetailModal({
serviceRecord,
onClose,
onUpdated,
onOpenPhotoUpload = null,
filesRefreshToken = 0,
onBack = null,
backLabel = 'Natrag',
}) {
@@ -116,7 +118,7 @@ export default function ServiceRecordDetailModal({
return () => {
cancelled = true;
};
}, [open, serviceRecord?.id]);
}, [open, serviceRecord?.id, filesRefreshToken]);
useEffect(() => {
if (!emailModalOpen) return;
@@ -276,7 +278,7 @@ export default function ServiceRecordDetailModal({
<div className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main">
<p className="text-xs text-text-muted">Trošak / KM / Dizalica</p>
<p>
{formatCost(serviceRecord.cost)} {serviceRecord.mileage ?? '-'} km {serviceRecord.crane_registration || serviceRecord.vehicle_registration || '-'}
{formatCost(serviceRecord.cost)} {serviceRecord.mileage ?? '-'} km {serviceRecord.crane_serial_number || serviceRecord.crane_registration || serviceRecord.vehicle_registration || '-'}
</p>
</div>
</div>
@@ -411,6 +413,14 @@ export default function ServiceRecordDetailModal({
)}
{editMode && (
<>
<button
type="button"
onClick={() => onOpenPhotoUpload?.(serviceRecord)}
disabled={editSaving}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
📎 Dodaj datoteke
</button>
<button
type="button"
onClick={() => {

View File

@@ -11,6 +11,7 @@ import { showToast } from '../../stores/toastStore.js';
const INITIAL = {
task: '',
service_title: '',
description: '',
parts: '',
cost: '',
@@ -144,6 +145,7 @@ export default function ServiceRecordForm({
try {
const payload = {
task: form.task,
service_title: form.service_title.trim(),
description: form.description.trim(),
parts: form.parts.trim(),
cost,
@@ -268,6 +270,18 @@ export default function ServiceRecordForm({
</label>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Naziv servisnog zapisa</span>
<input
type="text"
value={form.service_title}
onInput={field('service_title')}
disabled={disabled}
placeholder="Npr. Redovni servis hidraulike"
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main placeholder-text-muted/40 focus:outline-none disabled:opacity-50"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Opis posla *</span>
<textarea

View File

@@ -0,0 +1,247 @@
import { useState, useEffect, useRef } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import ModalShell from '../ui/ModalShell';
import { $isOffline } from '../../stores/networkStore.js';
import { uploadWorkOrderPhoto } 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 WorkOrderPhotoUpload({ open, workOrderId, onClose, onSuccess, onBack }) {
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);
const abortRef = useRef(null);
useEffect(() => {
setIsMounted(true);
return () => {
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 (!workOrderId) {
setError('Nije poznat putni nalog 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;
}
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 uploadWorkOrderPhoto(workOrderId, file, description, signal);
successCount++;
}
if (!signal.aborted) {
showToast(`${successCount} fotografija putnog naloga je uspješno uploadano.`, 'success');
e.target.reset();
setImages([]);
setDescription('');
onSuccess?.();
onClose?.();
}
} catch (err) {
if (err?.name === 'AbortError') {
return;
}
setError(err?.message || 'Greška pri uploadu fotografija putnog naloga.');
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 (
<ModalShell
onClose={handleCancel}
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 ${isOffline ? 'pointer-events-none opacity-50' : ''}`}
>
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div className="flex items-center gap-3">
{onBack && (
<button
type="button"
onClick={onBack}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Natrag"
>
Natrag
</button>
)}
<div>
<h3 className="text-lg font-semibold text-text-main">Upload fotografija putnog naloga</h3>
<p className="text-xs text-text-muted mt-0.5">
Putni nalog #{workOrderId}
</p>
</div>
</div>
<button
type="button"
onClick={handleCancel}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 px-5 py-4">
<div>
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted uppercase tracking-wide">
Fotografije putnog naloga ({images.length} odabrano)
</label>
<input
type="file"
accept={ACCEPTED_TYPES.join(',')}
multiple
onChange={handleFileChange}
disabled={submitting}
className="block w-full text-xs text-text-muted file:mr-4 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-mono file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100 file:cursor-pointer transition-all disabled:opacity-50"
/>
<p className="mt-1 text-[11px] text-text-muted">
JPG, PNG, WEBP, HEIC max {MAX_FILE_SIZE_MB} MB po datoteci
</p>
</div>
{images.length > 0 && (
<div className="rounded-lg border border-border-hairline bg-canvas-deep p-2 max-h-32 overflow-y-auto space-y-1">
{images.map((file, i) => (
<p key={i} className="text-[10px] font-mono text-text-muted truncate">
📎 [{i + 1}] {file.name}{' '}
<span className="text-text-muted/60">
({(file.size / 1024).toFixed(1)} KB)
</span>
</p>
))}
</div>
)}
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Opis (primjenjuje se na sve slike)</span>
<input
type="text"
value={description}
onInput={(e) => setDescription(e.currentTarget.value)}
disabled={submitting}
placeholder="Npr. Slike stanja na lokaciji prije/poslije intervencije"
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main placeholder-text-muted/40 focus:outline-none disabled:opacity-50"
/>
</label>
{submitting && progress.total > 1 && (
<div>
<div className="flex justify-between text-[11px] text-text-muted mb-1">
<span>Uploading...</span>
<span>{progress.current}/{progress.total}</span>
</div>
<div className="h-1.5 w-full rounded-full bg-canvas-deep overflow-hidden">
<div
className="h-full rounded-full bg-indigo-500 transition-all duration-300"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
</div>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<div className="flex justify-end gap-2 border-t border-border-hairline pt-3">
<button
type="button"
onClick={handleCancel}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
{submitting ? 'Prekini upload' : 'Odustani'}
</button>
<button
type="submit"
disabled={submitting || images.length === 0 || isOffline}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{submitting
? `Uploading ${progress.current}/${progress.total}...`
: `Upload ${images.length > 0 ? images.length + ' slike' : 'fotografija'}`}
</button>
</div>
</form>
</div>
</ModalShell>
);
}

View File

@@ -0,0 +1,50 @@
import { useMemo } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import { $tasks } from '../../stores/taskStore';
import { $user } from '../../stores/authStore';
import TaskCalendarWidget from '../dashboard/TaskCalendarWidget';
/**
* Mountan u Layout.astro (van AnimatedPage transforma) pa fixed pozicija
* uvijek ostaje uz viewport rub.
*
* Filtriranje taskova po ulozi:
* - Serviser → samo taskovi dodijeljeni njemu
* - Supervisor (nije serviser) → svi taskovi
*
* Kad korisnik klikne task u kalendaru, šaljemo custom event koji
* FleetDashboardShell sluša i otvara TaskServiceRecordsModal.
*/
export default function TaskCalendarPortal() {
const allTasks = useStore($tasks);
const user = useStore($user);
const isServiser = Boolean(user?.is_serviser);
const isTeamMember = Boolean(user?.is_team_member);
const isSupervisorNotServiser = isTeamMember && !isServiser;
const tasks = useMemo(() => {
if (!user?.id) return [];
// Supervisor vidi sve; serviser vidi samo svoje
if (isSupervisorNotServiser) return allTasks;
return allTasks.filter(
(t) => String(t.assigned_to || '') === String(user.id)
);
}, [allTasks, user, isSupervisorNotServiser]);
// Ne renderiramo ništa ako korisnik nije prijavljen
if (!user?.id) return null;
function handleTaskClick(task) {
window.dispatchEvent(
new CustomEvent('calendar:open-task', { detail: { task } })
);
}
return (
<TaskCalendarWidget
tasks={tasks}
onTaskClick={handleTaskClick}
/>
);
}

View File

@@ -1,6 +1,11 @@
import { useMemo } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
import { downloadWorkOrderInvoicesPdf, downloadWorkOrderPdf, downloadWorkOrderServiceRecordsPdf } from '../../stores/fleetDashboardStore';
import {
downloadGeneratedPdfByUrl,
downloadWorkOrderInvoicesPdf,
downloadWorkOrderPdf,
downloadWorkOrderServiceRecordsPdf,
} from '../../stores/fleetDashboardStore';
function formatTimestamp(value) {
if (!value) return '';
@@ -57,6 +62,12 @@ function getEntityAction(notification, meta) {
? 'Preuzmi PDF servisnih zapisa'
: 'Preuzmi PDF putnog naloga',
run() {
if (metadata.download_url) {
return downloadGeneratedPdfByUrl(
metadata.download_url,
metadata.filename || `${metadata.work_order_id}.${metadata.pdf_type || 'work-order'}.pdf`
);
}
if (metadata.pdf_type === 'invoices') {
return downloadWorkOrderInvoicesPdf(metadata.work_order_id);
}

View File

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

View File

@@ -11,10 +11,12 @@ export default function ThemeToggle() {
const toggleTheme = () => {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
document.documentElement.style.backgroundColor = '#ffffff';
localStorage.setItem('theme', 'light');
setIsDark(false);
} else {
document.documentElement.classList.add('dark');
document.documentElement.style.backgroundColor = '#1f2329';
localStorage.setItem('theme', 'dark');
setIsDark(true);
}
@@ -24,7 +26,7 @@ export default function ThemeToggle() {
<button
onClick={toggleTheme}
type="button"
className="p-2.5 rounded-s-xl bg-canvas-elevated border-y border-s border-border-hairline text-text-muted hover:text-text-main transition-all duration-200 focus:outline-none cursor-pointer flex items-center justify-center"
className="relative inline-flex h-10 w-10 items-center justify-center rounded-lg border border-border-hairline bg-canvas-elevated text-text-muted hover:bg-canvas-base"
aria-label="Prebaci temu"
>
{isDark ? (

View File

@@ -8,7 +8,7 @@ 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';
import TaskCalendarPortal from '../components/layout/TaskCalendarPortal.jsx';
interface Props {
title: string;
@@ -40,8 +40,11 @@ const isDev = import.meta.env.DEV;
const preferiraTamno = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (lokalnaTema === 'dark' || (!lokalnaTema && preferiraTamno)) {
document.documentElement.classList.add('dark');
// Postavi pozadinu direktno na html elementu kako bi bila dostupna i prije učitavanja CSS-a
document.documentElement.style.backgroundColor = '#1f2329';
} else {
document.documentElement.classList.remove('dark');
document.documentElement.style.backgroundColor = '#ffffff';
}
}
primijeniTemu();
@@ -49,14 +52,14 @@ const isDev = import.meta.env.DEV;
</script>
</head>
<body class="relative min-h-screen bg-canvas-base text-text-main font-sans antialiased transition-colors duration-300">
<body class="relative min-h-screen bg-canvas-base text-text-main font-sans antialiased">
<div class="absolute inset-0 bg-[linear-gradient(to_right,var(--color-text-main)_0.03_1px,transparent_1px),linear-gradient(to_bottom,var(--color-text-main)_0.03_1px,transparent_1px)] bg-[size:64px_64px] opacity-[0.4] dark:opacity-[0.02] pointer-events-none z-0"></div>
{!isAuthPage && <NetworkGuard client:only="preact" />}
<Toast client:load />
<ToastProvider client:load />
{!isAuthPage && <NetworkGuard client:only="preact" transition:persist="network-guard" />}
<Toast client:only="preact" transition:persist="toast" />
{!isAuthPage && isDev && <ToastTrigger client:load />}
{!isAuthPage && <Navbar client:load minimal={minimalNav} />}
{!isAuthPage && <Navbar client:only="preact" transition:persist="navbar" minimal={minimalNav} />}
{!isAuthPage && <TaskCalendarPortal client:only="preact" transition:persist="calendar-portal" />}
<div id="main-content" class="relative z-10 flex flex-col min-h-screen max-w-6xl mx-auto px-6 md:px-8 py-6">
<header class="flex flex-row justify-between items-end pb-6 mb-8 border-b border-border-hairline">
@@ -68,7 +71,7 @@ const isDev = import.meta.env.DEV;
</div>
</header>
<main class="flex-1 w-full" transition:animate="fade">
<main class="flex-1 w-full">
<slot />
</main>
@@ -85,6 +88,16 @@ const isDev = import.meta.env.DEV;
navigator.serviceWorker.register('/service-worker.js').catch(function (error) {
console.error('Service worker registration failed:', error);
});
window.addEventListener('online', function () {
navigator.serviceWorker.ready
.then(function (registration) {
if (!registration.active) return;
registration.active.postMessage('FLUSH_WRITE_QUEUE');
})
.catch(function () {
// no-op, SW may not be ready yet
});
});
return;
}
@@ -94,7 +107,7 @@ const isDev = import.meta.env.DEV;
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key.startsWith('erp-shell-'))
.filter((key) => key.startsWith('erp-shell-') || key.startsWith('erp-api-'))
.map((key) => caches.delete(key))
);
}

View File

@@ -12,6 +12,50 @@ export function formatEntityCode(prefix, id, size = 8) {
return `${prefix}-${short}`;
}
export function generateWorkOrderDisplayCode(firstName, lastName, dateValue = new Date()) {
const firstInitial = String(firstName || '').trim().charAt(0).toUpperCase();
const lastInitial = String(lastName || '').trim().charAt(0).toUpperCase();
if (!firstInitial || !lastInitial) return '';
const parsedDate = dateValue instanceof Date ? dateValue : new Date(dateValue);
if (Number.isNaN(parsedDate.getTime())) return '';
const day = String(parsedDate.getDate()).padStart(2, '0');
const month = String(parsedDate.getMonth() + 1).padStart(2, '0');
const year = String(parsedDate.getFullYear()).slice(-2);
return `${firstInitial}${lastInitial}${day}${month}${year}`;
}
export function formatWorkOrderDisplayCode(workOrder) {
const displayCode = String(workOrder?.display_code || '').trim().toUpperCase();
if (displayCode) return displayCode;
return '-';
}
const WORK_ORDER_CODE_PATTERN = /^([A-Z]{2})(\d{2})(\d{2})(\d{2})$/;
export function parseWorkOrderDisplayCode(value) {
const normalized = String(value || '').trim().toUpperCase();
const match = normalized.match(WORK_ORDER_CODE_PATTERN);
if (!match) {
return { valid: false, normalized };
}
const day = Number(match[2]);
const month = Number(match[3]);
const year = 2000 + Number(match[4]);
if (day < 1 || day > 31 || month < 1 || month > 12) {
return { valid: false, normalized };
}
const sortKey = year * 10000 + month * 100 + day;
return {
valid: true,
normalized,
prefix: match[1],
day,
month,
year,
sortKey,
};
}
export function formatPurposeLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
if (!normalized) return '';

View File

@@ -105,8 +105,18 @@ async function request(endpoint, options = {}) {
// 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 };
const normalizedDetails = (
errData
&& typeof errData === 'object'
&& typeof errData.detail === 'object'
&& errData.detail !== null
) ? errData.detail : errData;
if (typeof errData.detail === 'string' && errData.detail.trim()) {
errorMessage = errData.detail;
} else if (typeof normalizedDetails?.message === 'string' && normalizedDetails.message.trim()) {
errorMessage = normalizedDetails.message;
}
throw { message: String(errorMessage), status: response.status, details: normalizedDetails };
}
if (options.responseType === 'blob') return await response.blob();

View File

@@ -8,12 +8,25 @@ export const $clients = atom([]);
export const $clientsLoading = atom(false);
export const $clientsError = atom(null);
export async function fetchClients(signal) {
let _lastClientsFetchAt = 0;
const CLIENTS_FETCH_TTL_MS = 30_000; // 30 sekundi
export async function fetchClients(arg) {
const options = (arg && typeof arg === 'object' && !('aborted' in arg))
? arg
: { signal: arg };
const { signal, force = false } = options;
if (!force && $clients.get().length > 0 && Date.now() - _lastClientsFetchAt < CLIENTS_FETCH_TTL_MS) {
return;
}
$clientsLoading.set(true);
$clientsError.set(null);
try {
const data = await api.get('crm/clients/', { signal });
$clients.set(Array.isArray(data) ? data : (data.results ?? []));
_lastClientsFetchAt = Date.now();
} catch (err) {
if (err?.name !== 'AbortError') $clientsError.set(err.message ?? 'Greška');
} finally {
@@ -24,16 +37,19 @@ export async function fetchClients(signal) {
export async function createClient(payload) {
const data = await api.post('crm/clients/', payload);
$clients.set([...$clients.get(), data]);
_lastClientsFetchAt = Date.now();
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)));
_lastClientsFetchAt = Date.now();
return data;
}
export async function deleteClient(id) {
await api.delete(`crm/clients/${id}/`);
$clients.set($clients.get().filter((c) => c.id !== id));
_lastClientsFetchAt = Date.now();
}

View File

@@ -2,6 +2,7 @@ import { atom, computed } from 'nanostores';
import { api } from '../services/apiClient';
import { $accessToken } from './authStore';
import { showToast } from './toastStore';
import { $tasks } from './taskStore';
const DB_NAME = 'erp-offline-db';
const DB_VERSION = 1;
@@ -20,6 +21,10 @@ export const $serviceRecords = atom([]);
export const $offlineQueueCount = atom(0);
export const $syncLog = atom([]);
// Timestamp posljednjeg uspješnog fetch-a — koristi se za TTL provjeru
let _lastDashboardFetchAt = 0;
const DASHBOARD_FETCH_TTL_MS = 30_000; // 30 sekundi
let dbPromise = null;
let syncListenerStarted = false;
let isSyncInProgress = false;
@@ -440,12 +445,15 @@ export const $workOrdersWithVehicle = computed(
);
export const $dashboardStats = computed(
[$workOrders, $serviceRecords],
(workOrders, serviceRecords) => {
[$workOrders, $serviceRecords, $tasks],
(workOrders, serviceRecords, tasks) => {
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;
// Broji Taskove čiji je scheduled_date danas i status aktivan ili u tijeku
const servicesToday = tasks.filter(
(t) => t.scheduled_date === today && (t.status === 'aktivan' || t.status === 'servis')
).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;
@@ -484,7 +492,13 @@ export async function ensureVehiclesCatalog() {
}
export async function fetchFleetDashboardData(options = {}) {
const { silent = false } = options;
const { silent = false, force = false } = options;
// Preskoči fetch ako su podaci svježi (unutar TTL), osim ako je force=true
if (!force && $workOrders.get().length > 0 && Date.now() - _lastDashboardFetchAt < DASHBOARD_FETCH_TTL_MS) {
return;
}
startOfflineSyncListeners();
if (!silent) $dashboardLoading.set(true);
$dashboardError.set(null);
@@ -507,6 +521,7 @@ export async function fetchFleetDashboardData(options = {}) {
$workOrders.set(Array.isArray(workOrders) ? workOrders : []);
$serviceRecords.set(Array.isArray(serviceRecords) ? serviceRecords : []);
$vehicles.set(normalizeCraneList(vehicles));
_lastDashboardFetchAt = Date.now();
await persistDashboardCache();
await processOfflineQueue();
} catch (error) {
@@ -607,6 +622,17 @@ export async function uploadServicePhoto(serviceRecordId, imageFile, description
return response;
}
export async function uploadWorkOrderPhoto(workOrderId, imageFile, description = '', signal = null) {
const formData = new FormData();
formData.append('image', imageFile);
if (description.trim()) {
formData.append('description', description.trim());
}
const options = signal ? { signal } : {};
const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/images/`, formData, options);
return response;
}
export async function uploadServiceAttachment(serviceRecordId, file, description = '', signal = null) {
const formData = new FormData();
formData.append('service_record', serviceRecordId);
@@ -692,6 +718,15 @@ export async function fetchWorkOrderTaskServiceContext(workOrderId) {
return payload && typeof payload === 'object' ? payload : { tasks: [] };
}
export async function updateTaskWorkHoursTable(taskId, data) {
if (!taskId) {
throw new Error('Task ID je obavezan.');
}
const payload = await api.put(`tasks/tasks/${encodeURIComponent(taskId)}/work-hours-table/`, data ?? null);
showToast('Tablica radnih sati je uspješno spremljena.', 'success');
return payload;
}
export async function createWorkOrderInvoice(workOrderId, data = {}, signal = null) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
@@ -717,14 +752,40 @@ export async function createWorkOrderInvoice(workOrderId, data = {}, signal = nu
return response;
}
async function _refreshNotificationsAsync() {
if (!isBrowser()) return;
try {
const { fetchNotifications } = await import('./notificationStore.js');
await fetchNotifications();
} catch (_) {
// silent — ne blokiramo PDF flow zbog notifikacija
}
}
// Polling za async PDF generiranje: osveži notifikacije na 5s, 20s i 60s
// kako bi uhvatili Celery completion bez ovisnosti o Pusher konekciji.
function _schedulePdfNotificationPolling() {
if (!isBrowser()) return;
[5000, 20000, 60000].forEach((delay) => {
setTimeout(() => _refreshNotificationsAsync(), delay);
});
}
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');
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/pdf-request/`, {});
if (payload?.status === 'ready' && payload?.download_url) {
await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order.pdf`);
showToast('PDF putnog naloga je preuzet iz cache-a.', 'success');
_refreshNotificationsAsync();
return payload;
}
showToast('PDF putnog naloga se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
_schedulePdfNotificationPolling();
return payload;
}
export async function downloadWorkOrderInvoicesPdf(workOrderId) {
@@ -732,9 +793,16 @@ export async function downloadWorkOrderInvoicesPdf(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');
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-pdf-request/`, {});
if (payload?.status === 'ready' && payload?.download_url) {
await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order-invoices.pdf`);
showToast('PDF računa putnog naloga je preuzet iz cache-a.', 'success');
_refreshNotificationsAsync();
return payload;
}
showToast('PDF računa se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
_schedulePdfNotificationPolling();
return payload;
}
export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
@@ -742,9 +810,32 @@ export async function downloadWorkOrderServiceRecordsPdf(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');
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {});
if (payload?.status === 'ready' && payload?.download_url) {
await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order-service-records.pdf`);
showToast('PDF servisnih zapisa je preuzet iz cache-a.', 'success');
_refreshNotificationsAsync();
return payload;
}
showToast('PDF servisnih zapisa se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
_schedulePdfNotificationPolling();
return payload;
}
export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') {
if (!downloadUrl) {
showToast('Nedostaje URL za preuzimanje PDF-a.', 'error');
throw new Error('Nedostaje URL za preuzimanje PDF-a.');
}
try {
const blob = await api.get(downloadUrl, { responseType: 'blob' });
saveBlobToFile(blob, filename);
return true;
} catch (err) {
const msg = err?.message || 'Preuzimanje PDF-a nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
export function openWorkOrderInvoicesPdfPage(workOrderId) {

View File

@@ -4,14 +4,14 @@ 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();
// Heartbeat endpoint: GET /api/health/
// Time izbjegavamo periodično gađanje auth/token endpointa.
const HEARTBEAT_URL = new URL('health/', API_BASE).toString();
async function provjeriStvarnuVezu() {
try {
const response = await fetch(HEARTBEAT_URL, {
method: 'OPTIONS',
method: 'GET',
cache: 'no-store',
});

View File

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

View File

@@ -1,14 +1,21 @@
// src/stores/taskStore.js
// 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);
export const $taskTemplates = atom([]);
export const $taskTemplatesLoading = atom(false);
let _lastTaskFetchAt = 0;
const TASK_FETCH_TTL_MS = 30_000; // 30 sekundi
const STATUS_LABELS = {
aktivan: 'Aktivan',
servis: 'U tijeku',
spreman_za_zavrsetak: 'Spreman za završetak',
zavrsen: 'Završen',
neaktivan: 'Otkazano',
};
@@ -18,14 +25,18 @@ export function getStatusLabel(status) {
export function isTaskActive(task) {
const status = String(task?.status || '').toLowerCase();
return status === 'aktivan' || status === 'servis';
return status === 'aktivan' || status === 'servis' || status === 'spreman_za_zavrsetak';
}
export async function fetchTasks() {
export async function fetchTasks({ force = false } = {}) {
if (!force && $tasks.get().length > 0 && Date.now() - _lastTaskFetchAt < TASK_FETCH_TTL_MS) {
return;
}
$tasksLoading.set(true);
try {
const data = await api.get('tasks/tasks/');
$tasks.set(Array.isArray(data) ? data : (data?.results ?? []));
_lastTaskFetchAt = Date.now();
} catch (error) {
if (error?.status !== 401) {
showToast('Greška pri dohvatu zadataka.', 'error');
@@ -35,6 +46,23 @@ export async function fetchTasks() {
}
}
export async function fetchTaskTemplates() {
$taskTemplatesLoading.set(true);
try {
const data = await api.get('tasks/tasks/templates/');
const templates = Array.isArray(data) ? data : (data?.results ?? []);
$taskTemplates.set(templates);
return templates;
} catch (error) {
if (error?.status !== 401) {
showToast('Greška pri dohvatu predložaka zadataka.', 'error');
}
throw error;
} finally {
$taskTemplatesLoading.set(false);
}
}
export async function createTask(data) {
const response = await api.post('tasks/tasks/', data);
$tasks.set([response, ...$tasks.get()]);

View File

@@ -58,4 +58,17 @@
}
body.modal-open #main-content {
filter: blur(4px);
}
/* View transitions — stara stranica ostaje vidljiva dok nova ne fade-ina, bez bijelog flash-a */
::view-transition-old(root) {
animation: none;
}
::view-transition-new(root) {
animation: 150ms ease-out fade-in-page forwards;
}
@keyframes fade-in-page {
from { opacity: 0; }
to { opacity: 1; }
}