Compare commits
25 Commits
environmen
...
cbcbf0b3af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbcbf0b3af | ||
|
|
ad7b293f54 | ||
|
|
8485431180 | ||
|
|
bbdd02c9e9 | ||
|
|
fe7edf5643 | ||
|
|
0c9e1b7d50 | ||
|
|
bf8e25efe7 | ||
|
|
ef8718a4cf | ||
|
|
75257525c4 | ||
|
|
7acaef941f | ||
|
|
e14665d8ec | ||
|
|
ef51036bc7 | ||
|
|
13de9e113c | ||
|
|
e8330473cd | ||
|
|
5e7ec72a4e | ||
|
|
346682b172 | ||
|
|
8e4b3f8aea | ||
|
|
1ab2bfb3c3 | ||
|
|
3ad536a23e | ||
| 70151f1225 | |||
|
|
ac5f25a99c | ||
| 24cd9ae11c | |||
| 3268e53642 | |||
|
|
4e3bf6dab0 | ||
|
|
3b54499b17 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from celery.schedules import crontab
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
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'
|
DEBUG = os.environ.get('DEBUG', 'True') == 'True'
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
'unfold',
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
'django.contrib.auth',
|
'django.contrib.auth',
|
||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
@@ -27,6 +29,14 @@ INSTALLED_APPS = [
|
|||||||
'modules.crm',
|
'modules.crm',
|
||||||
'modules.fleet'
|
'modules.fleet'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
UNFOLD = {
|
||||||
|
'SITE_TITLE': 'ERP Admin',
|
||||||
|
'SITE_HEADER': 'ERP Administration',
|
||||||
|
'SITE_SYMBOL': 'dashboard',
|
||||||
|
'SHOW_HISTORY': True,
|
||||||
|
}
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'corsheaders.middleware.CorsMiddleware', # Mora biti prvi
|
'corsheaders.middleware.CorsMiddleware', # Mora biti prvi
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
@@ -61,7 +71,7 @@ WSGI_APPLICATION = 'core.wsgi.application'
|
|||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||||
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
{'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'},
|
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -160,12 +170,22 @@ CACHES = {
|
|||||||
|
|
||||||
CELERY_ACCEPT_CONTENT = ['json']
|
CELERY_ACCEPT_CONTENT = ['json']
|
||||||
CELERY_TASK_SERIALIZER = '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
|
# URL kojim će preglednik dohvaćati statiku
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = '/static/'
|
||||||
|
|
||||||
# Direktorij gdje će 'collectstatic' skupljati sve statičke datoteke (za produkciju)
|
# 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)
|
# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente)
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
|
|||||||
@@ -47,3 +47,13 @@ MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
|
|||||||
INTERNAL_IPS = ['127.0.0.1']
|
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'
|
||||||
@@ -16,9 +16,11 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Sigurnosne postavke
|
# Sigurnosne postavke
|
||||||
SECURE_SSL_REDIRECT = True
|
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
CSRF_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)
|
# Produkcijski CORS (samo domena na kojoj vrti Astro)
|
||||||
CORS_ALLOWED_ORIGINS = os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',')
|
CORS_ALLOWED_ORIGINS = os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',')
|
||||||
@@ -34,5 +36,10 @@ CSRF_TRUSTED_ORIGINS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Statika i Mediji
|
# Statika i Mediji
|
||||||
|
STATIC_URL = '/static/'
|
||||||
STATIC_ROOT = '/app/staticfiles'
|
STATIC_ROOT = '/app/staticfiles'
|
||||||
|
MEDIA_URL = '/media/'
|
||||||
MEDIA_ROOT = '/app/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')
|
||||||
@@ -2,14 +2,19 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.http import JsonResponse
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework_simplejwt.views import (
|
from rest_framework_simplejwt.views import (
|
||||||
TokenObtainPairView,
|
TokenObtainPairView,
|
||||||
TokenRefreshView,
|
TokenRefreshView,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def api_health(_request):
|
||||||
|
return JsonResponse({'status': 'ok'})
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
path('api/health/', api_health, name='api_health'),
|
||||||
|
|
||||||
# Uključujemo rute iz modula (svaki modul ima svoj urls.py)
|
# Uključujemo rute iz modula (svaki modul ima svoj urls.py)
|
||||||
path('api/crm/', include('modules.crm.urls')),
|
path('api/crm/', include('modules.crm.urls')),
|
||||||
@@ -30,4 +35,6 @@ if settings.DEBUG:
|
|||||||
urlpatterns += [
|
urlpatterns += [
|
||||||
path('__debug__/', include(debug_toolbar.urls)),
|
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)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
@@ -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,,
|
||||||
|
202
backend/core/users/management/commands/import_users_csv.py
Normal file
202
backend/core/users/management/commands/import_users_csv.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from modules.crm.models import Client
|
||||||
|
|
||||||
|
|
||||||
|
def _to_bool(value, field_name):
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
normalized = str(value).strip().lower()
|
||||||
|
if normalized in {"1", "true", "t", "yes", "y", "da"}:
|
||||||
|
return True
|
||||||
|
if normalized in {"0", "false", "f", "no", "n", "ne"}:
|
||||||
|
return False
|
||||||
|
raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(value, field_name):
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(str(value).strip())
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Uvozi korisnike (radnike) iz CSV datoteke u CustomUser model."
|
||||||
|
|
||||||
|
expected_columns = [
|
||||||
|
"email",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"password",
|
||||||
|
"is_serviser",
|
||||||
|
"is_kupac",
|
||||||
|
"is_team_member",
|
||||||
|
"is_staff",
|
||||||
|
"is_superuser",
|
||||||
|
"is_active",
|
||||||
|
"telefon",
|
||||||
|
"oib",
|
||||||
|
"occupation",
|
||||||
|
"residence",
|
||||||
|
"work_position",
|
||||||
|
"licence_number",
|
||||||
|
"is_verified",
|
||||||
|
"client_id",
|
||||||
|
"client_tax_id",
|
||||||
|
]
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("csv_path", type=str, help="Putanja do CSV datoteke.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Validira i simulira import bez spremanja u bazu.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--create-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Kreira samo nove korisnike; postojeće preskače.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--default-password",
|
||||||
|
type=str,
|
||||||
|
default="",
|
||||||
|
help="Fallback lozinka za nove korisnike ako polje 'password' nije popunjeno.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
csv_path = Path(options["csv_path"]).expanduser().resolve()
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
create_only = options["create_only"]
|
||||||
|
default_password = options["default_password"]
|
||||||
|
|
||||||
|
if not csv_path.exists():
|
||||||
|
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
created_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
if not reader.fieldnames:
|
||||||
|
raise CommandError("CSV nema zaglavlje (header).")
|
||||||
|
|
||||||
|
normalized_headers = [str(h).strip() for h in reader.fieldnames]
|
||||||
|
missing = [column for column in self.expected_columns if column not in normalized_headers]
|
||||||
|
if missing:
|
||||||
|
raise CommandError("CSV nema obavezna polja: " + ", ".join(missing))
|
||||||
|
|
||||||
|
row_number = 1
|
||||||
|
with transaction.atomic():
|
||||||
|
for row in reader:
|
||||||
|
row_number += 1
|
||||||
|
if not any((value or "").strip() for value in row.values()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
email = (row.get("email") or "").strip().lower()
|
||||||
|
if not email:
|
||||||
|
raise CommandError(f"Red {row_number}: email je obavezan.")
|
||||||
|
|
||||||
|
user = User.objects.filter(email__iexact=email).first()
|
||||||
|
if user and create_only:
|
||||||
|
skipped_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"Red {row_number}: preskočeno (korisnik već postoji) {email}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_create = user is None
|
||||||
|
if is_create:
|
||||||
|
password = (row.get("password") or "").strip() or default_password
|
||||||
|
if not password:
|
||||||
|
raise CommandError(
|
||||||
|
f"Red {row_number}: password je obavezan za nove korisnike "
|
||||||
|
"(ili koristite --default-password)."
|
||||||
|
)
|
||||||
|
user = User(email=email)
|
||||||
|
user.set_password(password)
|
||||||
|
else:
|
||||||
|
password = (row.get("password") or "").strip()
|
||||||
|
if password:
|
||||||
|
user.set_password(password)
|
||||||
|
|
||||||
|
client = self._resolve_client(row=row, row_number=row_number)
|
||||||
|
|
||||||
|
user.first_name = (row.get("first_name") or "").strip()
|
||||||
|
user.last_name = (row.get("last_name") or "").strip()
|
||||||
|
user.telefon = (row.get("telefon") or "").strip()
|
||||||
|
user.oib = (row.get("oib") or "").strip() or None
|
||||||
|
user.occupation = (row.get("occupation") or "").strip()
|
||||||
|
user.residence = (row.get("residence") or "").strip()
|
||||||
|
user.work_position = (row.get("work_position") or "").strip()
|
||||||
|
user.licence_number = (row.get("licence_number") or "").strip() or None
|
||||||
|
|
||||||
|
for bool_field in [
|
||||||
|
"is_serviser",
|
||||||
|
"is_kupac",
|
||||||
|
"is_team_member",
|
||||||
|
"is_staff",
|
||||||
|
"is_superuser",
|
||||||
|
"is_active",
|
||||||
|
"is_verified",
|
||||||
|
]:
|
||||||
|
parsed = _to_bool(row.get(bool_field), bool_field)
|
||||||
|
if parsed is not None:
|
||||||
|
setattr(user, bool_field, parsed)
|
||||||
|
|
||||||
|
user.client_profile = client
|
||||||
|
user.username = None
|
||||||
|
|
||||||
|
user.full_clean()
|
||||||
|
if not dry_run:
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
if is_create:
|
||||||
|
created_count += 1
|
||||||
|
else:
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Red {row_number}: {'kreiran' if is_create else 'ažuriran'} korisnik {email}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
transaction.set_rollback(True)
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f"Import korisnika završen. created={created_count}, updated={updated_count}, "
|
||||||
|
f"skipped={skipped_count}, dry_run={dry_run}"
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(summary))
|
||||||
|
|
||||||
|
def _resolve_client(self, *, row, row_number):
|
||||||
|
client_id = _to_int(row.get("client_id"), "client_id")
|
||||||
|
client_tax_id = (row.get("client_tax_id") or "").strip()
|
||||||
|
|
||||||
|
if client_id:
|
||||||
|
client = Client.objects.filter(id=client_id).first()
|
||||||
|
if not client:
|
||||||
|
raise CommandError(f"Red {row_number}: klijent s ID={client_id} ne postoji.")
|
||||||
|
return client
|
||||||
|
|
||||||
|
if client_tax_id:
|
||||||
|
client = Client.objects.filter(tax_id=client_tax_id).first()
|
||||||
|
if not client:
|
||||||
|
raise CommandError(f"Red {row_number}: klijent s tax_id={client_tax_id} ne postoji.")
|
||||||
|
return client
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
|
||||||
from .models import CustomUser
|
from .models import CustomUser
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
@@ -14,11 +13,8 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
'first_name': {'required': True, 'allow_blank': False}
|
'first_name': {'required': True, 'allow_blank': False}
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_assigned_asset(self, obj):
|
def _get_assigned_assets(self, obj):
|
||||||
try:
|
return obj.assigned_vehicles.all()
|
||||||
return obj.assigned_vehicles
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _serialize_asset(self, vehicle):
|
def _serialize_asset(self, vehicle):
|
||||||
if not vehicle:
|
if not vehicle:
|
||||||
@@ -34,10 +30,14 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_assigned_vehicle(self, obj):
|
def get_assigned_vehicle(self, obj):
|
||||||
return self._serialize_asset(self._get_assigned_asset(obj))
|
assigned = self._get_assigned_assets(obj)
|
||||||
|
vehicle = assigned.filter(asset_type='vehicle').first() or assigned.first()
|
||||||
|
return self._serialize_asset(vehicle)
|
||||||
|
|
||||||
def get_assigned_crane(self, obj):
|
def get_assigned_crane(self, obj):
|
||||||
return self._serialize_asset(self._get_assigned_asset(obj))
|
assigned = self._get_assigned_assets(obj)
|
||||||
|
crane = assigned.filter(asset_type='crane').first() or assigned.first()
|
||||||
|
return self._serialize_asset(crane)
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
"""
|
"""
|
||||||
@@ -66,7 +66,7 @@ class TeamMemberSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomUser
|
model = CustomUser
|
||||||
fields = ['id', 'email', 'full_name', 'is_team_member']
|
fields = ['id', 'email', 'full_name', 'is_team_member', 'is_serviser']
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
def get_full_name(self, obj):
|
def get_full_name(self, obj):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -23,3 +24,19 @@ def test_create_superuser():
|
|||||||
)
|
)
|
||||||
assert admin.is_superuser is True
|
assert admin.is_superuser is True
|
||||||
assert admin.is_staff is True
|
assert admin.is_staff is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_user_groups_many_to_many_relation():
|
||||||
|
user = User.objects.create_user(
|
||||||
|
email="m2m@example.com",
|
||||||
|
password="password123",
|
||||||
|
)
|
||||||
|
group_a = Group.objects.create(name="Serviseri")
|
||||||
|
group_b = Group.objects.create(name="Dispeceri")
|
||||||
|
|
||||||
|
user.groups.add(group_a, group_b)
|
||||||
|
|
||||||
|
assert user.groups.count() == 2
|
||||||
|
assert group_a.user_set.filter(id=user.id).exists()
|
||||||
|
assert group_b.user_set.filter(id=user.id).exists()
|
||||||
89
backend/entrypoint.sh
Normal file → Executable file
89
backend/entrypoint.sh
Normal file → Executable file
@@ -1,101 +1,22 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
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
|
if [ "$1" = "gunicorn" ]; then
|
||||||
echo "--> Pokrećem migracije..."
|
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
|
python manage.py migrate --noinput
|
||||||
fi
|
|
||||||
|
|
||||||
echo "--> Osiguravam prava nad statikom prije collectstatic..."
|
|
||||||
mkdir -p "${STATIC_ROOT}"
|
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
|
||||||
chown -R ${APP_USER}:${APP_USER} "${STATIC_ROOT}" || echo "--> chown statika nije uspio"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "--> Skupljam statiku..."
|
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
|
python manage.py collectstatic --noinput
|
||||||
fi
|
|
||||||
|
|
||||||
echo "--> Pokrećem Gunicorn kao appuser..."
|
echo "--> Pokrećem Gunicorn..."
|
||||||
exec_as_appuser gunicorn --bind 0.0.0.0:8000 --workers 3 core.wsgi:application
|
exec gunicorn --bind 0.0.0.0:8000 --workers 3 core.wsgi:application
|
||||||
|
|
||||||
elif [ "$1" = "celery" ]; then
|
elif [ "$1" = "celery" ]; then
|
||||||
echo "--> Pokrećem Celery kao appuser..."
|
echo "--> Pokrećem Celery..."
|
||||||
shift
|
shift
|
||||||
# Replace PID1 with celery running as appuser
|
exec celery "$@"
|
||||||
exec_as_appuser celery "$@"
|
|
||||||
|
|
||||||
else
|
else
|
||||||
exec "$@"
|
exec "$@"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
0
backend/modules/__init__.py
Normal file
0
backend/modules/__init__.py
Normal 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
|
||||||
|
0
backend/modules/crm/management/__init__.py
Normal file
0
backend/modules/crm/management/__init__.py
Normal file
0
backend/modules/crm/management/commands/__init__.py
Normal file
0
backend/modules/crm/management/commands/__init__.py
Normal file
130
backend/modules/crm/management/commands/import_clients_csv.py
Normal file
130
backend/modules/crm/management/commands/import_clients_csv.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from modules.crm.models import Client
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Uvozi kupce (vlasnike dizalica) iz CSV datoteke u Client model."
|
||||||
|
|
||||||
|
expected_columns = [
|
||||||
|
"name",
|
||||||
|
"client_type",
|
||||||
|
"tax_id",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"address",
|
||||||
|
"city",
|
||||||
|
"postal_code",
|
||||||
|
]
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("csv_path", type=str, help="Putanja do CSV datoteke.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Validira i simulira import bez spremanja u bazu.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--create-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Kreira samo nove kupce; postojeće preskače.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
csv_path = Path(options["csv_path"]).expanduser().resolve()
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
create_only = options["create_only"]
|
||||||
|
|
||||||
|
if not csv_path.exists():
|
||||||
|
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
if not reader.fieldnames:
|
||||||
|
raise CommandError("CSV nema zaglavlje (header).")
|
||||||
|
|
||||||
|
normalized_headers = [str(h).strip() for h in reader.fieldnames]
|
||||||
|
missing = [column for column in self.expected_columns if column not in normalized_headers]
|
||||||
|
if missing:
|
||||||
|
raise CommandError("CSV nema obavezna polja: " + ", ".join(missing))
|
||||||
|
|
||||||
|
row_number = 1
|
||||||
|
with transaction.atomic():
|
||||||
|
for row in reader:
|
||||||
|
row_number += 1
|
||||||
|
if not any((value or "").strip() for value in row.values()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tax_id = (row.get("tax_id") or "").strip()
|
||||||
|
email = (row.get("email") or "").strip().lower()
|
||||||
|
if not tax_id:
|
||||||
|
raise CommandError(f"Red {row_number}: tax_id je obavezan.")
|
||||||
|
if not email:
|
||||||
|
raise CommandError(f"Red {row_number}: email je obavezan.")
|
||||||
|
|
||||||
|
client = Client.objects.filter(tax_id=tax_id).first()
|
||||||
|
if client and create_only:
|
||||||
|
skipped_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"Red {row_number}: preskočeno (kupac već postoji) tax_id={tax_id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_create = client is None
|
||||||
|
if is_create:
|
||||||
|
client = Client(tax_id=tax_id)
|
||||||
|
|
||||||
|
client_type = (row.get("client_type") or "legal").strip()
|
||||||
|
if client_type not in {"legal", "individual"}:
|
||||||
|
raise CommandError(
|
||||||
|
f"Red {row_number}: client_type mora biti 'legal' ili 'individual'."
|
||||||
|
)
|
||||||
|
|
||||||
|
client.name = (row.get("name") or "").strip()
|
||||||
|
client.client_type = client_type
|
||||||
|
client.email = email
|
||||||
|
client.phone = (row.get("phone") or "").strip() or None
|
||||||
|
client.address = (row.get("address") or "").strip()
|
||||||
|
client.city = (row.get("city") or "").strip()
|
||||||
|
client.postal_code = (row.get("postal_code") or "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.full_clean()
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise CommandError(
|
||||||
|
f"Red {row_number}: validacija nije prošla -> {exc.message_dict}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
client.save()
|
||||||
|
|
||||||
|
if is_create:
|
||||||
|
created_count += 1
|
||||||
|
else:
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Red {row_number}: {'kreiran' if is_create else 'ažuriran'} kupac {client.name}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
transaction.set_rollback(True)
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f"Import kupaca završen. created={created_count}, updated={updated_count}, "
|
||||||
|
f"skipped={skipped_count}, dry_run={dry_run}"
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(summary))
|
||||||
@@ -5,7 +5,9 @@ from .models import (
|
|||||||
Crane,
|
Crane,
|
||||||
Vehicle,
|
Vehicle,
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
|
WorkOrderPhoto,
|
||||||
WorkOrderInvoice,
|
WorkOrderInvoice,
|
||||||
|
GeneratedWorkOrderPdf,
|
||||||
VehicleServiceRecord,
|
VehicleServiceRecord,
|
||||||
VehicleServicePhoto,
|
VehicleServicePhoto,
|
||||||
VehicleServiceAttachment,
|
VehicleServiceAttachment,
|
||||||
@@ -42,7 +44,7 @@ class ServiceAttachmentInline(admin.TabularInline):
|
|||||||
class WorkOrderInline(admin.TabularInline):
|
class WorkOrderInline(admin.TabularInline):
|
||||||
model = WorkOrder
|
model = WorkOrder
|
||||||
extra = 0
|
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',)
|
readonly_fields = ('date',)
|
||||||
|
|
||||||
|
|
||||||
@@ -60,6 +62,26 @@ class WorkOrderInvoiceInline(admin.TabularInline):
|
|||||||
readonly_fields = ('created_at',)
|
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)
|
@admin.register(Vehicle)
|
||||||
class VehicleAdmin(admin.ModelAdmin):
|
class VehicleAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
@@ -154,12 +176,12 @@ class CraneAdmin(VehicleAdmin):
|
|||||||
|
|
||||||
@admin.register(WorkOrder)
|
@admin.register(WorkOrder)
|
||||||
class WorkOrderAdmin(admin.ModelAdmin):
|
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')
|
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',)
|
readonly_fields = ('date',)
|
||||||
ordering = ('-date',)
|
ordering = ('-date',)
|
||||||
inlines = (WorkOrderInvoiceInline,)
|
inlines = (WorkOrderInvoiceInline, WorkOrderPhotoInline)
|
||||||
|
|
||||||
def distance_display(self, obj):
|
def distance_display(self, obj):
|
||||||
return obj.distance
|
return obj.distance
|
||||||
@@ -173,6 +195,15 @@ class WorkOrderInvoiceAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id')
|
search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id')
|
||||||
ordering = ('-datum', '-created_at')
|
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)
|
@admin.register(VehicleServiceRecord)
|
||||||
class VehicleServiceRecordAdmin(admin.ModelAdmin):
|
class VehicleServiceRecordAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'service_title', 'task', 'vehicle', 'performed_by', 'service_date', 'mileage', 'next_service_due_at', 'cost', 'short_description')
|
list_display = ('id', 'service_title', 'task', 'vehicle', 'performed_by', 'service_date', 'mileage', 'next_service_due_at', 'cost', 'short_description')
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,assigned_servicer_email,is_active
|
||||||
|
ZG-CRN-001,123456,Liebherr,LTM 1050,2022,WLH1234567890ABCD,1200,800,45200,15000,true,,serviser1@example.com,true
|
||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,client_name,client_type,client_tax_id,client_email,client_phone,client_address,client_city,client_postal_code,assigned_servicer_email,is_active
|
||||||
|
ZG-CRN-001,123456,Liebherr,LTM 1050,2022,WLH1234567890ABCD,1200,800,45200,15000,false,,Kupac Primjer d.o.o.,legal,12345678901,kupac.primjer@example.com,+38591111222,Ulica 1,Zagreb,10000,serviser1@example.com,true
|
||||||
|
@@ -0,0 +1,12 @@
|
|||||||
|
registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,client_name,client_type,client_tax_id,client_email,client_phone,client_address,client_city,client_postal_code,assigned_servicer_email,is_active
|
||||||
|
ZG-LTM-001,210501,Liebherr,LTM 1350,2021,WLHZGLTM0012021AA,3400,2900,128450,15000,false,,Adriacink transport d.o.o.,legal,10000000001,adriacink.transport@example.com,+38591111001,Ulica 1,Zagreb,10000,serviser1@example.com,true
|
||||||
|
ZG-LTM-002,210502,Liebherr,LTM 1100,2022,WLHZGLTM0022022BB,2850,2410,96420,15000,false,,PAKLOG,legal,10000000004,paklog@example.com,+38591111004,Ulica 4,Zagreb,10000,serviser2@example.com,true
|
||||||
|
ZG-LTM-003,210503,Liebherr,LTM 1110,2023,WLHZGLTM0032023CC,1620,1395,54780,15000,false,,Palace,legal,10000000005,palace@example.com,+38591111005,Ulica 5,Zagreb,10000,serviser3@example.com,true
|
||||||
|
ZG-LTM-004,210504,Liebherr,LTM 1750,2021,WLHZGLTM0042021UA,3400,2900,128450,15000,false,,Adriacink transport d.o.o.,legal,10000000001,adriacink.transport@example.com,+38591111001,Ulica 1,Zagreb,10000,serviser1@example.com,true
|
||||||
|
ZG-LTM-005,210505,Liebherr,LTM 1500,2021,WLHZGLTM0052023AA,3400,2900,128450,15000,false,,BOSMAN,legal,10000000002,bosman@example.com,+38591111002,Ulica 2,Zagreb,10000,serviser1@example.com,true
|
||||||
|
ZG-LTM-006,210506,Liebherr,LTM 1060,2022,WLHZGLTM0062022BB,2850,2410,96420,15000,false,,BOSSIL,legal,10000000003,bossil@example.com,+38591111003,Ulica 3,Zagreb,10000,serviser2@example.com,true
|
||||||
|
ZG-LTM-007,210507,Liebherr,LTM 1060,2022,WLHZGLTM0072022BB,2850,2410,96420,15000,false,,Petrokemija Kutina,legal,10000000006,petrokemija.kutina@example.com,+38591111006,Ulica 6,Kutina,44320,serviser3@example.com,true
|
||||||
|
ZG-LTM-008,210508,Liebherr,LTM 1040,2022,WLHZGLTM0082022BB,2850,2410,96420,15000,false,,Paron,legal,10000000007,paron@example.com,+38591111007,Ulica 7,Zagreb,10000,serviser3@example.com,true
|
||||||
|
ZG-LTM-009,210509,Liebherr,LTM 1040,2022,WLHZGLTM0092022BB,2850,2410,96420,15000,false,,Petrokemija Kutina,legal,10000000006,petrokemija.kutina@example.com,+38591111006,Ulica 6,Kutina,44320,serviser3@example.com,true
|
||||||
|
ZG-LTM-010,210510,Liebherr,LTM 1250,2022,WLHZGLTM0102022BB,2850,2410,96420,15000,false,,PRANGL,legal,10000000008,prangl@example.com,+38591111008,Ulica 8,Zagreb,10000,serviser3@example.com,true
|
||||||
|
ZG-LTM-011,210511,Liebherr,LTM 1090,2022,WLHZGLTM0112022BB,2850,2410,96420,15000,false,,Salona MONT,legal,10000000009,salona.mont@example.com,+38591111009,Ulica 9,Split,21000,serviser3@example.com,true
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
registration_number,make,model,year,vin,current_mileage,service_interval_km,assigned_servicer_email,is_active
|
||||||
|
ZG-SRV-001,Volkswagen,Caddy,2021,WV1ZZZ2KZMX000001,128400,15000,serviser1@example.com,true
|
||||||
|
ZG-SRV-002,Renault,Trafic,2022,VF1FL0000NY000002,96420,15000,serviser2@example.com,true
|
||||||
|
ZG-SRV-003,Ford,Transit Custom,2023,WF0YXXTTGYPU00003,54780,15000,serviser3@example.com,true
|
||||||
|
0
backend/modules/fleet/management/__init__.py
Normal file
0
backend/modules/fleet/management/__init__.py
Normal file
283
backend/modules/fleet/management/commands/import_cranes_csv.py
Normal file
283
backend/modules/fleet/management/commands/import_cranes_csv.py
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from modules.crm.models import Client
|
||||||
|
from modules.fleet.models import Vehicle
|
||||||
|
|
||||||
|
|
||||||
|
def _to_bool(value, field_name):
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
normalized = str(value).strip().lower()
|
||||||
|
if normalized in {"1", "true", "t", "yes", "y", "da"}:
|
||||||
|
return True
|
||||||
|
if normalized in {"0", "false", "f", "no", "n", "ne"}:
|
||||||
|
return False
|
||||||
|
raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(value, field_name):
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(str(value).strip())
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Uvozi dizalice iz CSV datoteke i sprema ih u Vehicle model (asset_type=crane)."
|
||||||
|
|
||||||
|
expected_columns = [
|
||||||
|
"registration_number",
|
||||||
|
"crane_serial_number",
|
||||||
|
"make",
|
||||||
|
"model",
|
||||||
|
"year",
|
||||||
|
"vin",
|
||||||
|
"superstructure_working_hours",
|
||||||
|
"chassis_working_hours",
|
||||||
|
"current_mileage",
|
||||||
|
"service_interval_km",
|
||||||
|
"is_company_vehicle",
|
||||||
|
"client_id",
|
||||||
|
"assigned_servicer_email",
|
||||||
|
"is_active",
|
||||||
|
]
|
||||||
|
optional_client_columns = [
|
||||||
|
"client_name",
|
||||||
|
"client_type",
|
||||||
|
"client_tax_id",
|
||||||
|
"client_email",
|
||||||
|
"client_phone",
|
||||||
|
"client_address",
|
||||||
|
"client_city",
|
||||||
|
"client_postal_code",
|
||||||
|
]
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"csv_path",
|
||||||
|
type=str,
|
||||||
|
help="Apsolutna ili relativna putanja do CSV datoteke.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Validira CSV i ispisuje rezultat bez spremanja u bazu.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--create-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Kreira samo nove zapise; postojeće registracije se preskaču.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
csv_path = Path(options["csv_path"]).expanduser().resolve()
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
create_only = options["create_only"]
|
||||||
|
|
||||||
|
if not csv_path.exists():
|
||||||
|
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
if not reader.fieldnames:
|
||||||
|
raise CommandError("CSV nema zaglavlje (header).")
|
||||||
|
|
||||||
|
normalized_headers = [str(h).strip() for h in reader.fieldnames]
|
||||||
|
missing = [column for column in self.expected_columns if column not in normalized_headers]
|
||||||
|
if missing:
|
||||||
|
raise CommandError(
|
||||||
|
"CSV nema obavezna polja: " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
row_number = 1
|
||||||
|
with transaction.atomic():
|
||||||
|
for row in reader:
|
||||||
|
row_number += 1
|
||||||
|
if not any((value or "").strip() for value in row.values()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
registration_number = (row.get("registration_number") or "").strip()
|
||||||
|
crane_serial_number = (row.get("crane_serial_number") or "").strip()
|
||||||
|
|
||||||
|
if not registration_number:
|
||||||
|
raise CommandError(f"Red {row_number}: registration_number je obavezan.")
|
||||||
|
if not crane_serial_number:
|
||||||
|
raise CommandError(f"Red {row_number}: crane_serial_number je obavezan.")
|
||||||
|
|
||||||
|
vehicle = Vehicle.objects.filter(registration_number=registration_number).first()
|
||||||
|
if vehicle and create_only:
|
||||||
|
skipped_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"Red {row_number}: preskočeno (već postoji) {registration_number}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if vehicle is None:
|
||||||
|
vehicle = Vehicle(registration_number=registration_number)
|
||||||
|
was_created = True
|
||||||
|
else:
|
||||||
|
was_created = False
|
||||||
|
|
||||||
|
assigned_servicer = None
|
||||||
|
assigned_servicer_email = (row.get("assigned_servicer_email") or "").strip()
|
||||||
|
if assigned_servicer_email:
|
||||||
|
assigned_servicer = User.objects.filter(email=assigned_servicer_email).first()
|
||||||
|
if not assigned_servicer:
|
||||||
|
raise CommandError(
|
||||||
|
f"Red {row_number}: korisnik s emailom '{assigned_servicer_email}' ne postoji."
|
||||||
|
)
|
||||||
|
|
||||||
|
is_company_vehicle = _to_bool(row.get("is_company_vehicle"), "is_company_vehicle")
|
||||||
|
is_active = _to_bool(row.get("is_active"), "is_active")
|
||||||
|
if is_company_vehicle is None:
|
||||||
|
is_company_vehicle = True
|
||||||
|
|
||||||
|
client = self._resolve_or_create_client(
|
||||||
|
row=row,
|
||||||
|
row_number=row_number,
|
||||||
|
is_company_vehicle=is_company_vehicle,
|
||||||
|
)
|
||||||
|
|
||||||
|
vehicle.asset_type = "crane"
|
||||||
|
vehicle.crane_serial_number = crane_serial_number
|
||||||
|
vehicle.make = (row.get("make") or "").strip()
|
||||||
|
vehicle.model = (row.get("model") or "").strip()
|
||||||
|
vehicle.year = _to_int(row.get("year"), "year")
|
||||||
|
vehicle.vin = (row.get("vin") or "").strip() or None
|
||||||
|
vehicle.superstructure_working_hours = _to_int(
|
||||||
|
row.get("superstructure_working_hours"),
|
||||||
|
"superstructure_working_hours",
|
||||||
|
) or 0
|
||||||
|
vehicle.chassis_working_hours = _to_int(
|
||||||
|
row.get("chassis_working_hours"),
|
||||||
|
"chassis_working_hours",
|
||||||
|
) or 0
|
||||||
|
vehicle.current_mileage = _to_int(row.get("current_mileage"), "current_mileage") or 0
|
||||||
|
vehicle.service_interval_km = _to_int(
|
||||||
|
row.get("service_interval_km"),
|
||||||
|
"service_interval_km",
|
||||||
|
) or 15000
|
||||||
|
if is_company_vehicle is not None:
|
||||||
|
vehicle.is_company_vehicle = is_company_vehicle
|
||||||
|
vehicle.client = client
|
||||||
|
vehicle.assigned_servicer = assigned_servicer
|
||||||
|
if is_active is not None:
|
||||||
|
vehicle.is_active = is_active
|
||||||
|
|
||||||
|
try:
|
||||||
|
vehicle.full_clean()
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise CommandError(f"Red {row_number}: validacija nije prošla -> {exc.message_dict}") from exc
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
vehicle.save()
|
||||||
|
|
||||||
|
if was_created:
|
||||||
|
created_count += 1
|
||||||
|
else:
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Red {row_number}: {'kreirano' if was_created else 'ažurirano'} {registration_number}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
transaction.set_rollback(True)
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f"Import završen. created={created_count}, updated={updated_count}, "
|
||||||
|
f"skipped={skipped_count}, dry_run={dry_run}"
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(summary))
|
||||||
|
|
||||||
|
def _resolve_or_create_client(self, *, row, row_number, is_company_vehicle):
|
||||||
|
client_id = _to_int(row.get("client_id"), "client_id")
|
||||||
|
if client_id:
|
||||||
|
client = Client.objects.filter(id=client_id).first()
|
||||||
|
if not client:
|
||||||
|
raise CommandError(
|
||||||
|
f"Red {row_number}: klijent s ID={client_id} ne postoji."
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
client_tax_id = (row.get("client_tax_id") or "").strip()
|
||||||
|
client_email = (row.get("client_email") or "").strip().lower()
|
||||||
|
client_name = (row.get("client_name") or "").strip()
|
||||||
|
|
||||||
|
# Reuse pattern iz postojećih seed komandi: pokušaj pronaći postojeći zapis prije kreiranja.
|
||||||
|
if client_tax_id:
|
||||||
|
existing = Client.objects.filter(tax_id=client_tax_id).first()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
if client_email:
|
||||||
|
existing = Client.objects.filter(email__iexact=client_email).first()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
if client_name:
|
||||||
|
existing = Client.objects.filter(name=client_name).first()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
if is_company_vehicle:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not any([client_tax_id, client_email, client_name]):
|
||||||
|
raise CommandError(
|
||||||
|
f"Red {row_number}: za klijentsku dizalicu (is_company_vehicle=false) "
|
||||||
|
"morate navesti client_id ili client podatke za kreiranje kupca."
|
||||||
|
)
|
||||||
|
|
||||||
|
missing_for_create = []
|
||||||
|
create_fields = {
|
||||||
|
"name": client_name,
|
||||||
|
"client_type": (row.get("client_type") or "legal").strip() or "legal",
|
||||||
|
"tax_id": client_tax_id,
|
||||||
|
"email": client_email,
|
||||||
|
"phone": (row.get("client_phone") or "").strip() or None,
|
||||||
|
"address": (row.get("client_address") or "").strip(),
|
||||||
|
"city": (row.get("client_city") or "").strip(),
|
||||||
|
"postal_code": (row.get("client_postal_code") or "").strip(),
|
||||||
|
}
|
||||||
|
for required_key in ["name", "tax_id", "email", "address", "city", "postal_code"]:
|
||||||
|
if not create_fields[required_key]:
|
||||||
|
missing_for_create.append(required_key)
|
||||||
|
|
||||||
|
if missing_for_create:
|
||||||
|
raise CommandError(
|
||||||
|
f"Red {row_number}: nedostaju podaci za kreiranje kupca: {', '.join(missing_for_create)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if create_fields["client_type"] not in {"legal", "individual"}:
|
||||||
|
raise CommandError(
|
||||||
|
f"Red {row_number}: client_type mora biti 'legal' ili 'individual'."
|
||||||
|
)
|
||||||
|
|
||||||
|
client, was_created = Client.objects.get_or_create(
|
||||||
|
tax_id=create_fields["tax_id"],
|
||||||
|
defaults=create_fields,
|
||||||
|
)
|
||||||
|
if was_created:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Red {row_number}: kreiran kupac '{client.name}' (OIB/VAT: {client.tax_id})"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return client
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from modules.fleet.models import Vehicle
|
||||||
|
|
||||||
|
|
||||||
|
def _to_bool(value, field_name):
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
normalized = str(value).strip().lower()
|
||||||
|
if normalized in {"1", "true", "t", "yes", "y", "da"}:
|
||||||
|
return True
|
||||||
|
if normalized in {"0", "false", "f", "no", "n", "ne"}:
|
||||||
|
return False
|
||||||
|
raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(value, field_name):
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(str(value).strip())
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Uvozi vozila servisera iz CSV datoteke i sprema ih u Vehicle model "
|
||||||
|
"(asset_type=vehicle, is_company_vehicle=true)."
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_columns = [
|
||||||
|
"registration_number",
|
||||||
|
"make",
|
||||||
|
"model",
|
||||||
|
"year",
|
||||||
|
"vin",
|
||||||
|
"current_mileage",
|
||||||
|
"service_interval_km",
|
||||||
|
"assigned_servicer_email",
|
||||||
|
"is_active",
|
||||||
|
]
|
||||||
|
|
||||||
|
users_template_columns = [
|
||||||
|
"email",
|
||||||
|
"is_serviser",
|
||||||
|
]
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"csv_path",
|
||||||
|
type=str,
|
||||||
|
help="Apsolutna ili relativna putanja do CSV datoteke vozila.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--users-csv-path",
|
||||||
|
type=str,
|
||||||
|
default="",
|
||||||
|
help=(
|
||||||
|
"Putanja do users_import_template.csv (fallback je "
|
||||||
|
"backend/core/users/import_templates/users_import_template.csv)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Validira CSV i ispisuje rezultat bez spremanja u bazu.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--create-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Kreira samo nove zapise; postojeće registracije se preskaču.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
csv_path = self._resolve_input_csv_path(options["csv_path"])
|
||||||
|
users_csv_path = self._resolve_users_csv_path(options.get("users_csv_path") or "")
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
create_only = options["create_only"]
|
||||||
|
|
||||||
|
if not csv_path.exists():
|
||||||
|
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
|
||||||
|
if not users_csv_path.exists():
|
||||||
|
raise CommandError(f"users_import_template.csv ne postoji: {users_csv_path}")
|
||||||
|
|
||||||
|
allowed_servicer_emails = self._load_servicer_emails_from_template(users_csv_path)
|
||||||
|
if not allowed_servicer_emails:
|
||||||
|
raise CommandError(
|
||||||
|
"users_import_template.csv ne sadrži nijednog servisera (is_serviser=true)."
|
||||||
|
)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
created_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
if not reader.fieldnames:
|
||||||
|
raise CommandError("CSV nema zaglavlje (header).")
|
||||||
|
|
||||||
|
normalized_headers = [str(h).strip() for h in reader.fieldnames]
|
||||||
|
missing = [column for column in self.expected_columns if column not in normalized_headers]
|
||||||
|
if missing:
|
||||||
|
raise CommandError("CSV nema obavezna polja: " + ", ".join(missing))
|
||||||
|
|
||||||
|
row_number = 1
|
||||||
|
with transaction.atomic():
|
||||||
|
for row in reader:
|
||||||
|
row_number += 1
|
||||||
|
if not any((value or "").strip() for value in row.values()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
registration_number = (row.get("registration_number") or "").strip()
|
||||||
|
if not registration_number:
|
||||||
|
raise CommandError(f"Red {row_number}: registration_number je obavezan.")
|
||||||
|
|
||||||
|
assigned_servicer_email = (row.get("assigned_servicer_email") or "").strip().lower()
|
||||||
|
if not assigned_servicer_email:
|
||||||
|
raise CommandError(f"Red {row_number}: assigned_servicer_email je obavezan.")
|
||||||
|
if assigned_servicer_email not in allowed_servicer_emails:
|
||||||
|
raise CommandError(
|
||||||
|
f"Red {row_number}: '{assigned_servicer_email}' nije valjan serviser "
|
||||||
|
"u users_import_template.csv."
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned_servicer = User.objects.filter(email__iexact=assigned_servicer_email).first()
|
||||||
|
if not assigned_servicer:
|
||||||
|
raise CommandError(
|
||||||
|
f"Red {row_number}: korisnik s emailom '{assigned_servicer_email}' ne postoji u bazi."
|
||||||
|
)
|
||||||
|
if not assigned_servicer.is_serviser:
|
||||||
|
raise CommandError(
|
||||||
|
f"Red {row_number}: korisnik '{assigned_servicer_email}' nije označen kao serviser."
|
||||||
|
)
|
||||||
|
|
||||||
|
vehicle = Vehicle.objects.filter(registration_number=registration_number).first()
|
||||||
|
if vehicle and create_only:
|
||||||
|
skipped_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"Red {row_number}: preskočeno (već postoji) {registration_number}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if vehicle is None:
|
||||||
|
vehicle = Vehicle(registration_number=registration_number)
|
||||||
|
was_created = True
|
||||||
|
else:
|
||||||
|
was_created = False
|
||||||
|
if vehicle.asset_type and vehicle.asset_type != "vehicle":
|
||||||
|
raise CommandError(
|
||||||
|
f"Red {row_number}: zapis {registration_number} već postoji kao "
|
||||||
|
f"asset_type='{vehicle.asset_type}', ne može se prepisati u 'vehicle'."
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active = _to_bool(row.get("is_active"), "is_active")
|
||||||
|
|
||||||
|
vehicle.asset_type = "vehicle"
|
||||||
|
vehicle.make = (row.get("make") or "").strip()
|
||||||
|
vehicle.model = (row.get("model") or "").strip()
|
||||||
|
vehicle.year = _to_int(row.get("year"), "year")
|
||||||
|
vehicle.vin = (row.get("vin") or "").strip() or None
|
||||||
|
vehicle.current_mileage = _to_int(row.get("current_mileage"), "current_mileage") or 0
|
||||||
|
vehicle.service_interval_km = _to_int(
|
||||||
|
row.get("service_interval_km"),
|
||||||
|
"service_interval_km",
|
||||||
|
) or 15000
|
||||||
|
vehicle.is_company_vehicle = True
|
||||||
|
vehicle.client = None
|
||||||
|
vehicle.assigned_servicer = assigned_servicer
|
||||||
|
if is_active is not None:
|
||||||
|
vehicle.is_active = is_active
|
||||||
|
|
||||||
|
try:
|
||||||
|
vehicle.full_clean()
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise CommandError(f"Red {row_number}: validacija nije prošla -> {exc.message_dict}") from exc
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
vehicle.save()
|
||||||
|
|
||||||
|
if was_created:
|
||||||
|
created_count += 1
|
||||||
|
else:
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Red {row_number}: {'kreirano' if was_created else 'ažurirano'} "
|
||||||
|
f"{registration_number} -> {assigned_servicer_email}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
transaction.set_rollback(True)
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f"Import vozila servisera završen. created={created_count}, updated={updated_count}, "
|
||||||
|
f"skipped={skipped_count}, dry_run={dry_run}"
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(summary))
|
||||||
|
|
||||||
|
def _resolve_input_csv_path(self, csv_path):
|
||||||
|
resolved = Path(csv_path).expanduser().resolve()
|
||||||
|
if resolved.exists():
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
raw = str(csv_path).strip()
|
||||||
|
candidates = []
|
||||||
|
if "/backend/" in raw:
|
||||||
|
candidates.append(raw.replace("/backend/", "/", 1))
|
||||||
|
if "\\backend\\" in raw:
|
||||||
|
candidates.append(raw.replace("\\backend\\", "\\", 1))
|
||||||
|
if raw.startswith("backend/"):
|
||||||
|
candidates.append(raw.replace("backend/", "", 1))
|
||||||
|
if raw.startswith("backend\\"):
|
||||||
|
candidates.append(raw.replace("backend\\", "", 1))
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
candidate_path = Path(candidate).expanduser().resolve()
|
||||||
|
if candidate_path.exists():
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"CSV nije pronađen na '{resolved}', koristim '{candidate_path}'."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return candidate_path
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
def _resolve_users_csv_path(self, users_csv_path):
|
||||||
|
if users_csv_path:
|
||||||
|
return Path(users_csv_path).expanduser().resolve()
|
||||||
|
backend_root = Path(__file__).resolve().parents[4]
|
||||||
|
return backend_root / "core" / "users" / "import_templates" / "users_import_template.csv"
|
||||||
|
|
||||||
|
def _load_servicer_emails_from_template(self, users_csv_path):
|
||||||
|
with users_csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
if not reader.fieldnames:
|
||||||
|
raise CommandError("users_import_template.csv nema zaglavlje (header).")
|
||||||
|
|
||||||
|
normalized_headers = [str(h).strip() for h in reader.fieldnames]
|
||||||
|
missing = [column for column in self.users_template_columns if column not in normalized_headers]
|
||||||
|
if missing:
|
||||||
|
raise CommandError(
|
||||||
|
"users_import_template.csv nema obavezna polja: " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
emails = set()
|
||||||
|
row_number = 1
|
||||||
|
for row in reader:
|
||||||
|
row_number += 1
|
||||||
|
if not any((value or "").strip() for value in row.values()):
|
||||||
|
continue
|
||||||
|
is_serviser = _to_bool(row.get("is_serviser"), f"is_serviser (users CSV, red {row_number})")
|
||||||
|
email = (row.get("email") or "").strip().lower()
|
||||||
|
if is_serviser and email:
|
||||||
|
emails.add(email)
|
||||||
|
return emails
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2.15 on 2026-07-11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("fleet", "0020_alter_workorderinvoice_created_at_and_more"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="vehicle",
|
||||||
|
name="assigned_servicer",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="assigned_vehicles",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="Serviser",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
43
backend/modules/fleet/migrations/0022_workorderphoto.py
Normal file
43
backend/modules/fleet/migrations/0022_workorderphoto.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -45,6 +45,24 @@ def _work_order_invoice_upload_to(instance, filename):
|
|||||||
unique_name = f"{uuid.uuid4().hex}_{filename}"
|
unique_name = f"{uuid.uuid4().hex}_{filename}"
|
||||||
return f"fleet/{vehicle_reg}/work_order_invoices/{unique_name}"
|
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):
|
class VehicleServicePhoto(BaseModel):
|
||||||
"""
|
"""
|
||||||
@@ -149,7 +167,7 @@ class Vehicle(BaseModel):
|
|||||||
)
|
)
|
||||||
current_mileage = models.PositiveIntegerField(default=0, verbose_name=_("Trenutna kilometraža (km)"))
|
current_mileage = models.PositiveIntegerField(default=0, verbose_name=_("Trenutna kilometraža (km)"))
|
||||||
service_interval_km = models.PositiveIntegerField(default=15000, verbose_name=_("Interval servisa (km)"))
|
service_interval_km = models.PositiveIntegerField(default=15000, verbose_name=_("Interval servisa (km)"))
|
||||||
assigned_servicer = models.OneToOneField(
|
assigned_servicer = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='assigned_vehicles',
|
related_name='assigned_vehicles',
|
||||||
@@ -232,6 +250,7 @@ class WorkOrder(BaseModel):
|
|||||||
('defektaza', _("Defektaža")),
|
('defektaza', _("Defektaža")),
|
||||||
('kontrola', _("Kontrola")),
|
('kontrola', _("Kontrola")),
|
||||||
('redovni_pregled', _("Redovni pregled")),
|
('redovni_pregled', _("Redovni pregled")),
|
||||||
|
('popravak', _("Popravak")),
|
||||||
]
|
]
|
||||||
|
|
||||||
vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='work_orders', verbose_name=_("Vozilo"))
|
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"))
|
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_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"))
|
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"))
|
purpose = models.CharField(max_length=50, choices=PURPOSE_CHOICES, blank=True, verbose_name=_("Svrha"))
|
||||||
notes = models.TextField(blank=True, verbose_name=_("Bilješke"))
|
notes = models.TextField(blank=True, verbose_name=_("Bilješke"))
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open', verbose_name=_("Status"))
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open', verbose_name=_("Status"))
|
||||||
@@ -304,6 +330,12 @@ class WorkOrder(BaseModel):
|
|||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
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:
|
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.")})
|
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})"
|
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):
|
class VehicleServiceRecord(BaseModel):
|
||||||
"""
|
"""
|
||||||
Zapisi o servisima/popravcima na vozilu.
|
Zapisi o servisima/popravcima na vozilu.
|
||||||
|
|||||||
128
backend/modules/fleet/pdf_layout.py
Normal file
128
backend/modules/fleet/pdf_layout.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.pdfbase import pdfmetrics
|
||||||
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
|
|
||||||
|
DEFAULT_MARGIN = 28
|
||||||
|
DEFAULT_HEADER_H = 86
|
||||||
|
DEFAULT_FOOTER_H = 72
|
||||||
|
|
||||||
|
|
||||||
|
def register_unicode_fonts():
|
||||||
|
if "Vera" in pdfmetrics.getRegisteredFontNames():
|
||||||
|
return
|
||||||
|
fonts_dir = os.path.join(os.path.dirname(__import__("reportlab").__file__), "fonts")
|
||||||
|
pdfmetrics.registerFont(TTFont("Vera", os.path.join(fonts_dir, "Vera.ttf")))
|
||||||
|
pdfmetrics.registerFont(TTFont("Vera-Bold", os.path.join(fonts_dir, "VeraBd.ttf")))
|
||||||
|
pdfmetrics.registerFont(TTFont("Vera-Italic", os.path.join(fonts_dir, "VeraIt.ttf")))
|
||||||
|
|
||||||
|
|
||||||
|
def draw_standard_header_footer(
|
||||||
|
pdf,
|
||||||
|
*,
|
||||||
|
page_num,
|
||||||
|
client_name,
|
||||||
|
manufacturer,
|
||||||
|
model,
|
||||||
|
serial,
|
||||||
|
upgrade_hours,
|
||||||
|
chassis_hours,
|
||||||
|
mileage,
|
||||||
|
work_order_number,
|
||||||
|
generated_date,
|
||||||
|
report_title="Izvještaj servisera",
|
||||||
|
page_size=A4,
|
||||||
|
margin=DEFAULT_MARGIN,
|
||||||
|
header_h=DEFAULT_HEADER_H,
|
||||||
|
footer_h=DEFAULT_FOOTER_H,
|
||||||
|
):
|
||||||
|
w, h = page_size
|
||||||
|
|
||||||
|
pdf.setStrokeColor(colors.black)
|
||||||
|
pdf.setLineWidth(0.7)
|
||||||
|
pdf.rect(margin, h - header_h, w - 2 * margin, header_h - 4, stroke=1, fill=0)
|
||||||
|
|
||||||
|
pdf.setFont("Vera-Bold", 11)
|
||||||
|
pdf.drawString(margin + 6, h - 22, str(client_name or "-")[:44])
|
||||||
|
pdf.drawCentredString(w / 2, h - 22, report_title)
|
||||||
|
pdf.setFont("Vera", 9)
|
||||||
|
pdf.drawRightString(w - margin - 6, h - 22, f"Stranica {page_num}")
|
||||||
|
|
||||||
|
pdf.setLineWidth(0.5)
|
||||||
|
pdf.line(margin, h - 36, w - margin, h - 36)
|
||||||
|
|
||||||
|
labels = [
|
||||||
|
"Model:",
|
||||||
|
"Serijski broj:",
|
||||||
|
"Radni sati nadogradnje:",
|
||||||
|
"Radni sati podvozja:",
|
||||||
|
"Km:",
|
||||||
|
"Broj naloga:",
|
||||||
|
]
|
||||||
|
values = [
|
||||||
|
(manufacturer, model),
|
||||||
|
serial,
|
||||||
|
upgrade_hours,
|
||||||
|
chassis_hours,
|
||||||
|
mileage,
|
||||||
|
work_order_number,
|
||||||
|
]
|
||||||
|
col_w = (w - 2 * margin) / len(labels)
|
||||||
|
pdf.setFont("Vera", 7.2)
|
||||||
|
for i, (lbl, val) in enumerate(zip(labels, values)):
|
||||||
|
x = margin + i * col_w + 3
|
||||||
|
pdf.drawString(x, h - 49, lbl)
|
||||||
|
value_text = str(val or "-")
|
||||||
|
value_font_size = 9
|
||||||
|
if i == 0 and isinstance(val, tuple):
|
||||||
|
manufacturer_text = str(val[0] or "-")[:24]
|
||||||
|
model_text = str(val[1] or "-")[:24]
|
||||||
|
pdf.setFont("Vera-Bold", 8.2)
|
||||||
|
pdf.drawString(x, h - 59, manufacturer_text)
|
||||||
|
pdf.setFont("Vera", 7.6)
|
||||||
|
pdf.drawString(x, h - 68, model_text)
|
||||||
|
elif i == len(labels) - 1:
|
||||||
|
value_font_size = 7
|
||||||
|
max_value_width = col_w - 8
|
||||||
|
while (
|
||||||
|
pdf.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width
|
||||||
|
and value_font_size > 5.5
|
||||||
|
):
|
||||||
|
value_font_size -= 0.3
|
||||||
|
if pdf.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width:
|
||||||
|
trimmed = value_text
|
||||||
|
while (
|
||||||
|
len(trimmed) > 3
|
||||||
|
and pdf.stringWidth(f"{trimmed}...", "Vera-Bold", value_font_size) > max_value_width
|
||||||
|
):
|
||||||
|
trimmed = trimmed[:-1]
|
||||||
|
value_text = f"{trimmed}..."
|
||||||
|
else:
|
||||||
|
value_text = value_text[:24]
|
||||||
|
if not (i == 0 and isinstance(val, tuple)):
|
||||||
|
pdf.setFont("Vera-Bold", value_font_size)
|
||||||
|
pdf.drawString(x, h - 61, value_text)
|
||||||
|
pdf.setFont("Vera", 7.2)
|
||||||
|
if i > 0:
|
||||||
|
pdf.line(margin + i * col_w, h - 36, margin + i * col_w, h - header_h + 4)
|
||||||
|
|
||||||
|
footer_y = footer_h
|
||||||
|
pdf.setLineWidth(0.5)
|
||||||
|
pdf.rect(margin, footer_y + 42, w - 2 * margin, 18, stroke=1, fill=0)
|
||||||
|
sig_labels = [f"Datum: {generated_date}", "Potpis servisera", "Pečat i potpis klijenta *1", "Pregledao"]
|
||||||
|
sig_w = (w - 2 * margin) / 4
|
||||||
|
pdf.setFont("Vera", 7.2)
|
||||||
|
for i, lbl in enumerate(sig_labels):
|
||||||
|
x = margin + i * sig_w
|
||||||
|
pdf.drawString(x + 4, footer_y + 45, lbl)
|
||||||
|
if i > 0:
|
||||||
|
pdf.line(x, footer_y + 42, x, footer_y + 60)
|
||||||
|
pdf.setFont("Vera", 6.5)
|
||||||
|
pdf.drawString(margin, footer_y + 26, "*1- potpisom klijent potvrđuje da je suglasan s podacima u radnom nalogu")
|
||||||
|
pdf.drawCentredString(
|
||||||
|
w / 2,
|
||||||
|
footer_y + 14,
|
||||||
|
"Ovlašteni servis LIEBHERR Werk-Ehingen GmbH, LIEBHERR-Werk Nenzing GmbH, Liebherr-MCCTech Rostock GmbH",
|
||||||
|
)
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
import re
|
import re
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from .models import (
|
from .models import (
|
||||||
Crane,
|
Crane,
|
||||||
Vehicle,
|
Vehicle,
|
||||||
VehicleNotification,
|
VehicleNotification,
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
|
WorkOrderPhoto,
|
||||||
WorkOrderInvoice,
|
WorkOrderInvoice,
|
||||||
VehicleServiceRecord,
|
VehicleServiceRecord,
|
||||||
VehicleServicePhoto,
|
VehicleServicePhoto,
|
||||||
@@ -96,7 +99,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = WorkOrder
|
model = WorkOrder
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'vehicle', 'crane', 'crane_label', 'creator', 'date',
|
'id', 'display_code', 'vehicle', 'crane', 'crane_label', 'creator', 'date',
|
||||||
'start_mileage', 'end_mileage',
|
'start_mileage', 'end_mileage',
|
||||||
'servicer_vehicle_registration', 'servicer_vehicle_make_model',
|
'servicer_vehicle_registration', 'servicer_vehicle_make_model',
|
||||||
'servicer_vehicle_start_mileage', 'servicer_vehicle_end_mileage',
|
'servicer_vehicle_start_mileage', 'servicer_vehicle_end_mileage',
|
||||||
@@ -110,6 +113,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
|
|||||||
'creator': {'required': False},
|
'creator': {'required': False},
|
||||||
'start_mileage': {'required': False, 'allow_null': True},
|
'start_mileage': {'required': False, 'allow_null': True},
|
||||||
'end_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_registration': {'required': False, 'allow_blank': True},
|
||||||
'servicer_vehicle_make_model': {'required': False, 'allow_blank': True},
|
'servicer_vehicle_make_model': {'required': False, 'allow_blank': True},
|
||||||
'servicer_vehicle_start_mileage': {'required': False, 'allow_null': True},
|
'servicer_vehicle_start_mileage': {'required': False, 'allow_null': True},
|
||||||
@@ -144,6 +148,14 @@ class WorkOrderSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
if user and 'creator' not in validated_data:
|
if user and 'creator' not in validated_data:
|
||||||
validated_data['creator'] = user
|
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)
|
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.'})
|
raise serializers.ValidationError({'servicer_vehicle_fuel_cost': 'Trošak goriva ne može biti negativan.'})
|
||||||
return attrs
|
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):
|
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
||||||
|
image_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WorkOrderInvoice
|
model = WorkOrderInvoice
|
||||||
fields = [
|
fields = [
|
||||||
@@ -192,11 +222,32 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
|||||||
'datum',
|
'datum',
|
||||||
'opis',
|
'opis',
|
||||||
'image',
|
'image',
|
||||||
|
'image_url',
|
||||||
'created_by',
|
'created_by',
|
||||||
'created_at',
|
'created_at',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', '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):
|
class VehicleServiceRecordSerializer(serializers.ModelSerializer):
|
||||||
crane = serializers.SerializerMethodField()
|
crane = serializers.SerializerMethodField()
|
||||||
@@ -207,12 +258,13 @@ class VehicleServiceRecordSerializer(serializers.ModelSerializer):
|
|||||||
performed_by_name = serializers.SerializerMethodField()
|
performed_by_name = serializers.SerializerMethodField()
|
||||||
vehicle_registration = serializers.SerializerMethodField()
|
vehicle_registration = serializers.SerializerMethodField()
|
||||||
task_label = serializers.SerializerMethodField()
|
task_label = serializers.SerializerMethodField()
|
||||||
|
crane_serial_number = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VehicleServiceRecord
|
model = VehicleServiceRecord
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'vehicle', 'crane', 'vehicle_registration', 'crane_registration',
|
'id', 'vehicle', 'crane', 'vehicle_registration', 'crane_registration',
|
||||||
'task', 'task_label',
|
'task', 'task_label', 'crane_serial_number',
|
||||||
'performed_by', 'performed_by_name',
|
'performed_by', 'performed_by_name',
|
||||||
'service_title', 'description', 'parts', 'cost',
|
'service_title', 'description', 'parts', 'cost',
|
||||||
'service_date', 'mileage', 'next_service_due_at',
|
'service_date', 'mileage', 'next_service_due_at',
|
||||||
@@ -261,6 +313,12 @@ class VehicleServiceRecordSerializer(serializers.ModelSerializer):
|
|||||||
return None
|
return None
|
||||||
return obj.task.title
|
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):
|
def create(self, validated_data):
|
||||||
request = self.context.get('request')
|
request = self.context.get('request')
|
||||||
user = getattr(request, 'user', None)
|
user = getattr(request, 'user', None)
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ class VehicleService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder:
|
def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder:
|
||||||
|
previous_status = instance.status
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
setattr(instance, k, v)
|
setattr(instance, k, v)
|
||||||
try:
|
try:
|
||||||
@@ -134,8 +135,17 @@ class VehicleService:
|
|||||||
raise ValidationError({"detail": str(e)})
|
raise ValidationError({"detail": str(e)})
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
instance.save()
|
instance.save()
|
||||||
# Ako je nalog zatvoren, obavijestimo kreatora/servisera
|
transitioned_to_closed = previous_status != 'closed' and instance.status == 'closed'
|
||||||
if 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:
|
try:
|
||||||
NotificationService.create_notification(
|
NotificationService.create_notification(
|
||||||
recipient=instance.creator,
|
recipient=instance.creator,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
import logging
|
import logging
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import base64
|
import base64
|
||||||
@@ -9,9 +11,18 @@ from PIL import Image, UnidentifiedImageError
|
|||||||
from reportlab.lib.pagesizes import A4
|
from reportlab.lib.pagesizes import A4
|
||||||
from reportlab.lib.utils import ImageReader
|
from reportlab.lib.utils import ImageReader
|
||||||
from reportlab.pdfgen import canvas
|
from reportlab.pdfgen import canvas
|
||||||
from .models import VehicleNotification
|
from .models import VehicleNotification, GeneratedWorkOrderPdf
|
||||||
|
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
@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()
|
buffer = BytesIO()
|
||||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||||
width, height = A4
|
width, height = A4
|
||||||
y = height - 50
|
margin = 28
|
||||||
|
header_h = 86
|
||||||
|
footer_h = 72
|
||||||
|
content_top = height - header_h - 10
|
||||||
|
content_bottom = footer_h + 52
|
||||||
|
client_name = getattr(getattr(work_order.vehicle, "client", None), "name", None) or "-"
|
||||||
|
manufacturer = str(work_order.vehicle.make or "-")
|
||||||
|
model_line = str(work_order.vehicle.model or "-")
|
||||||
|
serial_line = str(getattr(work_order.vehicle, "crane_serial_number", None) or "-")
|
||||||
|
upgrade_hours = str(getattr(work_order.vehicle, "superstructure_working_hours", "-") or "-")
|
||||||
|
chassis_hours = str(getattr(work_order.vehicle, "chassis_working_hours", "-") or "-")
|
||||||
|
mileage = str(getattr(work_order.vehicle, "current_mileage", "-") or "-")
|
||||||
|
generated_date = timezone.localtime(timezone.now()).strftime("%d.%m.%Y")
|
||||||
|
display_code = _work_order_display_code(work_order)
|
||||||
|
|
||||||
pdf.setFont("Helvetica-Bold", 14)
|
def draw_header_footer(page_num):
|
||||||
pdf.drawString(40, y, f"Računi putnog naloga WO-{work_order.pk}")
|
draw_standard_header_footer(
|
||||||
y -= 22
|
pdf,
|
||||||
pdf.setFont("Helvetica", 10)
|
page_num=page_num,
|
||||||
pdf.drawString(40, y, f"Dizalica: {work_order.vehicle.registration_number}")
|
client_name=client_name,
|
||||||
y -= 16
|
manufacturer=manufacturer,
|
||||||
pdf.drawString(40, y, f"Datum naloga: {work_order.date}")
|
model=model_line,
|
||||||
|
serial=serial_line,
|
||||||
|
upgrade_hours=upgrade_hours,
|
||||||
|
chassis_hours=chassis_hours,
|
||||||
|
mileage=mileage,
|
||||||
|
work_order_number=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')
|
invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')
|
||||||
if not invoices.exists():
|
if not invoices.exists():
|
||||||
y -= 24
|
y -= 20
|
||||||
pdf.drawString(40, y, "Nema računa za ovaj putni nalog.")
|
pdf.drawString(margin, y, "Nema računa za ovaj putni nalog.")
|
||||||
pdf.save()
|
pdf.save()
|
||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
for index, invoice in enumerate(invoices, start=1):
|
for index, invoice in enumerate(invoices, start=1):
|
||||||
pdf.showPage()
|
pdf.showPage()
|
||||||
y = height - 50
|
page_num += 1
|
||||||
pdf.setFont("Helvetica-Bold", 13)
|
draw_header_footer(page_num)
|
||||||
pdf.drawString(40, y, f"Račun #{index}")
|
y = content_top
|
||||||
|
y -= 8
|
||||||
|
pdf.setFont("Vera-Bold", 13)
|
||||||
|
pdf.drawString(margin, y, f"Račun #{index}")
|
||||||
y -= 22
|
y -= 22
|
||||||
pdf.setFont("Helvetica", 10)
|
pdf.setFont("Vera", 10)
|
||||||
pdf.drawString(40, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}")
|
pdf.drawString(margin, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}")
|
||||||
y -= 16
|
y -= 16
|
||||||
pdf.drawString(40, y, f"lokacija: {invoice.lokacija or '-'}")
|
pdf.drawString(margin, y, f"lokacija: {invoice.lokacija or '-'}")
|
||||||
y -= 16
|
y -= 16
|
||||||
pdf.drawString(40, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}")
|
pdf.drawString(margin, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}")
|
||||||
y -= 16
|
y -= 16
|
||||||
pdf.drawString(40, y, f"opis: {(invoice.opis or '-')[:140]}")
|
pdf.drawString(margin, y, f"opis: {(invoice.opis or '-')[:140]}")
|
||||||
y -= 20
|
y -= 20
|
||||||
|
|
||||||
if not invoice.image:
|
if not invoice.image:
|
||||||
pdf.drawString(40, y, "slika: Nema slike.")
|
pdf.drawString(margin, y, "slika: Nema slike.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
invoice.image.open('rb')
|
invoice.image.open('rb')
|
||||||
with Image.open(invoice.image) as source:
|
with Image.open(invoice.image) as source:
|
||||||
image = source.convert('RGB')
|
image = source.convert('RGB')
|
||||||
max_width = width - 80
|
max_width = width - (2 * margin)
|
||||||
max_height = y - 60
|
max_height = y - content_bottom
|
||||||
if max_height < 120:
|
if max_height < 120:
|
||||||
pdf.showPage()
|
pdf.showPage()
|
||||||
y = height - 60
|
page_num += 1
|
||||||
max_height = y - 60
|
draw_header_footer(page_num)
|
||||||
|
y = content_top
|
||||||
|
max_height = y - content_bottom
|
||||||
|
|
||||||
ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0)
|
ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0)
|
||||||
draw_width = max(1, int(image.width * ratio))
|
draw_width = max(1, int(image.width * ratio))
|
||||||
@@ -132,7 +184,7 @@ def _build_work_order_invoices_pdf(work_order):
|
|||||||
image_reader = ImageReader(image)
|
image_reader = ImageReader(image)
|
||||||
pdf.drawImage(
|
pdf.drawImage(
|
||||||
image_reader,
|
image_reader,
|
||||||
40,
|
margin,
|
||||||
y - draw_height,
|
y - draw_height,
|
||||||
width=draw_width,
|
width=draw_width,
|
||||||
height=draw_height,
|
height=draw_height,
|
||||||
@@ -140,7 +192,7 @@ def _build_work_order_invoices_pdf(work_order):
|
|||||||
mask='auto',
|
mask='auto',
|
||||||
)
|
)
|
||||||
except (UnidentifiedImageError, OSError):
|
except (UnidentifiedImageError, OSError):
|
||||||
pdf.drawString(40, y, "slika: Slika nije dostupna ili je oštećena.")
|
pdf.drawString(margin, y, "slika: Slika nije dostupna ili je oštećena.")
|
||||||
finally:
|
finally:
|
||||||
invoice.image.close()
|
invoice.image.close()
|
||||||
|
|
||||||
@@ -158,8 +210,110 @@ def build_work_order_invoices_pdf_task(work_order_id):
|
|||||||
|
|
||||||
pdf_bytes = _build_work_order_invoices_pdf(work_order)
|
pdf_bytes = _build_work_order_invoices_pdf(work_order)
|
||||||
pdf_b64 = base64.b64encode(pdf_bytes).decode('ascii')
|
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 {
|
return {
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"pdf_base64": pdf_b64,
|
"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)}
|
||||||
@@ -4,15 +4,13 @@ from modules.fleet.models import Vehicle
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_vehicle_servicer_one_to_one_constraint(test_user):
|
def test_vehicle_servicer_many_to_one_allows_multiple_vehicles(test_user):
|
||||||
|
|
||||||
# Prvo vozilo prolazi
|
|
||||||
Vehicle.objects.create(registration_number="ZG-111-AA", assigned_servicer=test_user)
|
Vehicle.objects.create(registration_number="ZG-111-AA", assigned_servicer=test_user)
|
||||||
|
|
||||||
# Drugo vozilo za istog servisera mora baciti IntegrityError
|
|
||||||
with pytest.raises(Exception): # Django IntegrityError
|
|
||||||
Vehicle.objects.create(registration_number="ZG-222-BB", assigned_servicer=test_user)
|
Vehicle.objects.create(registration_number="ZG-222-BB", assigned_servicer=test_user)
|
||||||
|
|
||||||
|
assert Vehicle.objects.filter(assigned_servicer=test_user).count() == 2
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_vehicle_next_service_logic():
|
def test_vehicle_next_service_logic():
|
||||||
# current=12000, interval=15000 -> next_service_at = 15000
|
# current=12000, interval=15000 -> next_service_at = 15000
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ class WorkOrderAndServiceRecordNotificationTests(TestCase):
|
|||||||
"creator": self.creator,
|
"creator": self.creator,
|
||||||
"start_mileage": 10000,
|
"start_mileage": 10000,
|
||||||
"end_mileage": 10120,
|
"end_mileage": 10120,
|
||||||
"purpose": "Routine visit"
|
"purpose": "kontrola"
|
||||||
}
|
}
|
||||||
|
|
||||||
wo = VehicleService.create_work_order(data)
|
wo = VehicleService.create_work_order(data)
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ from django.test import TestCase
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from modules.fleet.services import VehicleService
|
from modules.fleet.services import VehicleService
|
||||||
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
|
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):
|
class FleetServiceTests(TestCase):
|
||||||
@@ -19,7 +22,7 @@ class FleetServiceTests(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_create_work_order_via_service(self):
|
def test_create_work_order_via_service(self):
|
||||||
data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "Delivery"}
|
data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "kontrola"}
|
||||||
wo = VehicleService.create_work_order(data)
|
wo = VehicleService.create_work_order(data)
|
||||||
self.vehicle.refresh_from_db()
|
self.vehicle.refresh_from_db()
|
||||||
self.assertEqual(self.vehicle.current_mileage, 5200)
|
self.assertEqual(self.vehicle.current_mileage, 5200)
|
||||||
@@ -50,3 +53,79 @@ class FleetServiceTests(TestCase):
|
|||||||
# Nalog sa završnom kilometražom
|
# Nalog sa završnom kilometražom
|
||||||
wo.end_mileage = 1100
|
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)
|
||||||
140
backend/modules/fleet/tests/test_work_order_images_endpoint.py
Normal file
140
backend/modules/fleet/tests/test_work_order_images_endpoint.py
Normal 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
@@ -10,6 +10,13 @@ from modules.crm.models import Client
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceService:
|
||||||
|
@staticmethod
|
||||||
|
def create_full_invoice(*args, **kwargs):
|
||||||
|
from modules.invoicing.services import InvoiceService as _InvoiceService
|
||||||
|
return _InvoiceService.create_full_invoice(*args, **kwargs)
|
||||||
|
|
||||||
@shared_task(bind=True, max_retries=3)
|
@shared_task(bind=True, max_retries=3)
|
||||||
def process_paperless_document_task(self, document_id):
|
def process_paperless_document_task(self, document_id):
|
||||||
"""
|
"""
|
||||||
@@ -57,8 +64,6 @@ def process_paperless_document_task(self, document_id):
|
|||||||
"created_at": raw_created,
|
"created_at": raw_created,
|
||||||
}
|
}
|
||||||
|
|
||||||
from modules.invoicing.services import InvoiceService
|
|
||||||
|
|
||||||
lookup_field = User.USERNAME_FIELD
|
lookup_field = User.USERNAME_FIELD
|
||||||
system_user = User.objects.filter(**{lookup_field: "bot@erp.hr"}).first()
|
system_user = User.objects.filter(**{lookup_field: "bot@erp.hr"}).first()
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,56 @@
|
|||||||
# backend/modules/task_management/admin.py
|
# backend/modules/task_management/admin.py
|
||||||
|
|
||||||
from django.contrib import admin
|
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)
|
@admin.register(Task)
|
||||||
class TaskAdmin(admin.ModelAdmin):
|
class TaskAdmin(admin.ModelAdmin):
|
||||||
"""
|
list_display = ('title', 'status', 'scheduled_date', 'assigned_to', 'vehicle', 'work_order', 'created_at')
|
||||||
Konfiguracija prikaza modela u Admin panelu.
|
list_filter = ('status', 'scheduled_date', 'created_at', 'work_order')
|
||||||
"""
|
|
||||||
# Koja polja želimo vidjeti u tabličnom prikazu
|
|
||||||
list_display = ('title', 'status', 'assigned_to', 'work_order', 'created_at')
|
|
||||||
|
|
||||||
# Omogućuje filtriranje s desne strane
|
|
||||||
list_filter = ('status', 'created_at', 'work_order')
|
|
||||||
|
|
||||||
# Omogućuje pretraživanje po tekstu
|
|
||||||
search_fields = ('title', 'description', 'work_order__id', 'work_order__vehicle__registration_number')
|
search_fields = ('title', 'description', 'work_order__id', 'work_order__vehicle__registration_number')
|
||||||
|
readonly_fields = ('id', 'created_at', 'updated_at')
|
||||||
# Polja koja su samo za čitanje (npr. UUID koji se ne smije mijenjati)
|
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')
|
readonly_fields = ('id', 'created_at', 'updated_at')
|
||||||
7
backend/modules/task_management/exceptions.py
Normal file
7
backend/modules/task_management/exceptions.py
Normal 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.'
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
# backend/modules/task_management/models.py
|
# backend/modules/task_management/models.py
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
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):
|
class Task(BaseModel):
|
||||||
"""
|
"""
|
||||||
@@ -13,6 +14,8 @@ class Task(BaseModel):
|
|||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('aktivan', 'Aktivan'),
|
('aktivan', 'Aktivan'),
|
||||||
('servis', 'U tijeku'),
|
('servis', 'U tijeku'),
|
||||||
|
('spreman_za_zavrsetak', 'Spreman za završetak'),
|
||||||
|
('zavrsen', 'Završen'),
|
||||||
('neaktivan', 'Otkazano'),
|
('neaktivan', 'Otkazano'),
|
||||||
]
|
]
|
||||||
title = models.CharField(max_length=255)
|
title = models.CharField(max_length=255)
|
||||||
@@ -44,6 +47,13 @@ class Task(BaseModel):
|
|||||||
related_name='work_order_tasks',
|
related_name='work_order_tasks',
|
||||||
verbose_name=_("Putni nalog"),
|
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:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
@@ -53,7 +63,102 @@ class Task(BaseModel):
|
|||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
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({
|
raise ValidationError({
|
||||||
'work_order': "Putni nalog je obavezan prije zatvaranja zadatka."
|
'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}"
|
||||||
|
|||||||
@@ -1,12 +1,93 @@
|
|||||||
# backend/modules/task_management/serializers.py
|
# backend/modules/task_management/serializers.py
|
||||||
|
|
||||||
from rest_framework import serializers
|
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):
|
class TaskSerializer(serializers.ModelSerializer):
|
||||||
assigned_to_name = serializers.SerializerMethodField()
|
assigned_to_name = serializers.SerializerMethodField()
|
||||||
work_order_label = serializers.SerializerMethodField()
|
work_order_label = serializers.SerializerMethodField()
|
||||||
vehicle_registration = 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:
|
class Meta:
|
||||||
model = Task
|
model = Task
|
||||||
@@ -15,27 +96,50 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||||||
'assigned_to', 'assigned_to_name',
|
'assigned_to', 'assigned_to_name',
|
||||||
'vehicle', 'vehicle_registration',
|
'vehicle', 'vehicle_registration',
|
||||||
'work_order', 'work_order_label',
|
'work_order', 'work_order_label',
|
||||||
|
'scheduled_date',
|
||||||
|
'template_id', 'auto_close_work_order',
|
||||||
'created_at',
|
'created_at',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', '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):
|
def validate_title(self, value):
|
||||||
"""Primjer custom validacije: naziv ne smije biti prekratak."""
|
if not value:
|
||||||
|
return value
|
||||||
if len(value) < 3:
|
if len(value) < 3:
|
||||||
raise serializers.ValidationError("Naslov zadatka mora imati barem 3 znaka.")
|
raise serializers.ValidationError("Naslov zadatka mora imati barem 3 znaka.")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
current = getattr(self, 'instance', None)
|
current = getattr(self, 'instance', None)
|
||||||
|
template_id = attrs.get('template_id')
|
||||||
status_value = attrs.get('status', getattr(current, 'status', 'aktivan'))
|
status_value = attrs.get('status', getattr(current, 'status', 'aktivan'))
|
||||||
work_order = attrs.get('work_order', getattr(current, 'work_order', None))
|
work_order = attrs.get('work_order', getattr(current, 'work_order', None))
|
||||||
vehicle = attrs.get('vehicle', getattr(current, 'vehicle', 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:
|
if current is None and not vehicle:
|
||||||
raise serializers.ValidationError({
|
raise serializers.ValidationError({
|
||||||
'vehicle': "Dizalica je obavezna pri kreiranju taska."
|
'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:
|
if work_order and not vehicle:
|
||||||
vehicle = work_order.vehicle
|
vehicle = work_order.vehicle
|
||||||
attrs['vehicle'] = vehicle
|
attrs['vehicle'] = vehicle
|
||||||
@@ -44,7 +148,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||||||
'work_order': "Putni nalog mora pripadati istoj dizalici kao task."
|
'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({
|
raise serializers.ValidationError({
|
||||||
'work_order': "Putni nalog je obavezan prije zatvaranja zadatka."
|
'work_order': "Putni nalog je obavezan prije zatvaranja zadatka."
|
||||||
})
|
})
|
||||||
@@ -53,7 +157,10 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||||||
def get_work_order_label(self, obj):
|
def get_work_order_label(self, obj):
|
||||||
if not obj.work_order_id:
|
if not obj.work_order_id:
|
||||||
return None
|
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):
|
def get_assigned_to_name(self, obj):
|
||||||
if not obj.assigned_to_id:
|
if not obj.assigned_to_id:
|
||||||
|
|||||||
@@ -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 rest_framework.exceptions import ValidationError
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from .models import Task, TaskTemplate, TaskWorkEntry
|
||||||
|
from .exceptions import OpenWorkOrderBlockingTaskClose
|
||||||
|
from modules.fleet.models import VehicleServiceRecord
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TaskService:
|
class TaskService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_task_entry(data: dict, user=None) -> Task:
|
def create_task_entry(data: dict, user=None) -> Task:
|
||||||
"""
|
"""
|
||||||
Univerzalna metoda: prihvaća podatke i opcionalnog korisnika.
|
Univerzalna metoda: prihvaća podatke i opcionalnog korisnika.
|
||||||
"""
|
"""
|
||||||
# Poslovna pravila
|
payload = dict(data)
|
||||||
if data.get('status') == 'DONE' and (user and not user.is_staff):
|
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."})
|
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:
|
if user and 'assigned_to' not in payload:
|
||||||
payload['assigned_to'] = user
|
payload['assigned_to'] = user
|
||||||
|
|
||||||
@@ -27,19 +50,97 @@ class TaskService:
|
|||||||
except DjangoValidationError as exc:
|
except DjangoValidationError as exc:
|
||||||
logger.error(f"Neuspješna validacija zadatka: {exc}")
|
logger.error(f"Neuspješna validacija zadatka: {exc}")
|
||||||
raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
|
raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
entry.save()
|
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}")
|
logger.info(f"Zadatak uspješno kreiran: {entry.id}")
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update_task_entry(instance, data, user):
|
def _create_work_entries_from_template(*, task: Task, template: TaskTemplate):
|
||||||
# Validacija za update (npr. samo vlasnik ili admin smije ažurirati)
|
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:
|
if not user.is_staff and instance.assigned_to != user:
|
||||||
raise ValidationError("Nemate dozvolu za izmjenu ovog zadatka.")
|
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():
|
for attr, value in data.items():
|
||||||
|
if attr == 'template_id':
|
||||||
|
continue
|
||||||
setattr(instance, attr, value)
|
setattr(instance, attr, value)
|
||||||
|
if 'title' in data and len(str(data.get('title') or '').strip()) < 3:
|
||||||
|
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
|
||||||
try:
|
try:
|
||||||
instance.full_clean()
|
instance.full_clean()
|
||||||
except DjangoValidationError as exc:
|
except DjangoValidationError as exc:
|
||||||
|
|||||||
@@ -1,10 +1,64 @@
|
|||||||
# backend/modules/task_management/tasks.py
|
# backend/modules/task_management/tasks.py
|
||||||
|
|
||||||
|
import logging
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from .models import Task
|
from .models import Task
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from modules.fleet.models import Vehicle, WorkOrder
|
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
|
@shared_task
|
||||||
def create_task_from_invoice(invoice_data):
|
def create_task_from_invoice(invoice_data):
|
||||||
"""
|
"""
|
||||||
|
|||||||
0
backend/modules/task_management/tests/__init__.py
Normal file
0
backend/modules/task_management/tests/__init__.py
Normal file
@@ -1,5 +1,6 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
import datetime
|
||||||
|
|
||||||
from modules.task_management.models import Task
|
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")
|
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)
|
task = Task.objects.create(title="My Task", description="Desc", assigned_to=user)
|
||||||
assert str(task).startswith("My Task")
|
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
|
||||||
@@ -7,26 +7,77 @@ from modules.fleet.models import Vehicle, WorkOrder
|
|||||||
|
|
||||||
|
|
||||||
class TaskSerializerTests(TestCase):
|
class TaskSerializerTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(username="task-user-base", email="task-user-base@example.test", password="pass")
|
||||||
|
self.vehicle = Vehicle.objects.create(registration_number="ZG-TSK-01", asset_type='crane')
|
||||||
|
|
||||||
def test_validate_title_too_short_raises(self):
|
def test_validate_title_too_short_raises(self):
|
||||||
serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan"})
|
serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)})
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
def test_validate_title_ok(self):
|
def test_validate_title_ok(self):
|
||||||
serializer = TaskSerializer(data={"title": "Valid title", "description": "", "status": "aktivan"})
|
serializer = TaskSerializer(data={"title": "Valid title", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)})
|
||||||
assert serializer.is_valid(), serializer.errors
|
assert serializer.is_valid(), serializer.errors
|
||||||
|
|
||||||
def test_cannot_close_without_work_order(self):
|
def test_cannot_close_without_work_order(self):
|
||||||
serializer = TaskSerializer(data={"title": "Task 1", "description": "", "status": "neaktivan"})
|
serializer = TaskSerializer(data={"title": "Task 1", "description": "", "status": "neaktivan", "vehicle": str(self.vehicle.id)})
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
def test_can_close_with_work_order(self):
|
def test_can_close_with_work_order(self):
|
||||||
User = get_user_model()
|
work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='defektaza')
|
||||||
user = User.objects.create_user(username="task-user", email="task-user@example.test", password="pass")
|
task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle)
|
||||||
vehicle = Vehicle.objects.create(registration_number="ZG-TSK-02", asset_type='crane')
|
|
||||||
work_order = WorkOrder.objects.create(vehicle=vehicle, creator=user, purpose='defektaza')
|
|
||||||
task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=user)
|
|
||||||
|
|
||||||
serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True)
|
serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True)
|
||||||
assert serializer.is_valid(), serializer.errors
|
assert serializer.is_valid(), serializer.errors
|
||||||
|
|
||||||
|
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
|
||||||
@@ -2,8 +2,9 @@ from django.test import TestCase
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from modules.task_management.services import TaskService
|
from modules.task_management.services import TaskService
|
||||||
from modules.task_management.models import Task
|
from modules.task_management.exceptions import OpenWorkOrderBlockingTaskClose
|
||||||
from modules.fleet.models import Vehicle, WorkOrder
|
from modules.task_management.models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry
|
||||||
|
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
|
||||||
|
|
||||||
|
|
||||||
class TaskServiceTests(TestCase):
|
class TaskServiceTests(TestCase):
|
||||||
@@ -42,7 +43,14 @@ class TaskServiceTests(TestCase):
|
|||||||
|
|
||||||
def test_update_task_entry_allows_closing_with_work_order(self):
|
def test_update_task_entry_allows_closing_with_work_order(self):
|
||||||
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01", asset_type='crane')
|
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')
|
task = Task.objects.create(title="Za zatvaranje", description="", assigned_to=self.user, status='servis')
|
||||||
updated = TaskService.update_task_entry(
|
updated = TaskService.update_task_entry(
|
||||||
instance=task,
|
instance=task,
|
||||||
@@ -51,3 +59,137 @@ class TaskServiceTests(TestCase):
|
|||||||
)
|
)
|
||||||
assert updated.status == "neaktivan"
|
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)
|
||||||
@@ -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)
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
from django.test import TestCase, RequestFactory
|
from django.test import TestCase, RequestFactory
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from modules.task_management.views import TaskViewSet
|
from modules.task_management.views import TaskViewSet
|
||||||
from modules.task_management.serializers import TaskSerializer
|
from modules.task_management.serializers import TaskSerializer
|
||||||
from modules.task_management.models import Task
|
from modules.task_management.models import Task, TaskTemplate, TaskTemplateEntry
|
||||||
|
from modules.fleet.models import Vehicle
|
||||||
|
|
||||||
|
|
||||||
class TaskViewSetTests(TestCase):
|
class TaskViewSetTests(TestCase):
|
||||||
@@ -15,10 +17,13 @@ class TaskViewSetTests(TestCase):
|
|||||||
self.view = TaskViewSet()
|
self.view = TaskViewSet()
|
||||||
self.view.request = self.factory.post("/")
|
self.view.request = self.factory.post("/")
|
||||||
self.view.request.user = self.user
|
self.view.request.user = self.user
|
||||||
|
self.vehicle = Vehicle.objects.create(registration_number="ZG-TV-01", asset_type='crane')
|
||||||
|
self.api_client = APIClient()
|
||||||
|
self.api_client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
@patch("modules.task_management.views.TaskService.create_task_entry")
|
@patch("modules.task_management.views.TaskService.create_task_entry")
|
||||||
def test_perform_create_calls_service(self, mock_create):
|
def test_perform_create_calls_service(self, mock_create):
|
||||||
data = {"title": "Task from view", "description": "", "status": "aktivan"}
|
data = {"title": "Task from view", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)}
|
||||||
serializer = TaskSerializer(data=data, context={"request": self.view.request})
|
serializer = TaskSerializer(data=data, context={"request": self.view.request})
|
||||||
assert serializer.is_valid(), serializer.errors
|
assert serializer.is_valid(), serializer.errors
|
||||||
|
|
||||||
@@ -42,7 +47,12 @@ class TaskViewSetTests(TestCase):
|
|||||||
serializer.instance = task
|
serializer.instance = task
|
||||||
|
|
||||||
self.view.perform_update(serializer)
|
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
|
# confirm instance saved with new title
|
||||||
task.refresh_from_db()
|
task.refresh_from_db()
|
||||||
assert task.title == "Updated"
|
assert task.title == "Updated"
|
||||||
@@ -52,3 +62,31 @@ class TaskViewSetTests(TestCase):
|
|||||||
self.view.perform_destroy(task)
|
self.view.perform_destroy(task)
|
||||||
task.refresh_from_db()
|
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))
|
||||||
@@ -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")
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
# backend/modules/task_management/views.py
|
# backend/modules/task_management/views.py
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from rest_framework import viewsets, permissions
|
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 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
|
from .services import TaskService
|
||||||
|
|
||||||
# Postavljanje loggera za modul
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TaskViewSet(viewsets.ModelViewSet):
|
class TaskViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet koji koristi TaskService za rukovanje operacijama.
|
ViewSet koji koristi TaskService za rukovanje operacijama.
|
||||||
@@ -19,46 +22,73 @@ class TaskViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = Task.objects.filter(is_active=True).select_related(
|
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')
|
vehicle_id = self.request.query_params.get('vehicle')
|
||||||
if vehicle_id:
|
if vehicle_id:
|
||||||
qs = qs.filter(vehicle_id=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
|
return qs
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""
|
|
||||||
Umjesto direktnog serializer.save(), pozivamo servis.
|
|
||||||
Proslijeđujemo validirane podatke (dict) i korisnika.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
entry = TaskService.create_task_entry(
|
entry = TaskService.create_task_entry(
|
||||||
data=serializer.validated_data,
|
data=serializer.validated_data,
|
||||||
user=self.request.user)
|
user=self.request.user,
|
||||||
|
)
|
||||||
serializer.instance = entry
|
serializer.instance = entry
|
||||||
except ValidationError as e:
|
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}")
|
logger.warning(f"Validacijska greška pri kreiranju zadatka od strane {self.request.user}: {e}")
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
"""
|
auto_close_work_order = serializer.validated_data.pop('auto_close_work_order', False)
|
||||||
Servis obavlja biznis logiku (validaciju/modifikaciju),
|
|
||||||
a serializer.save() finalizira zapis.
|
|
||||||
"""
|
|
||||||
# Servis sada samo modificira instancu u memoriji
|
|
||||||
TaskService.update_task_entry(
|
TaskService.update_task_entry(
|
||||||
instance=serializer.instance,
|
instance=serializer.instance,
|
||||||
data=serializer.validated_data,
|
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()
|
serializer.save()
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
"""
|
|
||||||
Umjesto fizičkog brisanja, radimo soft-delete ako je potrebno.
|
|
||||||
"""
|
|
||||||
instance.is_active = False
|
instance.is_active = False
|
||||||
instance.save()
|
instance.save()
|
||||||
logger.info(f"Zapis zadatka {instance.id} je deaktiviran od strane {self.request.user}")
|
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)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
DJANGO_SETTINGS_MODULE = core.settings
|
DJANGO_SETTINGS_MODULE = core.settings
|
||||||
python_files = tests.py test_*.py *_tests.py
|
python_files = tests.py test_*.py *_tests.py
|
||||||
|
addopts = --import-mode=importlib
|
||||||
@@ -29,8 +29,13 @@ services:
|
|||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- PUBLIC_API_URL=${PUBLIC_API_URL}
|
|
||||||
- NODE_ENV=development
|
- 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:
|
flower:
|
||||||
container_name: 004erpflower
|
container_name: 004erpflower
|
||||||
@@ -46,6 +51,22 @@ services:
|
|||||||
- redis
|
- redis
|
||||||
- worker
|
- 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:
|
volumes:
|
||||||
media_volume:
|
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
121
docker-compose.prod.yml
Normal 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
|
||||||
@@ -27,7 +27,7 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
command: celery -A core worker --loglevel=info
|
command: celery -A core worker --loglevel=info
|
||||||
user: "1000:1000"
|
user: "1001:1001"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- media_volume:/app/media
|
- media_volume:/app/media
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ const envHosts = (process.env.PUBLIC_ALLOWED_HOSTS || '')
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const allowedHosts = Array.from(
|
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 hmrHost = process.env.PUBLIC_HMR_HOST || 'localhost';
|
||||||
const hmrProtocol = process.env.PUBLIC_HMR_PROTOCOL || 'ws';
|
const hmrProtocol = process.env.PUBLIC_HMR_PROTOCOL || 'ws';
|
||||||
const hmrClientPort = Number(process.env.PUBLIC_HMR_CLIENT_PORT || 4321);
|
const hmrClientPort = Number(process.env.PUBLIC_HMR_CLIENT_PORT || 4321);
|
||||||
|
const apiProxyTarget = process.env.PUBLIC_API_PROXY_TARGET || 'http://localhost:8001';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: 'server',
|
output: 'server',
|
||||||
@@ -59,6 +60,12 @@ export default defineConfig({
|
|||||||
host: hmrHost,
|
host: hmrHost,
|
||||||
clientPort: hmrClientPort,
|
clientPort: hmrClientPort,
|
||||||
},
|
},
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
watch: {
|
watch: {
|
||||||
usePolling: true,
|
usePolling: true,
|
||||||
interval: 100,
|
interval: 100,
|
||||||
|
|||||||
@@ -1,7 +1,147 @@
|
|||||||
const CACHE_NAME = 'erp-shell-v1';
|
const CACHE_NAME = 'erp-shell-v2';
|
||||||
|
const API_CACHE_NAME = 'erp-api-v1';
|
||||||
|
const WRITE_QUEUE_DB_NAME = 'erp-write-queue-db';
|
||||||
|
const WRITE_QUEUE_STORE = 'requests';
|
||||||
|
const WRITE_QUEUE_SYNC_TAG = 'erp-write-queue-sync';
|
||||||
const OFFLINE_URL = '/offline.html';
|
const OFFLINE_URL = '/offline.html';
|
||||||
const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL];
|
const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL];
|
||||||
|
|
||||||
|
const API_CACHE_BLOCKLIST = [
|
||||||
|
'/api/token/',
|
||||||
|
'/api/token/refresh/',
|
||||||
|
'/api/fleet/pusher-auth/',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isCacheableApiGet(request, url) {
|
||||||
|
if (request.method !== 'GET') return false;
|
||||||
|
if (url.origin !== self.location.origin) return false;
|
||||||
|
if (!url.pathname.startsWith('/api/')) return false;
|
||||||
|
return !API_CACHE_BLOCKLIST.some((blocked) => url.pathname.startsWith(blocked));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQueueableApiWrite(request, url) {
|
||||||
|
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) return false;
|
||||||
|
if (url.origin !== self.location.origin) return false;
|
||||||
|
if (!url.pathname.startsWith('/api/')) return false;
|
||||||
|
if (API_CACHE_BLOCKLIST.some((blocked) => url.pathname.startsWith(blocked))) return false;
|
||||||
|
const contentType = (request.headers.get('content-type') || '').toLowerCase();
|
||||||
|
if (contentType.includes('multipart/form-data')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openWriteQueueDb() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(WRITE_QUEUE_DB_NAME, 1);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains(WRITE_QUEUE_STORE)) {
|
||||||
|
db.createObjectStore(WRITE_QUEUE_STORE, { keyPath: 'id', autoIncrement: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeHeaders(headers) {
|
||||||
|
const serialized = {};
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
serialized[key] = value;
|
||||||
|
});
|
||||||
|
return serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllQueuedRequests(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(WRITE_QUEUE_STORE, 'readonly');
|
||||||
|
const store = tx.objectStore(WRITE_QUEUE_STORE);
|
||||||
|
const request = store.getAll();
|
||||||
|
request.onsuccess = () => resolve(request.result || []);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addQueuedRequest(db, queuedRequest) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(WRITE_QUEUE_STORE, 'readwrite');
|
||||||
|
const store = tx.objectStore(WRITE_QUEUE_STORE);
|
||||||
|
const request = store.add(queuedRequest);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteQueuedRequest(db, id) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(WRITE_QUEUE_STORE, 'readwrite');
|
||||||
|
const store = tx.objectStore(WRITE_QUEUE_STORE);
|
||||||
|
const request = store.delete(id);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueWriteRequest(request) {
|
||||||
|
const body = request.method === 'DELETE' ? null : await request.clone().text();
|
||||||
|
const db = await openWriteQueueDb();
|
||||||
|
const payload = {
|
||||||
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
|
headers: serializeHeaders(request.headers),
|
||||||
|
body,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
await addQueuedRequest(db, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createQueuedResponse() {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
queued: true,
|
||||||
|
offline: true,
|
||||||
|
detail: 'Zahtjev je spremljen i bit će sinkroniziran kad veza bude dostupna.',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 202,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scheduleWriteQueueSync() {
|
||||||
|
if (!self.registration || !self.registration.sync) return;
|
||||||
|
await self.registration.sync.register(WRITE_QUEUE_SYNC_TAG);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replayQueuedRequests() {
|
||||||
|
const db = await openWriteQueueDb();
|
||||||
|
const queuedRequests = await getAllQueuedRequests(db);
|
||||||
|
for (const queued of queuedRequests) {
|
||||||
|
const headers = new Headers(queued.headers || {});
|
||||||
|
const init = {
|
||||||
|
method: queued.method,
|
||||||
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
};
|
||||||
|
if (queued.body != null && queued.method !== 'DELETE') {
|
||||||
|
init.body = queued.body;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(queued.url, init);
|
||||||
|
if (response.ok) {
|
||||||
|
await deleteQueuedRequest(db, queued.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (response.status >= 500) {
|
||||||
|
throw new Error(`Replay failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
await deleteQueuedRequest(db, queued.id);
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
|
||||||
@@ -11,22 +151,76 @@ self.addEventListener('install', (event) => {
|
|||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
|
Promise.all([
|
||||||
caches.keys().then((cacheNames) =>
|
caches.keys().then((cacheNames) =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
cacheNames
|
cacheNames
|
||||||
.filter((name) => name !== CACHE_NAME)
|
.filter((name) => name !== CACHE_NAME && name !== API_CACHE_NAME)
|
||||||
.map((name) => caches.delete(name))
|
.map((name) => caches.delete(name))
|
||||||
)
|
)
|
||||||
)
|
),
|
||||||
|
replayQueuedRequests().catch((error) => {
|
||||||
|
console.error('Initial write-queue replay failed.', error);
|
||||||
|
}),
|
||||||
|
])
|
||||||
);
|
);
|
||||||
self.clients.claim();
|
self.clients.claim();
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
const { request } = event;
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (isQueueableApiWrite(request, url)) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request.clone()).catch(async () => {
|
||||||
|
try {
|
||||||
|
await queueWriteRequest(request);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to queue offline write request.', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
queued: false,
|
||||||
|
offline: true,
|
||||||
|
detail: 'Zahtjev nije moguće spremiti offline.',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await scheduleWriteQueueSync();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Background sync registration failed.', error);
|
||||||
|
}
|
||||||
|
return createQueuedResponse();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (request.method !== 'GET') return;
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
const url = new URL(request.url);
|
if (isCacheableApiGet(request, url)) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open(API_CACHE_NAME).then(async (cache) => {
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
const networkFetch = fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response && response.ok) {
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => cached);
|
||||||
|
|
||||||
|
return cached || networkFetch;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (url.origin !== self.location.origin) return;
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
if (request.mode === 'navigate') {
|
if (request.mode === 'navigate') {
|
||||||
@@ -60,3 +254,13 @@ self.addEventListener('fetch', (event) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.addEventListener('sync', (event) => {
|
||||||
|
if (event.tag !== WRITE_QUEUE_SYNC_TAG) return;
|
||||||
|
event.waitUntil(replayQueuedRequests());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data !== 'FLUSH_WRITE_QUEUE') return;
|
||||||
|
event.waitUntil(replayQueuedRequests());
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import NotificationBell from './NotificationBell';
|
|||||||
import ThemeToggle from './ui/ThemeToggle';
|
import ThemeToggle from './ui/ThemeToggle';
|
||||||
import AuthWidget from './AuthWidget';
|
import AuthWidget from './AuthWidget';
|
||||||
import UserDisplay from './ui/UserDisplay';
|
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';
|
import { hydrateAuthFromStorage } from '../stores/authStore';
|
||||||
|
|
||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
@@ -13,27 +14,82 @@ const ITEMS = [
|
|||||||
{ id: 'clients', label: 'Klijenti', href: '/clients' },
|
{ id: 'clients', label: 'Klijenti', href: '/clients' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Navbar({ minimal = false }) {
|
function normalizePath(p) {
|
||||||
const [pathname, setPathname] = useState('/');
|
const value = String(p || '/');
|
||||||
const normalizedPath = useMemo(() => {
|
|
||||||
const value = String(pathname || '/');
|
|
||||||
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
|
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
|
||||||
return value;
|
return value;
|
||||||
}, [pathname]);
|
}
|
||||||
|
|
||||||
|
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(() => {
|
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();
|
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(
|
// Kad se promijeni activeIndex (nova stranica), animiraj pill na novu poziciju
|
||||||
() => ITEMS.find((item) => item.href === normalizedPath) || ITEMS[0],
|
useEffect(() => {
|
||||||
[normalizedPath]
|
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 (
|
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">
|
<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" />}
|
{!minimal && <UserDisplay field="ime_prezime" className="text-xs text-text-muted" />}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Navigacijski linkovi */}
|
{/* Navigacijski linkovi s react-spring klizačem */}
|
||||||
<ul className="hidden items-center gap-1 text-sm sm:flex">
|
<ul className="relative hidden items-center gap-1 text-sm sm:flex">
|
||||||
{ITEMS.map((item) => (
|
{/* Animirani pozadinski klizač */}
|
||||||
<li key={item.id}>
|
<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
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={
|
className={
|
||||||
normalizedPath === item.href
|
pathname === item.href
|
||||||
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700'
|
? 'relative z-10 block rounded-lg px-3 py-2 font-medium text-indigo-700'
|
||||||
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep'
|
: 'relative z-10 block rounded-lg px-3 py-2 text-text-main hover:text-indigo-600'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -64,6 +133,7 @@ export default function Navbar({ minimal = false }) {
|
|||||||
|
|
||||||
{/* Desna strana */}
|
{/* Desna strana */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Mobilni dropdown */}
|
||||||
<details className="relative sm:hidden">
|
<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">
|
<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'} ▾
|
{currentItem?.label || 'Izbornik'} ▾
|
||||||
@@ -78,7 +148,7 @@ export default function Navbar({ minimal = false }) {
|
|||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={
|
className={
|
||||||
normalizedPath === item.href
|
pathname === item.href
|
||||||
? 'block bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700'
|
? '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'
|
: 'block px-3 py-2 text-sm text-text-main hover:bg-canvas-deep'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,23 +131,20 @@ export default function ClientsSection({ onSetServiceContext = null }) {
|
|||||||
headClassName="text-xs uppercase tracking-wide text-text-muted"
|
headClassName="text-xs uppercase tracking-wide text-text-muted"
|
||||||
bodyClassName="divide-y divide-border-hairline"
|
bodyClassName="divide-y divide-border-hairline"
|
||||||
rowClassName="hover:bg-canvas-deep"
|
rowClassName="hover:bg-canvas-deep"
|
||||||
|
isRowSelected={(item) => String(item.id) === String(selectedClientCraneId)}
|
||||||
tableClassName="min-w-full text-sm"
|
tableClassName="min-w-full text-sm"
|
||||||
wrapperClassName="overflow-x-auto"
|
wrapperClassName="overflow-x-auto"
|
||||||
renderRow={(item) => {
|
renderRow={(item) => (
|
||||||
const isSelected = String(item.id) === String(selectedClientCraneId);
|
|
||||||
const cellClassName = isSelected ? 'bg-emerald-50' : '';
|
|
||||||
const firstCellClassName = isSelected ? 'bg-emerald-50 text-emerald-800' : '';
|
|
||||||
return (
|
|
||||||
<>
|
<>
|
||||||
<td className={`py-2 pr-4 font-medium cursor-pointer ${firstCellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.registration_number}</td>
|
<td className="py-2 pr-4 font-medium cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>{item.registration_number}</td>
|
||||||
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.make} {item.model} {item.year ? `(${item.year})` : ''}</td>
|
<td className="py-2 pr-4 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>{item.make} {item.model} {item.year ? `(${item.year})` : ''}</td>
|
||||||
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.current_mileage?.toLocaleString('hr-HR')} km</td>
|
<td className="py-2 pr-4 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>{item.current_mileage?.toLocaleString('hr-HR')} km</td>
|
||||||
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>
|
<td className="py-2 pr-4 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>
|
||||||
{item.service_interval_km
|
{item.service_interval_km
|
||||||
? `${(Math.floor(item.current_mileage / item.service_interval_km) + 1) * item.service_interval_km} km`
|
? `${(Math.floor(item.current_mileage / item.service_interval_km) + 1) * item.service_interval_km} km`
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className={`py-2 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>
|
<td className="py-2 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>
|
||||||
<span className={item.is_company_vehicle
|
<span className={item.is_company_vehicle
|
||||||
? 'rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700'
|
? 'rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700'
|
||||||
: 'rounded-full bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700'
|
: 'rounded-full bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700'
|
||||||
@@ -156,7 +153,7 @@ export default function ClientsSection({ onSetServiceContext = null }) {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
);}}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useStore } from '@nanostores/preact';
|
import { useStore } from '@nanostores/preact';
|
||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { animated, useSpring } from '@react-spring/web';
|
import { animated, useSpring, useTransition } from '@react-spring/web';
|
||||||
import DashboardTopbar from './DashboardTopbar';
|
import DashboardTopbar from './DashboardTopbar';
|
||||||
import WorkOrderModal from './WorkOrderModal';
|
import WorkOrderModal from './WorkOrderModal';
|
||||||
import TaskCreateModal from './TaskCreateModal';
|
import TaskCreateModal from './TaskCreateModal';
|
||||||
@@ -9,6 +9,7 @@ import WorkOrderDetailModal from './WorkOrderDetailModal';
|
|||||||
import SyncLogPanel from './SyncLogPanel';
|
import SyncLogPanel from './SyncLogPanel';
|
||||||
import ServiceRecordForm from '../fleet/ServiceRecordForm';
|
import ServiceRecordForm from '../fleet/ServiceRecordForm';
|
||||||
import ServicePhotoUpload from '../fleet/ServicePhotoUpload';
|
import ServicePhotoUpload from '../fleet/ServicePhotoUpload';
|
||||||
|
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
|
||||||
import ServiceRecordDetailModal from '../fleet/ServiceRecordDetailModal';
|
import ServiceRecordDetailModal from '../fleet/ServiceRecordDetailModal';
|
||||||
import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
|
import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
|
||||||
import ClientsSection from '../crm/ClientsSection';
|
import ClientsSection from '../crm/ClientsSection';
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
$serviceRecords,
|
$serviceRecords,
|
||||||
createWorkOrder,
|
createWorkOrder,
|
||||||
fetchFleetDashboardData,
|
fetchFleetDashboardData,
|
||||||
|
fetchTeamMembers,
|
||||||
ensureVehiclesCatalog,
|
ensureVehiclesCatalog,
|
||||||
triggerOfflineSync,
|
triggerOfflineSync,
|
||||||
updateWorkOrder,
|
updateWorkOrder,
|
||||||
@@ -31,8 +33,8 @@ import {
|
|||||||
import { showToast } from '../../stores/toastStore';
|
import { showToast } from '../../stores/toastStore';
|
||||||
import { $accessToken, $authReady, $user, hydrateAuthFromStorage, validateToken } from '../../stores/authStore';
|
import { $accessToken, $authReady, $user, hydrateAuthFromStorage, validateToken } from '../../stores/authStore';
|
||||||
import { cacheCraneSerial, cacheCraneSerialsFromVehicles } from '../../stores/craneSerialStore';
|
import { cacheCraneSerial, cacheCraneSerialsFromVehicles } from '../../stores/craneSerialStore';
|
||||||
import { formatEntityCode } from '../../lib/displayIds';
|
import { formatWorkOrderDisplayCode, parseWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||||
import { $tasks, fetchTasks, isTaskActive, updateTask } from '../../stores/taskStore';
|
import { $tasks, fetchTasks, getStatusLabel, isTaskActive, updateTask } from '../../stores/taskStore';
|
||||||
import AnimatedDataTable from '../ui/AnimatedDataTable';
|
import AnimatedDataTable from '../ui/AnimatedDataTable';
|
||||||
import AnimatedPage from '../ui/AnimatedPage';
|
import AnimatedPage from '../ui/AnimatedPage';
|
||||||
|
|
||||||
@@ -65,12 +67,75 @@ function formatDate(value) {
|
|||||||
return parsed.toLocaleDateString('hr-HR');
|
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) {
|
function formatCost(value) {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
if (Number.isNaN(numeric)) return '-';
|
if (Number.isNaN(numeric)) return '-';
|
||||||
return `${numeric.toFixed(2)} EUR`;
|
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 = {
|
const PAGE_MODE_TO_SECTION = {
|
||||||
dashboard: 'dashboard',
|
dashboard: 'dashboard',
|
||||||
'work-orders': 'work-orders',
|
'work-orders': 'work-orders',
|
||||||
@@ -93,6 +158,8 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
const [selectedServiceRecord, setSelectedServiceRecord] = useState(null);
|
const [selectedServiceRecord, setSelectedServiceRecord] = useState(null);
|
||||||
// photoUpload: { open: bool, serviceRecordId: string|null }
|
// photoUpload: { open: bool, serviceRecordId: string|null }
|
||||||
const [photoUpload, setPhotoUpload] = useState({ open: false, serviceRecordId: 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 loading = useStore($dashboardLoading);
|
||||||
const error = useStore($dashboardError);
|
const error = useStore($dashboardError);
|
||||||
const stats = useStore($dashboardStats);
|
const stats = useStore($dashboardStats);
|
||||||
@@ -110,8 +177,13 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [taskFilter, setTaskFilter] = useState('active');
|
const [taskFilter, setTaskFilter] = useState('active');
|
||||||
const [taskScope, setTaskScope] = useState('all');
|
const [taskScope, setTaskScope] = useState('all');
|
||||||
|
const [supervisorServicers, setSupervisorServicers] = useState([]);
|
||||||
|
const [selectedServicerId, setSelectedServicerId] = useState('all');
|
||||||
const [workOrderScope, setWorkOrderScope] = useState('context');
|
const [workOrderScope, setWorkOrderScope] = useState('context');
|
||||||
|
const [workOrderCodeSortDirection, setWorkOrderCodeSortDirection] = useState('desc');
|
||||||
|
const [taskWorkOrderSortDirection, setTaskWorkOrderSortDirection] = useState('desc');
|
||||||
const [selectedCraneRowId, setSelectedCraneRowId] = useState(null);
|
const [selectedCraneRowId, setSelectedCraneRowId] = useState(null);
|
||||||
|
const [expandedServiceTaskId, setExpandedServiceTaskId] = useState(null);
|
||||||
const [hasMounted, setHasMounted] = useState(false);
|
const [hasMounted, setHasMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -121,9 +193,13 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextSection = String(resolvedInitialSection || 'dashboard');
|
function onCalendarOpenTask(event) {
|
||||||
setActiveSection(nextSection);
|
const task = event?.detail?.task;
|
||||||
}, [resolvedInitialSection]);
|
if (task) setSelectedTask(task);
|
||||||
|
}
|
||||||
|
window.addEventListener('calendar:open-task', onCalendarOpenTask);
|
||||||
|
return () => window.removeEventListener('calendar:open-task', onCalendarOpenTask);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -270,22 +346,85 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
|| contextCrane?.client_name
|
|| contextCrane?.client_name
|
||||||
|| null
|
|| null
|
||||||
), [clients, selectedClientId, contextCrane]);
|
), [clients, selectedClientId, contextCrane]);
|
||||||
|
const contextCraneLabel = useMemo(() => {
|
||||||
|
if (!hydratedContextCrane) return null;
|
||||||
|
const serial = String(hydratedContextCrane.crane_serial_number || '').trim();
|
||||||
|
if (serial) return `SN ${serial}`;
|
||||||
|
return hydratedContextCrane.registration_number || '-';
|
||||||
|
}, [hydratedContextCrane]);
|
||||||
const serviceRecordsForContext = useMemo(() => {
|
const serviceRecordsForContext = useMemo(() => {
|
||||||
if (!selectedVehicleId) return [];
|
if (!selectedVehicleId) return [];
|
||||||
return [...serviceRecords]
|
return [...serviceRecords]
|
||||||
.filter((record) => String(record.vehicle) === String(selectedVehicleId))
|
.filter((record) => String(record.vehicle) === String(selectedVehicleId))
|
||||||
.sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || '')));
|
.sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || '')));
|
||||||
}, [serviceRecords, selectedVehicleId]);
|
}, [serviceRecords, selectedVehicleId]);
|
||||||
|
const serviceTaskGroups = useMemo(() => {
|
||||||
|
if (!selectedVehicleId) return [];
|
||||||
|
const contextTasks = [...tasks]
|
||||||
|
.filter((task) => String(task.vehicle || '') === String(selectedVehicleId))
|
||||||
|
.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
|
||||||
|
const groups = contextTasks.map((task) => ({
|
||||||
|
id: String(task.id),
|
||||||
|
title: task.title || 'Radni zadatak',
|
||||||
|
status: task.status || '-',
|
||||||
|
task,
|
||||||
|
records: serviceRecordsForContext.filter((record) => String(record.task || '') === String(task.id)),
|
||||||
|
}));
|
||||||
|
const unassignedRecords = serviceRecordsForContext.filter((record) => !record.task);
|
||||||
|
if (unassignedRecords.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
id: 'unassigned-task',
|
||||||
|
title: 'Bez radnog zadatka',
|
||||||
|
status: '-',
|
||||||
|
task: null,
|
||||||
|
records: unassignedRecords,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [selectedVehicleId, tasks, serviceRecordsForContext]);
|
||||||
|
const serviceTaskTransitions = useTransition(serviceTaskGroups, {
|
||||||
|
keys: (group) => group.id,
|
||||||
|
from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
|
||||||
|
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
|
||||||
|
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
|
||||||
|
trail: 30,
|
||||||
|
config: { tension: 230, friction: 26 },
|
||||||
|
});
|
||||||
const scopedWorkOrders = useMemo(() => {
|
const scopedWorkOrders = useMemo(() => {
|
||||||
const sorted = [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
const sorted = [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
||||||
|
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') {
|
if (workOrderScope === 'all') {
|
||||||
return sorted;
|
return codeSorted;
|
||||||
}
|
}
|
||||||
if (!hydratedSelectedVehicleId) {
|
if (!hydratedSelectedVehicleId) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return sorted.filter((order) => String(order.vehicle) === String(hydratedSelectedVehicleId));
|
return codeSorted.filter((order) => String(order.vehicle) === String(hydratedSelectedVehicleId));
|
||||||
}, [workOrders, workOrderScope, hydratedSelectedVehicleId]);
|
}, [workOrders, workOrderScope, hydratedSelectedVehicleId, user?.id, user?.is_serviser, user?.is_team_member, workOrderCodeSortDirection]);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const totalPages = Math.max(1, Math.ceil(scopedWorkOrders.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(scopedWorkOrders.length / pageSize));
|
||||||
const latestWorkOrders = useMemo(
|
const latestWorkOrders = useMemo(
|
||||||
@@ -310,6 +449,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
const isTeamMember = Boolean(user?.is_team_member);
|
const isTeamMember = Boolean(user?.is_team_member);
|
||||||
const isServiser = Boolean(user?.is_serviser);
|
const isServiser = Boolean(user?.is_serviser);
|
||||||
const isCustomer = Boolean(user?.is_kupac);
|
const isCustomer = Boolean(user?.is_kupac);
|
||||||
|
const isSupervisorNotServiser = isTeamMember && !isServiser;
|
||||||
const canViewAllTasks = isTeamMember && (isServiser || !isCustomer);
|
const canViewAllTasks = isTeamMember && (isServiser || !isCustomer);
|
||||||
const canToggleTaskScope = isTeamMember && isServiser;
|
const canToggleTaskScope = isTeamMember && isServiser;
|
||||||
const isTaskAssignedToCurrentUser = (task) => (
|
const isTaskAssignedToCurrentUser = (task) => (
|
||||||
@@ -320,6 +460,42 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
setTaskScope(canViewAllTasks ? 'all' : 'mine');
|
setTaskScope(canViewAllTasks ? 'all' : 'mine');
|
||||||
}, [canViewAllTasks]);
|
}, [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 dashboardTasks = useMemo(() => {
|
||||||
const sorted = [...tasks].sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
|
const sorted = [...tasks].sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
|
||||||
const scoped = (() => {
|
const scoped = (() => {
|
||||||
@@ -331,14 +507,32 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
}
|
}
|
||||||
return sorted;
|
return sorted;
|
||||||
})();
|
})();
|
||||||
|
const filteredByServicer = (() => {
|
||||||
|
if (!isSupervisorNotServiser || selectedServicerId === 'all') {
|
||||||
|
return scoped;
|
||||||
|
}
|
||||||
|
return scoped.filter(
|
||||||
|
(task) => String(task?.assigned_to || '') === String(selectedServicerId)
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
const filteredByStatus = (() => {
|
||||||
if (taskFilter === 'active') {
|
if (taskFilter === 'active') {
|
||||||
return scoped.filter((task) => isTaskActive(task));
|
return filteredByServicer.filter((task) => isTaskActive(task));
|
||||||
}
|
}
|
||||||
if (taskFilter === 'inactive') {
|
if (taskFilter === 'inactive') {
|
||||||
return scoped.filter((task) => !isTaskActive(task));
|
return filteredByServicer.filter((task) => !isTaskActive(task));
|
||||||
}
|
}
|
||||||
return scoped;
|
return filteredByServicer;
|
||||||
}, [tasks, taskFilter, canViewAllTasks, canToggleTaskScope, taskScope, user?.id]);
|
})();
|
||||||
|
|
||||||
|
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(() => {
|
const selectedTaskServiceRecords = useMemo(() => {
|
||||||
if (!selectedTask?.id) return [];
|
if (!selectedTask?.id) return [];
|
||||||
return [...serviceRecords]
|
return [...serviceRecords]
|
||||||
@@ -363,6 +557,14 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [workOrderScope, hydratedSelectedVehicleId]);
|
}, [workOrderScope, hydratedSelectedVehicleId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expandedServiceTaskId) return;
|
||||||
|
const exists = serviceTaskGroups.some((group) => String(group.id) === String(expandedServiceTaskId));
|
||||||
|
if (!exists) {
|
||||||
|
setExpandedServiceTaskId(null);
|
||||||
|
}
|
||||||
|
}, [expandedServiceTaskId, serviceTaskGroups]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCraneRowId) return;
|
if (!selectedCraneRowId) return;
|
||||||
const exists = cranes.some((item) => String(item.id) === String(selectedCraneRowId));
|
const exists = cranes.some((item) => String(item.id) === String(selectedCraneRowId));
|
||||||
@@ -377,7 +579,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
showToast('Putni nalog je spremljen.', 'success');
|
showToast('Putni nalog je spremljen.', 'success');
|
||||||
await fetchFleetDashboardData({ silent: true });
|
await fetchFleetDashboardData({ silent: true });
|
||||||
} catch (error) {
|
} 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);
|
await updateWorkOrder(workOrder.id, changes);
|
||||||
showToast('Nalog je uspješno ažuriran.', 'success');
|
showToast('Nalog je uspješno ažuriran.', 'success');
|
||||||
await fetchFleetDashboardData({ silent: true });
|
await fetchFleetDashboardData({ silent: true });
|
||||||
|
await fetchTasks();
|
||||||
} catch (error) {
|
} 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');
|
showToast('Servisni kontekst je postavljen.', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openTaskModalFromContext() {
|
||||||
|
if (!selectedClientId || !selectedVehicleId) {
|
||||||
|
showToast('Odaberite kupca i dizalicu u Servisnom kontekstu prije kreiranja radnog zadatka.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const availableCranes = await ensureVehiclesCatalog();
|
||||||
|
const selectedFromContext = availableCranes.find(
|
||||||
|
(item) => String(item.id) === String(selectedVehicleId)
|
||||||
|
);
|
||||||
|
if (!selectedFromContext) {
|
||||||
|
showToast('Odabrana dizalica nije dostupna u trenutnom kontekstu.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Ne mogu dohvatiti dizalice. Provjerite mrežu ili prijavu.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsTaskModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
const workOrderColumns = [
|
const workOrderColumns = [
|
||||||
{ key: 'code', label: 'Putni nalog', className: 'px-4 py-3' },
|
{
|
||||||
|
key: 'code',
|
||||||
|
label: (
|
||||||
|
<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: '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: 'createdAt', label: 'Kreirano', className: 'px-4 py-3' },
|
||||||
{ key: 'status', label: 'Status', className: 'px-4 py-3' },
|
{ key: 'status', label: 'Status', className: 'px-4 py-3' },
|
||||||
{ key: 'creator', label: 'Creator', 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 = [
|
const taskColumns = [
|
||||||
{ key: 'title', label: 'Radni zadatak', className: 'px-4 py-3' },
|
{ key: 'title', label: 'Radni zadatak', className: 'px-4 py-3' },
|
||||||
{ key: 'status', label: 'Status', 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: 'createdAt', label: 'Vrijeme izrade', className: 'px-4 py-3' },
|
||||||
{ key: 'vehicle', label: 'Dizalica', 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' },
|
{ key: 'assigned', label: 'Dodijeljeno', className: 'px-4 py-3' },
|
||||||
];
|
];
|
||||||
const isDashboardPage = pageMode === 'dashboard';
|
const isDashboardPage = pageMode === 'dashboard';
|
||||||
@@ -474,26 +720,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
}
|
}
|
||||||
setIsWorkOrderModalOpen(true);
|
setIsWorkOrderModalOpen(true);
|
||||||
}}
|
}}
|
||||||
onOpenTaskModal={async () => {
|
onOpenTaskModal={openTaskModalFromContext}
|
||||||
if (!selectedClientId || !selectedVehicleId) {
|
|
||||||
showToast('Odaberite kupca i dizalicu u Servisnom kontekstu prije kreiranja radnog zadatka.', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const availableCranes = await ensureVehiclesCatalog();
|
|
||||||
const selectedFromContext = availableCranes.find(
|
|
||||||
(item) => String(item.id) === String(selectedVehicleId)
|
|
||||||
);
|
|
||||||
if (!selectedFromContext) {
|
|
||||||
showToast('Odabrana dizalica nije dostupna u trenutnom kontekstu.', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
showToast('Ne mogu dohvatiti dizalice. Provjerite mrežu ili prijavu.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsTaskModalOpen(true);
|
|
||||||
}}
|
|
||||||
onNewServiceRecord={({ craneId }) => {
|
onNewServiceRecord={({ craneId }) => {
|
||||||
const crane = cranes.find((item) => String(item.id) === String(craneId));
|
const crane = cranes.find((item) => String(item.id) === String(craneId));
|
||||||
openServiceRecordForCrane(crane ?? null);
|
openServiceRecordForCrane(crane ?? null);
|
||||||
@@ -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">
|
<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>
|
<h3 className="font-semibold text-text-main">Radni zadaci</h3>
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<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 && (
|
{canToggleTaskScope && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -611,14 +855,26 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={isTaskActive(task) ? 'text-emerald-700' : 'text-red-700'}>
|
<span className={isTaskActive(task) ? 'text-emerald-700' : 'text-red-700'}>
|
||||||
{task.status || '-'}
|
{getStatusLabel(task.status || '-')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">{formatDateTime(task.created_at)}</td>
|
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{task.vehicle_registration
|
{task.scheduled_date ? (
|
||||||
|| cranes.find((item) => String(item.id) === String(task.vehicle))?.registration_number
|
<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>
|
||||||
<td className="px-4 py-3">{task.work_order_label || '-'}</td>
|
<td className="px-4 py-3">{task.work_order_label || '-'}</td>
|
||||||
<td className="px-4 py-3">{task.assigned_to_name || '-'}</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>
|
<h3 className="font-semibold text-text-main">Putni nalozi dizalica</h3>
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
{workOrderScope === 'context'
|
{workOrderScope === 'context'
|
||||||
? (selectedContextClientName && hydratedContextCrane?.registration_number
|
? (selectedContextClientName && contextCraneLabel
|
||||||
? `Kontekst: ${selectedContextClientName} / ${hydratedContextCrane.registration_number}`
|
? `Kontekst: ${selectedContextClientName} / ${contextCraneLabel}`
|
||||||
: 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.')
|
: 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.')
|
||||||
: 'Prikaz svih putnih naloga.'}
|
: 'Prikaz svih putnih naloga.'}
|
||||||
</p>
|
</p>
|
||||||
@@ -700,13 +956,16 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
className="cursor-pointer px-4 py-3 font-medium text-text-main"
|
className="cursor-pointer px-4 py-3 font-medium text-text-main"
|
||||||
onClick={() => openWorkOrderDetails(workOrder)}
|
onClick={() => openWorkOrderDetails(workOrder)}
|
||||||
>
|
>
|
||||||
{formatEntityCode('PN', workOrder.id)}
|
{formatWorkOrderDisplayCode(workOrder)}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className="px-4 py-3"
|
className="px-4 py-3"
|
||||||
>
|
>
|
||||||
{[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ') || workOrder.craneLabel || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`}
|
{[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ') || workOrder.craneLabel || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`}
|
||||||
</td>
|
</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">
|
<td className="px-4 py-3">
|
||||||
{formatDate(workOrder.date)}
|
{formatDate(workOrder.date)}
|
||||||
</td>
|
</td>
|
||||||
@@ -724,15 +983,10 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const related = serviceRecords.find((record) => String(record.vehicle) === String(workOrder.vehicle));
|
setWorkOrderPhotoUpload({ open: true, workOrderId: workOrder.id });
|
||||||
if (!related) {
|
|
||||||
showToast('Nema servisnog zapisa za ovaj putni nalog. Kreirajte servisni zapis prvo.', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPhotoUpload({ open: true, serviceRecordId: related.id });
|
|
||||||
}}
|
}}
|
||||||
className="rounded px-2 py-1 text-xs font-medium text-text-muted hover:bg-canvas-deep hover:text-text-main"
|
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>
|
</button>
|
||||||
@@ -777,12 +1031,19 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-text-main">Servisni zapisi</h3>
|
<h3 className="font-semibold text-text-main">Servisni zapisi</h3>
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
{selectedContextClientName && hydratedContextCrane?.registration_number
|
{selectedContextClientName && contextCraneLabel
|
||||||
? `Kontekst: ${selectedContextClientName} / ${hydratedContextCrane.registration_number}`
|
? `Kontekst: ${selectedContextClientName} / ${contextCraneLabel}`
|
||||||
: 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.'}
|
: 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openTaskModalFromContext}
|
||||||
|
className="rounded-md bg-indigo-600 px-3 py-1 text-xs font-semibold text-white hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
+ Novi radni zadatak
|
||||||
|
</button>
|
||||||
{hydratedSelectedVehicleId && hydratedContextCrane && (
|
{hydratedSelectedVehicleId && hydratedContextCrane && (
|
||||||
<ServiceRecordCreateButton onClick={() => openServiceRecordForCrane(hydratedContextCrane)} />
|
<ServiceRecordCreateButton onClick={() => openServiceRecordForCrane(hydratedContextCrane)} />
|
||||||
)}
|
)}
|
||||||
@@ -795,28 +1056,69 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!hydratedSelectedVehicleId && (
|
||||||
|
<p className="px-4 py-6 text-center text-sm text-text-muted">
|
||||||
|
Odaberite dizalicu za prikaz servisnih zapisa.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hydratedSelectedVehicleId && loading && (
|
||||||
|
<p className="px-4 py-6 text-center text-sm text-text-muted">
|
||||||
|
Učitavanje...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hydratedSelectedVehicleId && !loading && serviceTaskGroups.length === 0 && (
|
||||||
|
<p className="px-4 py-6 text-center text-sm text-text-muted">
|
||||||
|
Nema grupiranih radnih zadataka za odabranu dizalicu.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hydratedSelectedVehicleId && !loading && serviceTaskGroups.length > 0 && (
|
||||||
|
<div className="divide-y divide-border-hairline">
|
||||||
|
{serviceTaskTransitions((style, group) => {
|
||||||
|
const isOpen = String(expandedServiceTaskId || '') === String(group.id);
|
||||||
|
return (
|
||||||
|
<animated.div key={group.id} style={style}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpandedServiceTaskId(isOpen ? null : group.id)}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-canvas-deep"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-text-main">{group.title}</span>
|
||||||
|
<span className="ml-2 text-xs text-text-muted">{group.status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700">
|
||||||
|
{group.records.length} servisnih zapisa
|
||||||
|
</span>
|
||||||
|
<span className="text-text-muted">{isOpen ? '▲' : '▼'}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t border-border-hairline bg-canvas-base/50 px-4 py-3">
|
||||||
|
{group.records.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
Nema servisnih zapisa za odabrani radni zadatak.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
<AnimatedDataTable
|
<AnimatedDataTable
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'date', label: 'Datum', className: 'px-4 py-3' },
|
{ key: 'date', label: 'Datum', className: 'px-4 py-3' },
|
||||||
{ key: 'crane', label: 'Dizalica', className: 'px-4 py-3' },
|
{ key: 'crane', label: 'DIZALICA', className: 'px-4 py-3' },
|
||||||
{ key: 'task', label: 'Radni zadatak', className: 'px-4 py-3' },
|
|
||||||
{ key: 'description', label: 'Opis', className: 'px-4 py-3' },
|
{ key: 'description', label: 'Opis', className: 'px-4 py-3' },
|
||||||
{ key: 'cost', label: 'Trošak', className: 'px-4 py-3' },
|
{ key: 'cost', label: 'Trošak', className: 'px-4 py-3' },
|
||||||
{ key: 'files', label: 'Datoteke', className: 'px-4 py-3' },
|
{ key: 'files', label: 'Datoteke', className: 'px-4 py-3' },
|
||||||
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
|
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
|
||||||
]}
|
]}
|
||||||
rows={serviceRecordsForContext}
|
rows={group.records}
|
||||||
rowKey={(record) => record.id}
|
rowKey={(record) => record.id}
|
||||||
loading={loading}
|
rowClassName="hover:bg-canvas-deep"
|
||||||
loadingMessage="Učitavanje..."
|
|
||||||
emptyMessage={!hydratedSelectedVehicleId
|
|
||||||
? 'Odaberite dizalicu za prikaz servisnih zapisa.'
|
|
||||||
: 'Nema aktivnih servisnih zapisa za odabranu dizalicu.'}
|
|
||||||
renderRow={(record) => (
|
renderRow={(record) => (
|
||||||
<>
|
<>
|
||||||
|
{/* <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">{formatDate(record.service_date)}</td>
|
||||||
<td className="px-4 py-3">{record.crane_registration || record.vehicle_registration || '-'}</td>
|
<td className="px-4 py-3">{formatServiceRecordCraneLabel(record, cranes)}</td>
|
||||||
<td className="px-4 py-3">{record.task_label || '-'}</td>
|
|
||||||
<td className="max-w-md truncate px-4 py-3" title={record.description || ''}>
|
<td className="max-w-md truncate px-4 py-3" title={record.description || ''}>
|
||||||
{record.description || '-'}
|
{record.description || '-'}
|
||||||
</td>
|
</td>
|
||||||
@@ -826,7 +1128,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setServiceRecordBackTask(null);
|
setServiceRecordBackTask(group.task);
|
||||||
setSelectedServiceRecord(record);
|
setSelectedServiceRecord(record);
|
||||||
}}
|
}}
|
||||||
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
||||||
@@ -844,6 +1146,14 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</animated.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -884,43 +1194,45 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
columns={vehicleColumns}
|
columns={vehicleColumns}
|
||||||
rows={cranes}
|
rows={cranes}
|
||||||
rowKey={(vehicle) => vehicle.id}
|
rowKey={(vehicle) => vehicle.id}
|
||||||
|
rowClassName="hover:bg-canvas-deep"
|
||||||
|
isRowSelected={(vehicle) => String(vehicle.id) === String(selectedCraneRowId)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
loadingMessage="Učitavanje vozila..."
|
loadingMessage="Učitavanje vozila..."
|
||||||
emptyMessage="Nema dostupnih vozila."
|
emptyMessage="Nema dostupnih vozila."
|
||||||
renderRow={(vehicle) => (
|
renderRow={(vehicle) => (
|
||||||
<>
|
<>
|
||||||
<td
|
<td
|
||||||
className={`px-4 py-3 font-medium ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50 text-emerald-800' : 'text-text-main'}`}
|
className="px-4 py-3 font-medium text-text-main"
|
||||||
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
||||||
>
|
>
|
||||||
{vehicle.registration_number || '-'}
|
{vehicle.registration_number || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
|
className="px-4 py-3"
|
||||||
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
||||||
>
|
>
|
||||||
{vehicle.crane_serial_number || '-'}
|
{vehicle.crane_serial_number || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
|
className="px-4 py-3"
|
||||||
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
||||||
>
|
>
|
||||||
{vehicle.make || '-'} {vehicle.model || ''}
|
{vehicle.make || '-'} {vehicle.model || ''}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
|
className="px-4 py-3"
|
||||||
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
||||||
>
|
>
|
||||||
{vehicle.client_name || '-'}
|
{vehicle.client_name || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
|
className="px-4 py-3"
|
||||||
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
||||||
>
|
>
|
||||||
{vehicle.current_mileage ?? '-'}
|
{vehicle.current_mileage ?? '-'}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={`px-4 py-3 ${String(vehicle.id) === String(selectedCraneRowId) ? 'bg-emerald-50' : ''}`}
|
className="px-4 py-3"
|
||||||
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
||||||
>
|
>
|
||||||
<span className={vehicle.is_active ? 'text-emerald-700' : 'text-red-700'}>
|
<span className={vehicle.is_active ? 'text-emerald-700' : 'text-red-700'}>
|
||||||
@@ -984,6 +1296,16 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
open={photoUpload.open}
|
open={photoUpload.open}
|
||||||
serviceRecordId={photoUpload.serviceRecordId}
|
serviceRecordId={photoUpload.serviceRecordId}
|
||||||
onClose={() => setPhotoUpload({ open: false, serviceRecordId: null })}
|
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 })}
|
onSuccess={() => fetchFleetDashboardData({ silent: true })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -998,6 +1320,12 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
setSelectedServiceRecord(updatedRecord);
|
setSelectedServiceRecord(updatedRecord);
|
||||||
fetchFleetDashboardData({ silent: true });
|
fetchFleetDashboardData({ silent: true });
|
||||||
}}
|
}}
|
||||||
|
onOpenPhotoUpload={(record) => {
|
||||||
|
const targetId = record?.id ?? selectedServiceRecord?.id;
|
||||||
|
if (!targetId) return;
|
||||||
|
setPhotoUpload({ open: true, serviceRecordId: targetId });
|
||||||
|
}}
|
||||||
|
filesRefreshToken={serviceRecordFilesRefreshToken}
|
||||||
onBack={serviceRecordBackTask
|
onBack={serviceRecordBackTask
|
||||||
? () => {
|
? () => {
|
||||||
setSelectedServiceRecord(null);
|
setSelectedServiceRecord(null);
|
||||||
@@ -1023,6 +1351,12 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
setSelectedTask(updatedTask);
|
setSelectedTask(updatedTask);
|
||||||
return updatedTask;
|
return updatedTask;
|
||||||
}}
|
}}
|
||||||
|
onUpdateTask={async (task, changes) => {
|
||||||
|
const updatedTask = await updateTask(task.id, changes);
|
||||||
|
setSelectedTask(updatedTask);
|
||||||
|
await fetchFleetDashboardData({ silent: true });
|
||||||
|
return updatedTask;
|
||||||
|
}}
|
||||||
onCreateServiceRecord={(task) => {
|
onCreateServiceRecord={(task) => {
|
||||||
const taskCrane = cranes.find((item) => String(item.id) === String(task?.vehicle));
|
const taskCrane = cranes.find((item) => String(item.id) === String(task?.vehicle));
|
||||||
setSelectedTask(null);
|
setSelectedTask(null);
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ export default function ServiceContextSelector({ onNewServiceRecord }) {
|
|||||||
const selectedClient = availableClients.find((c) => String(c.id) === String(selectedClientId));
|
const selectedClient = availableClients.find((c) => String(c.id) === String(selectedClientId));
|
||||||
const selectedCrane = cranes.find((item) => String(item.id) === String(selectedVehicleId));
|
const selectedCrane = cranes.find((item) => String(item.id) === String(selectedVehicleId));
|
||||||
const showSelector = editMode || !selectedClientId;
|
const showSelector = editMode || !selectedClientId;
|
||||||
|
const selectedCraneLabel = (() => {
|
||||||
|
if (!selectedCrane) return '';
|
||||||
|
const serial = String(selectedCrane.crane_serial_number || '').trim();
|
||||||
|
if (serial) {
|
||||||
|
return `SN ${serial} ${selectedCrane.make || ''} ${selectedCrane.model || ''}`.trim();
|
||||||
|
}
|
||||||
|
return `${selectedCrane.registration_number || '-'} ${selectedCrane.make || ''} ${selectedCrane.model || ''}`.trim();
|
||||||
|
})();
|
||||||
|
|
||||||
function handleClientChange(e) {
|
function handleClientChange(e) {
|
||||||
setSelectedClient(e.target.value);
|
setSelectedClient(e.target.value);
|
||||||
@@ -97,7 +105,7 @@ export default function ServiceContextSelector({ onNewServiceRecord }) {
|
|||||||
{clientCranes.length > 0
|
{clientCranes.length > 0
|
||||||
? clientCranes.map((item) => (
|
? clientCranes.map((item) => (
|
||||||
<option key={item.id} value={item.id}>
|
<option key={item.id} value={item.id}>
|
||||||
{item.registration_number} {item.make} {item.model}
|
{item.crane_serial_number ? `SN ${item.crane_serial_number}` : (item.registration_number || '-')} {item.make} {item.model}
|
||||||
</option>
|
</option>
|
||||||
))
|
))
|
||||||
: <option disabled>Nema dizalica za ovog kupca</option>
|
: <option disabled>Nema dizalica za ovog kupca</option>
|
||||||
@@ -137,7 +145,7 @@ export default function ServiceContextSelector({ onNewServiceRecord }) {
|
|||||||
<>
|
<>
|
||||||
<span className="text-xs text-text-muted">/</span>
|
<span className="text-xs text-text-muted">/</span>
|
||||||
<span className="rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-700">
|
<span className="rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-700">
|
||||||
{selectedCrane.registration_number} {selectedCrane.make} {selectedCrane.model}
|
{selectedCraneLabel}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
276
frontend/src/components/dashboard/TaskCalendarWidget.jsx
Normal file
276
frontend/src/components/dashboard/TaskCalendarWidget.jsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,62 +1,130 @@
|
|||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { createTask, getStatusLabel } from '../../stores/taskStore';
|
import { createTask, fetchTaskTemplates, getStatusLabel } from '../../stores/taskStore';
|
||||||
import { formatEntityCode, formatPurposeLabel } from '../../lib/displayIds';
|
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 = {
|
const INITIAL_FORM = {
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
status: 'aktivan',
|
status: 'aktivan',
|
||||||
work_order: '',
|
work_order: '',
|
||||||
|
scheduled_date: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TaskCreateModal({ open, workOrders = [], contextCrane = null, onClose, onSuccess }) {
|
export default function TaskCreateModal({ open, workOrders = [], contextCrane = null, onClose, onSuccess }) {
|
||||||
const [form, setForm] = useState(INITIAL_FORM);
|
const [form, setForm] = useState(INITIAL_FORM);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setForm(INITIAL_FORM);
|
setForm(INITIAL_FORM);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setError('');
|
setError('');
|
||||||
|
setTemplateMode(false);
|
||||||
|
setTemplateId('');
|
||||||
|
setTemplates([]);
|
||||||
|
setTemplateLoading(false);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [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;
|
if (!open) return null;
|
||||||
|
|
||||||
function setField(name, value) {
|
function setField(name, value) {
|
||||||
setForm((prev) => ({ ...prev, [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) {
|
async function submit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError('');
|
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) {
|
if (!contextCrane?.id) {
|
||||||
setError('Odaberite dizalicu u Servisnom kontekstu prije kreiranja zadatka.');
|
setError('Odaberite dizalicu u Servisnom kontekstu prije kreiranja zadatka.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
let payload;
|
||||||
try {
|
if (templateMode) {
|
||||||
await createTask({
|
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,
|
title,
|
||||||
description: String(form.description || '').trim(),
|
description: String(form.description || '').trim(),
|
||||||
status: form.status || 'aktivan',
|
status: form.status || 'aktivan',
|
||||||
vehicle: contextCrane.id,
|
vehicle: contextCrane.id,
|
||||||
work_order: form.work_order || null,
|
work_order: form.work_order || null,
|
||||||
});
|
scheduled_date: form.scheduled_date || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await createTask(payload);
|
||||||
await onSuccess?.();
|
await onSuccess?.();
|
||||||
onClose?.();
|
onClose?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -67,15 +135,13 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ModalShell
|
||||||
className="fixed inset-0 z-50 overflow-y-auto bg-slate-950/15 p-4 pt-20 backdrop-blur-[2px]"
|
onClose={onClose}
|
||||||
onClick={onClose}
|
overlayClassName="z-50 overflow-y-auto p-4 pt-20"
|
||||||
>
|
contentClassName="flex items-start justify-center min-h-full"
|
||||||
<div className="flex min-h-full items-start justify-center">
|
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="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 className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-text-main">Novi radni zadatak</h3>
|
<h3 className="text-lg font-semibold text-text-main">Novi radni zadatak</h3>
|
||||||
@@ -83,6 +149,15 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
Kontekst dizalice: {contextCrane?.registration_number || 'Nije odabrana'}
|
Kontekst dizalice: {contextCrane?.registration_number || 'Nije odabrana'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -92,17 +167,42 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
|
<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">
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-text-main">Naslov zadatka *</span>
|
<span className="font-medium text-text-main">Naslov zadatka *</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.title}
|
value={form.title}
|
||||||
onInput={(event) => setField('title', event.currentTarget.value)}
|
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"
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
|
||||||
required
|
required={!templateMode}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -112,7 +212,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
rows={3}
|
rows={3}
|
||||||
value={form.description}
|
value={form.description}
|
||||||
onInput={(event) => setField('description', event.currentTarget.value)}
|
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"
|
className="resize-none rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -123,7 +223,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
<select
|
<select
|
||||||
value={form.status}
|
value={form.status}
|
||||||
onChange={(event) => setField('status', event.currentTarget.value)}
|
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"
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{STATUS_OPTIONS.map((status) => (
|
{STATUS_OPTIONS.map((status) => (
|
||||||
@@ -135,21 +235,37 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
<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>
|
<span className="font-medium text-text-main">Povezani putni nalog</span>
|
||||||
<select
|
<select
|
||||||
value={form.work_order}
|
value={form.work_order}
|
||||||
onChange={(event) => setField('work_order', event.currentTarget.value)}
|
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"
|
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>
|
<option value="">— Bez povezanog naloga —</option>
|
||||||
{workOrders.map((workOrder) => (
|
{availableWorkOrders.map((workOrder) => (
|
||||||
<option key={workOrder.id} value={String(workOrder.id)}>
|
<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>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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">
|
<span className="text-[11px] text-amber-600">
|
||||||
Nema dostupnih naloga za odabranu dizalicu.
|
Nema dostupnih naloga za odabranu dizalicu.
|
||||||
</span>
|
</span>
|
||||||
@@ -175,14 +291,13 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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'}
|
{submitting ? 'Spremanje...' : 'Kreiraj radni zadatak'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalShell>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import ModalShell from '../ui/ModalShell';
|
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) {
|
function formatDate(value) {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
@@ -9,6 +12,13 @@ function formatDate(value) {
|
|||||||
return parsed.toLocaleDateString('hr-HR');
|
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) {
|
function formatCost(value) {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
if (Number.isNaN(numeric)) return '-';
|
if (Number.isNaN(numeric)) return '-';
|
||||||
@@ -23,12 +33,22 @@ export default function TaskServiceRecordsModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onOpenServiceRecord,
|
onOpenServiceRecord,
|
||||||
onAssignWorkOrder,
|
onAssignWorkOrder,
|
||||||
|
onUpdateTask,
|
||||||
onCreateServiceRecord,
|
onCreateServiceRecord,
|
||||||
}) {
|
}) {
|
||||||
const [showWorkOrderEditor, setShowWorkOrderEditor] = useState(false);
|
const [showWorkOrderEditor, setShowWorkOrderEditor] = useState(false);
|
||||||
const [selectedWorkOrderId, setSelectedWorkOrderId] = useState('');
|
const [selectedWorkOrderId, setSelectedWorkOrderId] = useState('');
|
||||||
const [assigningWorkOrder, setAssigningWorkOrder] = useState(false);
|
const [assigningWorkOrder, setAssigningWorkOrder] = useState(false);
|
||||||
const [assignError, setAssignError] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open || typeof window === 'undefined') return;
|
if (!open || typeof window === 'undefined') return;
|
||||||
@@ -41,6 +61,15 @@ export default function TaskServiceRecordsModal({
|
|||||||
setShowWorkOrderEditor(!task.work_order);
|
setShowWorkOrderEditor(!task.work_order);
|
||||||
setAssignError('');
|
setAssignError('');
|
||||||
setAssigningWorkOrder(false);
|
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]);
|
}, [task?.id, task?.work_order]);
|
||||||
|
|
||||||
const currentWorkOrder = useMemo(
|
const currentWorkOrder = useMemo(
|
||||||
@@ -53,6 +82,8 @@ export default function TaskServiceRecordsModal({
|
|||||||
[workOrders]
|
[workOrders]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const effectiveWorkOrderId = task?.work_order ? String(task.work_order) : String(selectedWorkOrderId || '');
|
||||||
|
|
||||||
const handleWorkOrderChange = async (event) => {
|
const handleWorkOrderChange = async (event) => {
|
||||||
const nextValue = String(event.currentTarget.value || '');
|
const nextValue = String(event.currentTarget.value || '');
|
||||||
setSelectedWorkOrderId(nextValue);
|
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;
|
if (!open || !task) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,7 +166,7 @@ export default function TaskServiceRecordsModal({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-text-main">Radni zadatak detalji</h3>
|
<h3 className="text-lg font-semibold text-text-main">Radni zadatak detalji</h3>
|
||||||
<p className="text-xs text-text-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -97,12 +181,146 @@ export default function TaskServiceRecordsModal({
|
|||||||
|
|
||||||
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto p-4">
|
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto p-4">
|
||||||
<div className="space-y-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">
|
<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>
|
<h4 className="mb-2 text-sm font-semibold text-text-main">Putni nalozi</h4>
|
||||||
{currentWorkOrder && !showWorkOrderEditor ? (
|
{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">
|
<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">
|
<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>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -123,7 +341,7 @@ export default function TaskServiceRecordsModal({
|
|||||||
<option value="">— Odaberi putni nalog —</option>
|
<option value="">— Odaberi putni nalog —</option>
|
||||||
{sortedWorkOrders.map((workOrder) => (
|
{sortedWorkOrders.map((workOrder) => (
|
||||||
<option key={workOrder.id} value={String(workOrder.id)}>
|
<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>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
476
frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx
Normal file
476
frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx
Normal 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 (od–do):</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 (od–do):</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,17 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { useStore } from '@nanostores/preact';
|
|
||||||
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
|
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
|
||||||
|
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
|
||||||
import ModalShell from '../ui/ModalShell';
|
import ModalShell from '../ui/ModalShell';
|
||||||
import {
|
import {
|
||||||
createWorkOrderInvoice,
|
createWorkOrderInvoice,
|
||||||
fetchVehicleById,
|
|
||||||
fetchTeamMembers,
|
fetchTeamMembers,
|
||||||
updateVehicle,
|
|
||||||
fetchWorkOrderInvoices,
|
fetchWorkOrderInvoices,
|
||||||
fetchWorkOrderImages,
|
fetchWorkOrderImages,
|
||||||
openWorkOrderInvoicesPdfPage,
|
openWorkOrderInvoicesPdfPage,
|
||||||
sendWorkOrderEmail,
|
sendWorkOrderEmail,
|
||||||
} from '../../stores/fleetDashboardStore';
|
} from '../../stores/fleetDashboardStore';
|
||||||
import { showToast } from '../../stores/toastStore';
|
import { showToast } from '../../stores/toastStore';
|
||||||
import { $craneSerialByVehicle, cacheCraneSerial, getCachedCraneSerial } from '../../stores/craneSerialStore';
|
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||||
import { formatEntityCode } from '../../lib/displayIds';
|
|
||||||
|
|
||||||
const PURPOSE_OPTIONS = [
|
const PURPOSE_OPTIONS = [
|
||||||
{ value: 'defektaza', label: 'Defektaža' },
|
{ value: 'defektaza', label: 'Defektaža' },
|
||||||
@@ -23,21 +20,23 @@ const PURPOSE_OPTIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmit }) {
|
export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmit }) {
|
||||||
const serialByVehicle = useStore($craneSerialByVehicle);
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
has_travel_order: false,
|
has_travel_order: false,
|
||||||
|
display_code: '',
|
||||||
status: 'open',
|
status: 'open',
|
||||||
start_mileage: '',
|
start_mileage: '',
|
||||||
end_mileage: '',
|
end_mileage: '',
|
||||||
|
servicer_vehicle_registration: '',
|
||||||
|
servicer_vehicle_make_model: '',
|
||||||
|
servicer_vehicle_start_mileage: '',
|
||||||
|
servicer_vehicle_end_mileage: '',
|
||||||
|
servicer_vehicle_fuel_cost: '',
|
||||||
location: '',
|
location: '',
|
||||||
travel_start_at: '',
|
travel_start_at: '',
|
||||||
travel_end_at: '',
|
travel_end_at: '',
|
||||||
purpose: '',
|
purpose: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
crane_current_mileage: '',
|
|
||||||
crane_superstructure_working_hours: '',
|
|
||||||
crane_chassis_working_hours: '',
|
|
||||||
});
|
});
|
||||||
const [imageFiles, setImageFiles] = useState([]);
|
const [imageFiles, setImageFiles] = useState([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -55,6 +54,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
});
|
});
|
||||||
const [invoices, setInvoices] = useState([]);
|
const [invoices, setInvoices] = useState([]);
|
||||||
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
|
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
|
||||||
|
const [photoUploadOpen, setPhotoUploadOpen] = useState(false);
|
||||||
const [invoiceSaving, setInvoiceSaving] = useState(false);
|
const [invoiceSaving, setInvoiceSaving] = useState(false);
|
||||||
const [invoiceForm, setInvoiceForm] = useState({
|
const [invoiceForm, setInvoiceForm] = useState({
|
||||||
naziv_racuna: '',
|
naziv_racuna: '',
|
||||||
@@ -63,7 +63,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
opis: '',
|
opis: '',
|
||||||
imageFile: null,
|
imageFile: null,
|
||||||
});
|
});
|
||||||
const [resolvedCraneSerialNumber, setResolvedCraneSerialNumber] = useState('');
|
|
||||||
const [mapPickerOpen, setMapPickerOpen] = useState(false);
|
const [mapPickerOpen, setMapPickerOpen] = useState(false);
|
||||||
const [locationQuery, setLocationQuery] = useState('');
|
const [locationQuery, setLocationQuery] = useState('');
|
||||||
const [locationResults, setLocationResults] = useState([]);
|
const [locationResults, setLocationResults] = useState([]);
|
||||||
@@ -71,15 +70,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
const [searchingLocation, setSearchingLocation] = useState(false);
|
const [searchingLocation, setSearchingLocation] = useState(false);
|
||||||
const [mapError, setMapError] = useState('');
|
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(() => {
|
const craneLabel = useMemo(() => {
|
||||||
if (!workOrder) return '-';
|
if (!workOrder) return '-';
|
||||||
return workOrder.craneLabel || workOrder.craneInfo?.display_name || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open || !workOrder) return;
|
if (!open || !workOrder) return;
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
const initialSerial = String(workOrder.craneInfo?.crane_serial_number || cachedCraneSerial || '').trim();
|
|
||||||
setResolvedCraneSerialNumber(initialSerial);
|
|
||||||
cacheCraneSerial(targetCraneId, initialSerial);
|
|
||||||
setForm({
|
setForm({
|
||||||
has_travel_order: !!(workOrder.travel_start_at || workOrder.travel_end_at || workOrder.location || workOrder.start_mileage != null || workOrder.end_mileage != null),
|
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',
|
status: workOrder.status || 'open',
|
||||||
start_mileage: String(workOrder.start_mileage ?? ''),
|
start_mileage: String(workOrder.start_mileage ?? ''),
|
||||||
end_mileage: workOrder.end_mileage == null ? '' : String(workOrder.end_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) : '',
|
travel_end_at: workOrder.travel_end_at ? String(workOrder.travel_end_at).slice(0, 16) : '',
|
||||||
purpose: workOrder.purpose || '',
|
purpose: workOrder.purpose || '',
|
||||||
notes: workOrder.notes || '',
|
notes: workOrder.notes || '',
|
||||||
crane_current_mileage: String(workOrder.craneInfo?.current_mileage ?? ''),
|
servicer_vehicle_registration: workOrder.servicer_vehicle_registration || '',
|
||||||
crane_superstructure_working_hours: String(workOrder.craneInfo?.superstructure_working_hours ?? ''),
|
servicer_vehicle_make_model: workOrder.servicer_vehicle_make_model || '',
|
||||||
crane_chassis_working_hours: String(workOrder.craneInfo?.chassis_working_hours ?? ''),
|
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);
|
setSubmitting(false);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -133,6 +123,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
});
|
});
|
||||||
setInvoices([]);
|
setInvoices([]);
|
||||||
setInvoiceModalOpen(false);
|
setInvoiceModalOpen(false);
|
||||||
|
setPhotoUploadOpen(false);
|
||||||
setInvoiceSaving(false);
|
setInvoiceSaving(false);
|
||||||
setInvoiceForm({
|
setInvoiceForm({
|
||||||
naziv_racuna: '',
|
naziv_racuna: '',
|
||||||
@@ -147,38 +138,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
setSelectedLocation(null);
|
setSelectedLocation(null);
|
||||||
setSearchingLocation(false);
|
setSearchingLocation(false);
|
||||||
setMapError('');
|
setMapError('');
|
||||||
}, [open, workOrder, cachedCraneSerial, targetCraneId]);
|
}, [open, workOrder]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !targetCraneId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const freshCrane = await fetchVehicleById(targetCraneId);
|
|
||||||
if (cancelled || !freshCrane) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const incomingSerial = String(freshCrane.crane_serial_number || '').trim();
|
|
||||||
if (incomingSerial) {
|
|
||||||
cacheCraneSerial(targetCraneId, incomingSerial);
|
|
||||||
setResolvedCraneSerialNumber(incomingSerial);
|
|
||||||
}
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
crane_current_mileage: String(freshCrane.current_mileage ?? prev.crane_current_mileage ?? ''),
|
|
||||||
crane_superstructure_working_hours: String(freshCrane.superstructure_working_hours ?? prev.crane_superstructure_working_hours ?? ''),
|
|
||||||
crane_chassis_working_hours: String(freshCrane.chassis_working_hours ?? prev.crane_chassis_working_hours ?? ''),
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
// no-op: fallback remains workOrder.craneInfo values
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [open, workOrder?.id, targetCraneId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!emailModalOpen) return;
|
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);
|
const endMileage = form.end_mileage === '' ? null : Number(form.end_mileage);
|
||||||
let travelStart = null;
|
let travelStart = null;
|
||||||
let travelEnd = 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)) {
|
if (startMileage != null && (Number.isNaN(startMileage) || startMileage < 0)) {
|
||||||
setError('Početna kilometraža mora biti valjan nenegativan broj.');
|
setError('Početna kilometraža mora biti valjan nenegativan broj.');
|
||||||
return;
|
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.');
|
setError('Završna kilometraža mora biti veća ili jednaka početnoj.');
|
||||||
return;
|
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)) {
|
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.');
|
setError('Unesite i početak i kraj puta, ili ostavite oba prazna.');
|
||||||
return;
|
return;
|
||||||
@@ -369,50 +310,28 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
setError('Svrha naloga je obavezna.');
|
setError('Svrha naloga je obavezna.');
|
||||||
return;
|
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);
|
setSubmitting(true);
|
||||||
try {
|
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, {
|
await onSubmit(workOrder, {
|
||||||
|
display_code: displayCode,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
start_mileage: startMileage,
|
start_mileage: startMileage,
|
||||||
end_mileage: endMileage,
|
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(),
|
location: form.location.trim(),
|
||||||
travel_start_at: travelStart ? travelStart.toISOString() : null,
|
travel_start_at: travelStart ? travelStart.toISOString() : null,
|
||||||
travel_end_at: travelEnd ? travelEnd.toISOString() : null,
|
travel_end_at: travelEnd ? travelEnd.toISOString() : null,
|
||||||
@@ -523,7 +442,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
|
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
|
||||||
<div>
|
<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>
|
<p className="text-sm text-text-muted">Dizalica: {craneLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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">
|
<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>
|
<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">
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-text-main">Status *</span>
|
<span className="font-medium text-text-main">Status *</span>
|
||||||
<select
|
<select
|
||||||
@@ -607,81 +540,55 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
<h4 className="mb-2 text-sm font-semibold text-text-main">Vozilo servisera</h4>
|
<h4 className="mb-2 text-sm font-semibold text-text-main">Vozilo servisera</h4>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={workOrder?.craneInfo?.registration_number || ''}
|
value={form.servicer_vehicle_registration}
|
||||||
className="rounded-lg border border-border-hairline bg-canvas-deep px-3 py-2 text-text-muted"
|
onInput={(event) => setForm({ ...form, servicer_vehicle_registration: event.currentTarget.value })}
|
||||||
disabled
|
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>
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-text-main">Marka/Model</span>
|
<span className="font-medium text-text-main">Marka/Model vozila servisera</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={workOrder?.craneInfo?.display_name || ''}
|
value={form.servicer_vehicle_make_model}
|
||||||
className="rounded-lg border border-border-hairline bg-canvas-deep px-3 py-2 text-text-muted"
|
onInput={(event) => setForm({ ...form, servicer_vehicle_make_model: event.currentTarget.value })}
|
||||||
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 })}
|
|
||||||
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>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={form.end_mileage}
|
|
||||||
onInput={(event) => setForm({ ...form, 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>
|
|
||||||
</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"
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-text-main">Radni sati nadogradnje</span>
|
<span className="font-medium text-text-main">Početna km vozila servisera</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
value={form.crane_superstructure_working_hours}
|
value={form.servicer_vehicle_start_mileage}
|
||||||
onInput={(event) => setForm({ ...form, crane_superstructure_working_hours: event.currentTarget.value })}
|
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"
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-text-main">Radni sati podvozja</span>
|
<span className="font-medium text-text-main">Završna km vozila servisera</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
value={form.crane_chassis_working_hours}
|
value={form.servicer_vehicle_end_mileage}
|
||||||
onInput={(event) => setForm({ ...form, crane_chassis_working_hours: event.currentTarget.value })}
|
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"
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
/>
|
/>
|
||||||
@@ -811,6 +718,14 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
Izmijeni
|
Izmijeni
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
@@ -819,6 +734,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
>
|
>
|
||||||
{submitting ? 'Spremam...' : 'Spremi izmjene'}
|
{submitting ? 'Spremam...' : 'Spremi izmjene'}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -1110,6 +1026,20 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
</div>
|
</div>
|
||||||
</ModalShell>
|
</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(() => {});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { animated, useTransition } from '@react-spring/web';
|
import { animated, useTransition } from '@react-spring/web';
|
||||||
|
import { api } from '../../services/apiClient';
|
||||||
|
|
||||||
function getBrowserApiBase() {
|
function getBrowserApiBase() {
|
||||||
const apiUrl = import.meta.env.PUBLIC_API_URL || '';
|
const apiUrl = import.meta.env.PUBLIC_API_URL || '';
|
||||||
@@ -44,6 +45,109 @@ export function resolveMediaUrl(pathOrUrl) {
|
|||||||
return new URL(finalSrc, apiUrl).toString();
|
return new URL(finalSrc, apiUrl).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isProtectedMediaUrl(url) {
|
||||||
|
return typeof url === 'string' && /\/api\/fleet\//i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeObjectUrls(entries = {}) {
|
||||||
|
Object.values(entries).forEach((url) => {
|
||||||
|
if (typeof url === 'string' && url.startsWith('blob:')) {
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthenticatedMediaSources(mediaPaths = []) {
|
||||||
|
const [authenticatedSources, setAuthenticatedSources] = useState({});
|
||||||
|
const authenticatedSourcesRef = useRef({});
|
||||||
|
|
||||||
|
const resolvedMediaPaths = useMemo(() => {
|
||||||
|
const unique = new Set();
|
||||||
|
if (!Array.isArray(mediaPaths)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
mediaPaths.forEach((item) => {
|
||||||
|
const resolved = resolveMediaUrl(item);
|
||||||
|
if (resolved) {
|
||||||
|
unique.add(resolved);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(unique);
|
||||||
|
}, [mediaPaths]);
|
||||||
|
const resolvedMediaPathsKey = useMemo(() => resolvedMediaPaths.join('|'), [resolvedMediaPaths]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
authenticatedSourcesRef.current = authenticatedSources;
|
||||||
|
}, [authenticatedSources]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const protectedPaths = resolvedMediaPaths.filter((url) => isProtectedMediaUrl(url));
|
||||||
|
|
||||||
|
if (protectedPaths.length === 0) {
|
||||||
|
setAuthenticatedSources((previous) => {
|
||||||
|
revokeObjectUrls(previous);
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const nextSources = {};
|
||||||
|
const createdObjectUrls = [];
|
||||||
|
|
||||||
|
const fetched = await Promise.all(
|
||||||
|
protectedPaths.map(async (url) => {
|
||||||
|
try {
|
||||||
|
const blob = await api.get(url, { responseType: 'blob' });
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
createdObjectUrls.push(blobUrl);
|
||||||
|
return [url, blobUrl];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
fetched.forEach((entry) => {
|
||||||
|
if (!entry) return;
|
||||||
|
const [url, blobUrl] = entry;
|
||||||
|
nextSources[url] = blobUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
createdObjectUrls.forEach((blobUrl) => window.URL.revokeObjectURL(blobUrl));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthenticatedSources((previous) => {
|
||||||
|
revokeObjectUrls(previous);
|
||||||
|
return nextSources;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [resolvedMediaPathsKey]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
revokeObjectUrls(authenticatedSourcesRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getAuthenticatedMediaSrc = (pathOrUrl) => {
|
||||||
|
const resolved = resolveMediaUrl(pathOrUrl);
|
||||||
|
if (!resolved) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return authenticatedSources[resolved] || resolved;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getAuthenticatedMediaSrc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function WorkOrderImageCarousel({ images = [], editMode = false, emptyLabel = 'Nema priložene tehničke dokumentacije' }) {
|
export default function WorkOrderImageCarousel({ images = [], editMode = false, emptyLabel = 'Nema priložene tehničke dokumentacije' }) {
|
||||||
const normalizedImages = useMemo(() => (
|
const normalizedImages = useMemo(() => (
|
||||||
Array.isArray(images)
|
Array.isArray(images)
|
||||||
@@ -51,6 +155,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
|
|||||||
: (typeof images === 'string' && images ? [images] : [])
|
: (typeof images === 'string' && images ? [images] : [])
|
||||||
), [images]);
|
), [images]);
|
||||||
const [currentImgIdx, setCurrentImgIdx] = useState(0);
|
const [currentImgIdx, setCurrentImgIdx] = useState(0);
|
||||||
|
const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(normalizedImages);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentImgIdx(0);
|
setCurrentImgIdx(0);
|
||||||
@@ -87,7 +192,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
|
|||||||
setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
|
setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
const finalSrc = resolveMediaUrl(normalizedImages[currentImgIdx] || '');
|
const finalSrc = getAuthenticatedMediaSrc(normalizedImages[currentImgIdx] || '');
|
||||||
const imageTransitions = useTransition(currentImgIdx, {
|
const imageTransitions = useTransition(currentImgIdx, {
|
||||||
from: { opacity: 0, transform: 'translate3d(8%,0,0) scale(1.01)' },
|
from: { opacity: 0, transform: 'translate3d(8%,0,0) scale(1.01)' },
|
||||||
enter: { opacity: 1, transform: 'translate3d(0%,0,0) scale(1)' },
|
enter: { opacity: 1, transform: 'translate3d(0%,0,0) scale(1)' },
|
||||||
@@ -101,7 +206,7 @@ export default function WorkOrderImageCarousel({ images = [], editMode = false,
|
|||||||
{imageTransitions((style, idx) => (
|
{imageTransitions((style, idx) => (
|
||||||
<animated.img
|
<animated.img
|
||||||
key={idx}
|
key={idx}
|
||||||
src={resolveMediaUrl(normalizedImages[idx] || finalSrc)}
|
src={getAuthenticatedMediaSrc(normalizedImages[idx] || finalSrc)}
|
||||||
alt={`Dokumentacija s terena ${idx + 1}`}
|
alt={`Dokumentacija s terena ${idx + 1}`}
|
||||||
style={style}
|
style={style}
|
||||||
className="absolute inset-0 h-full w-full object-cover will-change-transform"
|
className="absolute inset-0 h-full w-full object-cover will-change-transform"
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import {
|
|||||||
fetchWorkOrderById,
|
fetchWorkOrderById,
|
||||||
fetchWorkOrderInvoices,
|
fetchWorkOrderInvoices,
|
||||||
fetchWorkOrderTaskServiceContext,
|
fetchWorkOrderTaskServiceContext,
|
||||||
|
updateTaskWorkHoursTable,
|
||||||
} from '../../stores/fleetDashboardStore';
|
} from '../../stores/fleetDashboardStore';
|
||||||
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
|
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
|
||||||
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
||||||
import { formatEntityCode } from '../../lib/displayIds';
|
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||||
import { resolveMediaUrl } from './WorkOrderImageCarousel';
|
import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
||||||
|
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
|
||||||
|
|
||||||
function readWorkOrderIdFromQuery() {
|
function readWorkOrderIdFromQuery() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -53,6 +55,8 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
const [taskContext, setTaskContext] = useState({ tasks: [] });
|
const [taskContext, setTaskContext] = useState({ tasks: [] });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [editingTask, setEditingTask] = useState(null);
|
||||||
|
const [savingWorkHours, setSavingWorkHours] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hydrateAuthFromStorage();
|
hydrateAuthFromStorage();
|
||||||
@@ -118,9 +122,57 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
if (!workOrder) {
|
if (!workOrder) {
|
||||||
return 'Računi putnog naloga';
|
return 'Računi putnog naloga';
|
||||||
}
|
}
|
||||||
return `${formatEntityCode('PN', workOrder.id)}`;
|
return formatWorkOrderDisplayCode(workOrder);
|
||||||
}, [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 (
|
return (
|
||||||
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
|
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
|
||||||
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
||||||
@@ -144,7 +196,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
|
|
||||||
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h2 className="text-lg font-semibold text-text-main">Task i povezani servisni zapisi</h2>
|
<h2 className="text-lg font-semibold text-text-main">Servisni zapisi</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
|
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
|
||||||
@@ -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">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
|
||||||
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
|
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
|
||||||
</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">
|
<div className="mt-3 space-y-3">
|
||||||
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
|
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
|
||||||
<p className="text-xs text-text-muted">Nema povezanih servisnih zapisa.</p>
|
<p className="text-xs text-text-muted">Nema povezanih servisnih zapisa.</p>
|
||||||
@@ -185,13 +246,13 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
record.photos.map((photo) => (
|
record.photos.map((photo) => (
|
||||||
<a
|
<a
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
href={resolveMediaUrl(photo.optimized_url || photo.image_url)}
|
href={getAuthenticatedMediaSrc(photo.optimized_url || photo.image_url)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex"
|
className="inline-flex"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={resolveMediaUrl(photo.optimized_url || photo.image_url)}
|
src={getAuthenticatedMediaSrc(photo.optimized_url || photo.image_url)}
|
||||||
alt={`Servisna slika ${photo.id}`}
|
alt={`Servisna slika ${photo.id}`}
|
||||||
className="h-20 w-20 rounded border border-border-hairline object-cover"
|
className="h-20 w-20 rounded border border-border-hairline object-cover"
|
||||||
/>
|
/>
|
||||||
@@ -244,7 +305,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
|
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
<InvoiceImagePreview
|
<InvoiceImagePreview
|
||||||
imageUrl={resolveMediaUrl(invoice.image)}
|
imageUrl={getAuthenticatedMediaSrc(invoice.image_url || invoice.image) || resolveMediaUrl(invoice.image_url || invoice.image)}
|
||||||
alt={`Račun ${invoice.naziv_racuna}`}
|
alt={`Račun ${invoice.naziv_racuna}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,6 +314,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<TaskWorkHoursTableModal
|
||||||
|
open={!!editingTask}
|
||||||
|
task={editingTask}
|
||||||
|
defaultLocation={workOrder?.location || ''}
|
||||||
|
saving={savingWorkHours}
|
||||||
|
onClose={() => setEditingTask(null)}
|
||||||
|
onSave={handleSaveWorkHoursTable}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'preact/hooks';
|
|||||||
import { useStore } from '@nanostores/preact';
|
import { useStore } from '@nanostores/preact';
|
||||||
import ModalShell from '../ui/ModalShell';
|
import ModalShell from '../ui/ModalShell';
|
||||||
import { $user } from '../../stores/authStore';
|
import { $user } from '../../stores/authStore';
|
||||||
|
import { generateWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||||
|
|
||||||
const PURPOSE_OPTIONS = [
|
const PURPOSE_OPTIONS = [
|
||||||
{ value: 'defektaza', label: 'Defektaža' },
|
{ value: 'defektaza', label: 'Defektaža' },
|
||||||
@@ -11,6 +12,7 @@ const PURPOSE_OPTIONS = [
|
|||||||
|
|
||||||
const INITIAL_FORM = {
|
const INITIAL_FORM = {
|
||||||
has_travel_order: false,
|
has_travel_order: false,
|
||||||
|
display_code: '',
|
||||||
start_mileage: '',
|
start_mileage: '',
|
||||||
end_mileage: '',
|
end_mileage: '',
|
||||||
servicer_vehicle_registration: '',
|
servicer_vehicle_registration: '',
|
||||||
@@ -54,6 +56,7 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
|
|||||||
|
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
display_code: generateWorkOrderDisplayCode(user?.first_name, user?.last_name, new Date()),
|
||||||
start_mileage: selectedCrane ? String(selectedCrane.current_mileage ?? '') : prev.start_mileage,
|
start_mileage: selectedCrane ? String(selectedCrane.current_mileage ?? '') : prev.start_mileage,
|
||||||
servicer_vehicle_registration: user?.assigned_vehicle?.registration_number || '',
|
servicer_vehicle_registration: user?.assigned_vehicle?.registration_number || '',
|
||||||
servicer_vehicle_make_model: `${user?.assigned_vehicle?.make || ''} ${user?.assigned_vehicle?.model || ''}`.trim(),
|
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.');
|
setErrorMessage('Svrha naloga je obavezna.');
|
||||||
return;
|
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 startMileage = null;
|
||||||
let endMileage = null;
|
let endMileage = null;
|
||||||
@@ -191,6 +203,7 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
|
|||||||
try {
|
try {
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
vehicle: selectedCrane.id,
|
vehicle: selectedCrane.id,
|
||||||
|
display_code: displayCode,
|
||||||
start_mileage: startMileage,
|
start_mileage: startMileage,
|
||||||
end_mileage: endMileage,
|
end_mileage: endMileage,
|
||||||
servicer_vehicle_registration: form.servicer_vehicle_registration.trim(),
|
servicer_vehicle_registration: form.servicer_vehicle_registration.trim(),
|
||||||
@@ -233,6 +246,20 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
|
<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">
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-text-main">Status *</span>
|
<span className="font-medium text-text-main">Status *</span>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export default function ServicePhotoUpload({ open, serviceRecordId, onClose, onS
|
|||||||
return (
|
return (
|
||||||
<ModalShell
|
<ModalShell
|
||||||
onClose={handleCancel}
|
onClose={handleCancel}
|
||||||
overlayClassName="z-50 p-4"
|
overlayClassName="z-[70] p-4"
|
||||||
contentClassName="flex items-center justify-center min-h-full"
|
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' : ''}`}
|
panelClassName={`w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl ${isOffline ? 'pointer-events-none opacity-50' : ''}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export default function ServiceRecordDetailModal({
|
|||||||
serviceRecord,
|
serviceRecord,
|
||||||
onClose,
|
onClose,
|
||||||
onUpdated,
|
onUpdated,
|
||||||
|
onOpenPhotoUpload = null,
|
||||||
|
filesRefreshToken = 0,
|
||||||
onBack = null,
|
onBack = null,
|
||||||
backLabel = 'Natrag',
|
backLabel = 'Natrag',
|
||||||
}) {
|
}) {
|
||||||
@@ -116,7 +118,7 @@ export default function ServiceRecordDetailModal({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, serviceRecord?.id]);
|
}, [open, serviceRecord?.id, filesRefreshToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!emailModalOpen) return;
|
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">
|
<div className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main">
|
||||||
<p className="text-xs text-text-muted">Trošak / KM / Dizalica</p>
|
<p className="text-xs text-text-muted">Trošak / KM / Dizalica</p>
|
||||||
<p>
|
<p>
|
||||||
{formatCost(serviceRecord.cost)} • {serviceRecord.mileage ?? '-'} km • {serviceRecord.crane_registration || serviceRecord.vehicle_registration || '-'}
|
{formatCost(serviceRecord.cost)} • {serviceRecord.mileage ?? '-'} km • {serviceRecord.crane_serial_number || serviceRecord.crane_registration || serviceRecord.vehicle_registration || '-'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -411,6 +413,14 @@ export default function ServiceRecordDetailModal({
|
|||||||
)}
|
)}
|
||||||
{editMode && (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { showToast } from '../../stores/toastStore.js';
|
|||||||
|
|
||||||
const INITIAL = {
|
const INITIAL = {
|
||||||
task: '',
|
task: '',
|
||||||
|
service_title: '',
|
||||||
description: '',
|
description: '',
|
||||||
parts: '',
|
parts: '',
|
||||||
cost: '',
|
cost: '',
|
||||||
@@ -144,6 +145,7 @@ export default function ServiceRecordForm({
|
|||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
task: form.task,
|
task: form.task,
|
||||||
|
service_title: form.service_title.trim(),
|
||||||
description: form.description.trim(),
|
description: form.description.trim(),
|
||||||
parts: form.parts.trim(),
|
parts: form.parts.trim(),
|
||||||
cost,
|
cost,
|
||||||
@@ -268,6 +270,18 @@ export default function ServiceRecordForm({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-text-main">Opis posla *</span>
|
<span className="font-medium text-text-main">Opis posla *</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
247
frontend/src/components/fleet/WorkOrderPhotoUpload.jsx
Normal file
247
frontend/src/components/fleet/WorkOrderPhotoUpload.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
frontend/src/components/layout/TaskCalendarPortal.jsx
Normal file
50
frontend/src/components/layout/TaskCalendarPortal.jsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useMemo } from 'preact/hooks';
|
import { useMemo } from 'preact/hooks';
|
||||||
import ModalShell from '../ui/ModalShell';
|
import ModalShell from '../ui/ModalShell';
|
||||||
import { downloadWorkOrderInvoicesPdf, downloadWorkOrderPdf, downloadWorkOrderServiceRecordsPdf } from '../../stores/fleetDashboardStore';
|
import {
|
||||||
|
downloadGeneratedPdfByUrl,
|
||||||
|
downloadWorkOrderInvoicesPdf,
|
||||||
|
downloadWorkOrderPdf,
|
||||||
|
downloadWorkOrderServiceRecordsPdf,
|
||||||
|
} from '../../stores/fleetDashboardStore';
|
||||||
|
|
||||||
function formatTimestamp(value) {
|
function formatTimestamp(value) {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
@@ -57,6 +62,12 @@ function getEntityAction(notification, meta) {
|
|||||||
? 'Preuzmi PDF servisnih zapisa'
|
? 'Preuzmi PDF servisnih zapisa'
|
||||||
: 'Preuzmi PDF putnog naloga',
|
: 'Preuzmi PDF putnog naloga',
|
||||||
run() {
|
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') {
|
if (metadata.pdf_type === 'invoices') {
|
||||||
return downloadWorkOrderInvoicesPdf(metadata.work_order_id);
|
return downloadWorkOrderInvoicesPdf(metadata.work_order_id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default function AnimatedDataTable({
|
|||||||
headClassName = 'bg-canvas-deep text-left text-xs uppercase tracking-wide text-text-muted',
|
headClassName = 'bg-canvas-deep text-left text-xs uppercase tracking-wide text-text-muted',
|
||||||
bodyClassName = 'divide-y divide-border-hairline',
|
bodyClassName = 'divide-y divide-border-hairline',
|
||||||
rowClassName = 'hover:bg-canvas-deep',
|
rowClassName = 'hover:bg-canvas-deep',
|
||||||
|
isRowSelected = () => false,
|
||||||
wrapperClassName = 'overflow-x-auto',
|
wrapperClassName = 'overflow-x-auto',
|
||||||
trail = 35,
|
trail = 35,
|
||||||
}) {
|
}) {
|
||||||
@@ -23,9 +24,20 @@ export default function AnimatedDataTable({
|
|||||||
|
|
||||||
const transitions = useTransition(rows, {
|
const transitions = useTransition(rows, {
|
||||||
keys: (row) => rowKey(row),
|
keys: (row) => rowKey(row),
|
||||||
from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
|
from: { opacity: 0, transform: 'translate3d(0,8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||||
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
|
enter: (row) => ({
|
||||||
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
|
opacity: 1,
|
||||||
|
transform: 'translate3d(0,0,0)',
|
||||||
|
boxShadow: isRowSelected(row)
|
||||||
|
? 'inset 0 0 0 2px rgba(16,185,129,0.75)'
|
||||||
|
: 'inset 0 0 0 0px rgba(16,185,129,0)',
|
||||||
|
}),
|
||||||
|
update: (row) => ({
|
||||||
|
boxShadow: isRowSelected(row)
|
||||||
|
? 'inset 0 0 0 2px rgba(16,185,129,0.75)'
|
||||||
|
: 'inset 0 0 0 0px rgba(16,185,129,0)',
|
||||||
|
}),
|
||||||
|
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||||
trail,
|
trail,
|
||||||
config: { tension: 230, friction: 26 },
|
config: { tension: 230, friction: 26 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ export default function ThemeToggle() {
|
|||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
if (document.documentElement.classList.contains('dark')) {
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
|
document.documentElement.style.backgroundColor = '#ffffff';
|
||||||
localStorage.setItem('theme', 'light');
|
localStorage.setItem('theme', 'light');
|
||||||
setIsDark(false);
|
setIsDark(false);
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
|
document.documentElement.style.backgroundColor = '#1f2329';
|
||||||
localStorage.setItem('theme', 'dark');
|
localStorage.setItem('theme', 'dark');
|
||||||
setIsDark(true);
|
setIsDark(true);
|
||||||
}
|
}
|
||||||
@@ -24,7 +26,7 @@ export default function ThemeToggle() {
|
|||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
type="button"
|
type="button"
|
||||||
className="p-2.5 rounded-s-xl bg-canvas-elevated border-y border-s border-border-hairline text-text-muted hover:text-text-main transition-all duration-200 focus:outline-none cursor-pointer flex items-center justify-center"
|
className="relative inline-flex h-10 w-10 items-center justify-center rounded-lg border border-border-hairline bg-canvas-elevated text-text-muted hover:bg-canvas-base"
|
||||||
aria-label="Prebaci temu"
|
aria-label="Prebaci temu"
|
||||||
>
|
>
|
||||||
{isDark ? (
|
{isDark ? (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Toast from '../components/ui/Toast.jsx';
|
|||||||
import ToastTrigger from '../components/ToastTrigger.jsx';
|
import ToastTrigger from '../components/ToastTrigger.jsx';
|
||||||
import NetworkGuard from '../components/ui/NetworkGuard.jsx';
|
import NetworkGuard from '../components/ui/NetworkGuard.jsx';
|
||||||
import Navbar from '../components/Navbar.jsx';
|
import Navbar from '../components/Navbar.jsx';
|
||||||
import ToastProvider from '../components/toast/ToastProvider';
|
import TaskCalendarPortal from '../components/layout/TaskCalendarPortal.jsx';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -40,8 +40,11 @@ const isDev = import.meta.env.DEV;
|
|||||||
const preferiraTamno = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const preferiraTamno = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
if (lokalnaTema === 'dark' || (!lokalnaTema && preferiraTamno)) {
|
if (lokalnaTema === 'dark' || (!lokalnaTema && preferiraTamno)) {
|
||||||
document.documentElement.classList.add('dark');
|
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 {
|
} else {
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
|
document.documentElement.style.backgroundColor = '#ffffff';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
primijeniTemu();
|
primijeniTemu();
|
||||||
@@ -49,14 +52,14 @@ const isDev = import.meta.env.DEV;
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</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>
|
<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" />}
|
{!isAuthPage && <NetworkGuard client:only="preact" transition:persist="network-guard" />}
|
||||||
<Toast client:load />
|
<Toast client:only="preact" transition:persist="toast" />
|
||||||
<ToastProvider client:load />
|
|
||||||
{!isAuthPage && isDev && <ToastTrigger client:load />}
|
{!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">
|
<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">
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex-1 w-full" transition:animate="fade">
|
<main class="flex-1 w-full">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -85,6 +88,16 @@ const isDev = import.meta.env.DEV;
|
|||||||
navigator.serviceWorker.register('/service-worker.js').catch(function (error) {
|
navigator.serviceWorker.register('/service-worker.js').catch(function (error) {
|
||||||
console.error('Service worker registration failed:', error);
|
console.error('Service worker registration failed:', error);
|
||||||
});
|
});
|
||||||
|
window.addEventListener('online', function () {
|
||||||
|
navigator.serviceWorker.ready
|
||||||
|
.then(function (registration) {
|
||||||
|
if (!registration.active) return;
|
||||||
|
registration.active.postMessage('FLUSH_WRITE_QUEUE');
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// no-op, SW may not be ready yet
|
||||||
|
});
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +107,7 @@ const isDev = import.meta.env.DEV;
|
|||||||
const keys = await caches.keys();
|
const keys = await caches.keys();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
keys
|
keys
|
||||||
.filter((key) => key.startsWith('erp-shell-'))
|
.filter((key) => key.startsWith('erp-shell-') || key.startsWith('erp-api-'))
|
||||||
.map((key) => caches.delete(key))
|
.map((key) => caches.delete(key))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,50 @@ export function formatEntityCode(prefix, id, size = 8) {
|
|||||||
return `${prefix}-${short}`;
|
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) {
|
export function formatPurposeLabel(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase();
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
if (!normalized) return '';
|
if (!normalized) return '';
|
||||||
|
|||||||
@@ -105,8 +105,18 @@ async function request(endpoint, options = {}) {
|
|||||||
// Ako je greška DRF validacijska, flatten-iraj je u string ili objekt
|
// Ako je greška DRF validacijska, flatten-iraj je u string ili objekt
|
||||||
throw { message: Object.values(errData).flat().join(', '), details: errData, status: 400 };
|
throw { message: Object.values(errData).flat().join(', '), details: errData, status: 400 };
|
||||||
}
|
}
|
||||||
if (errData.detail) errorMessage = errData.detail;
|
const normalizedDetails = (
|
||||||
throw { message: String(errorMessage), status: response.status };
|
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();
|
if (options.responseType === 'blob') return await response.blob();
|
||||||
|
|||||||
@@ -8,12 +8,25 @@ export const $clients = atom([]);
|
|||||||
export const $clientsLoading = atom(false);
|
export const $clientsLoading = atom(false);
|
||||||
export const $clientsError = atom(null);
|
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);
|
$clientsLoading.set(true);
|
||||||
$clientsError.set(null);
|
$clientsError.set(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.get('crm/clients/', { signal });
|
const data = await api.get('crm/clients/', { signal });
|
||||||
$clients.set(Array.isArray(data) ? data : (data.results ?? []));
|
$clients.set(Array.isArray(data) ? data : (data.results ?? []));
|
||||||
|
_lastClientsFetchAt = Date.now();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.name !== 'AbortError') $clientsError.set(err.message ?? 'Greška');
|
if (err?.name !== 'AbortError') $clientsError.set(err.message ?? 'Greška');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -24,16 +37,19 @@ export async function fetchClients(signal) {
|
|||||||
export async function createClient(payload) {
|
export async function createClient(payload) {
|
||||||
const data = await api.post('crm/clients/', payload);
|
const data = await api.post('crm/clients/', payload);
|
||||||
$clients.set([...$clients.get(), data]);
|
$clients.set([...$clients.get(), data]);
|
||||||
|
_lastClientsFetchAt = Date.now();
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateClient(id, payload) {
|
export async function updateClient(id, payload) {
|
||||||
const data = await api.patch(`crm/clients/${id}/`, payload);
|
const data = await api.patch(`crm/clients/${id}/`, payload);
|
||||||
$clients.set($clients.get().map((c) => (c.id === id ? data : c)));
|
$clients.set($clients.get().map((c) => (c.id === id ? data : c)));
|
||||||
|
_lastClientsFetchAt = Date.now();
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteClient(id) {
|
export async function deleteClient(id) {
|
||||||
await api.delete(`crm/clients/${id}/`);
|
await api.delete(`crm/clients/${id}/`);
|
||||||
$clients.set($clients.get().filter((c) => c.id !== id));
|
$clients.set($clients.get().filter((c) => c.id !== id));
|
||||||
|
_lastClientsFetchAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { atom, computed } from 'nanostores';
|
|||||||
import { api } from '../services/apiClient';
|
import { api } from '../services/apiClient';
|
||||||
import { $accessToken } from './authStore';
|
import { $accessToken } from './authStore';
|
||||||
import { showToast } from './toastStore';
|
import { showToast } from './toastStore';
|
||||||
|
import { $tasks } from './taskStore';
|
||||||
|
|
||||||
const DB_NAME = 'erp-offline-db';
|
const DB_NAME = 'erp-offline-db';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 1;
|
||||||
@@ -20,6 +21,10 @@ export const $serviceRecords = atom([]);
|
|||||||
export const $offlineQueueCount = atom(0);
|
export const $offlineQueueCount = atom(0);
|
||||||
export const $syncLog = atom([]);
|
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 dbPromise = null;
|
||||||
let syncListenerStarted = false;
|
let syncListenerStarted = false;
|
||||||
let isSyncInProgress = false;
|
let isSyncInProgress = false;
|
||||||
@@ -440,12 +445,15 @@ export const $workOrdersWithVehicle = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const $dashboardStats = computed(
|
export const $dashboardStats = computed(
|
||||||
[$workOrders, $serviceRecords],
|
[$workOrders, $serviceRecords, $tasks],
|
||||||
(workOrders, serviceRecords) => {
|
(workOrders, serviceRecords, tasks) => {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const openWorkOrders = workOrders.filter((workOrder) => workOrder.status !== 'closed').length;
|
const openWorkOrders = workOrders.filter((workOrder) => workOrder.status !== 'closed').length;
|
||||||
const closedWorkOrders = 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) => {
|
const warnings = serviceRecords.filter((record) => {
|
||||||
if (record.next_service_due_at == null || record.mileage == null) return false;
|
if (record.next_service_due_at == null || record.mileage == null) return false;
|
||||||
return Number(record.next_service_due_at) - Number(record.mileage) <= 1000;
|
return Number(record.next_service_due_at) - Number(record.mileage) <= 1000;
|
||||||
@@ -484,7 +492,13 @@ export async function ensureVehiclesCatalog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchFleetDashboardData(options = {}) {
|
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();
|
startOfflineSyncListeners();
|
||||||
if (!silent) $dashboardLoading.set(true);
|
if (!silent) $dashboardLoading.set(true);
|
||||||
$dashboardError.set(null);
|
$dashboardError.set(null);
|
||||||
@@ -507,6 +521,7 @@ export async function fetchFleetDashboardData(options = {}) {
|
|||||||
$workOrders.set(Array.isArray(workOrders) ? workOrders : []);
|
$workOrders.set(Array.isArray(workOrders) ? workOrders : []);
|
||||||
$serviceRecords.set(Array.isArray(serviceRecords) ? serviceRecords : []);
|
$serviceRecords.set(Array.isArray(serviceRecords) ? serviceRecords : []);
|
||||||
$vehicles.set(normalizeCraneList(vehicles));
|
$vehicles.set(normalizeCraneList(vehicles));
|
||||||
|
_lastDashboardFetchAt = Date.now();
|
||||||
await persistDashboardCache();
|
await persistDashboardCache();
|
||||||
await processOfflineQueue();
|
await processOfflineQueue();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -607,6 +622,17 @@ export async function uploadServicePhoto(serviceRecordId, imageFile, description
|
|||||||
return response;
|
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) {
|
export async function uploadServiceAttachment(serviceRecordId, file, description = '', signal = null) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('service_record', serviceRecordId);
|
formData.append('service_record', serviceRecordId);
|
||||||
@@ -692,6 +718,15 @@ export async function fetchWorkOrderTaskServiceContext(workOrderId) {
|
|||||||
return payload && typeof payload === 'object' ? payload : { tasks: [] };
|
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) {
|
export async function createWorkOrderInvoice(workOrderId, data = {}, signal = null) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
throw new Error('Work order ID je obavezan.');
|
throw new Error('Work order ID je obavezan.');
|
||||||
@@ -717,14 +752,40 @@ export async function createWorkOrderInvoice(workOrderId, data = {}, signal = nu
|
|||||||
return response;
|
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) {
|
export async function downloadWorkOrderPdf(workOrderId) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
throw new Error('Work order ID je obavezan.');
|
throw new Error('Work order ID je obavezan.');
|
||||||
}
|
}
|
||||||
showToast('Kreiran je zahtjev za PDF putnog naloga.', 'info');
|
showToast('Kreiran je zahtjev za PDF putnog naloga.', 'info');
|
||||||
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/pdf/`, { responseType: 'blob' });
|
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/pdf-request/`, {});
|
||||||
saveBlobToFile(blob, `${workOrderId}.work-order.pdf`);
|
if (payload?.status === 'ready' && payload?.download_url) {
|
||||||
showToast('PDF putnog naloga je preuzet.', 'success');
|
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) {
|
export async function downloadWorkOrderInvoicesPdf(workOrderId) {
|
||||||
@@ -732,9 +793,16 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) {
|
|||||||
throw new Error('Work order ID je obavezan.');
|
throw new Error('Work order ID je obavezan.');
|
||||||
}
|
}
|
||||||
showToast('Kreiran je zahtjev za PDF računa putnog naloga.', 'info');
|
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' });
|
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-pdf-request/`, {});
|
||||||
saveBlobToFile(blob, `${workOrderId}.work-order-invoices.pdf`);
|
if (payload?.status === 'ready' && payload?.download_url) {
|
||||||
showToast('PDF računa putnog naloga je preuzet.', 'success');
|
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) {
|
export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
|
||||||
@@ -742,9 +810,32 @@ export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
|
|||||||
throw new Error('Work order ID je obavezan.');
|
throw new Error('Work order ID je obavezan.');
|
||||||
}
|
}
|
||||||
showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info');
|
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' });
|
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {});
|
||||||
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.pdf`);
|
if (payload?.status === 'ready' && payload?.download_url) {
|
||||||
showToast('PDF servisnih zapisa putnog naloga je preuzet.', 'success');
|
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) {
|
export function openWorkOrderInvoicesPdfPage(workOrderId) {
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ export const $isOffline = atom(false);
|
|||||||
|
|
||||||
const API_BASE = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
|
const API_BASE = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/';
|
||||||
|
|
||||||
// Heartbeat endpoint: OPTIONS /api/token/
|
// Heartbeat endpoint: GET /api/health/
|
||||||
// DRF odgovara bez 405 greške i dovoljno je za provjeru dostupnosti backenda.
|
// Time izbjegavamo periodično gađanje auth/token endpointa.
|
||||||
const HEARTBEAT_URL = new URL('token/', API_BASE).toString();
|
const HEARTBEAT_URL = new URL('health/', API_BASE).toString();
|
||||||
|
|
||||||
async function provjeriStvarnuVezu() {
|
async function provjeriStvarnuVezu() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(HEARTBEAT_URL, {
|
const response = await fetch(HEARTBEAT_URL, {
|
||||||
method: 'OPTIONS',
|
method: 'GET',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,74 +12,98 @@ const LS_VEHICLE = 'topbar_selected_vehicle';
|
|||||||
export const $selectedClientId = atom(null);
|
export const $selectedClientId = atom(null);
|
||||||
export const $selectedVehicleId = atom(null);
|
export const $selectedVehicleId = atom(null);
|
||||||
|
|
||||||
|
function normalizeId(value) {
|
||||||
|
return value ? String(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
function emitServiceContextChanged(clientId, vehicleId) {
|
function emitServiceContextChanged(clientId, vehicleId) {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
window.dispatchEvent(new CustomEvent('service-context:changed', {
|
window.dispatchEvent(new CustomEvent('service-context:changed', {
|
||||||
detail: {
|
detail: {
|
||||||
clientId: clientId || null,
|
clientId: normalizeId(clientId),
|
||||||
vehicleId: vehicleId || null,
|
vehicleId: normalizeId(vehicleId),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Čita localStorage i puni atome. Idempotentno — sigurno pozvati više puta. */
|
function persistContext(clientId, vehicleId) {
|
||||||
export function hydrateServiceContext() {
|
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const client = localStorage.getItem(LS_CLIENT);
|
|
||||||
const vehicle = localStorage.getItem(LS_VEHICLE);
|
|
||||||
$selectedClientId.set(client || null);
|
|
||||||
$selectedVehicleId.set(vehicle || null);
|
|
||||||
emitServiceContextChanged(client || null, vehicle || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Postavi novog kupca i resetiraj odabrano vozilo. */
|
|
||||||
export function setSelectedClient(id) {
|
|
||||||
$selectedClientId.set(id || null);
|
|
||||||
$selectedVehicleId.set(null);
|
|
||||||
if (id) {
|
|
||||||
localStorage.setItem(LS_CLIENT, id);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(LS_CLIENT);
|
|
||||||
}
|
|
||||||
localStorage.removeItem(LS_VEHICLE);
|
|
||||||
emitServiceContextChanged(id || null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Postavi odabrano vozilo. */
|
|
||||||
export function setSelectedVehicle(id) {
|
|
||||||
$selectedVehicleId.set(id || null);
|
|
||||||
if (id) {
|
|
||||||
localStorage.setItem(LS_VEHICLE, id);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(LS_VEHICLE);
|
|
||||||
}
|
|
||||||
emitServiceContextChanged($selectedClientId.get(), id || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Postavi cijeli servisni kontekst odjednom (kupac + vozilo). */
|
|
||||||
export function setServiceContext(clientId, vehicleId) {
|
|
||||||
$selectedClientId.set(clientId || null);
|
|
||||||
$selectedVehicleId.set(vehicleId || null);
|
|
||||||
|
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
localStorage.setItem(LS_CLIENT, clientId);
|
localStorage.setItem(LS_CLIENT, clientId);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(LS_CLIENT);
|
localStorage.removeItem(LS_CLIENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vehicleId) {
|
if (vehicleId) {
|
||||||
localStorage.setItem(LS_VEHICLE, vehicleId);
|
localStorage.setItem(LS_VEHICLE, vehicleId);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(LS_VEHICLE);
|
localStorage.removeItem(LS_VEHICLE);
|
||||||
}
|
}
|
||||||
emitServiceContextChanged(clientId || null, vehicleId || null);
|
}
|
||||||
|
|
||||||
|
/** Čita localStorage i puni atome. Idempotentno — sigurno pozvati više puta. */
|
||||||
|
export function hydrateServiceContext() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const client = normalizeId(localStorage.getItem(LS_CLIENT));
|
||||||
|
const vehicle = normalizeId(localStorage.getItem(LS_VEHICLE));
|
||||||
|
const currentClient = normalizeId($selectedClientId.get());
|
||||||
|
const currentVehicle = normalizeId($selectedVehicleId.get());
|
||||||
|
if (currentClient === client && currentVehicle === vehicle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$selectedClientId.set(client);
|
||||||
|
$selectedVehicleId.set(vehicle);
|
||||||
|
emitServiceContextChanged(client, vehicle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Postavi novog kupca i resetiraj odabrano vozilo. */
|
||||||
|
export function setSelectedClient(id) {
|
||||||
|
const nextClient = normalizeId(id);
|
||||||
|
const currentClient = normalizeId($selectedClientId.get());
|
||||||
|
const currentVehicle = normalizeId($selectedVehicleId.get());
|
||||||
|
if (currentClient === nextClient && currentVehicle === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$selectedClientId.set(nextClient);
|
||||||
|
$selectedVehicleId.set(null);
|
||||||
|
persistContext(nextClient, null);
|
||||||
|
emitServiceContextChanged(nextClient, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Postavi odabrano vozilo. */
|
||||||
|
export function setSelectedVehicle(id) {
|
||||||
|
const nextVehicle = normalizeId(id);
|
||||||
|
const currentVehicle = normalizeId($selectedVehicleId.get());
|
||||||
|
const currentClient = normalizeId($selectedClientId.get());
|
||||||
|
if (currentVehicle === nextVehicle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$selectedVehicleId.set(nextVehicle);
|
||||||
|
persistContext(currentClient, nextVehicle);
|
||||||
|
emitServiceContextChanged(currentClient, nextVehicle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Postavi cijeli servisni kontekst odjednom (kupac + vozilo). */
|
||||||
|
export function setServiceContext(clientId, vehicleId) {
|
||||||
|
const nextClient = normalizeId(clientId);
|
||||||
|
const nextVehicle = normalizeId(vehicleId);
|
||||||
|
const currentClient = normalizeId($selectedClientId.get());
|
||||||
|
const currentVehicle = normalizeId($selectedVehicleId.get());
|
||||||
|
if (currentClient === nextClient && currentVehicle === nextVehicle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$selectedClientId.set(nextClient);
|
||||||
|
$selectedVehicleId.set(nextVehicle);
|
||||||
|
persistContext(nextClient, nextVehicle);
|
||||||
|
emitServiceContextChanged(nextClient, nextVehicle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Poništi cijeli kontekst. */
|
/** Poništi cijeli kontekst. */
|
||||||
export function clearServiceContext() {
|
export function clearServiceContext() {
|
||||||
|
if ($selectedClientId.get() === null && $selectedVehicleId.get() === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$selectedClientId.set(null);
|
$selectedClientId.set(null);
|
||||||
$selectedVehicleId.set(null);
|
$selectedVehicleId.set(null);
|
||||||
localStorage.removeItem(LS_CLIENT);
|
persistContext(null, null);
|
||||||
localStorage.removeItem(LS_VEHICLE);
|
|
||||||
emitServiceContextChanged(null, null);
|
emitServiceContextChanged(null, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
// src/stores/taskStore.js
|
// src/stores/taskStore.js
|
||||||
import { atom } from 'nanostores';
|
import { atom } from 'nanostores';
|
||||||
import { api } from '../services/apiClient';
|
import { api } from '../services/apiClient';
|
||||||
import { showToast } from './toastStore';
|
import { showToast } from './toastStore';
|
||||||
|
|
||||||
export const $tasks = atom([]);
|
export const $tasks = atom([]);
|
||||||
export const $tasksLoading = atom(false);
|
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 = {
|
const STATUS_LABELS = {
|
||||||
aktivan: 'Aktivan',
|
aktivan: 'Aktivan',
|
||||||
servis: 'U tijeku',
|
servis: 'U tijeku',
|
||||||
|
spreman_za_zavrsetak: 'Spreman za završetak',
|
||||||
|
zavrsen: 'Završen',
|
||||||
neaktivan: 'Otkazano',
|
neaktivan: 'Otkazano',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,14 +25,18 @@ export function getStatusLabel(status) {
|
|||||||
|
|
||||||
export function isTaskActive(task) {
|
export function isTaskActive(task) {
|
||||||
const status = String(task?.status || '').toLowerCase();
|
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);
|
$tasksLoading.set(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.get('tasks/tasks/');
|
const data = await api.get('tasks/tasks/');
|
||||||
$tasks.set(Array.isArray(data) ? data : (data?.results ?? []));
|
$tasks.set(Array.isArray(data) ? data : (data?.results ?? []));
|
||||||
|
_lastTaskFetchAt = Date.now();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.status !== 401) {
|
if (error?.status !== 401) {
|
||||||
showToast('Greška pri dohvatu zadataka.', 'error');
|
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) {
|
export async function createTask(data) {
|
||||||
const response = await api.post('tasks/tasks/', data);
|
const response = await api.post('tasks/tasks/', data);
|
||||||
$tasks.set([response, ...$tasks.get()]);
|
$tasks.set([response, ...$tasks.get()]);
|
||||||
|
|||||||
@@ -59,3 +59,16 @@
|
|||||||
body.modal-open #main-content {
|
body.modal-open #main-content {
|
||||||
filter: blur(4px);
|
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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user