Compare commits
95 Commits
24cd9ae11c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
242c8dc9b2 | ||
|
|
1172dfabd7 | ||
|
|
fc2a683b9c | ||
|
|
f243bad6db | ||
|
|
334d1acd37 | ||
|
|
dd27e59c2c | ||
|
|
3931c9065b | ||
|
|
5af66c31b6 | ||
|
|
b6fe8d8969 | ||
|
|
b985c285f9 | ||
|
|
aa690d4ac4 | ||
|
|
52408bc5a9 | ||
|
|
8e9d23fcfa | ||
|
|
a7220d27f5 | ||
|
|
1e68c663f2 | ||
|
|
2826ee9bab | ||
|
|
9bd5f53675 | ||
|
|
d2dc37aef9 | ||
|
|
e3b3ff90a9 | ||
|
|
024bf59733 | ||
|
|
8577aaa01b | ||
|
|
a9ab83c202 | ||
|
|
681f73d85a | ||
|
|
181cab1f45 | ||
|
|
38f1786469 | ||
|
|
f37d130bc7 | ||
|
|
fe4ba3b5dd | ||
|
|
1f6c0d6086 | ||
|
|
3aecbdd6ea | ||
|
|
ee18af7877 | ||
|
|
27b7c31c13 | ||
|
|
7053ec32f8 | ||
|
|
bf381dcf13 | ||
|
|
cb165df30c | ||
|
|
8b90916641 | ||
|
|
8ad1f40601 | ||
|
|
925cd25d98 | ||
|
|
ed71bf500f | ||
|
|
18c65a6f84 | ||
|
|
456c9e4c3e | ||
|
|
594881f6cd | ||
|
|
9a7c7e3163 | ||
|
|
7da1c84139 | ||
|
|
e8272a00bb | ||
|
|
d49ec15cc7 | ||
|
|
1d7d631fbd | ||
|
|
45142bba4b | ||
|
|
e7641e7be0 | ||
|
|
52f54e3a75 | ||
|
|
a8f441c150 | ||
|
|
2a9cac0c81 | ||
|
|
79145c58e4 | ||
|
|
fd8c6d8040 | ||
|
|
06321fc3d1 | ||
|
|
6470a2996c | ||
|
|
16264cb8a9 | ||
|
|
51a65f9b80 | ||
|
|
882e255523 | ||
|
|
3a33751012 | ||
|
|
fe739b14c0 | ||
|
|
8b82481bb4 | ||
|
|
525152b817 | ||
|
|
ae6ae08fb1 | ||
|
|
1055820741 | ||
|
|
908025abc6 | ||
|
|
666ddb56c9 | ||
|
|
ebb642e572 | ||
|
|
5945423346 | ||
|
|
42fcd836ed | ||
|
|
b92596f6a6 | ||
|
|
1a18498b30 | ||
|
|
61996bd1a0 | ||
|
|
ae35a6041d | ||
|
|
d35eaeccf4 | ||
|
|
cbcbf0b3af | ||
|
|
ad7b293f54 | ||
|
|
8485431180 | ||
|
|
bbdd02c9e9 | ||
|
|
fe7edf5643 | ||
|
|
0c9e1b7d50 | ||
|
|
bf8e25efe7 | ||
|
|
ef8718a4cf | ||
|
|
75257525c4 | ||
|
|
7acaef941f | ||
|
|
e14665d8ec | ||
|
|
ef51036bc7 | ||
|
|
13de9e113c | ||
|
|
e8330473cd | ||
|
|
5e7ec72a4e | ||
|
|
346682b172 | ||
|
|
8e4b3f8aea | ||
|
|
1ab2bfb3c3 | ||
|
|
3ad536a23e | ||
| 70151f1225 | |||
|
|
ac5f25a99c |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
24
README.md
24
README.md
@@ -1 +1,23 @@
|
|||||||
Frontend + Backend + Database + Deployment
|
Frontend + Backend + Database + Deployment
|
||||||
|
|
||||||
|
### Deploy na serveru
|
||||||
|
|
||||||
|
Nakon što je gitea webhook i testirana skripta za automatsko povlačenje promjena iz repozitorija
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/erp/app
|
||||||
|
git fetch origin
|
||||||
|
git reset --hard origin/main ili git pull --ff-only origin main
|
||||||
|
|
||||||
|
# rebuild backend + worker + beat + frontend
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend
|
||||||
|
|
||||||
|
# digni servise s novim image-ima
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --force-recreate backend worker beat frontend
|
||||||
|
|
||||||
|
# OBAVEZNO migracije (scheduled_date)
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend python manage.py migrate
|
||||||
|
|
||||||
|
# provjera
|
||||||
|
curl -i https://api-004.captain.mitteworkspace.cloud/api/health/
|
||||||
|
```
|
||||||
@@ -2,11 +2,33 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
from celery import Task
|
||||||
|
import gc
|
||||||
|
import logging
|
||||||
|
|
||||||
# Postavi Django settings modul
|
# Postavi Django settings modul
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
|
||||||
app = Celery('core')
|
app = Celery('core')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAwareTask(Task):
|
||||||
|
"""Celery base task s hookovima za memory-heavy async operacije."""
|
||||||
|
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
def on_success(self, retval, task_id, args, kwargs):
|
||||||
|
logger.info("Celery task uspješan: %s (%s)", self.name, task_id)
|
||||||
|
super().on_success(retval, task_id, args, kwargs)
|
||||||
|
|
||||||
|
def on_failure(self, exc, task_id, args, kwargs, einfo):
|
||||||
|
logger.exception("Celery task neuspješan: %s (%s): %s", self.name, task_id, exc)
|
||||||
|
super().on_failure(exc, task_id, args, kwargs, einfo)
|
||||||
|
|
||||||
|
def after_return(self, status, retval, task_id, args, kwargs, einfo):
|
||||||
|
gc.collect()
|
||||||
|
super().after_return(status, retval, task_id, args, kwargs, einfo)
|
||||||
|
|
||||||
# Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_'
|
# Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_'
|
||||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||||
|
|||||||
@@ -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'},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -144,6 +154,10 @@ REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
|||||||
|
|
||||||
CELERY_BROKER_URL = REDIS_URL
|
CELERY_BROKER_URL = REDIS_URL
|
||||||
CELERY_RESULT_BACKEND = REDIS_URL
|
CELERY_RESULT_BACKEND = REDIS_URL
|
||||||
|
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
|
||||||
|
CELERY_TASK_ACKS_LATE = True
|
||||||
|
CELERY_WORKER_MAX_TASKS_PER_CHILD = 20
|
||||||
|
CELERY_WORKER_MAX_MEMORY_PER_CHILD = 300000
|
||||||
|
|
||||||
# Ako želiš koristiti Redis kao brzi cache sustav unutar Djanga (izvrsno za ERP performanse)
|
# Ako želiš koristiti Redis kao brzi cache sustav unutar Djanga (izvrsno za ERP performanse)
|
||||||
# Za ovo ti je potreban paket 'django-redis' u requirements.txt
|
# Za ovo ti je potreban paket 'django-redis' u requirements.txt
|
||||||
@@ -160,6 +174,24 @@ 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),
|
||||||
|
},
|
||||||
|
'cleanup-expired-generated-archives-hourly': {
|
||||||
|
'task': 'modules.fleet.tasks.cleanup_expired_generated_archives_task',
|
||||||
|
'schedule': crontab(minute=10),
|
||||||
|
},
|
||||||
|
'cleanup-stale-tmp-archives-daily': {
|
||||||
|
'task': 'modules.fleet.tasks.cleanup_stale_tmp_archives_task',
|
||||||
|
'schedule': crontab(hour=3, minute=30),
|
||||||
|
},
|
||||||
|
'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/'
|
||||||
|
|||||||
@@ -46,4 +46,14 @@ INSTALLED_APPS += ['debug_toolbar']
|
|||||||
MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
|
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'
|
||||||
@@ -40,3 +40,6 @@ STATIC_URL = '/static/'
|
|||||||
STATIC_ROOT = '/app/staticfiles'
|
STATIC_ROOT = '/app/staticfiles'
|
||||||
MEDIA_URL = '/media/'
|
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)
|
||||||
@@ -9,7 +9,7 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
list_display = ('email', 'first_name', 'last_name', 'occupation', 'work_position', 'residence', 'is_serviser', 'is_kupac', 'is_team_member', 'is_active', 'client_profile')
|
list_display = ('email', 'first_name', 'last_name', 'occupation', 'work_position', 'residence', 'is_serviser', 'is_kupac', 'is_team_member', 'is_active', 'client_profile')
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('email', 'password')}),
|
(None, {'fields': ('email', 'password')}),
|
||||||
('Osobni podaci', {'fields': ('first_name', 'last_name', 'telefon', 'oib', 'occupation', 'work_position', 'residence')}),
|
('Osobni podaci', {'fields': ('first_name', 'last_name', 'telefon', 'oib', 'occupation', 'work_position', 'residence', 'signature')}),
|
||||||
('ERP Status', {'fields': ('is_active', 'is_staff', 'is_superuser', 'is_serviser', 'is_kupac', 'is_team_member', 'is_verified', 'client_profile')}),
|
('ERP Status', {'fields': ('is_active', 'is_staff', 'is_superuser', 'is_serviser', 'is_kupac', 'is_team_member', 'is_verified', 'client_profile')}),
|
||||||
('Važni datumi', {'fields': ('last_login', 'date_joined')}),
|
('Važni datumi', {'fields': ('last_login', 'date_joined')}),
|
||||||
)
|
)
|
||||||
@@ -18,7 +18,7 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
add_fieldsets = (
|
add_fieldsets = (
|
||||||
(None, {
|
(None, {
|
||||||
'classes': ('wide',),
|
'classes': ('wide',),
|
||||||
'fields': ('email', 'password', 'is_team_member'),
|
'fields': ('email', 'password', 'first_name', 'last_name', 'telefon', 'occupation', 'signature', 'is_team_member'),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ serviser2@example.com,Ime2,Prezime2,password123,true,false,true,false,false,true
|
|||||||
serviser3@example.com,Ime3,Prezime3,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,,
|
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,,
|
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,,
|
||||||
|
16
backend/core/users/migrations/0007_customuser_signature.py
Normal file
16
backend/core/users/migrations/0007_customuser_signature.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0006_customuser_work_position'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='signature',
|
||||||
|
field=models.TextField(blank=True, default='', verbose_name='Email potpis'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def prefill_empty_signatures(apps, schema_editor):
|
||||||
|
CustomUser = apps.get_model('users', 'CustomUser')
|
||||||
|
for user in CustomUser.objects.all().iterator():
|
||||||
|
signature_value = user.signature if isinstance(user.signature, str) else ''
|
||||||
|
if signature_value.strip():
|
||||||
|
continue
|
||||||
|
first_name = (user.first_name or '').strip()
|
||||||
|
last_name = (user.last_name or '').strip()
|
||||||
|
full_name = f"{first_name} {last_name}".strip()
|
||||||
|
fallback_name = (user.email or '').strip() or 'Ime i prezime'
|
||||||
|
display_name = full_name or fallback_name
|
||||||
|
occupation = (user.occupation or '').strip()
|
||||||
|
name_line = f"{occupation} {display_name}".strip() if occupation else display_name
|
||||||
|
mobile = (user.telefon or '').strip() or "+385 91 636 3466"
|
||||||
|
signature = (
|
||||||
|
"Poslano iz aplikacije\n"
|
||||||
|
"S poštovanjem / Freundliche Grüße / Kind regards\n\n"
|
||||||
|
f"{name_line}\n\n"
|
||||||
|
"KNEZ LJUBO d.o.o.\n"
|
||||||
|
"Ivlje 44, 10040 Zagreb\n\n"
|
||||||
|
"Office: Donje Svetice 40, 10000 Zagreb\n\n"
|
||||||
|
f"Mobile: {mobile}\n\n"
|
||||||
|
"www.knezljubo.hr"
|
||||||
|
)
|
||||||
|
user.signature = signature
|
||||||
|
user.save(update_fields=['signature'])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('users', '0007_customuser_signature'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(prefill_empty_signatures, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -3,6 +3,17 @@ from django.core.exceptions import ValidationError
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from .managers import CustomUserManager
|
from .managers import CustomUserManager
|
||||||
|
|
||||||
|
DEFAULT_EMAIL_SIGNATURE_TEMPLATE = (
|
||||||
|
"Poslano iz aplikacije\n"
|
||||||
|
"S poštovanjem / Freundliche Grüße / Kind regards\n\n"
|
||||||
|
"{name_line}\n\n"
|
||||||
|
"KNEZ LJUBO d.o.o.\n"
|
||||||
|
"Ivlje 44, 10040 Zagreb\n\n"
|
||||||
|
"Office: Donje Svetice 40, 10000 Zagreb\n\n"
|
||||||
|
"Mobile: {mobile}\n\n"
|
||||||
|
"www.knezljubo.hr"
|
||||||
|
)
|
||||||
|
|
||||||
class CustomUser(AbstractUser):
|
class CustomUser(AbstractUser):
|
||||||
# Email koristimo za login, pa mora biti jedinstven
|
# Email koristimo za login, pa mora biti jedinstven
|
||||||
username=None
|
username=None
|
||||||
@@ -19,6 +30,7 @@ class CustomUser(AbstractUser):
|
|||||||
occupation = models.CharField(max_length=120, blank=True, default='', verbose_name="Zanimanje")
|
occupation = models.CharField(max_length=120, blank=True, default='', verbose_name="Zanimanje")
|
||||||
residence = models.CharField(max_length=255, blank=True, default='', verbose_name="Prebivalište")
|
residence = models.CharField(max_length=255, blank=True, default='', verbose_name="Prebivalište")
|
||||||
work_position = models.CharField(max_length=120, blank=True, default='', verbose_name="Na radnom mjestu")
|
work_position = models.CharField(max_length=120, blank=True, default='', verbose_name="Na radnom mjestu")
|
||||||
|
signature = models.TextField(blank=True, default='', verbose_name="Email potpis")
|
||||||
licence_number = models.CharField(max_length=50, blank=True, null=True, verbose_name="Broj licence")
|
licence_number = models.CharField(max_length=50, blank=True, null=True, verbose_name="Broj licence")
|
||||||
is_verified = models.BooleanField(default=False, verbose_name="Verificiran profil")
|
is_verified = models.BooleanField(default=False, verbose_name="Verificiran profil")
|
||||||
|
|
||||||
@@ -47,6 +59,27 @@ class CustomUser(AbstractUser):
|
|||||||
if self.is_kupac and not self.client_profile:
|
if self.is_kupac and not self.client_profile:
|
||||||
raise ValidationError({'client_profile': "Korisnik koji je označen kao kupac mora imati povezan klijentski profil."})
|
raise ValidationError({'client_profile': "Korisnik koji je označen kao kupac mora imati povezan klijentski profil."})
|
||||||
|
|
||||||
|
def build_default_signature(self):
|
||||||
|
full_name = self.get_full_name().strip()
|
||||||
|
fallback_name = self.email.strip() if isinstance(self.email, str) and self.email.strip() else 'Ime i prezime'
|
||||||
|
display_name = full_name or fallback_name
|
||||||
|
occupation = self.occupation.strip() if isinstance(self.occupation, str) else ''
|
||||||
|
name_line = f"{occupation} {display_name}".strip() if occupation else display_name
|
||||||
|
mobile = self.telefon.strip() if isinstance(self.telefon, str) and self.telefon.strip() else "+385 91 636 3466"
|
||||||
|
return DEFAULT_EMAIL_SIGNATURE_TEMPLATE.format(
|
||||||
|
name_line=name_line,
|
||||||
|
mobile=mobile,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_email_signature(self):
|
||||||
|
stored_signature = self.signature.strip() if isinstance(self.signature, str) else ''
|
||||||
|
if stored_signature:
|
||||||
|
return stored_signature
|
||||||
|
return self.build_default_signature()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
signature = self.signature.strip() if isinstance(self.signature, str) else ''
|
||||||
|
if not signature:
|
||||||
|
self.signature = self.build_default_signature()
|
||||||
self.full_clean() # Prisiljava pokretanje clean() prije svakog save()
|
self.full_clean() # Prisiljava pokretanje clean() prije svakog save()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
39
backend/core/users/tests/test_email_signature.py
Normal file
39
backend/core/users/tests/test_email_signature.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_new_user_gets_prefilled_signature():
|
||||||
|
User = get_user_model()
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
user = User.objects.create_user(
|
||||||
|
email=f"signature-{suffix}@example.test",
|
||||||
|
password="test1234",
|
||||||
|
first_name="Mario",
|
||||||
|
last_name="Tkalac",
|
||||||
|
telefon="+385916363466",
|
||||||
|
)
|
||||||
|
|
||||||
|
signature = user.signature
|
||||||
|
assert signature.startswith("Poslano iz aplikacije\n")
|
||||||
|
assert "S poštovanjem / Freundliche Grüße / Kind regards" in signature
|
||||||
|
assert "Mario Tkalac" in signature
|
||||||
|
assert "KNEZ LJUBO d.o.o." in signature
|
||||||
|
assert "Office: Donje Svetice 40, 10000 Zagreb" in signature
|
||||||
|
assert "Mobile: +385916363466" in signature
|
||||||
|
assert "www.knezljubo.hr" in signature
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_custom_signature_is_preserved():
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.create_user(
|
||||||
|
email=f"custom-signature-{uuid.uuid4().hex[:8]}@example.test",
|
||||||
|
password="test1234",
|
||||||
|
signature="Custom email signature",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user.signature == "Custom email signature"
|
||||||
|
assert user.get_email_signature() == "Custom email signature"
|
||||||
@@ -58,4 +58,29 @@ class TestUserAPI:
|
|||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
assert self.user.first_name == "Inženjer"
|
assert self.user.first_name == "Inženjer"
|
||||||
|
|
||||||
|
def test_get_servicers_returns_only_servicers(self):
|
||||||
|
serviser = User.objects.create_user(
|
||||||
|
email="serviser@test.hr",
|
||||||
|
first_name="Serviser",
|
||||||
|
last_name="Jedan",
|
||||||
|
password="strongpassword123",
|
||||||
|
is_serviser=True,
|
||||||
|
is_team_member=True,
|
||||||
|
)
|
||||||
|
non_serviser = User.objects.create_user(
|
||||||
|
email="nons@test.hr",
|
||||||
|
first_name="Nije",
|
||||||
|
last_name="Serviser",
|
||||||
|
password="strongpassword123",
|
||||||
|
is_serviser=False,
|
||||||
|
is_team_member=True,
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
url = reverse('user-servicers')
|
||||||
|
response = self.client.get(url)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
returned_ids = {str(item['id']) for item in response.data}
|
||||||
|
assert str(serviser.id) in returned_ids
|
||||||
|
assert str(non_serviser.id) not in returned_ids
|
||||||
@@ -30,6 +30,12 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||||||
serializer = TeamMemberSerializer(queryset, many=True)
|
serializer = TeamMemberSerializer(queryset, many=True)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='servicers')
|
||||||
|
def servicers(self, request):
|
||||||
|
queryset = User.objects.filter(is_active=True, is_serviser=True).order_by('first_name', 'last_name', 'email')
|
||||||
|
serializer = TeamMemberSerializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
# Delegiramo logiku servisu
|
# Delegiramo logiku servisu
|
||||||
UserService.update_user_profile(
|
UserService.update_user_profile(
|
||||||
|
|||||||
@@ -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
|
python manage.py migrate --noinput
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
|
||||||
run_as_appuser_now python manage.py migrate --noinput
|
|
||||||
else
|
|
||||||
python manage.py migrate --noinput
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "--> Osiguravam prava nad statikom prije collectstatic..."
|
echo "--> Skupljam statiku..."
|
||||||
mkdir -p "${STATIC_ROOT}"
|
python manage.py collectstatic --noinput
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
|
||||||
chown -R ${APP_USER}:${APP_USER} "${STATIC_ROOT}" || echo "--> chown statika nije uspio"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "--> Skupljam statiku..."
|
echo "--> Pokrećem Gunicorn..."
|
||||||
if command -v gosu >/dev/null 2>&1 || command -v su-exec >/dev/null 2>&1 || command -v runuser >/dev/null 2>&1; then
|
exec gunicorn --bind 0.0.0.0:8000 --workers 3 core.wsgi:application
|
||||||
run_as_appuser_now python manage.py collectstatic --noinput
|
|
||||||
else
|
|
||||||
python manage.py collectstatic --noinput
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "--> Pokrećem Gunicorn kao appuser..."
|
|
||||||
exec_as_appuser gunicorn --bind 0.0.0.0:8000 --workers 3 core.wsgi:application
|
|
||||||
|
|
||||||
elif [ "$1" = "celery" ]; then
|
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,10 @@
|
|||||||
|
CONTACT ID,EMAIL,sib_contact_owner,LASTNAME,FIRSTNAME,_DETECTED_LANGUAGE,DOUBLE_OPT-IN,OPT_IN,CONTACT_TIMEZONE,EXT_ID,LINKEDIN,JOB_TITLE,WHATSAPP,LANDLINE_NUMBER,SMS,ADDED_TIME,MODIFIED_TIME
|
||||||
|
1,mtkalac.mt@gmail.com,,TKALAC,MARIO,,,,,,,,,,385955002483,20-07-2026,20-07-2026
|
||||||
|
2,domagoj@knezljubo.hr,,Knez,Domagoj,Croatian,No,NO,(GMT+02:00) Zagreb,domagojknezljubo,,direktor,385913228904,,385913228904,20-07-2026,20-07-2026
|
||||||
|
3,ljubo@knezljubo.hr,,Ljubo,Knez,Croatian,No,NO,(GMT+02:00) Zagreb,ljuboknezljubo,,direktor,385913228902,,385913228902,20-07-2026,20-07-2026
|
||||||
|
4,mario.tkalac@knezljubo.hr,,Tkalac,Mario,Croatian,No,NO,(GMT+02:00) Zagreb,mariotkalacknezljubo,,serviser,385916303466,,385916303466,20-07-2026,20-07-2026
|
||||||
|
5,fran@knezljubo.hr,,Mati<EFBFBD>,Fran,Croatian,No,NO,(GMT+02:00) Zagreb,franmaticknezljubo,,prodaja,385916004943,,385916004943,20-07-2026,20-07-2026
|
||||||
|
6,iva.markic@knezljubo.hr,,Marki<EFBFBD>,Iva,Croatian,No,NO,(GMT+02:00) Zagreb,ivamarkicknezljubo,,knjigovo<EFBFBD>a,385919423525,,385919423525,20-07-2026,20-07-2026
|
||||||
|
7,ramo@knezljubo.hr,,Husanovi<EFBFBD>,Ramo,Croatian,No,NO,(GMT+02:00) Zagreb,ramohusanovicknezljubo,,serviser,385913228910,,385913228910,20-07-2026,20-07-2026
|
||||||
|
8,office@knezljubo.hr,,Cepi<EFBFBD>,Dario,Croatian,No,NO,(GMT+02:00) Zagreb,dariocepicknezljubo,logistika,385994067776,,385994067776,20-07-2026,20-07-2026
|
||||||
|
9,josip@knezljubo.hr,,Pintari<EFBFBD>,Josip,Croatian,No,NO,(GMT+02:00) Zagreb,josippintaricknezljubo,serviser,385913228906,,385913228906,20-07-2026,20-07-2026
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
from pathlib import Path
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.admin import ModelAdmin
|
from django.contrib.admin import ModelAdmin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
@@ -5,7 +6,12 @@ from .models import (
|
|||||||
Crane,
|
Crane,
|
||||||
Vehicle,
|
Vehicle,
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
|
WorkOrderPhoto,
|
||||||
WorkOrderInvoice,
|
WorkOrderInvoice,
|
||||||
|
WorkOrderAdditionalCostsTable,
|
||||||
|
ServiceContextNote,
|
||||||
|
GeneratedWorkOrderPdf,
|
||||||
|
EmailDispatchLog,
|
||||||
VehicleServiceRecord,
|
VehicleServiceRecord,
|
||||||
VehicleServicePhoto,
|
VehicleServicePhoto,
|
||||||
VehicleServiceAttachment,
|
VehicleServiceAttachment,
|
||||||
@@ -42,7 +48,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', 'origin_location', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
|
||||||
readonly_fields = ('date',)
|
readonly_fields = ('date',)
|
||||||
|
|
||||||
|
|
||||||
@@ -56,8 +62,47 @@ class ServiceRecordInline(admin.TabularInline):
|
|||||||
class WorkOrderInvoiceInline(admin.TabularInline):
|
class WorkOrderInvoiceInline(admin.TabularInline):
|
||||||
model = WorkOrderInvoice
|
model = WorkOrderInvoice
|
||||||
extra = 0
|
extra = 0
|
||||||
fields = ('naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active')
|
fields = ('thumbnail_preview', 'naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active')
|
||||||
readonly_fields = ('created_at',)
|
readonly_fields = ('thumbnail_preview', 'created_at')
|
||||||
|
|
||||||
|
@admin.display(description="Pregled")
|
||||||
|
def thumbnail_preview(self, obj):
|
||||||
|
if not obj.pk or not obj.image:
|
||||||
|
return "—"
|
||||||
|
suffix = Path(obj.image.name or '').suffix.lower()
|
||||||
|
if suffix == '.pdf':
|
||||||
|
return format_html(
|
||||||
|
'<a href="/api/fleet/work-order-invoices/{}/image/" target="_blank">📄 PDF</a>',
|
||||||
|
obj.pk,
|
||||||
|
)
|
||||||
|
return format_html(
|
||||||
|
'<a href="/api/fleet/work-order-invoices/{}/image/?w=1600&q=90&fmt=jpeg" target="_blank">'
|
||||||
|
'<img src="/api/fleet/work-order-invoices/{}/image/?w=200&q=75&fmt=jpeg" '
|
||||||
|
'style="height:64px;width:auto;border-radius:4px;object-fit:cover;" />'
|
||||||
|
'</a>',
|
||||||
|
obj.pk,
|
||||||
|
obj.pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -154,12 +199,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', 'origin_location', '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
|
||||||
@@ -168,10 +213,56 @@ class WorkOrderAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(WorkOrderInvoice)
|
@admin.register(WorkOrderInvoice)
|
||||||
class WorkOrderInvoiceAdmin(admin.ModelAdmin):
|
class WorkOrderInvoiceAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'work_order', 'naziv_racuna', 'lokacija', 'datum', 'created_by', 'is_active')
|
list_display = ('id', 'image_preview', 'work_order', 'naziv_racuna', 'lokacija', 'datum', 'created_by', 'is_active')
|
||||||
list_filter = ('datum', 'is_active', 'work_order')
|
list_filter = ('datum', 'is_active', 'work_order')
|
||||||
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')
|
||||||
|
readonly_fields = ('image_preview', 'created_at')
|
||||||
|
|
||||||
|
@admin.display(description="Slika")
|
||||||
|
def image_preview(self, obj):
|
||||||
|
if not obj.pk or not obj.image:
|
||||||
|
return "—"
|
||||||
|
suffix = Path(obj.image.name or '').suffix.lower()
|
||||||
|
if suffix == '.pdf':
|
||||||
|
return format_html(
|
||||||
|
'<a href="/api/fleet/work-order-invoices/{}/image/" target="_blank">📄 PDF</a>',
|
||||||
|
obj.pk,
|
||||||
|
)
|
||||||
|
return format_html(
|
||||||
|
'<a href="/api/fleet/work-order-invoices/{}/image/?w=1600&q=90&fmt=jpeg" target="_blank">'
|
||||||
|
'<img src="/api/fleet/work-order-invoices/{}/image/?w=200&q=75&fmt=jpeg" '
|
||||||
|
'style="height:80px;width:auto;border-radius:4px;object-fit:cover;" />'
|
||||||
|
'</a>',
|
||||||
|
obj.pk,
|
||||||
|
obj.pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(WorkOrderAdditionalCostsTable)
|
||||||
|
class WorkOrderAdditionalCostsTableAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'work_order', 'total_for_payout', 'updated_at')
|
||||||
|
search_fields = ('work_order__display_code', 'work_order__vehicle__registration_number')
|
||||||
|
readonly_fields = ('created_at', 'updated_at')
|
||||||
|
ordering = ('-updated_at',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ServiceContextNote)
|
||||||
|
class ServiceContextNoteAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'recipient', 'created_by', 'audience_source', 'note_date', 'is_closed', 'created_at')
|
||||||
|
list_filter = ('audience_source', 'is_closed', 'note_date')
|
||||||
|
search_fields = ('note', 'recipient__email', 'recipient__first_name', 'recipient__last_name', 'created_by__email')
|
||||||
|
readonly_fields = ('created_at', 'updated_at', 'closed_at')
|
||||||
|
ordering = ('is_closed', 'note_date', '-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):
|
||||||
@@ -194,4 +285,50 @@ class VehicleServiceRecordAdmin(admin.ModelAdmin):
|
|||||||
def short_description(self, obj):
|
def short_description(self, obj):
|
||||||
if obj.description:
|
if obj.description:
|
||||||
return obj.description[:60] + ('…' if len(obj.description) > 60 else '')
|
return obj.description[:60] + ('…' if len(obj.description) > 60 else '')
|
||||||
return "—"
|
return "—"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(EmailDispatchLog)
|
||||||
|
class EmailDispatchLogAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
'id',
|
||||||
|
'dispatch_type',
|
||||||
|
'status',
|
||||||
|
'requested_by',
|
||||||
|
'work_order',
|
||||||
|
'service_record',
|
||||||
|
'recipients_count',
|
||||||
|
'sent_at',
|
||||||
|
'created_at',
|
||||||
|
)
|
||||||
|
list_filter = ('dispatch_type', 'status', 'created_at', 'sent_at')
|
||||||
|
search_fields = (
|
||||||
|
'subject',
|
||||||
|
'message',
|
||||||
|
'error_message',
|
||||||
|
'requested_by__email',
|
||||||
|
'requested_by__username',
|
||||||
|
'work_order__display_code',
|
||||||
|
)
|
||||||
|
readonly_fields = (
|
||||||
|
'dispatch_type',
|
||||||
|
'requested_by',
|
||||||
|
'work_order',
|
||||||
|
'service_record',
|
||||||
|
'recipients',
|
||||||
|
'subject',
|
||||||
|
'message',
|
||||||
|
'attachments',
|
||||||
|
'status',
|
||||||
|
'error_message',
|
||||||
|
'metadata',
|
||||||
|
'sent_at',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
)
|
||||||
|
ordering = ('-created_at',)
|
||||||
|
|
||||||
|
@admin.display(description='Primatelja')
|
||||||
|
def recipients_count(self, obj):
|
||||||
|
recipients = obj.recipients or []
|
||||||
|
return len(recipients) if isinstance(recipients, list) else 0
|
||||||
BIN
backend/modules/fleet/docx_templates/Servisni N-R.docx
Normal file
BIN
backend/modules/fleet/docx_templates/Servisni N-R.docx
Normal file
Binary file not shown.
17
backend/modules/fleet/email_utils.py
Normal file
17
backend/modules/fleet/email_utils.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
def append_user_signature(body, user):
|
||||||
|
body_text = str(body or '').strip()
|
||||||
|
if user is None:
|
||||||
|
return body_text
|
||||||
|
|
||||||
|
signature = ''
|
||||||
|
get_signature = getattr(user, 'get_email_signature', None)
|
||||||
|
if callable(get_signature):
|
||||||
|
signature = str(get_signature() or '').strip()
|
||||||
|
else:
|
||||||
|
signature = str(getattr(user, 'signature', '') or '').strip()
|
||||||
|
|
||||||
|
if not signature:
|
||||||
|
return body_text
|
||||||
|
if not body_text:
|
||||||
|
return signature
|
||||||
|
return f"{body_text}\n\n{signature}"
|
||||||
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),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fleet', '0026_backfill_workorder_display_code'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workorder',
|
||||||
|
name='origin_location',
|
||||||
|
field=models.CharField(blank=True, max_length=255, verbose_name='Lokacija ishodišta'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import modules.fleet.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fleet', '0027_workorder_origin_location'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workorderinvoice',
|
||||||
|
name='image',
|
||||||
|
field=models.FileField(blank=True, null=True, upload_to=modules.fleet.models._work_order_invoice_upload_to, verbose_name='Datoteka računa'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.15 on 2026-07-19 17:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fleet', '0028_alter_workorderinvoice_image'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workorder',
|
||||||
|
name='purpose',
|
||||||
|
field=models.CharField(blank=True, choices=[('defektaza', 'Defektaža'), ('kontrola', 'Kontrola'), ('redovni_pregled', 'Redovni pregled'), ('popravak', 'Popravak')], max_length=50, verbose_name='Svrha'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fleet', '0028_alter_workorderinvoice_image'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='WorkOrderAdditionalCostsTable',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('data', models.JSONField(blank=True, default=None, null=True, verbose_name='Podaci tablice dodatnih troškova')),
|
||||||
|
('total_for_payout', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12, verbose_name='Ukupno za isplatu')),
|
||||||
|
('work_order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='additional_costs_table', to='fleet.workorder', verbose_name='Putni nalog')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Tablica dodatnih troškova putnog naloga',
|
||||||
|
'verbose_name_plural': 'Tablice dodatnih troškova putnog naloga',
|
||||||
|
'ordering': ['-updated_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
11
backend/modules/fleet/migrations/0030_merge_20260719_2057.py
Normal file
11
backend/modules/fleet/migrations/0030_merge_20260719_2057.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fleet', '0029_alter_workorder_purpose'),
|
||||||
|
('fleet', '0029_workorderadditionalcoststable'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 5.2.15 on 2026-07-19 19:01
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fleet', '0030_merge_20260719_2057'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workorderadditionalcoststable',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workorderadditionalcoststable',
|
||||||
|
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='workorderadditionalcoststable',
|
||||||
|
name='is_active',
|
||||||
|
field=models.BooleanField(default=True, verbose_name='Aktivan zapis'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workorderadditionalcoststable',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
|
||||||
|
),
|
||||||
|
]
|
||||||
41
backend/modules/fleet/migrations/0032_servicecontextnote.py
Normal file
41
backend/modules/fleet/migrations/0032_servicecontextnote.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Generated by Django 5.2.15 on 2026-07-20 03:05
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('task_management', '0010_alter_tasktemplate_status'),
|
||||||
|
('fleet', '0031_alter_workorderadditionalcoststable_created_at_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ServiceContextNote',
|
||||||
|
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')),
|
||||||
|
('audience_source', models.CharField(choices=[('self', 'Samo autor'), ('member', 'Odabrani član tima'), ('team', 'Svi članovi tima')], default='self', max_length=16, verbose_name='Publika')),
|
||||||
|
('note', models.TextField(verbose_name='Bilješka')),
|
||||||
|
('note_date', models.DateField(blank=True, db_index=True, null=True, verbose_name='Datum podsjetnika')),
|
||||||
|
('is_closed', models.BooleanField(default=False, verbose_name='Ručno zatvoreno')),
|
||||||
|
('closed_at', models.DateTimeField(blank=True, null=True, verbose_name='Vrijeme zatvaranja')),
|
||||||
|
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_service_context_notes', to=settings.AUTH_USER_MODEL, verbose_name='Kreirao')),
|
||||||
|
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_context_notes', to=settings.AUTH_USER_MODEL, verbose_name='Primatelj')),
|
||||||
|
('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='service_context_notes', to='task_management.task', verbose_name='Radni zadatak')),
|
||||||
|
('work_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='service_context_notes', to='fleet.workorder', verbose_name='Putni nalog')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Bilješka servisnog konteksta',
|
||||||
|
'verbose_name_plural': 'Bilješke servisnog konteksta',
|
||||||
|
'ordering': ['is_closed', 'note_date', '-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
43
backend/modules/fleet/migrations/0033_emaildispatchlog.py
Normal file
43
backend/modules/fleet/migrations/0033_emaildispatchlog.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 5.2.15 on 2026-07-21 11:50
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fleet', '0032_servicecontextnote'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EmailDispatchLog',
|
||||||
|
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')),
|
||||||
|
('dispatch_type', models.CharField(choices=[('work_order', 'Putni nalog'), ('service_record', 'Servisni zapis')], db_index=True, max_length=32, verbose_name='Tip slanja')),
|
||||||
|
('recipients', models.JSONField(blank=True, default=list, verbose_name='Primatelji')),
|
||||||
|
('subject', models.CharField(blank=True, max_length=255, verbose_name='Naslov')),
|
||||||
|
('message', models.TextField(blank=True, verbose_name='Poruka')),
|
||||||
|
('attachments', models.JSONField(blank=True, default=list, verbose_name='Privitci')),
|
||||||
|
('status', models.CharField(choices=[('queued', 'U obradi'), ('sent', 'Poslano'), ('failed', 'Neuspješno')], db_index=True, default='queued', max_length=16, verbose_name='Status')),
|
||||||
|
('error_message', models.TextField(blank=True, verbose_name='Greška')),
|
||||||
|
('metadata', models.JSONField(blank=True, default=dict, verbose_name='Metapodaci')),
|
||||||
|
('sent_at', models.DateTimeField(blank=True, null=True, verbose_name='Poslano u')),
|
||||||
|
('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_email_dispatch_logs', to=settings.AUTH_USER_MODEL, verbose_name='Zatražio')),
|
||||||
|
('service_record', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_dispatch_logs', to='fleet.vehicleservicerecord', verbose_name='Servisni zapis')),
|
||||||
|
('work_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_dispatch_logs', to='fleet.workorder', verbose_name='Putni nalog')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Log slanja emaila',
|
||||||
|
'verbose_name_plural': 'Logovi slanja emaila',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Generated by Django 5.2.15 on 2026-07-31 12:30
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fleet', '0033_emaildispatchlog'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MonthlyServicerDayEntry',
|
||||||
|
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')),
|
||||||
|
('entry_date', models.DateField(db_index=True, verbose_name='Datum')),
|
||||||
|
('entry_type', models.CharField(choices=[('office', 'Rad u uredu'), ('sick_leave', 'Bolovanje'), ('vacation', 'Godišnji odmor')], max_length=24, verbose_name='Vrsta unosa')),
|
||||||
|
('description', models.CharField(blank=True, max_length=255, verbose_name='Opis posla')),
|
||||||
|
('location', models.CharField(blank=True, max_length=255, verbose_name='Mjesto rada')),
|
||||||
|
('start_time', models.TimeField(blank=True, null=True, verbose_name='Početak rada')),
|
||||||
|
('end_time', models.TimeField(blank=True, null=True, verbose_name='Kraj rada')),
|
||||||
|
('regular_hours', models.DecimalField(decimal_places=2, default=Decimal('8.00'), max_digits=5, verbose_name='Redovan rad')),
|
||||||
|
('overtime_hours', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=5, verbose_name='Prekovremeni rad')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='monthly_servicer_day_entries', to=settings.AUTH_USER_MODEL, verbose_name='Serviser')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Dnevni unos mjesečnog izvještaja servisera',
|
||||||
|
'verbose_name_plural': 'Dnevni unosi mjesečnog izvještaja servisera',
|
||||||
|
'ordering': ['entry_date'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='monthlyservicerdayentry',
|
||||||
|
constraint=models.UniqueConstraint(fields=('user', 'entry_date', 'is_active'), name='uniq_monthly_servicer_day_entry_user_date_active'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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', '0034_monthlyservicerdayentry'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GeneratedFleetArchive',
|
||||||
|
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')),
|
||||||
|
('archive_type', models.CharField(choices=[('service_tasks', 'Pojedinačni servisni taskovi'), ('work_orders', 'Putni nalozi i računi')], db_index=True, max_length=32, verbose_name='Tip ZIP arhive')),
|
||||||
|
('year', models.PositiveSmallIntegerField(db_index=True, verbose_name='Godina')),
|
||||||
|
('month', models.PositiveSmallIntegerField(db_index=True, verbose_name='Mjesec')),
|
||||||
|
('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_fleet_archive_upload_to, verbose_name='ZIP 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_fleet_archives', to=settings.AUTH_USER_MODEL, verbose_name='Zatražio')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Generirana ZIP arhiva',
|
||||||
|
'verbose_name_plural': 'Generirane ZIP arhive',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fleet', '0035_generatedfleetarchive'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='WorkOrderTravelExpensesTable',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('broj_sati', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='Broj sati')),
|
||||||
|
('kolicina_dnevnica', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='Količina dnevnica')),
|
||||||
|
('iznos_dnevnica', models.DecimalField(decimal_places=2, default=Decimal('30.00'), max_digits=10, verbose_name='Iznos dnevnice')),
|
||||||
|
('daily_rate_country', models.CharField(choices=[('HR', 'Hrvatska'), ('BIH', 'BiH'), ('SI', 'Slovenija'), ('CG', 'Crna Gora')], default='HR', max_length=8, verbose_name='Država dnevnice')),
|
||||||
|
('total_for_payout', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12, verbose_name='Ukupan iznos')),
|
||||||
|
('work_order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='travel_expenses_table', to='fleet.workorder', verbose_name='Putni nalog')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Tablica obračuna putnih troškova',
|
||||||
|
'verbose_name_plural': 'Tablice obračuna putnih troškova',
|
||||||
|
'ordering': ['-updated_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fleet', '0036_workordertravelexpenses_table'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workordertravelexpensestable',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workordertravelexpensestable',
|
||||||
|
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='workordertravelexpensestable',
|
||||||
|
name='is_active',
|
||||||
|
field=models.BooleanField(default=True, verbose_name='Aktivan zapis'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workordertravelexpensestable',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import re
|
import re
|
||||||
|
from datetime import time
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_save, pre_delete
|
||||||
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 django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.dispatch import receiver
|
||||||
from core.base_models import BaseModel
|
from core.base_models import BaseModel
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -45,6 +48,34 @@ 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}"
|
||||||
|
|
||||||
|
|
||||||
|
def _generated_fleet_archive_upload_to(instance, filename):
|
||||||
|
requester = 'unknown'
|
||||||
|
try:
|
||||||
|
requester = str(instance.requested_by_id or 'unknown')
|
||||||
|
except Exception:
|
||||||
|
requester = 'unknown'
|
||||||
|
unique_name = f"{uuid.uuid4().hex}_{filename}"
|
||||||
|
return f"fleet/generated_archives/{requester}/{unique_name}"
|
||||||
|
|
||||||
|
|
||||||
class VehicleServicePhoto(BaseModel):
|
class VehicleServicePhoto(BaseModel):
|
||||||
"""
|
"""
|
||||||
@@ -113,6 +144,67 @@ class VehicleServiceAttachment(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Attachment {self.pk} for {self.service_record} ({self.file.name if self.file else 'no-file'})"
|
return f"Attachment {self.pk} for {self.service_record} ({self.file.name if self.file else 'no-file'})"
|
||||||
|
|
||||||
|
|
||||||
|
def _service_record_work_order_id(instance):
|
||||||
|
if isinstance(instance, VehicleServiceRecord):
|
||||||
|
task = getattr(instance, 'task', None)
|
||||||
|
if task and getattr(task, 'work_order_id', None):
|
||||||
|
return task.work_order_id
|
||||||
|
if getattr(instance, 'task_id', None):
|
||||||
|
return VehicleServiceRecord.objects.filter(pk=instance.pk).values_list('task__work_order_id', flat=True).first()
|
||||||
|
return None
|
||||||
|
|
||||||
|
service_record = getattr(instance, 'service_record', None)
|
||||||
|
if service_record and getattr(service_record, 'task', None) and getattr(service_record.task, 'work_order_id', None):
|
||||||
|
return service_record.task.work_order_id
|
||||||
|
|
||||||
|
service_record_id = getattr(instance, 'service_record_id', None)
|
||||||
|
if not service_record_id:
|
||||||
|
return None
|
||||||
|
return VehicleServiceRecord.objects.filter(pk=service_record_id).values_list('task__work_order_id', flat=True).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate_service_records_pdf_cache(work_order_id):
|
||||||
|
if not work_order_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
cached_pdfs = GeneratedWorkOrderPdf.objects.filter(
|
||||||
|
is_active=True,
|
||||||
|
work_order_id=work_order_id,
|
||||||
|
pdf_type='service_records',
|
||||||
|
)
|
||||||
|
for cached in cached_pdfs:
|
||||||
|
file_name = getattr(cached.file, 'name', '')
|
||||||
|
if file_name:
|
||||||
|
try:
|
||||||
|
if cached.file.storage.exists(file_name):
|
||||||
|
cached.file.delete(save=False)
|
||||||
|
except (FileNotFoundError, OSError, ValueError):
|
||||||
|
pass
|
||||||
|
cached.file = None
|
||||||
|
cached.is_active = False
|
||||||
|
cached.status = 'failed'
|
||||||
|
cached.error_message = 'PDF cache invalidiran zbog promjene servisnih zapisa.'
|
||||||
|
cached.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender='fleet.VehicleServiceRecord')
|
||||||
|
@receiver(pre_delete, sender='fleet.VehicleServiceRecord')
|
||||||
|
def _invalidate_service_records_pdf_for_service_record(sender, instance, **kwargs):
|
||||||
|
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender='fleet.VehicleServicePhoto')
|
||||||
|
@receiver(pre_delete, sender='fleet.VehicleServicePhoto')
|
||||||
|
def _invalidate_service_records_pdf_for_service_photo(sender, instance, **kwargs):
|
||||||
|
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender='fleet.VehicleServiceAttachment')
|
||||||
|
@receiver(pre_delete, sender='fleet.VehicleServiceAttachment')
|
||||||
|
def _invalidate_service_records_pdf_for_service_attachment(sender, instance, **kwargs):
|
||||||
|
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
|
||||||
|
|
||||||
class Vehicle(BaseModel):
|
class Vehicle(BaseModel):
|
||||||
ASSET_TYPE_CHOICES = [
|
ASSET_TYPE_CHOICES = [
|
||||||
('vehicle', _("Vozilo")),
|
('vehicle', _("Vozilo")),
|
||||||
@@ -232,6 +324,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"))
|
||||||
@@ -266,9 +359,17 @@ class WorkOrder(BaseModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
verbose_name=_("Vozilo servisera - trošak goriva (EUR)"),
|
verbose_name=_("Vozilo servisera - trošak goriva (EUR)"),
|
||||||
)
|
)
|
||||||
|
origin_location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija ishodišta"))
|
||||||
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 +405,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.")})
|
||||||
|
|
||||||
@@ -319,11 +426,11 @@ class WorkOrderInvoice(BaseModel):
|
|||||||
lokacija = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija"))
|
lokacija = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija"))
|
||||||
datum = models.DateField(default=timezone.now, verbose_name=_("Datum"))
|
datum = models.DateField(default=timezone.now, verbose_name=_("Datum"))
|
||||||
opis = models.TextField(blank=True, verbose_name=_("Opis"))
|
opis = models.TextField(blank=True, verbose_name=_("Opis"))
|
||||||
image = models.ImageField(
|
image = models.FileField(
|
||||||
upload_to=_work_order_invoice_upload_to,
|
upload_to=_work_order_invoice_upload_to,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name=_("Slika računa"),
|
verbose_name=_("Datoteka računa"),
|
||||||
)
|
)
|
||||||
created_by = models.ForeignKey(
|
created_by = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
@@ -344,6 +451,218 @@ class WorkOrderInvoice(BaseModel):
|
|||||||
return f"{self.naziv_racuna} ({self.work_order_id})"
|
return f"{self.naziv_racuna} ({self.work_order_id})"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkOrderAdditionalCostsTable(BaseModel):
|
||||||
|
work_order = models.OneToOneField(
|
||||||
|
WorkOrder,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='additional_costs_table',
|
||||||
|
verbose_name=_("Putni nalog"),
|
||||||
|
)
|
||||||
|
data = models.JSONField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
verbose_name=_("Podaci tablice dodatnih troškova"),
|
||||||
|
)
|
||||||
|
total_for_payout = models.DecimalField(
|
||||||
|
max_digits=12,
|
||||||
|
decimal_places=2,
|
||||||
|
default=Decimal('0.00'),
|
||||||
|
verbose_name=_("Ukupno za isplatu"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-updated_at']
|
||||||
|
verbose_name = _("Tablica dodatnih troškova putnog naloga")
|
||||||
|
verbose_name_plural = _("Tablice dodatnih troškova putnog naloga")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Additional costs table for work order {self.work_order_id}"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkOrderTravelExpensesTable(BaseModel):
|
||||||
|
RATE_CHOICES = [
|
||||||
|
('HR', _("Hrvatska")),
|
||||||
|
('BIH', _("BiH")),
|
||||||
|
('SI', _("Slovenija")),
|
||||||
|
('CG', _("Crna Gora")),
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_RATE_COUNTRY = 'HR'
|
||||||
|
DEFAULT_RATE_AMOUNT = Decimal('30.00')
|
||||||
|
|
||||||
|
work_order = models.OneToOneField(
|
||||||
|
WorkOrder,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='travel_expenses_table',
|
||||||
|
verbose_name=_("Putni nalog"),
|
||||||
|
)
|
||||||
|
broj_sati = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
default=Decimal('0.00'),
|
||||||
|
verbose_name=_("Broj sati"),
|
||||||
|
)
|
||||||
|
kolicina_dnevnica = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
default=Decimal('0.00'),
|
||||||
|
verbose_name=_("Količina dnevnica"),
|
||||||
|
)
|
||||||
|
iznos_dnevnica = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
default=DEFAULT_RATE_AMOUNT,
|
||||||
|
verbose_name=_("Iznos dnevnice"),
|
||||||
|
)
|
||||||
|
daily_rate_country = models.CharField(
|
||||||
|
max_length=8,
|
||||||
|
choices=RATE_CHOICES,
|
||||||
|
default=DEFAULT_RATE_COUNTRY,
|
||||||
|
verbose_name=_("Država dnevnice"),
|
||||||
|
)
|
||||||
|
total_for_payout = models.DecimalField(
|
||||||
|
max_digits=12,
|
||||||
|
decimal_places=2,
|
||||||
|
default=Decimal('0.00'),
|
||||||
|
verbose_name=_("Ukupan iznos"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-updated_at']
|
||||||
|
verbose_name = _("Tablica obračuna putnih troškova")
|
||||||
|
verbose_name_plural = _("Tablice obračuna putnih troškova")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Travel expenses table for work order {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 GeneratedFleetArchive(BaseModel):
|
||||||
|
ARCHIVE_TYPE_CHOICES = [
|
||||||
|
('service_tasks', _("Pojedinačni servisni taskovi")),
|
||||||
|
('work_orders', _("Putni nalozi i računi")),
|
||||||
|
]
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('pending', _("U obradi")),
|
||||||
|
('ready', _("Spremno")),
|
||||||
|
('failed', _("Neuspješno")),
|
||||||
|
]
|
||||||
|
|
||||||
|
requested_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='requested_generated_fleet_archives',
|
||||||
|
verbose_name=_("Zatražio"),
|
||||||
|
)
|
||||||
|
archive_type = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=ARCHIVE_TYPE_CHOICES,
|
||||||
|
db_index=True,
|
||||||
|
verbose_name=_("Tip ZIP arhive"),
|
||||||
|
)
|
||||||
|
year = models.PositiveSmallIntegerField(db_index=True, verbose_name=_("Godina"))
|
||||||
|
month = models.PositiveSmallIntegerField(db_index=True, verbose_name=_("Mjesec"))
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='pending',
|
||||||
|
db_index=True,
|
||||||
|
verbose_name=_("Status"),
|
||||||
|
)
|
||||||
|
file = models.FileField(
|
||||||
|
upload_to=_generated_fleet_archive_upload_to,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("ZIP 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 = _("Generirana ZIP arhiva")
|
||||||
|
verbose_name_plural = _("Generirane ZIP arhive")
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.archive_type}:{self.year}-{self.month:02d}:{self.status}"
|
||||||
|
|
||||||
|
|
||||||
class VehicleServiceRecord(BaseModel):
|
class VehicleServiceRecord(BaseModel):
|
||||||
"""
|
"""
|
||||||
Zapisi o servisima/popravcima na vozilu.
|
Zapisi o servisima/popravcima na vozilu.
|
||||||
@@ -430,4 +749,235 @@ class VehicleNotification(BaseModel):
|
|||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title} -> {self.recipient} ({self.level})"
|
return f"{self.title} -> {self.recipient} ({self.level})"
|
||||||
|
|
||||||
|
|
||||||
|
class EmailDispatchLog(BaseModel):
|
||||||
|
DISPATCH_TYPE_CHOICES = [
|
||||||
|
('work_order', _("Putni nalog")),
|
||||||
|
('service_record', _("Servisni zapis")),
|
||||||
|
]
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('queued', _("U obradi")),
|
||||||
|
('sent', _("Poslano")),
|
||||||
|
('failed', _("Neuspješno")),
|
||||||
|
]
|
||||||
|
|
||||||
|
dispatch_type = models.CharField(max_length=32, choices=DISPATCH_TYPE_CHOICES, db_index=True, verbose_name=_("Tip slanja"))
|
||||||
|
requested_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='requested_email_dispatch_logs',
|
||||||
|
verbose_name=_("Zatražio"),
|
||||||
|
)
|
||||||
|
work_order = models.ForeignKey(
|
||||||
|
'fleet.WorkOrder',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='email_dispatch_logs',
|
||||||
|
verbose_name=_("Putni nalog"),
|
||||||
|
)
|
||||||
|
service_record = models.ForeignKey(
|
||||||
|
'fleet.VehicleServiceRecord',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='email_dispatch_logs',
|
||||||
|
verbose_name=_("Servisni zapis"),
|
||||||
|
)
|
||||||
|
recipients = models.JSONField(default=list, blank=True, verbose_name=_("Primatelji"))
|
||||||
|
subject = models.CharField(max_length=255, blank=True, verbose_name=_("Naslov"))
|
||||||
|
message = models.TextField(blank=True, verbose_name=_("Poruka"))
|
||||||
|
attachments = models.JSONField(default=list, blank=True, verbose_name=_("Privitci"))
|
||||||
|
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='queued', db_index=True, verbose_name=_("Status"))
|
||||||
|
error_message = models.TextField(blank=True, verbose_name=_("Greška"))
|
||||||
|
metadata = models.JSONField(default=dict, blank=True, verbose_name=_("Metapodaci"))
|
||||||
|
sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Poslano u"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Log slanja emaila")
|
||||||
|
verbose_name_plural = _("Logovi slanja emaila")
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.dispatch_type} -> {len(self.recipients or [])} ({self.status})"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
if self.dispatch_type == 'work_order' and not self.work_order_id:
|
||||||
|
raise ValidationError({'work_order': _("Putni nalog je obavezan za ovaj tip slanja.")})
|
||||||
|
if self.dispatch_type == 'service_record' and not self.service_record_id:
|
||||||
|
raise ValidationError({'service_record': _("Servisni zapis je obavezan za ovaj tip slanja.")})
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceContextNote(BaseModel):
|
||||||
|
AUDIENCE_SOURCE_CHOICES = [
|
||||||
|
('self', 'Samo autor'),
|
||||||
|
('member', 'Odabrani član tima'),
|
||||||
|
('team', 'Svi članovi tima'),
|
||||||
|
]
|
||||||
|
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='created_service_context_notes',
|
||||||
|
verbose_name=_("Kreirao"),
|
||||||
|
)
|
||||||
|
recipient = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='service_context_notes',
|
||||||
|
verbose_name=_("Primatelj"),
|
||||||
|
)
|
||||||
|
audience_source = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=AUDIENCE_SOURCE_CHOICES,
|
||||||
|
default='self',
|
||||||
|
verbose_name=_("Publika"),
|
||||||
|
)
|
||||||
|
work_order = models.ForeignKey(
|
||||||
|
WorkOrder,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='service_context_notes',
|
||||||
|
verbose_name=_("Putni nalog"),
|
||||||
|
)
|
||||||
|
task = models.ForeignKey(
|
||||||
|
'task_management.Task',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='service_context_notes',
|
||||||
|
verbose_name=_("Radni zadatak"),
|
||||||
|
)
|
||||||
|
note = models.TextField(verbose_name=_("Bilješka"))
|
||||||
|
note_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
verbose_name=_("Datum podsjetnika"),
|
||||||
|
)
|
||||||
|
is_closed = models.BooleanField(default=False, verbose_name=_("Ručno zatvoreno"))
|
||||||
|
closed_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Vrijeme zatvaranja"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Bilješka servisnog konteksta")
|
||||||
|
verbose_name_plural = _("Bilješke servisnog konteksta")
|
||||||
|
ordering = ['is_closed', 'note_date', '-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Note {self.pk} -> {self.recipient_id}"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
if self.task_id and self.work_order_id and self.task and self.task.work_order_id:
|
||||||
|
if self.task.work_order_id != self.work_order_id:
|
||||||
|
raise ValidationError({
|
||||||
|
'task': _("Radni zadatak mora biti povezan s istim putnim nalogom kao bilješka."),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class MonthlyServicerDayEntry(BaseModel):
|
||||||
|
ENTRY_TYPE_CHOICES = [
|
||||||
|
('office', _('Rad u uredu')),
|
||||||
|
('sick_leave', _('Bolovanje')),
|
||||||
|
('vacation', _('Godišnji odmor')),
|
||||||
|
]
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='monthly_servicer_day_entries',
|
||||||
|
verbose_name=_('Serviser'),
|
||||||
|
)
|
||||||
|
entry_date = models.DateField(db_index=True, verbose_name=_('Datum'))
|
||||||
|
entry_type = models.CharField(
|
||||||
|
max_length=24,
|
||||||
|
choices=ENTRY_TYPE_CHOICES,
|
||||||
|
verbose_name=_('Vrsta unosa'),
|
||||||
|
)
|
||||||
|
description = models.CharField(max_length=255, blank=True, verbose_name=_('Opis posla'))
|
||||||
|
location = models.CharField(max_length=255, blank=True, verbose_name=_('Mjesto rada'))
|
||||||
|
start_time = models.TimeField(null=True, blank=True, verbose_name=_('Početak rada'))
|
||||||
|
end_time = models.TimeField(null=True, blank=True, verbose_name=_('Kraj rada'))
|
||||||
|
regular_hours = models.DecimalField(
|
||||||
|
max_digits=5,
|
||||||
|
decimal_places=2,
|
||||||
|
default=Decimal('8.00'),
|
||||||
|
verbose_name=_('Redovan rad'),
|
||||||
|
)
|
||||||
|
overtime_hours = models.DecimalField(
|
||||||
|
max_digits=5,
|
||||||
|
decimal_places=2,
|
||||||
|
default=Decimal('0.00'),
|
||||||
|
verbose_name=_('Prekovremeni rad'),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('Dnevni unos mjesečnog izvještaja servisera')
|
||||||
|
verbose_name_plural = _('Dnevni unosi mjesečnog izvještaja servisera')
|
||||||
|
ordering = ['entry_date']
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=['user', 'entry_date', 'is_active'],
|
||||||
|
name='uniq_monthly_servicer_day_entry_user_date_active',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user_id} @ {self.entry_date} ({self.entry_type})"
|
||||||
|
|
||||||
|
def _calculate_regular_hours(self, start_time, end_time):
|
||||||
|
if start_time is None or end_time is None:
|
||||||
|
return Decimal('8.00')
|
||||||
|
|
||||||
|
start_minutes = (start_time.hour * 60) + start_time.minute + (start_time.second / 60)
|
||||||
|
end_minutes = (end_time.hour * 60) + end_time.minute + (end_time.second / 60)
|
||||||
|
if end_minutes <= start_minutes:
|
||||||
|
raise ValidationError({
|
||||||
|
'end_time': _('Kraj rada mora biti nakon početka rada.'),
|
||||||
|
})
|
||||||
|
|
||||||
|
hours = Decimal(str((end_minutes - start_minutes) / 60)).quantize(Decimal('0.01'))
|
||||||
|
return hours
|
||||||
|
|
||||||
|
def apply_defaults(self):
|
||||||
|
start_time = self.start_time
|
||||||
|
end_time = self.end_time
|
||||||
|
|
||||||
|
if self.entry_type == 'office':
|
||||||
|
if not self.description:
|
||||||
|
self.description = 'Rad u uredu'
|
||||||
|
if not self.location:
|
||||||
|
self.location = 'Zagreb (ured)'
|
||||||
|
if start_time is None:
|
||||||
|
start_time = time(8, 0)
|
||||||
|
if end_time is None:
|
||||||
|
end_time = time(16, 0)
|
||||||
|
elif self.entry_type == 'vacation':
|
||||||
|
if not self.description:
|
||||||
|
self.description = 'Godišnji odmor'
|
||||||
|
self.location = self.location or ''
|
||||||
|
elif self.entry_type == 'sick_leave':
|
||||||
|
if not self.description:
|
||||||
|
self.description = 'Bolovanje'
|
||||||
|
self.location = self.location or ''
|
||||||
|
else:
|
||||||
|
self.location = self.location or ''
|
||||||
|
|
||||||
|
self.start_time = start_time
|
||||||
|
self.end_time = end_time
|
||||||
|
self.regular_hours = self._calculate_regular_hours(start_time, end_time)
|
||||||
|
self.overtime_hours = Decimal('0.00')
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
self.apply_defaults()
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.full_clean()
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from modules.task_management.models import Task
|
||||||
from .models import (
|
from .models import (
|
||||||
Crane,
|
Crane,
|
||||||
Vehicle,
|
Vehicle,
|
||||||
|
ServiceContextNote,
|
||||||
|
MonthlyServicerDayEntry,
|
||||||
VehicleNotification,
|
VehicleNotification,
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
|
WorkOrderAdditionalCostsTable,
|
||||||
|
WorkOrderTravelExpensesTable,
|
||||||
|
WorkOrderPhoto,
|
||||||
WorkOrderInvoice,
|
WorkOrderInvoice,
|
||||||
VehicleServiceRecord,
|
VehicleServiceRecord,
|
||||||
VehicleServicePhoto,
|
VehicleServicePhoto,
|
||||||
@@ -12,6 +23,8 @@ from .models import (
|
|||||||
)
|
)
|
||||||
from . import services
|
from . import services
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class VehicleSerializer(serializers.ModelSerializer):
|
class VehicleSerializer(serializers.ModelSerializer):
|
||||||
# Čitljivo ime klijenta — read-only, za prikaz u frontendu i adminu
|
# Čitljivo ime klijenta — read-only, za prikaz u frontendu i adminu
|
||||||
@@ -27,7 +40,7 @@ class VehicleSerializer(serializers.ModelSerializer):
|
|||||||
'is_company_vehicle', 'client', 'client_name',
|
'is_company_vehicle', 'client', 'client_name',
|
||||||
'assigned_servicer', 'is_active',
|
'assigned_servicer', 'is_active',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'current_mileage', 'client_name']
|
read_only_fields = ['id', 'client_name']
|
||||||
|
|
||||||
def get_client_name(self, obj):
|
def get_client_name(self, obj):
|
||||||
if obj.client_id:
|
if obj.client_id:
|
||||||
@@ -89,6 +102,161 @@ class VehicleNotificationSerializer(serializers.ModelSerializer):
|
|||||||
read_only_fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'created_at']
|
read_only_fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'created_at']
|
||||||
# is_read is writable via the "mark_read" action, but normally read-only in list
|
# is_read is writable via the "mark_read" action, but normally read-only in list
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceContextNoteSerializer(serializers.ModelSerializer):
|
||||||
|
created_by_name = serializers.SerializerMethodField()
|
||||||
|
recipient_name = serializers.SerializerMethodField()
|
||||||
|
work_order_label = serializers.SerializerMethodField()
|
||||||
|
task_title = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ServiceContextNote
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'created_by',
|
||||||
|
'created_by_name',
|
||||||
|
'recipient',
|
||||||
|
'recipient_name',
|
||||||
|
'audience_source',
|
||||||
|
'work_order',
|
||||||
|
'work_order_label',
|
||||||
|
'task',
|
||||||
|
'task_title',
|
||||||
|
'note',
|
||||||
|
'note_date',
|
||||||
|
'is_closed',
|
||||||
|
'closed_at',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
def get_created_by_name(self, obj):
|
||||||
|
return obj.created_by.get_full_name() or obj.created_by.email
|
||||||
|
|
||||||
|
def get_recipient_name(self, obj):
|
||||||
|
return obj.recipient.get_full_name() or obj.recipient.email
|
||||||
|
|
||||||
|
def get_work_order_label(self, obj):
|
||||||
|
if not obj.work_order_id:
|
||||||
|
return None
|
||||||
|
display_code = str(getattr(obj.work_order, 'display_code', '') or '').strip().upper()
|
||||||
|
return display_code or f"WO-{obj.work_order_id}"
|
||||||
|
|
||||||
|
def get_task_title(self, obj):
|
||||||
|
if not obj.task_id:
|
||||||
|
return None
|
||||||
|
return obj.task.title
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceContextNoteCreateSerializer(serializers.Serializer):
|
||||||
|
AUDIENCE_CHOICES = ['self', 'member', 'team']
|
||||||
|
|
||||||
|
note = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
|
||||||
|
note_date = serializers.DateField(required=False, allow_null=True)
|
||||||
|
audience = serializers.ChoiceField(choices=AUDIENCE_CHOICES, default='self')
|
||||||
|
target_user = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=User.objects.filter(is_active=True, is_team_member=True),
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
)
|
||||||
|
target_users = serializers.ListField(
|
||||||
|
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(is_active=True, is_team_member=True)),
|
||||||
|
required=False,
|
||||||
|
allow_empty=False,
|
||||||
|
)
|
||||||
|
work_order = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=WorkOrder.objects.filter(is_active=True),
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
)
|
||||||
|
task = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Task.objects.filter(is_active=True),
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
request = self.context.get('request')
|
||||||
|
user = getattr(request, 'user', None)
|
||||||
|
if user is None:
|
||||||
|
raise serializers.ValidationError('Autentikacija je obavezna.')
|
||||||
|
|
||||||
|
audience = attrs.get('audience', 'self')
|
||||||
|
target_user = attrs.get('target_user')
|
||||||
|
target_users = attrs.get('target_users') or []
|
||||||
|
work_order = attrs.get('work_order')
|
||||||
|
task = attrs.get('task')
|
||||||
|
note = str(attrs.get('note', '') or '').strip()
|
||||||
|
can_manage_recipients = bool(
|
||||||
|
getattr(user, 'is_serviser', False)
|
||||||
|
or getattr(user, 'is_staff', False)
|
||||||
|
or (getattr(user, 'is_team_member', False) and not getattr(user, 'is_serviser', False))
|
||||||
|
)
|
||||||
|
|
||||||
|
if not note:
|
||||||
|
raise serializers.ValidationError({'note': 'Bilješka ne može biti prazna.'})
|
||||||
|
|
||||||
|
if not can_manage_recipients and audience != 'self':
|
||||||
|
raise serializers.ValidationError({'audience': 'Samo supervisor ili serviser može slati bilješke drugim članovima tima.'})
|
||||||
|
if audience == 'member' and not target_user and len(target_users) == 0:
|
||||||
|
raise serializers.ValidationError({'target_users': 'Odaberite barem jednog servisera.'})
|
||||||
|
if audience in {'self', 'team'} and target_user:
|
||||||
|
raise serializers.ValidationError({'target_user': 'Odabrani tip publike ne koristi ciljano polje člana tima.'})
|
||||||
|
if audience in {'self', 'team'} and len(target_users) > 0:
|
||||||
|
raise serializers.ValidationError({'target_users': 'Odabrani tip publike ne koristi listu članova tima.'})
|
||||||
|
if target_user and not getattr(target_user, 'is_team_member', False):
|
||||||
|
raise serializers.ValidationError({'target_user': 'Bilješku je moguće poslati samo članu tima.'})
|
||||||
|
for selected_user in target_users:
|
||||||
|
if not getattr(selected_user, 'is_team_member', False):
|
||||||
|
raise serializers.ValidationError({'target_users': 'Bilješku je moguće poslati samo članovima tima.'})
|
||||||
|
|
||||||
|
if work_order:
|
||||||
|
if not work_order.is_active or str(work_order.status or '').lower() != 'open':
|
||||||
|
raise serializers.ValidationError({'work_order': 'Moguće je povezati samo aktivni otvoreni putni nalog.'})
|
||||||
|
|
||||||
|
if task:
|
||||||
|
status = str(task.status or '').lower()
|
||||||
|
if status in {'zavrsen', 'neaktivan'}:
|
||||||
|
raise serializers.ValidationError({'task': 'Moguće je povezati samo aktivni radni zadatak.'})
|
||||||
|
|
||||||
|
if task and work_order and task.work_order_id and str(task.work_order_id) != str(work_order.id):
|
||||||
|
raise serializers.ValidationError({'task': 'Radni zadatak ne pripada odabranom putnom nalogu.'})
|
||||||
|
|
||||||
|
attrs['note'] = note
|
||||||
|
if task and not work_order and task.work_order_id:
|
||||||
|
attrs['work_order'] = task.work_order
|
||||||
|
if audience == 'member' and len(target_users) == 0 and target_user:
|
||||||
|
attrs['target_users'] = [target_user]
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class MonthlyServicerDayEntrySerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = MonthlyServicerDayEntry
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'entry_date',
|
||||||
|
'entry_type',
|
||||||
|
'description',
|
||||||
|
'location',
|
||||||
|
'start_time',
|
||||||
|
'end_time',
|
||||||
|
'regular_hours',
|
||||||
|
'overtime_hours',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
'id',
|
||||||
|
'description',
|
||||||
|
'location',
|
||||||
|
'regular_hours',
|
||||||
|
'overtime_hours',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
|
||||||
class WorkOrderSerializer(serializers.ModelSerializer):
|
class WorkOrderSerializer(serializers.ModelSerializer):
|
||||||
crane = serializers.SerializerMethodField()
|
crane = serializers.SerializerMethodField()
|
||||||
crane_label = serializers.SerializerMethodField()
|
crane_label = serializers.SerializerMethodField()
|
||||||
@@ -96,12 +264,12 @@ 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',
|
||||||
'servicer_vehicle_fuel_cost',
|
'servicer_vehicle_fuel_cost',
|
||||||
'location', 'travel_start_at', 'travel_end_at',
|
'origin_location', 'location', 'travel_start_at', 'travel_end_at',
|
||||||
'purpose', 'notes', 'status'
|
'purpose', 'notes', 'status'
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'date']
|
read_only_fields = ['id', 'date']
|
||||||
@@ -110,11 +278,13 @@ 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},
|
||||||
'servicer_vehicle_end_mileage': {'required': False, 'allow_null': True},
|
'servicer_vehicle_end_mileage': {'required': False, 'allow_null': True},
|
||||||
'servicer_vehicle_fuel_cost': {'required': False, 'allow_null': True},
|
'servicer_vehicle_fuel_cost': {'required': False, 'allow_null': True},
|
||||||
|
'origin_location': {'required': False, 'allow_blank': True},
|
||||||
'location': {'required': False, 'allow_blank': True},
|
'location': {'required': False, 'allow_blank': True},
|
||||||
'travel_start_at': {'required': False, 'allow_null': True},
|
'travel_start_at': {'required': False, 'allow_null': True},
|
||||||
'travel_end_at': {'required': False, 'allow_null': True},
|
'travel_end_at': {'required': False, 'allow_null': True},
|
||||||
@@ -144,6 +314,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,23 +358,258 @@ 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()
|
||||||
|
image_content_type = serializers.SerializerMethodField()
|
||||||
|
work_order_display_code = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WorkOrderInvoice
|
model = WorkOrderInvoice
|
||||||
fields = [
|
fields = [
|
||||||
'id',
|
'id',
|
||||||
'work_order',
|
'work_order',
|
||||||
|
'work_order_display_code',
|
||||||
'naziv_racuna',
|
'naziv_racuna',
|
||||||
'lokacija',
|
'lokacija',
|
||||||
'datum',
|
'datum',
|
||||||
'opis',
|
'opis',
|
||||||
'image',
|
'image',
|
||||||
|
'image_url',
|
||||||
|
'image_content_type',
|
||||||
'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
|
||||||
|
base = request.build_absolute_uri(
|
||||||
|
reverse('work-order-invoice-image', kwargs={'pk': obj.pk})
|
||||||
|
)
|
||||||
|
suffix = Path(obj.image.name or '').suffix.lower()
|
||||||
|
if suffix == '.pdf':
|
||||||
|
return base
|
||||||
|
return f"{base}?w=1280&q=80&fmt=jpeg"
|
||||||
|
|
||||||
|
def get_image_content_type(self, obj):
|
||||||
|
if not obj.image:
|
||||||
|
return None
|
||||||
|
suffix = Path(obj.image.name or '').suffix.lower()
|
||||||
|
if suffix == '.pdf':
|
||||||
|
return 'application/pdf'
|
||||||
|
if suffix in {'.jpg', '.jpeg'}:
|
||||||
|
return 'image/jpeg'
|
||||||
|
if suffix == '.png':
|
||||||
|
return 'image/png'
|
||||||
|
if suffix == '.webp':
|
||||||
|
return 'image/webp'
|
||||||
|
if suffix == '.gif':
|
||||||
|
return 'image/gif'
|
||||||
|
return 'application/octet-stream'
|
||||||
|
|
||||||
|
def get_work_order_display_code(self, obj):
|
||||||
|
work_order = getattr(obj, 'work_order', None)
|
||||||
|
if work_order is None:
|
||||||
|
return None
|
||||||
|
display_code = str(getattr(work_order, 'display_code', '') or '').strip()
|
||||||
|
if display_code:
|
||||||
|
return display_code
|
||||||
|
return str(getattr(work_order, 'id', '') or '').strip() or None
|
||||||
|
|
||||||
|
def validate_image(self, value):
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
suffix = Path(getattr(value, 'name', '')).suffix.lower()
|
||||||
|
allowed_suffixes = {'.jpg', '.jpeg', '.png', '.webp', '.gif', '.pdf'}
|
||||||
|
if suffix not in allowed_suffixes:
|
||||||
|
raise serializers.ValidationError('Datoteka računa mora biti slika (.jpg, .jpeg, .png, .webp, .gif) ili PDF (.pdf).')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class WorkOrderAdditionalCostsTableSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = WorkOrderAdditionalCostsTable
|
||||||
|
fields = ['id', 'work_order', 'data', 'total_for_payout', 'created_at', 'updated_at']
|
||||||
|
read_only_fields = ['id', 'total_for_payout', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_decimal(value):
|
||||||
|
if value in (None, ''):
|
||||||
|
return Decimal('0.00')
|
||||||
|
try:
|
||||||
|
normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.')
|
||||||
|
return Decimal(normalized)
|
||||||
|
except (InvalidOperation, ValueError, TypeError):
|
||||||
|
return Decimal('0.00')
|
||||||
|
|
||||||
|
def _normalize_row(self, row):
|
||||||
|
return {
|
||||||
|
'naziv': str(row.get('naziv', '') or '').strip(),
|
||||||
|
'broj_racuna': str(row.get('broj_racuna', '') or '').strip(),
|
||||||
|
'ukupan_iznos': str(row.get('ukupan_iznos', '') or '').strip(),
|
||||||
|
'prilog': str(row.get('prilog', '') or '').strip(),
|
||||||
|
'source_invoice_id': str(row.get('source_invoice_id', '') or '').strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_data(self, value):
|
||||||
|
if value is None:
|
||||||
|
return {'rows': []}
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise serializers.ValidationError("Podaci tablice moraju biti JSON objekt.")
|
||||||
|
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[:100]:
|
||||||
|
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}
|
||||||
|
|
||||||
|
def _calculate_total_for_payout(self, data):
|
||||||
|
rows = data.get('rows', []) if isinstance(data, dict) else []
|
||||||
|
total = sum((self._parse_decimal(row.get('ukupan_iznos')) for row in rows), Decimal('0.00'))
|
||||||
|
return total.quantize(Decimal('0.01'))
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data['total_for_payout'] = self._calculate_total_for_payout(validated_data.get('data'))
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
data = validated_data.get('data', instance.data or {'rows': []})
|
||||||
|
validated_data['total_for_payout'] = self._calculate_total_for_payout(data)
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkOrderTravelExpensesTableSerializer(serializers.ModelSerializer):
|
||||||
|
RATE_DEFAULTS = {
|
||||||
|
'HR': Decimal('30.00'),
|
||||||
|
'BIH': Decimal('50.00'),
|
||||||
|
'SI': Decimal('80.00'),
|
||||||
|
'CG': Decimal('50.00'),
|
||||||
|
}
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = WorkOrderTravelExpensesTable
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'work_order',
|
||||||
|
'broj_sati',
|
||||||
|
'kolicina_dnevnica',
|
||||||
|
'iznos_dnevnica',
|
||||||
|
'daily_rate_country',
|
||||||
|
'total_for_payout',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'total_for_payout', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_decimal(value):
|
||||||
|
if value in (None, ''):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.')
|
||||||
|
return Decimal(normalized)
|
||||||
|
except (InvalidOperation, ValueError, TypeError):
|
||||||
|
raise serializers.ValidationError('Neispravna decimalna vrijednost.')
|
||||||
|
|
||||||
|
def validate_daily_rate_country(self, value):
|
||||||
|
normalized = str(value or '').strip().upper()
|
||||||
|
if not normalized:
|
||||||
|
return WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY
|
||||||
|
valid_choices = {choice for choice, _label in WorkOrderTravelExpensesTable.RATE_CHOICES}
|
||||||
|
if normalized not in valid_choices:
|
||||||
|
raise serializers.ValidationError('Neispravna država dnevnice.')
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
instance = getattr(self, 'instance', None)
|
||||||
|
country = attrs.get(
|
||||||
|
'daily_rate_country',
|
||||||
|
getattr(instance, 'daily_rate_country', WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY),
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'iznos_dnevnica' not in attrs or attrs.get('iznos_dnevnica') in (None, ''):
|
||||||
|
attrs['iznos_dnevnica'] = self.RATE_DEFAULTS.get(
|
||||||
|
country,
|
||||||
|
WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'broj_sati' in attrs:
|
||||||
|
attrs['broj_sati'] = self._parse_decimal(attrs['broj_sati'])
|
||||||
|
if 'kolicina_dnevnica' in attrs:
|
||||||
|
attrs['kolicina_dnevnica'] = self._parse_decimal(attrs['kolicina_dnevnica'])
|
||||||
|
if 'iznos_dnevnica' in attrs:
|
||||||
|
attrs['iznos_dnevnica'] = self._parse_decimal(attrs['iznos_dnevnica'])
|
||||||
|
|
||||||
|
attrs['broj_sati'] = attrs.get(
|
||||||
|
'broj_sati',
|
||||||
|
getattr(instance, 'broj_sati', Decimal('0.00')),
|
||||||
|
) or Decimal('0.00')
|
||||||
|
attrs['kolicina_dnevnica'] = attrs.get(
|
||||||
|
'kolicina_dnevnica',
|
||||||
|
getattr(instance, 'kolicina_dnevnica', Decimal('0.00')),
|
||||||
|
) or Decimal('0.00')
|
||||||
|
attrs['iznos_dnevnica'] = attrs.get(
|
||||||
|
'iznos_dnevnica',
|
||||||
|
getattr(instance, 'iznos_dnevnica', self.RATE_DEFAULTS.get(country, WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT)),
|
||||||
|
) or self.RATE_DEFAULTS.get(country, WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT)
|
||||||
|
attrs['daily_rate_country'] = country
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def _calculate_total_for_payout(self, validated_data):
|
||||||
|
quantity = validated_data.get('kolicina_dnevnica', Decimal('0.00'))
|
||||||
|
rate = validated_data.get('iznos_dnevnica', Decimal('0.00'))
|
||||||
|
if not isinstance(quantity, Decimal):
|
||||||
|
quantity = self._parse_decimal(quantity) or Decimal('0.00')
|
||||||
|
if not isinstance(rate, Decimal):
|
||||||
|
rate = self._parse_decimal(rate) or Decimal('0.00')
|
||||||
|
return (quantity * rate).quantize(Decimal('0.01'))
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data['total_for_payout'] = self._calculate_total_for_payout(validated_data)
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
merged = {
|
||||||
|
'kolicina_dnevnica': validated_data.get('kolicina_dnevnica', instance.kolicina_dnevnica),
|
||||||
|
'iznos_dnevnica': validated_data.get('iznos_dnevnica', instance.iznos_dnevnica),
|
||||||
|
}
|
||||||
|
validated_data['total_for_payout'] = self._calculate_total_for_payout(merged)
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
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 +620,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 +675,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,
|
||||||
|
|||||||
@@ -1,22 +1,49 @@
|
|||||||
# backend/modules/fleet/tasks.py
|
# backend/modules/fleet/tasks.py
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
from core.celery import ResourceAwareTask
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
|
from django.core.mail import EmailMessage
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.core.files import File
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
import logging
|
import logging
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
import base64
|
import base64
|
||||||
|
import re
|
||||||
|
import csv
|
||||||
|
import mimetypes
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from smtplib import SMTPSenderRefused
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
|
from django.core.validators import validate_email
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
from django.db import transaction
|
||||||
|
from pypdf import PdfReader
|
||||||
|
from pypdf.errors import PdfReadError
|
||||||
|
import pytesseract
|
||||||
|
from pytesseract import TesseractNotFoundError
|
||||||
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, GeneratedFleetArchive
|
||||||
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
|
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
|
||||||
|
from .email_utils import append_user_signature
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
register_unicode_fonts()
|
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)
|
||||||
def send_notification_email(self, notification_id):
|
def send_notification_email(self, notification_id):
|
||||||
"""
|
"""
|
||||||
@@ -77,6 +104,451 @@ def send_email_task(recipient_email, subject, message, notif_id):
|
|||||||
logger.error(f"Async mail failed: {exc}")
|
logger.error(f"Async mail failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_content_type(filename):
|
||||||
|
mime, _ = mimetypes.guess_type(str(filename or ''))
|
||||||
|
return mime or 'application/octet-stream'
|
||||||
|
|
||||||
|
|
||||||
|
def _file_attachment(file_field, fallback_name):
|
||||||
|
if not file_field:
|
||||||
|
return None
|
||||||
|
file_field.open('rb')
|
||||||
|
try:
|
||||||
|
content = file_field.read()
|
||||||
|
finally:
|
||||||
|
file_field.close()
|
||||||
|
if not content:
|
||||||
|
return None
|
||||||
|
filename = Path(str(getattr(file_field, 'name', '') or fallback_name)).name or fallback_name
|
||||||
|
return (filename, content, _guess_content_type(filename))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_image_attachments_for_work_order(work_order):
|
||||||
|
from .models import WorkOrderPhoto, WorkOrderInvoice
|
||||||
|
|
||||||
|
attachments = []
|
||||||
|
photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
|
||||||
|
for index, photo in enumerate(photos, start=1):
|
||||||
|
attachment = _file_attachment(photo.image, fallback_name=f"work-order-photo-{index}.bin")
|
||||||
|
if attachment:
|
||||||
|
attachments.append(attachment)
|
||||||
|
|
||||||
|
invoices = WorkOrderInvoice.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
|
||||||
|
for index, invoice in enumerate(invoices, start=1):
|
||||||
|
attachment = _file_attachment(invoice.image, fallback_name=f"work-order-invoice-{index}.bin")
|
||||||
|
if attachment:
|
||||||
|
attachments.append(attachment)
|
||||||
|
return attachments
|
||||||
|
|
||||||
|
|
||||||
|
def _user_display_name(user):
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
full_name = user.get_full_name()
|
||||||
|
if full_name:
|
||||||
|
return full_name
|
||||||
|
if getattr(user, 'email', None):
|
||||||
|
return user.email
|
||||||
|
if getattr(user, 'username', None):
|
||||||
|
return user.username
|
||||||
|
return str(getattr(user, 'pk', ''))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_monthly_tasks_csv_attachment(work_order, *, year, month, month_key):
|
||||||
|
from modules.task_management.models import Task
|
||||||
|
|
||||||
|
tasks = (
|
||||||
|
Task.objects
|
||||||
|
.filter(
|
||||||
|
is_active=True,
|
||||||
|
vehicle_id=work_order.vehicle_id,
|
||||||
|
scheduled_date__year=year,
|
||||||
|
scheduled_date__month=month,
|
||||||
|
)
|
||||||
|
.select_related('assigned_to', 'work_order')
|
||||||
|
.order_by('scheduled_date', 'created_at')
|
||||||
|
)
|
||||||
|
|
||||||
|
csv_buffer = StringIO()
|
||||||
|
writer = csv.writer(csv_buffer)
|
||||||
|
writer.writerow(['Datum', 'Naslov zadatka', 'Status', 'Serviser', 'Putni nalog', 'Opis'])
|
||||||
|
for task in tasks:
|
||||||
|
assignee_name = _user_display_name(task.assigned_to) or '-'
|
||||||
|
work_order_label = str(getattr(getattr(task, 'work_order', None), 'display_code', '') or '').strip().upper() or '-'
|
||||||
|
writer.writerow([
|
||||||
|
task.scheduled_date.isoformat() if task.scheduled_date else '',
|
||||||
|
task.title or '',
|
||||||
|
task.status or '',
|
||||||
|
assignee_name,
|
||||||
|
work_order_label,
|
||||||
|
task.description or '',
|
||||||
|
])
|
||||||
|
csv_content = csv_buffer.getvalue().encode('utf-8')
|
||||||
|
filename = f"{_work_order_display_code(work_order)}.monthly-tasks-{month_key}.csv"
|
||||||
|
return (filename, csv_content, 'text/csv'), tasks.count()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_from_email():
|
||||||
|
candidates = [
|
||||||
|
getattr(settings, 'DEFAULT_FROM_EMAIL', None),
|
||||||
|
getattr(settings, 'EMAIL_HOST_USER', None),
|
||||||
|
]
|
||||||
|
for candidate in candidates:
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
normalized = str(candidate).strip()
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
validate_email(normalized)
|
||||||
|
return normalized
|
||||||
|
except DjangoValidationError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_generated_pdf_type(filename):
|
||||||
|
normalized = str(filename or '').strip().lower()
|
||||||
|
if normalized.endswith('.work-order-invoices.pdf'):
|
||||||
|
return 'invoices'
|
||||||
|
if normalized.endswith('.work-order-service-records.pdf'):
|
||||||
|
return 'service_records'
|
||||||
|
if normalized.endswith('.work-order.pdf'):
|
||||||
|
return 'work_order'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_pdf_attachments_for_download(*, work_order, requested_by, attachments):
|
||||||
|
cached_payload = []
|
||||||
|
ttl_hours = int(getattr(settings, 'GENERATED_PDF_TTL_HOURS', 24))
|
||||||
|
expires_at = timezone.now() + timedelta(hours=ttl_hours)
|
||||||
|
|
||||||
|
for filename, content, content_type in attachments:
|
||||||
|
if str(content_type or '').lower() != 'application/pdf':
|
||||||
|
continue
|
||||||
|
pdf_type = _detect_generated_pdf_type(filename)
|
||||||
|
if not pdf_type:
|
||||||
|
continue
|
||||||
|
generated = GeneratedWorkOrderPdf.objects.create(
|
||||||
|
work_order=work_order,
|
||||||
|
requested_by=requested_by,
|
||||||
|
pdf_type=pdf_type,
|
||||||
|
status='ready',
|
||||||
|
filename=filename,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
generated.file.save(filename, ContentFile(content), save=False)
|
||||||
|
generated.save(update_fields=['file', 'updated_at'])
|
||||||
|
cached_payload.append(
|
||||||
|
{
|
||||||
|
'generated_pdf_id': str(generated.pk),
|
||||||
|
'pdf_type': pdf_type,
|
||||||
|
'filename': filename,
|
||||||
|
'download_url': f"fleet/work-orders/{work_order.pk}/generated-pdfs/{generated.pk}/download/",
|
||||||
|
'expires_at': expires_at.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return cached_payload
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_work_order_email_bundle_task(
|
||||||
|
*,
|
||||||
|
work_order_id,
|
||||||
|
requested_by_id,
|
||||||
|
recipients,
|
||||||
|
subject='',
|
||||||
|
message='',
|
||||||
|
include_work_order_pdf=True,
|
||||||
|
include_service_records_pdf=False,
|
||||||
|
include_invoices_pdf=False,
|
||||||
|
include_images=False,
|
||||||
|
include_monthly_tasks=False,
|
||||||
|
month_year=None,
|
||||||
|
month_number=None,
|
||||||
|
month_key='',
|
||||||
|
service_note_id=None,
|
||||||
|
dispatch_log_id=None,
|
||||||
|
):
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from .models import WorkOrder, ServiceContextNote, EmailDispatchLog
|
||||||
|
from .services import NotificationService
|
||||||
|
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
requested_by = User.objects.filter(pk=requested_by_id, is_active=True).first()
|
||||||
|
dispatch_log = EmailDispatchLog.objects.filter(pk=dispatch_log_id).first() if dispatch_log_id else None
|
||||||
|
work_order = WorkOrder.objects.select_related('vehicle', 'creator', 'vehicle__client').filter(pk=work_order_id).first()
|
||||||
|
if work_order is None:
|
||||||
|
if dispatch_log:
|
||||||
|
dispatch_log.status = 'failed'
|
||||||
|
dispatch_log.error_message = 'Putni nalog nije pronađen.'
|
||||||
|
dispatch_log.save(update_fields=['status', 'error_message', 'updated_at'])
|
||||||
|
if requested_by:
|
||||||
|
NotificationService.create_notification(
|
||||||
|
recipient=requested_by,
|
||||||
|
title='Greška kod slanja emaila',
|
||||||
|
message='Putni nalog za slanje emaila nije pronađen.',
|
||||||
|
level='warning',
|
||||||
|
send_email=False,
|
||||||
|
metadata={
|
||||||
|
'entity_type': 'work_order_email',
|
||||||
|
'stage': 'failed',
|
||||||
|
'work_order_id': str(work_order_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {'status': 'failed', 'error': 'work-order-not-found'}
|
||||||
|
|
||||||
|
body_lines = [str(message or 'U prilogu je dokumentacija putnog naloga.')]
|
||||||
|
if service_note_id:
|
||||||
|
note = ServiceContextNote.objects.select_related('created_by').filter(
|
||||||
|
pk=service_note_id,
|
||||||
|
is_active=True,
|
||||||
|
).first()
|
||||||
|
if note:
|
||||||
|
creator_name = _user_display_name(note.created_by) or '-'
|
||||||
|
note_date = note.note_date.isoformat() if note.note_date else '-'
|
||||||
|
body_lines.append("")
|
||||||
|
body_lines.append("Odabrana bilješka servisnog konteksta:")
|
||||||
|
body_lines.append(f"- Autor: {creator_name}")
|
||||||
|
body_lines.append(f"- Datum bilješke: {note_date}")
|
||||||
|
body_lines.append(f"- Tekst: {note.note}")
|
||||||
|
|
||||||
|
attachments = []
|
||||||
|
if include_work_order_pdf:
|
||||||
|
attachments.append((
|
||||||
|
f"{_work_order_display_code(work_order)}.work-order.pdf",
|
||||||
|
_build_work_order_pdf(work_order),
|
||||||
|
'application/pdf',
|
||||||
|
))
|
||||||
|
if include_service_records_pdf:
|
||||||
|
attachments.append((
|
||||||
|
f"{_work_order_display_code(work_order)}.work-order-service-records.pdf",
|
||||||
|
_build_work_order_service_records_pdf(work_order),
|
||||||
|
'application/pdf',
|
||||||
|
))
|
||||||
|
if include_invoices_pdf:
|
||||||
|
attachments.append((
|
||||||
|
f"{_work_order_display_code(work_order)}.work-order-invoices.pdf",
|
||||||
|
_build_work_order_invoices_pdf(work_order),
|
||||||
|
'application/pdf',
|
||||||
|
))
|
||||||
|
if include_images:
|
||||||
|
image_attachments = _build_image_attachments_for_work_order(work_order)
|
||||||
|
attachments.extend(image_attachments)
|
||||||
|
body_lines.append("")
|
||||||
|
body_lines.append(f"Dodano slika/datoteka: {len(image_attachments)}.")
|
||||||
|
monthly_tasks_count = 0
|
||||||
|
if include_monthly_tasks and month_year and month_number and month_key:
|
||||||
|
monthly_attachment, monthly_tasks_count = _build_monthly_tasks_csv_attachment(
|
||||||
|
work_order,
|
||||||
|
year=month_year,
|
||||||
|
month=month_number,
|
||||||
|
month_key=month_key,
|
||||||
|
)
|
||||||
|
attachments.append(monthly_attachment)
|
||||||
|
body_lines.append("")
|
||||||
|
body_lines.append(f"Priložen je popis mjesečnih radnih taskova za {month_key} (ukupno: {monthly_tasks_count}).")
|
||||||
|
|
||||||
|
cached_pdf_payload = []
|
||||||
|
if attachments:
|
||||||
|
cached_pdf_payload = _cache_pdf_attachments_for_download(
|
||||||
|
work_order=work_order,
|
||||||
|
requested_by=requested_by,
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
|
||||||
|
from_email = _resolve_from_email()
|
||||||
|
if not from_email:
|
||||||
|
if dispatch_log:
|
||||||
|
dispatch_log.status = 'failed'
|
||||||
|
dispatch_log.error_message = 'SMTP pošiljatelj nije valjan.'
|
||||||
|
dispatch_log.metadata = {
|
||||||
|
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
|
||||||
|
'cached_pdfs': cached_pdf_payload,
|
||||||
|
}
|
||||||
|
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
|
||||||
|
if requested_by:
|
||||||
|
NotificationService.create_notification(
|
||||||
|
recipient=requested_by,
|
||||||
|
title='Greška kod slanja emaila',
|
||||||
|
message='SMTP pošiljatelj nije valjan. Provjerite DEFAULT_FROM_EMAIL/EMAIL_HOST_USER postavke.',
|
||||||
|
level='warning',
|
||||||
|
send_email=False,
|
||||||
|
metadata={
|
||||||
|
'entity_type': 'work_order_email',
|
||||||
|
'stage': 'failed',
|
||||||
|
'work_order_id': str(work_order.pk),
|
||||||
|
'cached_pdfs': cached_pdf_payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {'status': 'failed', 'error': 'invalid-from-email'}
|
||||||
|
|
||||||
|
total_attachment_bytes = sum(len(content) for _, content, _ in attachments)
|
||||||
|
max_attachment_bytes = int(getattr(settings, 'WORK_ORDER_EMAIL_MAX_ATTACHMENT_BYTES', 17 * 1024 * 1024))
|
||||||
|
|
||||||
|
if total_attachment_bytes > max_attachment_bytes:
|
||||||
|
if dispatch_log:
|
||||||
|
dispatch_log.status = 'failed'
|
||||||
|
dispatch_log.error_message = 'Ukupna veličina privitaka je prevelika.'
|
||||||
|
dispatch_log.metadata = {
|
||||||
|
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
|
||||||
|
'attachments_bytes': total_attachment_bytes,
|
||||||
|
'max_attachments_bytes': max_attachment_bytes,
|
||||||
|
'cached_pdfs': cached_pdf_payload,
|
||||||
|
}
|
||||||
|
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
|
||||||
|
if requested_by:
|
||||||
|
NotificationService.create_notification(
|
||||||
|
recipient=requested_by,
|
||||||
|
title='Email nije poslan — preveliki privitci',
|
||||||
|
message='Ukupna veličina privitaka je prevelika za slanje. Smanjite broj/veličinu privitaka.',
|
||||||
|
level='warning',
|
||||||
|
send_email=False,
|
||||||
|
metadata={
|
||||||
|
'entity_type': 'work_order_email',
|
||||||
|
'stage': 'failed',
|
||||||
|
'work_order_id': str(work_order.pk),
|
||||||
|
'attachments_bytes': total_attachment_bytes,
|
||||||
|
'max_attachments_bytes': max_attachment_bytes,
|
||||||
|
'cached_pdfs': cached_pdf_payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {'status': 'failed', 'error': 'attachments-too-large'}
|
||||||
|
|
||||||
|
if not attachments:
|
||||||
|
if dispatch_log:
|
||||||
|
dispatch_log.status = 'failed'
|
||||||
|
dispatch_log.error_message = 'Nije odabran nijedan privitak.'
|
||||||
|
dispatch_log.save(update_fields=['status', 'error_message', 'updated_at'])
|
||||||
|
if requested_by:
|
||||||
|
NotificationService.create_notification(
|
||||||
|
recipient=requested_by,
|
||||||
|
title='Greška kod slanja emaila',
|
||||||
|
message='Nije odabran nijedan PDF/prilog za slanje.',
|
||||||
|
level='warning',
|
||||||
|
send_email=False,
|
||||||
|
metadata={
|
||||||
|
'entity_type': 'work_order_email',
|
||||||
|
'stage': 'failed',
|
||||||
|
'work_order_id': str(work_order_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {'status': 'failed', 'error': 'no-attachments'}
|
||||||
|
|
||||||
|
email_message = EmailMessage(
|
||||||
|
subject=subject or f"Putni nalog {_work_order_display_code(work_order)}",
|
||||||
|
body=append_user_signature("\n".join(body_lines).strip(), requested_by),
|
||||||
|
from_email=from_email,
|
||||||
|
to=recipients or [],
|
||||||
|
)
|
||||||
|
for filename, content, content_type in attachments:
|
||||||
|
email_message.attach(filename, content, content_type)
|
||||||
|
if dispatch_log:
|
||||||
|
dispatch_log.attachments = [
|
||||||
|
{
|
||||||
|
'filename': filename,
|
||||||
|
'content_type': content_type,
|
||||||
|
'size_bytes': len(content),
|
||||||
|
}
|
||||||
|
for filename, content, content_type in attachments
|
||||||
|
]
|
||||||
|
dispatch_log.message = email_message.body
|
||||||
|
dispatch_log.save(update_fields=['attachments', 'message', 'updated_at'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
email_message.send(fail_silently=False)
|
||||||
|
except SMTPSenderRefused as exc:
|
||||||
|
logger.exception("SMTP sender refused for work order email bundle: %s", exc)
|
||||||
|
if dispatch_log:
|
||||||
|
dispatch_log.status = 'failed'
|
||||||
|
dispatch_log.error_message = f'SMTP sender refused ({exc.smtp_code}).'
|
||||||
|
dispatch_log.metadata = {
|
||||||
|
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
|
||||||
|
'smtp_code': exc.smtp_code,
|
||||||
|
'from_email': from_email,
|
||||||
|
'cached_pdfs': cached_pdf_payload,
|
||||||
|
}
|
||||||
|
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
|
||||||
|
if requested_by:
|
||||||
|
NotificationService.create_notification(
|
||||||
|
recipient=requested_by,
|
||||||
|
title='Greška kod slanja emaila',
|
||||||
|
message='SMTP server je odbio pošiljatelja. Provjerite email konfiguraciju.',
|
||||||
|
level='warning',
|
||||||
|
send_email=False,
|
||||||
|
metadata={
|
||||||
|
'entity_type': 'work_order_email',
|
||||||
|
'stage': 'failed',
|
||||||
|
'work_order_id': str(work_order.pk),
|
||||||
|
'smtp_code': exc.smtp_code,
|
||||||
|
'from_email': from_email,
|
||||||
|
'cached_pdfs': cached_pdf_payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {'status': 'failed', 'error': 'smtp-sender-refused'}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Greška kod slanja work order email bundle: %s", exc)
|
||||||
|
if dispatch_log:
|
||||||
|
dispatch_log.status = 'failed'
|
||||||
|
dispatch_log.error_message = str(exc)
|
||||||
|
dispatch_log.metadata = {
|
||||||
|
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
|
||||||
|
'cached_pdfs': cached_pdf_payload,
|
||||||
|
}
|
||||||
|
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
|
||||||
|
if requested_by:
|
||||||
|
NotificationService.create_notification(
|
||||||
|
recipient=requested_by,
|
||||||
|
title='Greška kod slanja emaila',
|
||||||
|
message=f'Slanje emaila za putni nalog {_work_order_display_code(work_order)} nije uspjelo.',
|
||||||
|
level='warning',
|
||||||
|
send_email=False,
|
||||||
|
metadata={
|
||||||
|
'entity_type': 'work_order_email',
|
||||||
|
'stage': 'failed',
|
||||||
|
'work_order_id': str(work_order.pk),
|
||||||
|
'recipients_count': len(recipients or []),
|
||||||
|
'cached_pdfs': cached_pdf_payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {'status': 'failed', 'error': str(exc)}
|
||||||
|
|
||||||
|
if requested_by:
|
||||||
|
NotificationService.create_notification(
|
||||||
|
recipient=requested_by,
|
||||||
|
title='Email uspješno poslan',
|
||||||
|
message=f'Email za putni nalog {_work_order_display_code(work_order)} je uspješno poslan.',
|
||||||
|
level='success',
|
||||||
|
send_email=False,
|
||||||
|
metadata={
|
||||||
|
'entity_type': 'work_order_email',
|
||||||
|
'stage': 'completed',
|
||||||
|
'work_order_id': str(work_order.pk),
|
||||||
|
'recipients_count': len(recipients or []),
|
||||||
|
'attachments_count': len(attachments),
|
||||||
|
'monthly_tasks_count': monthly_tasks_count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if dispatch_log:
|
||||||
|
dispatch_log.status = 'sent'
|
||||||
|
dispatch_log.error_message = ''
|
||||||
|
dispatch_log.sent_at = timezone.now()
|
||||||
|
dispatch_log.metadata = {
|
||||||
|
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
|
||||||
|
'recipients_count': len(recipients or []),
|
||||||
|
'attachments_count': len(attachments),
|
||||||
|
'monthly_tasks_count': monthly_tasks_count,
|
||||||
|
}
|
||||||
|
dispatch_log.save(update_fields=['status', 'error_message', 'sent_at', 'metadata', 'updated_at'])
|
||||||
|
return {
|
||||||
|
'status': 'ok',
|
||||||
|
'work_order_id': str(work_order.pk),
|
||||||
|
'recipients_count': len(recipients or []),
|
||||||
|
'attachments_count': len(attachments),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_work_order_invoices_pdf(work_order):
|
def _build_work_order_invoices_pdf(work_order):
|
||||||
buffer = BytesIO()
|
buffer = BytesIO()
|
||||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||||
@@ -94,6 +566,7 @@ def _build_work_order_invoices_pdf(work_order):
|
|||||||
chassis_hours = str(getattr(work_order.vehicle, "chassis_working_hours", "-") or "-")
|
chassis_hours = str(getattr(work_order.vehicle, "chassis_working_hours", "-") or "-")
|
||||||
mileage = str(getattr(work_order.vehicle, "current_mileage", "-") or "-")
|
mileage = str(getattr(work_order.vehicle, "current_mileage", "-") or "-")
|
||||||
generated_date = timezone.localtime(timezone.now()).strftime("%d.%m.%Y")
|
generated_date = timezone.localtime(timezone.now()).strftime("%d.%m.%Y")
|
||||||
|
display_code = _work_order_display_code(work_order)
|
||||||
|
|
||||||
def draw_header_footer(page_num):
|
def draw_header_footer(page_num):
|
||||||
draw_standard_header_footer(
|
draw_standard_header_footer(
|
||||||
@@ -106,7 +579,7 @@ def _build_work_order_invoices_pdf(work_order):
|
|||||||
upgrade_hours=upgrade_hours,
|
upgrade_hours=upgrade_hours,
|
||||||
chassis_hours=chassis_hours,
|
chassis_hours=chassis_hours,
|
||||||
mileage=mileage,
|
mileage=mileage,
|
||||||
work_order_number=str(work_order.pk),
|
work_order_number=display_code,
|
||||||
generated_date=generated_date,
|
generated_date=generated_date,
|
||||||
report_title="Računi putnog naloga",
|
report_title="Računi putnog naloga",
|
||||||
page_size=A4,
|
page_size=A4,
|
||||||
@@ -121,7 +594,7 @@ def _build_work_order_invoices_pdf(work_order):
|
|||||||
y -= 8
|
y -= 8
|
||||||
|
|
||||||
pdf.setFont("Vera-Bold", 13)
|
pdf.setFont("Vera-Bold", 13)
|
||||||
pdf.drawString(margin, y, f"Računi putnog naloga WO-{work_order.pk}")
|
pdf.drawString(margin, y, f"Računi putnog naloga {display_code}")
|
||||||
y -= 20
|
y -= 20
|
||||||
pdf.setFont("Vera", 10)
|
pdf.setFont("Vera", 10)
|
||||||
pdf.drawString(margin, y, f"Datum naloga: {work_order.date}")
|
pdf.drawString(margin, y, f"Datum naloga: {work_order.date}")
|
||||||
@@ -156,6 +629,11 @@ def _build_work_order_invoices_pdf(work_order):
|
|||||||
pdf.drawString(margin, y, "slika: Nema slike.")
|
pdf.drawString(margin, y, "slika: Nema slike.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
suffix = Path(invoice.image.name or '').suffix.lower()
|
||||||
|
if suffix == '.pdf':
|
||||||
|
pdf.drawString(margin, y, "prilog: PDF račun (otvorite datoteku računa za pregled).")
|
||||||
|
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:
|
||||||
@@ -169,10 +647,19 @@ def _build_work_order_invoices_pdf(work_order):
|
|||||||
y = content_top
|
y = content_top
|
||||||
max_height = y - content_bottom
|
max_height = y - content_bottom
|
||||||
|
|
||||||
|
# Kompresiraj u JPEG u memoriji radi manje veličine PDF-a
|
||||||
|
compress_w = min(image.width, 1280)
|
||||||
|
if image.width > compress_w:
|
||||||
|
ratio_c = compress_w / float(image.width)
|
||||||
|
image = image.resize((compress_w, max(1, int(image.height * ratio_c))), Image.LANCZOS)
|
||||||
|
buf = BytesIO()
|
||||||
|
image.save(buf, format='JPEG', quality=75, optimize=True)
|
||||||
|
buf.seek(0)
|
||||||
|
|
||||||
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))
|
||||||
draw_height = max(1, int(image.height * ratio))
|
draw_height = max(1, int(image.height * ratio))
|
||||||
image_reader = ImageReader(image)
|
image_reader = ImageReader(buf)
|
||||||
pdf.drawImage(
|
pdf.drawImage(
|
||||||
image_reader,
|
image_reader,
|
||||||
margin,
|
margin,
|
||||||
@@ -201,8 +688,353 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_decimal(value):
|
||||||
|
if value in (None, ''):
|
||||||
|
return Decimal('0.00')
|
||||||
|
try:
|
||||||
|
normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.')
|
||||||
|
return Decimal(normalized)
|
||||||
|
except (InvalidOperation, ValueError, TypeError):
|
||||||
|
return Decimal('0.00')
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_invoice_text(invoice):
|
||||||
|
if not invoice.image:
|
||||||
|
return ''
|
||||||
|
suffix = Path(invoice.image.name or '').suffix.lower()
|
||||||
|
if suffix == '.pdf':
|
||||||
|
invoice.image.open('rb')
|
||||||
|
try:
|
||||||
|
reader = PdfReader(invoice.image)
|
||||||
|
return '\n'.join((page.extract_text() or '') for page in reader.pages)
|
||||||
|
finally:
|
||||||
|
invoice.image.close()
|
||||||
|
|
||||||
|
invoice.image.open('rb')
|
||||||
|
try:
|
||||||
|
with Image.open(invoice.image) as source:
|
||||||
|
image = source.convert('RGB')
|
||||||
|
return pytesseract.image_to_string(image, lang='hrv+eng')
|
||||||
|
finally:
|
||||||
|
invoice.image.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_invoice_number(text, fallback):
|
||||||
|
if not text:
|
||||||
|
return fallback
|
||||||
|
patterns = [
|
||||||
|
r'(?i)(?:broj\s*računa|račun\s*broj|invoice\s*no\.?)\s*[:#]?\s*([A-Z0-9\/\-\._]+)',
|
||||||
|
r'(?i)(?:broj)\s*[:#]?\s*([A-Z0-9\/\-\._]{4,})',
|
||||||
|
]
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, text)
|
||||||
|
if match:
|
||||||
|
candidate = (match.group(1) or '').strip()
|
||||||
|
if candidate:
|
||||||
|
return candidate
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_total_amount(text):
|
||||||
|
if not text:
|
||||||
|
return Decimal('0.00')
|
||||||
|
|
||||||
|
def _line_candidates(line):
|
||||||
|
number_pattern = r'(?<!\d)(\d{1,3}(?:[.\s]\d{3})*(?:,\d{2})|\d+(?:[.,]\d{2}))(?!\d)'
|
||||||
|
return [_parse_decimal(match) for match in re.findall(number_pattern, line)]
|
||||||
|
|
||||||
|
preferred_lines = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
lower = line.lower()
|
||||||
|
if any(token in lower for token in ('ukupno', 'za platiti', 'iznos', 'total')):
|
||||||
|
preferred_lines.append(line)
|
||||||
|
|
||||||
|
for line in preferred_lines:
|
||||||
|
candidates = [value for value in _line_candidates(line) if value > Decimal('0.00')]
|
||||||
|
if candidates:
|
||||||
|
return max(candidates)
|
||||||
|
|
||||||
|
all_candidates = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
all_candidates.extend([value for value in _line_candidates(line) if value > Decimal('0.00')])
|
||||||
|
if all_candidates:
|
||||||
|
return max(all_candidates)
|
||||||
|
return Decimal('0.00')
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def process_work_order_invoice_ocr(invoice_id):
|
||||||
|
from .models import WorkOrderInvoice, WorkOrderAdditionalCostsTable, GeneratedWorkOrderPdf
|
||||||
|
|
||||||
|
invoice = (
|
||||||
|
WorkOrderInvoice.objects
|
||||||
|
.select_related('work_order')
|
||||||
|
.filter(pk=invoice_id, is_active=True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if invoice is None or invoice.work_order_id is None:
|
||||||
|
return {"status": "skipped", "reason": "invoice-not-found"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = _extract_invoice_text(invoice)
|
||||||
|
except (UnidentifiedImageError, OSError, TesseractNotFoundError, ValueError, RuntimeError, PdfReadError):
|
||||||
|
text = ''
|
||||||
|
|
||||||
|
attachment_name = Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else ''
|
||||||
|
row = {
|
||||||
|
'naziv': (invoice.naziv_racuna or '').strip() or attachment_name or f'Račun {invoice.pk}',
|
||||||
|
'broj_racuna': _extract_invoice_number(text, fallback=str(invoice.pk)),
|
||||||
|
'ukupan_iznos': f"{_extract_total_amount(text):.2f}",
|
||||||
|
'prilog': attachment_name,
|
||||||
|
'source_invoice_id': str(invoice.pk),
|
||||||
|
}
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
table, _ = WorkOrderAdditionalCostsTable.objects.select_for_update().get_or_create(
|
||||||
|
work_order=invoice.work_order,
|
||||||
|
defaults={'data': {'rows': []}},
|
||||||
|
)
|
||||||
|
existing_rows = table.data.get('rows', []) if isinstance(table.data, dict) else []
|
||||||
|
existing_row = next(
|
||||||
|
(
|
||||||
|
item for item in existing_rows
|
||||||
|
if isinstance(item, dict) and str(item.get('source_invoice_id', '')) == str(invoice.pk)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
normalized_rows = [
|
||||||
|
item for item in existing_rows
|
||||||
|
if isinstance(item, dict) and str(item.get('source_invoice_id', '')) != str(invoice.pk)
|
||||||
|
]
|
||||||
|
if existing_row and row['ukupan_iznos'] == '0.00':
|
||||||
|
existing_amount = str(existing_row.get('ukupan_iznos', '') or '').strip()
|
||||||
|
if existing_amount:
|
||||||
|
row['ukupan_iznos'] = existing_amount
|
||||||
|
if existing_row and not row['broj_racuna']:
|
||||||
|
row['broj_racuna'] = str(existing_row.get('broj_racuna', '') or '').strip()
|
||||||
|
normalized_rows.append(row)
|
||||||
|
total = sum((_parse_decimal(item.get('ukupan_iznos')) for item in normalized_rows if isinstance(item, dict)), Decimal('0.00'))
|
||||||
|
table.data = {'rows': normalized_rows}
|
||||||
|
table.total_for_payout = total.quantize(Decimal('0.01'))
|
||||||
|
table.save(update_fields=['data', 'total_for_payout', 'updated_at'])
|
||||||
|
GeneratedWorkOrderPdf.objects.filter(
|
||||||
|
is_active=True,
|
||||||
|
work_order=invoice.work_order,
|
||||||
|
pdf_type__in=['work_order', 'invoices'],
|
||||||
|
).update(
|
||||||
|
is_active=False,
|
||||||
|
status='failed',
|
||||||
|
error_message='PDF cache invalidiran zbog OCR ažuriranja dodatnih troškova.',
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "ok", "invoice_id": str(invoice.pk)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(base=ResourceAwareTask)
|
||||||
|
def build_monthly_archive_cached_task(generated_archive_id):
|
||||||
|
from .models import GeneratedFleetArchive
|
||||||
|
from .views import (
|
||||||
|
_build_monthly_service_tasks_archive_to_temp_file,
|
||||||
|
_build_monthly_work_orders_archive_to_temp_file,
|
||||||
|
_notify_monthly_archive_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = (
|
||||||
|
GeneratedFleetArchive.objects
|
||||||
|
.select_related('requested_by')
|
||||||
|
.filter(pk=generated_archive_id, is_active=True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if generated is None:
|
||||||
|
return {"status": "failed", "error": "Generated archive record not found"}
|
||||||
|
|
||||||
|
tmp_archive_path = None
|
||||||
|
try:
|
||||||
|
if generated.archive_type == 'service_tasks':
|
||||||
|
tmp_archive_path = _build_monthly_service_tasks_archive_to_temp_file(
|
||||||
|
user=generated.requested_by,
|
||||||
|
year=generated.year,
|
||||||
|
month=generated.month,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
tmp_archive_path = _build_monthly_work_orders_archive_to_temp_file(
|
||||||
|
user=generated.requested_by,
|
||||||
|
year=generated.year,
|
||||||
|
month=generated.month,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tmp_archive_path is None or tmp_archive_path.stat().st_size <= 0:
|
||||||
|
raise ValueError('ZIP arhiva je prazna.')
|
||||||
|
|
||||||
|
filename = generated.filename or f"{generated.archive_type}-{generated.year}-{generated.month}.zip"
|
||||||
|
with tmp_archive_path.open('rb') as temp_handle:
|
||||||
|
generated.file.save(filename, File(temp_handle), save=False)
|
||||||
|
generated.status = 'ready'
|
||||||
|
generated.error_message = ''
|
||||||
|
generated.save(update_fields=['file', 'status', 'error_message', 'updated_at'])
|
||||||
|
|
||||||
|
if generated.requested_by:
|
||||||
|
_notify_monthly_archive_request(
|
||||||
|
user=generated.requested_by,
|
||||||
|
archive_type=generated.archive_type,
|
||||||
|
stage='completed',
|
||||||
|
year=generated.year,
|
||||||
|
month=generated.month,
|
||||||
|
generated_archive=generated,
|
||||||
|
)
|
||||||
|
return {"status": "ready", "generated_archive_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:
|
||||||
|
_notify_monthly_archive_request(
|
||||||
|
user=generated.requested_by,
|
||||||
|
archive_type=generated.archive_type,
|
||||||
|
stage='failed',
|
||||||
|
year=generated.year,
|
||||||
|
month=generated.month,
|
||||||
|
generated_archive=generated,
|
||||||
|
)
|
||||||
|
logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc)
|
||||||
|
return {"status": "failed", "error": str(exc)}
|
||||||
|
finally:
|
||||||
|
if tmp_archive_path is not None:
|
||||||
|
tmp_archive_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def cleanup_expired_generated_archives_task():
|
||||||
|
now = timezone.now()
|
||||||
|
expired = GeneratedFleetArchive.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 = 'ZIP arhiva je istekla.'
|
||||||
|
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
||||||
|
deleted += 1
|
||||||
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def cleanup_stale_tmp_archives_task():
|
||||||
|
from .views import _cleanup_stale_tmp_archives
|
||||||
|
|
||||||
|
deleted = _cleanup_stale_tmp_archives()
|
||||||
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
@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)}
|
||||||
169
backend/modules/fleet/tests/test_service_context_notes.py
Normal file
169
backend/modules/fleet/tests/test_service_context_notes.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from modules.fleet.models import ServiceContextNote, Vehicle, WorkOrder
|
||||||
|
from modules.task_management.models import Task
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceContextNotesApiTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
user_model = get_user_model()
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
self.supervisor = user_model.objects.create_user(
|
||||||
|
username=f'supervisor-{suffix}',
|
||||||
|
email=f'supervisor-{suffix}@example.test',
|
||||||
|
password='test1234',
|
||||||
|
is_team_member=True,
|
||||||
|
is_serviser=False,
|
||||||
|
)
|
||||||
|
self.servicer = user_model.objects.create_user(
|
||||||
|
username=f'servicer-{suffix}',
|
||||||
|
email=f'servicer-{suffix}@example.test',
|
||||||
|
password='test1234',
|
||||||
|
is_team_member=True,
|
||||||
|
is_serviser=True,
|
||||||
|
)
|
||||||
|
self.other = user_model.objects.create_user(
|
||||||
|
username=f'other-{suffix}',
|
||||||
|
email=f'other-{suffix}@example.test',
|
||||||
|
password='test1234',
|
||||||
|
is_team_member=False,
|
||||||
|
is_serviser=False,
|
||||||
|
)
|
||||||
|
self.second_servicer = user_model.objects.create_user(
|
||||||
|
username=f'servicer2-{suffix}',
|
||||||
|
email=f'servicer2-{suffix}@example.test',
|
||||||
|
password='test1234',
|
||||||
|
is_team_member=True,
|
||||||
|
is_serviser=True,
|
||||||
|
)
|
||||||
|
self.team_member_non_servicer = user_model.objects.create_user(
|
||||||
|
username=f'team-{suffix}',
|
||||||
|
email=f'team-{suffix}@example.test',
|
||||||
|
password='test1234',
|
||||||
|
is_team_member=True,
|
||||||
|
is_serviser=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.vehicle = Vehicle.objects.create(
|
||||||
|
asset_type='crane',
|
||||||
|
registration_number=f'SN-{suffix[:6].upper()}',
|
||||||
|
crane_serial_number='654321',
|
||||||
|
assigned_servicer=self.servicer,
|
||||||
|
)
|
||||||
|
self.work_order = WorkOrder.objects.create(
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
creator=self.supervisor,
|
||||||
|
purpose='kontrola',
|
||||||
|
status='open',
|
||||||
|
)
|
||||||
|
self.task = Task.objects.create(
|
||||||
|
title='Aktivni servis',
|
||||||
|
assigned_to=self.servicer,
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
work_order=self.work_order,
|
||||||
|
status='aktivan',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_supervisor_can_create_team_note_for_active_context(self):
|
||||||
|
self.client.force_authenticate(user=self.supervisor)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('service-context-note-list'),
|
||||||
|
data={
|
||||||
|
'note': 'Provjeriti opremu prije izlaska na teren.',
|
||||||
|
'note_date': '2026-07-21',
|
||||||
|
'audience': 'team',
|
||||||
|
'work_order': str(self.work_order.id),
|
||||||
|
'task': str(self.task.id),
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 201, response.content)
|
||||||
|
self.assertGreaterEqual(int(response.data.get('created_count', 0)), 2)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=self.servicer)
|
||||||
|
list_response = self.client.get(reverse('service-context-note-list'))
|
||||||
|
self.assertEqual(list_response.status_code, 200, list_response.content)
|
||||||
|
self.assertEqual(len(list_response.data), 1)
|
||||||
|
self.assertEqual(list_response.data[0]['audience_source'], 'team')
|
||||||
|
self.assertEqual(str(list_response.data[0]['task']), str(self.task.id))
|
||||||
|
|
||||||
|
def test_servicer_can_create_member_note_for_other_servicer(self):
|
||||||
|
self.client.force_authenticate(user=self.servicer)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('service-context-note-list'),
|
||||||
|
data={
|
||||||
|
'note': 'Serviser šalje bilješku drugom serviseru.',
|
||||||
|
'audience': 'member',
|
||||||
|
'target_user': str(self.second_servicer.id),
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 201, response.content)
|
||||||
|
self.assertEqual(int(response.data.get('created_count', 0)), 1)
|
||||||
|
|
||||||
|
def test_recipient_can_close_personal_note(self):
|
||||||
|
note = ServiceContextNote.objects.create(
|
||||||
|
created_by=self.supervisor,
|
||||||
|
recipient=self.servicer,
|
||||||
|
audience_source='member',
|
||||||
|
note='Osobna bilješka',
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=self.servicer)
|
||||||
|
close_url = reverse('service-context-note-close', kwargs={'pk': note.id})
|
||||||
|
close_response = self.client.post(close_url, data={}, format='json')
|
||||||
|
self.assertEqual(close_response.status_code, 200, close_response.content)
|
||||||
|
note.refresh_from_db()
|
||||||
|
self.assertTrue(note.is_closed)
|
||||||
|
|
||||||
|
list_response = self.client.get(reverse('service-context-note-list'))
|
||||||
|
self.assertEqual(list_response.status_code, 200)
|
||||||
|
self.assertEqual(len(list_response.data), 0)
|
||||||
|
|
||||||
|
def test_supervisor_can_target_multiple_servicers(self):
|
||||||
|
self.client.force_authenticate(user=self.supervisor)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('service-context-note-list'),
|
||||||
|
data={
|
||||||
|
'note': 'Zajednička bilješka za dva servisera.',
|
||||||
|
'audience': 'member',
|
||||||
|
'target_users': [str(self.servicer.id), str(self.second_servicer.id)],
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 201, response.content)
|
||||||
|
self.assertEqual(int(response.data.get('created_count', 0)), 2)
|
||||||
|
|
||||||
|
def test_supervisor_can_create_member_note_for_non_servicer_team_member(self):
|
||||||
|
self.client.force_authenticate(user=self.supervisor)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('service-context-note-list'),
|
||||||
|
data={
|
||||||
|
'note': 'Bilješka za team člana koji nije serviser.',
|
||||||
|
'audience': 'member',
|
||||||
|
'target_users': [str(self.team_member_non_servicer.id)],
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 201, response.content)
|
||||||
|
self.assertEqual(int(response.data.get('created_count', 0)), 1)
|
||||||
|
|
||||||
|
def test_member_note_rejects_non_team_member_target(self):
|
||||||
|
self.client.force_authenticate(user=self.supervisor)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('service-context-note-list'),
|
||||||
|
data={
|
||||||
|
'note': 'Bilješka ne-smije ići van tima.',
|
||||||
|
'audience': 'member',
|
||||||
|
'target_users': [str(self.other.id)],
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400, response.content)
|
||||||
|
self.assertIn('target_users', response.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):
|
||||||
@@ -49,4 +52,80 @@ 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)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core import mail
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from modules.fleet.models import GeneratedWorkOrderPdf, Vehicle, VehicleNotification, WorkOrder
|
||||||
|
from modules.fleet.tasks import send_work_order_email_bundle_task
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
|
||||||
|
DEFAULT_FROM_EMAIL='no-reply@example.test',
|
||||||
|
)
|
||||||
|
class WorkOrderEmailTaskCachedPdfTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
user_model = get_user_model()
|
||||||
|
self.user = user_model.objects.create_user(
|
||||||
|
username='task-mail-user',
|
||||||
|
email='task-mail-user@example.test',
|
||||||
|
password='test1234',
|
||||||
|
)
|
||||||
|
self.vehicle = Vehicle.objects.create(
|
||||||
|
asset_type='crane',
|
||||||
|
registration_number='TASK-CACHE-001',
|
||||||
|
crane_serial_number='112233',
|
||||||
|
)
|
||||||
|
self.work_order = WorkOrder.objects.create(
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
creator=self.user,
|
||||||
|
display_code='MT010126',
|
||||||
|
purpose='kontrola',
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch('django.core.mail.message.EmailMessage.send', side_effect=Exception('smtp-fail'))
|
||||||
|
def test_failed_email_creates_cached_pdf_links_in_notification(self, _mock_send):
|
||||||
|
result = send_work_order_email_bundle_task(
|
||||||
|
work_order_id=str(self.work_order.pk),
|
||||||
|
requested_by_id=str(self.user.pk),
|
||||||
|
recipients=['client@example.test'],
|
||||||
|
include_work_order_pdf=True,
|
||||||
|
include_service_records_pdf=False,
|
||||||
|
include_invoices_pdf=False,
|
||||||
|
include_images=False,
|
||||||
|
include_monthly_tasks=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.get('status'), 'failed')
|
||||||
|
|
||||||
|
cached = GeneratedWorkOrderPdf.objects.filter(work_order=self.work_order, pdf_type='work_order', status='ready')
|
||||||
|
self.assertTrue(cached.exists())
|
||||||
|
|
||||||
|
notif = VehicleNotification.objects.filter(
|
||||||
|
recipient=self.user,
|
||||||
|
metadata__entity_type='work_order_email',
|
||||||
|
metadata__stage='failed',
|
||||||
|
).order_by('-created_at').first()
|
||||||
|
self.assertIsNotNone(notif)
|
||||||
|
cached_pdfs = notif.metadata.get('cached_pdfs') if isinstance(notif.metadata, dict) else None
|
||||||
|
self.assertTrue(isinstance(cached_pdfs, list) and len(cached_pdfs) > 0)
|
||||||
|
self.assertIn('download_url', cached_pdfs[0])
|
||||||
|
|
||||||
|
def test_success_email_appends_requester_signature(self):
|
||||||
|
mail.outbox.clear()
|
||||||
|
|
||||||
|
result = send_work_order_email_bundle_task(
|
||||||
|
work_order_id=str(self.work_order.pk),
|
||||||
|
requested_by_id=str(self.user.pk),
|
||||||
|
recipients=['client@example.test'],
|
||||||
|
message='Test poruka bez potpisa.',
|
||||||
|
include_work_order_pdf=True,
|
||||||
|
include_service_records_pdf=False,
|
||||||
|
include_invoices_pdf=False,
|
||||||
|
include_images=False,
|
||||||
|
include_monthly_tasks=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.get('status'), 'ok')
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
sent_body = mail.outbox[0].body
|
||||||
|
self.assertIn('Test poruka bez potpisa.', sent_body)
|
||||||
|
self.assertIn('S poštovanjem / Freundliche Grüße / Kind regards', sent_body)
|
||||||
|
self.assertIn(self.user.email, sent_body)
|
||||||
990
backend/modules/fleet/tests/test_work_order_images_endpoint.py
Normal file
990
backend/modules/fleet/tests/test_work_order_images_endpoint.py
Normal file
@@ -0,0 +1,990 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
import uuid
|
||||||
|
from io import BytesIO
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import zipfile
|
||||||
|
from unittest.mock import patch
|
||||||
|
from PIL import Image
|
||||||
|
from pypdf import PdfReader
|
||||||
|
from kombu.exceptions import OperationalError as KombuOperationalError
|
||||||
|
|
||||||
|
from modules.fleet.models import (
|
||||||
|
Vehicle,
|
||||||
|
WorkOrder,
|
||||||
|
WorkOrderInvoice,
|
||||||
|
VehicleServiceRecord,
|
||||||
|
VehicleServicePhoto,
|
||||||
|
GeneratedWorkOrderPdf,
|
||||||
|
GeneratedFleetArchive,
|
||||||
|
VehicleNotification,
|
||||||
|
)
|
||||||
|
from modules.task_management.models import Task, TaskWorkHoursTable
|
||||||
|
from modules.fleet.tasks import build_monthly_archive_cached_task
|
||||||
|
from modules.fleet.views import (
|
||||||
|
_build_monthly_servicer_report_rows,
|
||||||
|
_calculate_daily_quantity_from_hours,
|
||||||
|
_compress_image_for_docx,
|
||||||
|
_work_order_related_tasks_queryset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_pdf(filename='test.pdf'):
|
||||||
|
content = b'%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF'
|
||||||
|
return SimpleUploadedFile(
|
||||||
|
name=filename,
|
||||||
|
content=content,
|
||||||
|
content_type='application/pdf',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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',
|
||||||
|
make='Liebherr',
|
||||||
|
model='LTM 1090',
|
||||||
|
crane_serial_number='123456',
|
||||||
|
superstructure_working_hours=111,
|
||||||
|
chassis_working_hours=222,
|
||||||
|
current_mileage=33344,
|
||||||
|
)
|
||||||
|
self.work_order = WorkOrder.objects.create(
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
creator=self.user,
|
||||||
|
display_code='MT150726',
|
||||||
|
purpose='kontrola',
|
||||||
|
notes='Napomena iz putnog naloga',
|
||||||
|
)
|
||||||
|
self.task = Task.objects.create(
|
||||||
|
title='Test servisni zadatak',
|
||||||
|
assigned_to=self.user,
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
work_order=self.work_order,
|
||||||
|
scheduled_date=date(2033, 12, 24),
|
||||||
|
service_report_note='Napomena iz taska',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.service_record = VehicleServiceRecord.objects.create(
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
task=self.task,
|
||||||
|
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/'))
|
||||||
|
|
||||||
|
def test_work_order_invoice_serializer_supports_pdf_file(self):
|
||||||
|
invoice = WorkOrderInvoice.objects.create(
|
||||||
|
work_order=self.work_order,
|
||||||
|
naziv_racuna='PDF račun',
|
||||||
|
image=create_test_pdf('invoice.pdf'),
|
||||||
|
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)
|
||||||
|
self.assertEqual(list_response.data[0].get('image_content_type'), 'application/pdf')
|
||||||
|
|
||||||
|
file_response = self.client.get(image_url)
|
||||||
|
self.assertEqual(file_response.status_code, 200)
|
||||||
|
self.assertEqual(file_response['Content-Type'], 'application/pdf')
|
||||||
|
|
||||||
|
def test_additional_costs_table_can_be_saved_and_total_is_computed(self):
|
||||||
|
url = f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/"
|
||||||
|
response = self.client.put(
|
||||||
|
url,
|
||||||
|
data={
|
||||||
|
"rows": [
|
||||||
|
{"naziv": "Cestarina", "broj_racuna": "R-100", "ukupan_iznos": "12,50", "prilog": "racun-1.pdf"},
|
||||||
|
{"naziv": "Gorivo", "broj_racuna": "R-101", "ukupan_iznos": "7.50", "prilog": "racun-2.jpg"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(payload.get('total_for_payout'), '20.00')
|
||||||
|
rows = payload.get('data', {}).get('rows', [])
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
|
||||||
|
def test_task_service_context_contains_additional_costs_table(self):
|
||||||
|
table_url = f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/"
|
||||||
|
save_response = self.client.put(
|
||||||
|
table_url,
|
||||||
|
data={"rows": [{"naziv": "Parking", "broj_racuna": "P-1", "ukupan_iznos": "5.00", "prilog": "parking.pdf"}]},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(save_response.status_code, 200, save_response.content)
|
||||||
|
|
||||||
|
context_response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/task-service-context/")
|
||||||
|
self.assertEqual(context_response.status_code, 200, context_response.content)
|
||||||
|
payload = context_response.json()
|
||||||
|
self.assertIn('additional_costs_table', payload)
|
||||||
|
self.assertEqual(payload['additional_costs_table']['total_for_payout'], '5.00')
|
||||||
|
|
||||||
|
def test_compress_image_for_docx_keeps_standard_phone_photos(self):
|
||||||
|
file = BytesIO()
|
||||||
|
Image.new('RGB', (4000, 3000), color='blue').save(file, format='JPEG', quality=85)
|
||||||
|
file.seek(0)
|
||||||
|
uploaded = SimpleUploadedFile('phone-12mp.jpg', file.getvalue(), content_type='image/jpeg')
|
||||||
|
|
||||||
|
result = _compress_image_for_docx(uploaded)
|
||||||
|
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertGreater(len(result.getvalue()), 0)
|
||||||
|
|
||||||
|
def test_compress_image_for_docx_handles_exif_orientation(self):
|
||||||
|
exif = Image.Exif()
|
||||||
|
exif[0x0112] = 6
|
||||||
|
|
||||||
|
file = BytesIO()
|
||||||
|
Image.new('RGB', (3000, 2000), color='green').save(file, format='JPEG', quality=85, exif=exif.tobytes())
|
||||||
|
file.seek(0)
|
||||||
|
uploaded = SimpleUploadedFile('rotated-phone.jpg', file.getvalue(), content_type='image/jpeg')
|
||||||
|
|
||||||
|
result = _compress_image_for_docx(uploaded)
|
||||||
|
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertGreater(len(result.getvalue()), 0)
|
||||||
|
|
||||||
|
def test_work_order_related_tasks_queryset_excludes_other_work_orders_for_same_vehicle(self):
|
||||||
|
other_work_order = WorkOrder.objects.create(
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
creator=self.user,
|
||||||
|
display_code='MT160726',
|
||||||
|
purpose='kontrola',
|
||||||
|
)
|
||||||
|
other_task = Task.objects.create(
|
||||||
|
title='Drugi nalog isti stroj',
|
||||||
|
assigned_to=self.user,
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
work_order=other_work_order,
|
||||||
|
scheduled_date=date(2033, 12, 25),
|
||||||
|
)
|
||||||
|
VehicleServiceRecord.objects.create(
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
task=other_task,
|
||||||
|
performed_by=self.user,
|
||||||
|
description='Ne smije se pojaviti u prvom nalogu.',
|
||||||
|
service_title='Krivi nalog',
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_ids = list(_work_order_related_tasks_queryset(self.work_order).values_list('id', flat=True))
|
||||||
|
|
||||||
|
self.assertIn(self.task.pk, resolved_ids)
|
||||||
|
self.assertNotIn(other_task.pk, resolved_ids)
|
||||||
|
|
||||||
|
def test_invoice_upload_immediately_appears_in_additional_costs_table(self):
|
||||||
|
create_response = self.client.post(
|
||||||
|
reverse('work-order-invoice-list'),
|
||||||
|
data={
|
||||||
|
'work_order': str(self.work_order.pk),
|
||||||
|
'naziv_racuna': 'Test2',
|
||||||
|
'datum': '2026-07-19',
|
||||||
|
'image': create_test_image('test2.jpg'),
|
||||||
|
},
|
||||||
|
format='multipart',
|
||||||
|
)
|
||||||
|
self.assertEqual(create_response.status_code, 201, create_response.content)
|
||||||
|
|
||||||
|
table_response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/")
|
||||||
|
self.assertEqual(table_response.status_code, 200, table_response.content)
|
||||||
|
rows = table_response.json().get('data', {}).get('rows', [])
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0].get('naziv'), 'Test2')
|
||||||
|
|
||||||
|
def test_additional_costs_update_invalidates_work_order_pdf_cache(self):
|
||||||
|
cached = GeneratedWorkOrderPdf.objects.create(
|
||||||
|
work_order=self.work_order,
|
||||||
|
requested_by=self.user,
|
||||||
|
pdf_type='work_order',
|
||||||
|
status='ready',
|
||||||
|
filename='MT010101.work-order.pdf',
|
||||||
|
expires_at=timezone.now() + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
response = self.client.put(
|
||||||
|
f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/",
|
||||||
|
data={"rows": [{"naziv": "Test", "broj_racuna": "R-1", "ukupan_iznos": "10.00", "prilog": "r1.pdf"}]},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
cached.refresh_from_db()
|
||||||
|
self.assertFalse(cached.is_active)
|
||||||
|
self.assertEqual(cached.status, 'failed')
|
||||||
|
|
||||||
|
def test_travel_expenses_update_invalidates_work_order_pdf_cache(self):
|
||||||
|
cached = GeneratedWorkOrderPdf.objects.create(
|
||||||
|
work_order=self.work_order,
|
||||||
|
requested_by=self.user,
|
||||||
|
pdf_type='work_order',
|
||||||
|
status='ready',
|
||||||
|
filename='MT150726.work-order.pdf',
|
||||||
|
expires_at=timezone.now() + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
response = self.client.put(
|
||||||
|
f"/api/fleet/work-orders/{self.work_order.pk}/travel-expenses-table/",
|
||||||
|
data={
|
||||||
|
'broj_sati': '9.5',
|
||||||
|
'kolicina_dnevnica': '0.5',
|
||||||
|
'iznos_dnevnica': '30',
|
||||||
|
'daily_rate_country': 'HR',
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
cached.refresh_from_db()
|
||||||
|
self.assertFalse(cached.is_active)
|
||||||
|
self.assertEqual(cached.status, 'failed')
|
||||||
|
|
||||||
|
def test_service_record_delete_invalidates_service_records_pdf_cache(self):
|
||||||
|
cached = GeneratedWorkOrderPdf.objects.create(
|
||||||
|
work_order=self.work_order,
|
||||||
|
requested_by=self.user,
|
||||||
|
pdf_type='service_records',
|
||||||
|
status='ready',
|
||||||
|
filename='MT150726.work-order-service-records.pdf',
|
||||||
|
expires_at=timezone.now() + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
cached.file.save(
|
||||||
|
'MT150726.work-order-service-records.pdf',
|
||||||
|
ContentFile(b'%PDF-1.4 cached service records'),
|
||||||
|
save=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
VehicleServiceRecord.objects.filter(pk=self.service_record.pk).delete()
|
||||||
|
|
||||||
|
cached.refresh_from_db()
|
||||||
|
self.assertFalse(cached.is_active)
|
||||||
|
self.assertEqual(cached.status, 'failed')
|
||||||
|
self.assertFalse(cached.file.name)
|
||||||
|
|
||||||
|
def test_service_records_pdf_rebuilds_when_cached_file_is_missing(self):
|
||||||
|
cached = GeneratedWorkOrderPdf.objects.create(
|
||||||
|
work_order=self.work_order,
|
||||||
|
requested_by=self.user,
|
||||||
|
pdf_type='service_records',
|
||||||
|
status='ready',
|
||||||
|
filename='MT150726.work-order-service-records.pdf',
|
||||||
|
expires_at=timezone.now() + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
cached.file.save(
|
||||||
|
'MT150726.work-order-service-records.pdf',
|
||||||
|
ContentFile(b'%PDF-1.4 cached service records'),
|
||||||
|
save=True,
|
||||||
|
)
|
||||||
|
cached.file.storage.delete(cached.file.name)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-pdf/")
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
self.assertEqual(response['Content-Type'], 'application/pdf')
|
||||||
|
|
||||||
|
cached.refresh_from_db()
|
||||||
|
self.assertFalse(cached.is_active)
|
||||||
|
self.assertEqual(cached.status, 'failed')
|
||||||
|
|
||||||
|
def test_service_records_docx_contains_embedded_service_photos(self):
|
||||||
|
VehicleServicePhoto.objects.create(
|
||||||
|
service_record=self.service_record,
|
||||||
|
image=create_test_image('service-record-docx.jpg', size=(320, 200), color='blue'),
|
||||||
|
description='DOCX photo',
|
||||||
|
uploaded_by=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/")
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
self.assertEqual(
|
||||||
|
response['Content-Type'],
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
)
|
||||||
|
|
||||||
|
archive = zipfile.ZipFile(BytesIO(response.content))
|
||||||
|
media_files = [name for name in archive.namelist() if name.startswith('word/media/')]
|
||||||
|
self.assertTrue(media_files, archive.namelist())
|
||||||
|
settings_xml = archive.read('word/settings.xml').decode('utf-8')
|
||||||
|
self.assertNotIn('documentProtection', settings_xml)
|
||||||
|
self.assertNotIn('writeProtection', settings_xml)
|
||||||
|
|
||||||
|
def test_task_specific_service_records_docx_uses_task_note_and_scheduled_date(self):
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/?task_id={self.task.pk}"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
content_disposition = response.get('Content-Disposition', '')
|
||||||
|
self.assertIn('MT150726.SN-Test_servisni_zadatak.docx', content_disposition)
|
||||||
|
|
||||||
|
archive = zipfile.ZipFile(BytesIO(response.content))
|
||||||
|
document_xml = archive.read('word/document.xml').decode('utf-8')
|
||||||
|
self.assertIn('Napomena iz taska', document_xml)
|
||||||
|
self.assertNotIn('Napomena iz putnog naloga', document_xml)
|
||||||
|
self.assertIn('24.12.2033', document_xml)
|
||||||
|
self.assertEqual(document_xml.count('w:type="page"'), 1)
|
||||||
|
|
||||||
|
def test_task_specific_service_records_docx_uses_dynamic_docx_header_values(self):
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/?task_id={self.task.pk}"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
|
||||||
|
archive = zipfile.ZipFile(BytesIO(response.content))
|
||||||
|
header_xml = archive.read('word/header1.xml').decode('utf-8')
|
||||||
|
self.assertIn('Liebherr LTM 1090', header_xml)
|
||||||
|
self.assertIn('123456', header_xml)
|
||||||
|
self.assertIn('111', header_xml)
|
||||||
|
self.assertIn('222', header_xml)
|
||||||
|
self.assertIn('33344', header_xml)
|
||||||
|
self.assertIn('MT150726', header_xml)
|
||||||
|
self.assertNotIn('MT250226', header_xml)
|
||||||
|
self.assertNotIn('LTM-1350', header_xml)
|
||||||
|
|
||||||
|
def test_task_specific_service_records_pdf_uses_task_header_values(self):
|
||||||
|
vehicle_two = Vehicle.objects.create(
|
||||||
|
asset_type='crane',
|
||||||
|
registration_number='WO-IMG-003',
|
||||||
|
make='Tadano',
|
||||||
|
model='ATF 120',
|
||||||
|
crane_serial_number='SN-456',
|
||||||
|
superstructure_working_hours=777,
|
||||||
|
chassis_working_hours=888,
|
||||||
|
current_mileage=99911,
|
||||||
|
)
|
||||||
|
work_order_two = WorkOrder.objects.create(
|
||||||
|
vehicle=vehicle_two,
|
||||||
|
creator=self.user,
|
||||||
|
display_code='MT170726',
|
||||||
|
purpose='kontrola',
|
||||||
|
)
|
||||||
|
task_two = Task.objects.create(
|
||||||
|
title='PDF kontekst task',
|
||||||
|
assigned_to=self.user,
|
||||||
|
vehicle=vehicle_two,
|
||||||
|
work_order=work_order_two,
|
||||||
|
scheduled_date=date(2033, 12, 27),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/fleet/work-orders/{work_order_two.pk}/service-records-pdf/?task_id={task_two.pk}"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
self.assertEqual(response['Content-Type'], 'application/pdf')
|
||||||
|
|
||||||
|
reader = PdfReader(BytesIO(response.content))
|
||||||
|
text = "\n".join((page.extract_text() or '') for page in reader.pages)
|
||||||
|
self.assertIn('Tadano', text)
|
||||||
|
self.assertIn('ATF 120', text)
|
||||||
|
self.assertIn('SN-456', text)
|
||||||
|
self.assertIn('777', text)
|
||||||
|
self.assertIn('888', text)
|
||||||
|
self.assertIn('99911', text)
|
||||||
|
self.assertIn('MT170726', text)
|
||||||
|
self.assertNotIn('MT150726', text)
|
||||||
|
|
||||||
|
def test_service_records_docx_includes_work_and_travel_total_row(self):
|
||||||
|
TaskWorkHoursTable.objects.create(
|
||||||
|
task=self.task,
|
||||||
|
data={
|
||||||
|
'rows': [{
|
||||||
|
'day': 'PON',
|
||||||
|
'date': '01.12.2033',
|
||||||
|
'work_time_from': '08:00',
|
||||||
|
'work_time_to': '12:30',
|
||||||
|
'travel_time_from': '07:00',
|
||||||
|
'travel_time_to': '08:00',
|
||||||
|
'break_hours': '0,5',
|
||||||
|
'work_hours': '4,0',
|
||||||
|
'travel_hours': '1,0',
|
||||||
|
'departure_place': 'Zagreb',
|
||||||
|
'arrival_place': 'Split',
|
||||||
|
'vehicle_km': '120',
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/?task_id={self.task.pk}")
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
|
||||||
|
archive = zipfile.ZipFile(BytesIO(response.content))
|
||||||
|
document_xml = archive.read('word/document.xml').decode('utf-8', errors='ignore')
|
||||||
|
self.assertIn('Ukupno', document_xml)
|
||||||
|
self.assertIn('4,0', document_xml)
|
||||||
|
self.assertIn('1,0', document_xml)
|
||||||
|
|
||||||
|
def test_service_records_pdf_includes_work_and_travel_total_row(self):
|
||||||
|
TaskWorkHoursTable.objects.create(
|
||||||
|
task=self.task,
|
||||||
|
data={
|
||||||
|
'rows': [{
|
||||||
|
'day': 'PON',
|
||||||
|
'date': '01.12.2033',
|
||||||
|
'work_time_from': '08:00',
|
||||||
|
'work_time_to': '12:30',
|
||||||
|
'travel_time_from': '07:00',
|
||||||
|
'travel_time_to': '08:00',
|
||||||
|
'break_hours': '0,5',
|
||||||
|
'work_hours': '4,0',
|
||||||
|
'travel_hours': '1,0',
|
||||||
|
'departure_place': 'Zagreb',
|
||||||
|
'arrival_place': 'Split',
|
||||||
|
'vehicle_km': '120',
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-pdf/?task_id={self.task.pk}")
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
self.assertEqual(response['Content-Type'], 'application/pdf')
|
||||||
|
|
||||||
|
reader = PdfReader(BytesIO(response.content))
|
||||||
|
text = "\n".join((page.extract_text() or '') for page in reader.pages)
|
||||||
|
self.assertIn('UKUPNO', text)
|
||||||
|
self.assertIn('4,0', text)
|
||||||
|
self.assertIn('1,0', text)
|
||||||
|
|
||||||
|
def test_monthly_service_tasks_archive_returns_zip_with_task_docx(self):
|
||||||
|
response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12')
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
self.assertEqual(response['Content-Type'], 'application/zip')
|
||||||
|
|
||||||
|
archive = zipfile.ZipFile(BytesIO(response.content))
|
||||||
|
names = archive.namelist()
|
||||||
|
self.assertIn('MT150726.SN-Test_servisni_zadatak.docx', names)
|
||||||
|
|
||||||
|
def test_monthly_service_tasks_archive_docx_uses_dynamic_header_values(self):
|
||||||
|
response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12')
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
|
||||||
|
archive = zipfile.ZipFile(BytesIO(response.content))
|
||||||
|
docx_name = 'MT150726.SN-Test_servisni_zadatak.docx'
|
||||||
|
self.assertIn(docx_name, archive.namelist())
|
||||||
|
|
||||||
|
docx_bytes = archive.read(docx_name)
|
||||||
|
docx_archive = zipfile.ZipFile(BytesIO(docx_bytes))
|
||||||
|
header_xml = docx_archive.read('word/header1.xml').decode('utf-8', errors='ignore')
|
||||||
|
self.assertIn('Liebherr LTM 1090', header_xml)
|
||||||
|
self.assertIn('123456', header_xml)
|
||||||
|
self.assertIn('111', header_xml)
|
||||||
|
self.assertIn('222', header_xml)
|
||||||
|
self.assertIn('33344', header_xml)
|
||||||
|
self.assertIn('MT150726', header_xml)
|
||||||
|
self.assertNotIn('MT250226', header_xml)
|
||||||
|
self.assertNotIn('LTM-1350', header_xml)
|
||||||
|
|
||||||
|
def test_monthly_service_tasks_archive_uses_task_specific_headers_for_each_docx(self):
|
||||||
|
vehicle_two = Vehicle.objects.create(
|
||||||
|
asset_type='crane',
|
||||||
|
registration_number='WO-IMG-002',
|
||||||
|
make='Tadano',
|
||||||
|
model='ATF 90',
|
||||||
|
crane_serial_number='SN-999',
|
||||||
|
superstructure_working_hours=444,
|
||||||
|
chassis_working_hours=555,
|
||||||
|
current_mileage=66677,
|
||||||
|
)
|
||||||
|
work_order_two = WorkOrder.objects.create(
|
||||||
|
vehicle=vehicle_two,
|
||||||
|
creator=self.user,
|
||||||
|
display_code='MT160726',
|
||||||
|
purpose='kontrola',
|
||||||
|
)
|
||||||
|
Task.objects.create(
|
||||||
|
title='Drugi zadatak',
|
||||||
|
assigned_to=self.user,
|
||||||
|
vehicle=vehicle_two,
|
||||||
|
work_order=work_order_two,
|
||||||
|
scheduled_date=date(2033, 12, 26),
|
||||||
|
service_report_note='Napomena drugog taska',
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12')
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
|
||||||
|
archive = zipfile.ZipFile(BytesIO(response.content))
|
||||||
|
first_docx = 'MT150726.SN-Test_servisni_zadatak.docx'
|
||||||
|
second_docx = 'MT160726.SN-Drugi_zadatak.docx'
|
||||||
|
self.assertIn(first_docx, archive.namelist())
|
||||||
|
self.assertIn(second_docx, archive.namelist())
|
||||||
|
|
||||||
|
first_header = zipfile.ZipFile(BytesIO(archive.read(first_docx))).read('word/header1.xml').decode('utf-8', errors='ignore')
|
||||||
|
second_header = zipfile.ZipFile(BytesIO(archive.read(second_docx))).read('word/header1.xml').decode('utf-8', errors='ignore')
|
||||||
|
|
||||||
|
self.assertIn('Liebherr LTM 1090', first_header)
|
||||||
|
self.assertIn('MT150726', first_header)
|
||||||
|
self.assertNotIn('Tadano ATF 90', first_header)
|
||||||
|
self.assertNotIn('MT160726', first_header)
|
||||||
|
|
||||||
|
self.assertIn('Tadano ATF 90', second_header)
|
||||||
|
self.assertIn('SN-999', second_header)
|
||||||
|
self.assertIn('444', second_header)
|
||||||
|
self.assertIn('555', second_header)
|
||||||
|
self.assertIn('66677', second_header)
|
||||||
|
self.assertIn('MT160726', second_header)
|
||||||
|
self.assertNotIn('Liebherr LTM 1090', second_header)
|
||||||
|
self.assertNotIn('MT150726', second_header)
|
||||||
|
|
||||||
|
def test_monthly_work_orders_archive_includes_work_orders_for_user_even_without_assigned_task(self):
|
||||||
|
other_user = get_user_model().objects.create_user(
|
||||||
|
username=f'wo-other-{uuid.uuid4().hex[:8]}',
|
||||||
|
email=f'wo-other-{uuid.uuid4().hex[:8]}@example.test',
|
||||||
|
password='test1234',
|
||||||
|
)
|
||||||
|
linked_work_order = WorkOrder.objects.create(
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
creator=self.user,
|
||||||
|
display_code='MT170726',
|
||||||
|
purpose='kontrola',
|
||||||
|
)
|
||||||
|
Task.objects.create(
|
||||||
|
title='Zadatak drugog servisera',
|
||||||
|
assigned_to=other_user,
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
work_order=linked_work_order,
|
||||||
|
scheduled_date=date(2033, 12, 20),
|
||||||
|
)
|
||||||
|
WorkOrderInvoice.objects.create(
|
||||||
|
work_order=linked_work_order,
|
||||||
|
naziv_racuna='Prosinac račun osoba',
|
||||||
|
datum='2033-12-12',
|
||||||
|
image=create_test_pdf('racun-prosinac-osoba.pdf'),
|
||||||
|
created_by=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get('/api/fleet/reports/monthly-work-orders-archive/?year=2033&month=12')
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
|
||||||
|
archive = zipfile.ZipFile(BytesIO(response.content))
|
||||||
|
self.assertIn('MT150726.work-order.pdf', archive.namelist())
|
||||||
|
self.assertIn('MT170726.work-order.pdf', archive.namelist())
|
||||||
|
|
||||||
|
def test_monthly_work_orders_archive_contains_work_orders_and_invoices_folder(self):
|
||||||
|
WorkOrderInvoice.objects.create(
|
||||||
|
work_order=self.work_order,
|
||||||
|
naziv_racuna='Prosinac račun',
|
||||||
|
datum='2033-12-10',
|
||||||
|
image=create_test_pdf('racun-prosinac.pdf'),
|
||||||
|
created_by=self.user,
|
||||||
|
)
|
||||||
|
response = self.client.get('/api/fleet/reports/monthly-work-orders-archive/?year=2033&month=12')
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
self.assertEqual(response['Content-Type'], 'application/zip')
|
||||||
|
|
||||||
|
archive = zipfile.ZipFile(BytesIO(response.content))
|
||||||
|
names = archive.namelist()
|
||||||
|
self.assertIn('MT150726.work-order.pdf', names)
|
||||||
|
self.assertTrue(any(name.startswith('Racuni/MT150726/') for name in names), names)
|
||||||
|
|
||||||
|
def test_work_order_pdf_travel_expense_uses_task_work_hours_table(self):
|
||||||
|
TaskWorkHoursTable.objects.create(
|
||||||
|
task=self.task,
|
||||||
|
data={
|
||||||
|
'rows': [
|
||||||
|
{
|
||||||
|
'date': '24.12.2033',
|
||||||
|
'work_time_from': '08:00',
|
||||||
|
'work_time_to': '16:00',
|
||||||
|
'travel_hours': '1',
|
||||||
|
'work_hours': '6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '25.12.2033',
|
||||||
|
'work_time_from': '09:00',
|
||||||
|
'work_time_to': '15:00',
|
||||||
|
'travel_hours': '0,5',
|
||||||
|
'work_hours': '2',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
save_response = self.client.put(
|
||||||
|
f'/api/fleet/work-orders/{self.work_order.pk}/travel-expenses-table/',
|
||||||
|
data={
|
||||||
|
'broj_sati': '9.5',
|
||||||
|
'kolicina_dnevnica': '0.5',
|
||||||
|
'iznos_dnevnica': '80',
|
||||||
|
'daily_rate_country': 'SI',
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(save_response.status_code, 200, save_response.content)
|
||||||
|
self.assertEqual(save_response.data['total_for_payout'], '40.00')
|
||||||
|
|
||||||
|
response = self.client.get(f'/api/fleet/work-orders/{self.work_order.pk}/pdf/')
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
self.assertEqual(response['Content-Type'], 'application/pdf')
|
||||||
|
|
||||||
|
reader = PdfReader(BytesIO(response.content))
|
||||||
|
text = '\n'.join((page.extract_text() or '') for page in reader.pages)
|
||||||
|
self.assertIn('24.12.2033.', text)
|
||||||
|
self.assertIn('25.12.2033.', text)
|
||||||
|
self.assertIn('9,5', text)
|
||||||
|
self.assertIn('0,5', text)
|
||||||
|
self.assertIn('80.00 €', text)
|
||||||
|
self.assertIn('40.00 €', text)
|
||||||
|
|
||||||
|
def test_work_order_travel_expenses_table_defaults_to_hr_rate_and_calculates_total(self):
|
||||||
|
TaskWorkHoursTable.objects.create(
|
||||||
|
task=self.task,
|
||||||
|
data={
|
||||||
|
'rows': [
|
||||||
|
{
|
||||||
|
'date': '24.12.2033',
|
||||||
|
'work_time_from': '08:00',
|
||||||
|
'work_time_to': '16:00',
|
||||||
|
'travel_hours': '1',
|
||||||
|
'work_hours': '6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '25.12.2033',
|
||||||
|
'work_time_from': '09:00',
|
||||||
|
'work_time_to': '15:00',
|
||||||
|
'travel_hours': '0,5',
|
||||||
|
'work_hours': '2',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(f'/api/fleet/work-orders/{self.work_order.pk}/travel-expenses-table/')
|
||||||
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
|
self.assertEqual(response.data['broj_sati'], '9.50')
|
||||||
|
self.assertEqual(response.data['kolicina_dnevnica'], '0.50')
|
||||||
|
self.assertEqual(response.data['daily_rate_country'], 'HR')
|
||||||
|
self.assertEqual(response.data['iznos_dnevnica'], '30.00')
|
||||||
|
self.assertEqual(response.data['total_for_payout'], '15.00')
|
||||||
|
|
||||||
|
def test_calculate_daily_quantity_from_hours_follows_business_rules(self):
|
||||||
|
self.assertEqual(_calculate_daily_quantity_from_hours(0), 0.0)
|
||||||
|
self.assertEqual(_calculate_daily_quantity_from_hours(7), 0.0)
|
||||||
|
self.assertEqual(_calculate_daily_quantity_from_hours(8), 0.5)
|
||||||
|
self.assertEqual(_calculate_daily_quantity_from_hours(10.5), 0.5)
|
||||||
|
self.assertEqual(_calculate_daily_quantity_from_hours(12), 1.0)
|
||||||
|
self.assertEqual(_calculate_daily_quantity_from_hours(13.5), 1.0)
|
||||||
|
|
||||||
|
def test_monthly_servicer_report_rows_use_task_work_hours_table_hours(self):
|
||||||
|
TaskWorkHoursTable.objects.create(
|
||||||
|
task=self.task,
|
||||||
|
data={
|
||||||
|
'rows': [
|
||||||
|
{
|
||||||
|
'date': '24.12.2033',
|
||||||
|
'work_time_from': '08:00',
|
||||||
|
'work_time_to': '16:00',
|
||||||
|
'travel_hours': '2',
|
||||||
|
'work_hours': '6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '25.12.2033',
|
||||||
|
'work_time_from': '09:00',
|
||||||
|
'work_time_to': '15:00',
|
||||||
|
'travel_hours': '1,5',
|
||||||
|
'work_hours': '4',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = _build_monthly_servicer_report_rows(self.user, 2033, 12)
|
||||||
|
row_by_date = {row['date']: row for row in rows if row['source'] == 'task'}
|
||||||
|
self.assertIn(date(2033, 12, 24), row_by_date)
|
||||||
|
self.assertIn(date(2033, 12, 25), row_by_date)
|
||||||
|
self.assertEqual(row_by_date[date(2033, 12, 24)]['redovan_rad'], '8')
|
||||||
|
self.assertEqual(row_by_date[date(2033, 12, 24)]['prekovremeni'], '8')
|
||||||
|
self.assertEqual(row_by_date[date(2033, 12, 25)]['redovan_rad'], '8')
|
||||||
|
self.assertEqual(row_by_date[date(2033, 12, 25)]['prekovremeni'], '5.5')
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_service_tasks_archive_request_creates_ready_download_with_notification(self):
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/fleet/reports/monthly-service-tasks-archive-request/',
|
||||||
|
data={'year': 2033, 'month': 12},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertIn(response.status_code, [200, 202], response.content)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertIn('generated_archive_id', payload)
|
||||||
|
|
||||||
|
generated = GeneratedFleetArchive.objects.filter(
|
||||||
|
pk=payload['generated_archive_id'],
|
||||||
|
requested_by=self.user,
|
||||||
|
archive_type='service_tasks',
|
||||||
|
).first()
|
||||||
|
self.assertIsNotNone(generated)
|
||||||
|
if generated.status != 'ready':
|
||||||
|
build_monthly_archive_cached_task.apply(args=[str(generated.pk)]).get()
|
||||||
|
generated.refresh_from_db()
|
||||||
|
self.assertEqual(generated.status, 'ready')
|
||||||
|
|
||||||
|
download_response = self.client.get(f"/api/fleet/reports/generated-archives/{generated.pk}/download/")
|
||||||
|
self.assertEqual(download_response.status_code, 200)
|
||||||
|
self.assertEqual(download_response['Content-Type'], 'application/zip')
|
||||||
|
archive_bytes = download_response.content if hasattr(download_response, 'content') else b''.join(download_response.streaming_content)
|
||||||
|
|
||||||
|
archive = zipfile.ZipFile(BytesIO(archive_bytes))
|
||||||
|
self.assertIn('MT150726.SN-Test_servisni_zadatak.docx', archive.namelist())
|
||||||
|
|
||||||
|
notifications = VehicleNotification.objects.filter(recipient=self.user).order_by('created_at')
|
||||||
|
self.assertTrue(notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='requested').exists())
|
||||||
|
self.assertTrue(notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='completed').exists())
|
||||||
|
requested_notification = notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='requested').last()
|
||||||
|
completed_notification = notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='completed').last()
|
||||||
|
self.assertEqual(str(requested_notification.metadata.get('generated_archive_id')), str(generated.pk))
|
||||||
|
self.assertEqual(str(completed_notification.metadata.get('generated_archive_id')), str(generated.pk))
|
||||||
|
|
||||||
|
def test_monthly_service_tasks_archive_request_ignores_stale_cached_archive(self):
|
||||||
|
stale = GeneratedFleetArchive.objects.create(
|
||||||
|
requested_by=self.user,
|
||||||
|
archive_type='service_tasks',
|
||||||
|
year=2033,
|
||||||
|
month=12,
|
||||||
|
status='ready',
|
||||||
|
filename='stale-archive.zip',
|
||||||
|
expires_at=timezone.now() + timedelta(days=2),
|
||||||
|
)
|
||||||
|
stale.file.save('stale-archive.zip', ContentFile(b'OLD-ARCHIVE'), save=True)
|
||||||
|
stale_updated_at = timezone.now() - timedelta(days=1)
|
||||||
|
GeneratedFleetArchive.objects.filter(pk=stale.pk).update(updated_at=stale_updated_at)
|
||||||
|
self.task.service_report_note = 'Nova napomena nakon cache-a'
|
||||||
|
self.task.save()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/fleet/reports/monthly-service-tasks-archive-request/',
|
||||||
|
data={'year': 2033, 'month': 12},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertIn(response.status_code, [200, 202], response.content)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertIn('generated_archive_id', payload)
|
||||||
|
self.assertNotEqual(str(stale.pk), str(payload['generated_archive_id']))
|
||||||
|
|
||||||
|
stale.refresh_from_db()
|
||||||
|
self.assertFalse(stale.is_active)
|
||||||
|
self.assertEqual(stale.status, 'failed')
|
||||||
|
|
||||||
|
def test_monthly_service_tasks_archive_request_uses_async_local_fallback_when_broker_unavailable(self):
|
||||||
|
with (
|
||||||
|
patch('modules.fleet.views.build_monthly_archive_cached_task.delay', side_effect=KombuOperationalError('broker-down')),
|
||||||
|
patch('modules.fleet.views._schedule_monthly_archive_local_fallback') as fallback_mock,
|
||||||
|
):
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/fleet/reports/monthly-service-tasks-archive-request/',
|
||||||
|
data={'year': 2033, 'month': 12},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 202, response.content)
|
||||||
|
fallback_mock.assert_called_once()
|
||||||
|
|
||||||
|
generated = (
|
||||||
|
GeneratedFleetArchive.objects
|
||||||
|
.filter(
|
||||||
|
requested_by=self.user,
|
||||||
|
archive_type='service_tasks',
|
||||||
|
year=2033,
|
||||||
|
month=12,
|
||||||
|
)
|
||||||
|
.order_by('-created_at')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(generated)
|
||||||
|
self.assertEqual(generated.status, 'pending')
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_service_tasks_archive_request_replaces_stale_pending_archive(self):
|
||||||
|
stale_pending = GeneratedFleetArchive.objects.create(
|
||||||
|
requested_by=self.user,
|
||||||
|
archive_type='service_tasks',
|
||||||
|
year=2033,
|
||||||
|
month=12,
|
||||||
|
status='pending',
|
||||||
|
filename='pending-archive.zip',
|
||||||
|
expires_at=timezone.now() + timedelta(days=2),
|
||||||
|
)
|
||||||
|
stale_created_at = timezone.now() - timedelta(minutes=10)
|
||||||
|
GeneratedFleetArchive.objects.filter(pk=stale_pending.pk).update(created_at=stale_created_at)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/fleet/reports/monthly-service-tasks-archive-request/',
|
||||||
|
data={'year': 2033, 'month': 12},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertIn(response.status_code, [200, 202], response.content)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertIn('generated_archive_id', payload)
|
||||||
|
self.assertNotEqual(str(stale_pending.pk), str(payload['generated_archive_id']))
|
||||||
|
|
||||||
|
stale_pending.refresh_from_db()
|
||||||
|
self.assertFalse(stale_pending.is_active)
|
||||||
|
self.assertEqual(stale_pending.status, 'failed')
|
||||||
|
self.assertIn('pending statusu', stale_pending.error_message)
|
||||||
|
|
||||||
|
def test_monthly_work_orders_archive_request_creates_ready_download_with_notification(self):
|
||||||
|
WorkOrderInvoice.objects.create(
|
||||||
|
work_order=self.work_order,
|
||||||
|
naziv_racuna='Prosinac račun',
|
||||||
|
datum='2033-12-10',
|
||||||
|
image=create_test_pdf('racun-prosinac.pdf'),
|
||||||
|
created_by=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/fleet/reports/monthly-work-orders-archive-request/',
|
||||||
|
data={'year': 2033, 'month': 12},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertIn(response.status_code, [200, 202], response.content)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertIn('generated_archive_id', payload)
|
||||||
|
|
||||||
|
generated = GeneratedFleetArchive.objects.filter(
|
||||||
|
pk=payload['generated_archive_id'],
|
||||||
|
requested_by=self.user,
|
||||||
|
archive_type='work_orders',
|
||||||
|
).first()
|
||||||
|
self.assertIsNotNone(generated)
|
||||||
|
if generated.status != 'ready':
|
||||||
|
build_monthly_archive_cached_task.apply(args=[str(generated.pk)]).get()
|
||||||
|
generated.refresh_from_db()
|
||||||
|
self.assertEqual(generated.status, 'ready')
|
||||||
|
|
||||||
|
download_response = self.client.get(f"/api/fleet/reports/generated-archives/{generated.pk}/download/")
|
||||||
|
self.assertEqual(download_response.status_code, 200)
|
||||||
|
self.assertEqual(download_response['Content-Type'], 'application/zip')
|
||||||
|
archive_bytes = download_response.content if hasattr(download_response, 'content') else b''.join(download_response.streaming_content)
|
||||||
|
|
||||||
|
archive = zipfile.ZipFile(BytesIO(archive_bytes))
|
||||||
|
names = archive.namelist()
|
||||||
|
self.assertIn('MT150726.work-order.pdf', names)
|
||||||
|
self.assertTrue(any(name.startswith('Racuni/MT150726/') for name in names), names)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
VehicleNotification.objects.filter(
|
||||||
|
recipient=self.user,
|
||||||
|
metadata__entity_type='fleet_archive',
|
||||||
|
metadata__archive_type='work_orders',
|
||||||
|
metadata__stage='completed',
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
requested_notification = VehicleNotification.objects.filter(
|
||||||
|
recipient=self.user,
|
||||||
|
metadata__entity_type='fleet_archive',
|
||||||
|
metadata__archive_type='work_orders',
|
||||||
|
metadata__stage='requested',
|
||||||
|
).order_by('created_at').last()
|
||||||
|
self.assertEqual(str(requested_notification.metadata.get('generated_archive_id')), str(generated.pk))
|
||||||
128
backend/modules/fleet/tests/test_work_order_send_email.py
Normal file
128
backend/modules/fleet/tests/test_work_order_send_email.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from modules.fleet.models import ServiceContextNote, Vehicle, WorkOrder, WorkOrderInvoice, WorkOrderPhoto
|
||||||
|
from modules.task_management.models import Task
|
||||||
|
|
||||||
|
|
||||||
|
def _test_image(filename='test.jpg', size=(40, 40), color='blue'):
|
||||||
|
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',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
|
||||||
|
DEFAULT_FROM_EMAIL='no-reply@example.test',
|
||||||
|
)
|
||||||
|
class WorkOrderSendEmailTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
user_model = get_user_model()
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
self.user = user_model.objects.create_user(
|
||||||
|
username=f'wo-mail-user-{suffix}',
|
||||||
|
email=f'wo-mail-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-MAIL-001',
|
||||||
|
crane_serial_number='654321',
|
||||||
|
)
|
||||||
|
self.work_order = WorkOrder.objects.create(
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
creator=self.user,
|
||||||
|
display_code='MT010126',
|
||||||
|
purpose='kontrola',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_send_email_supports_multiple_recipients_and_all_requested_attachments(self):
|
||||||
|
WorkOrderPhoto.objects.create(
|
||||||
|
work_order=self.work_order,
|
||||||
|
image=_test_image('work-order-photo.jpg'),
|
||||||
|
description='Work order image',
|
||||||
|
uploaded_by=self.user,
|
||||||
|
)
|
||||||
|
WorkOrderInvoice.objects.create(
|
||||||
|
work_order=self.work_order,
|
||||||
|
naziv_racuna='Račun 1',
|
||||||
|
image=_test_image('invoice-image.jpg'),
|
||||||
|
created_by=self.user,
|
||||||
|
)
|
||||||
|
note = ServiceContextNote.objects.create(
|
||||||
|
created_by=self.user,
|
||||||
|
recipient=self.user,
|
||||||
|
audience_source='self',
|
||||||
|
work_order=self.work_order,
|
||||||
|
note='Odabrana test bilješka.',
|
||||||
|
)
|
||||||
|
Task.objects.create(
|
||||||
|
title='Mjesečni task',
|
||||||
|
description='Provjera mjesečnog task popisa.',
|
||||||
|
assigned_to=self.user,
|
||||||
|
vehicle=self.vehicle,
|
||||||
|
work_order=self.work_order,
|
||||||
|
scheduled_date=date(2026, 7, 10),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('modules.fleet.views.send_work_order_email_bundle_task.delay') as mocked_delay:
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('workorder-send-email', kwargs={'pk': self.work_order.pk}),
|
||||||
|
data={
|
||||||
|
'recipients': ['client@example.test', 'supervisor@example.test'],
|
||||||
|
'subject': 'Test slanje dokumentacije',
|
||||||
|
'message': 'U privitku su svi traženi dokumenti.',
|
||||||
|
'include_work_order_pdf': True,
|
||||||
|
'include_service_records_pdf': True,
|
||||||
|
'include_invoices_pdf': True,
|
||||||
|
'include_images': True,
|
||||||
|
'include_monthly_tasks': True,
|
||||||
|
'month': '2026-07',
|
||||||
|
'service_note_id': str(note.pk),
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 202, response.data)
|
||||||
|
self.assertTrue(response.data.get('queued'))
|
||||||
|
self.assertEqual(len(response.data.get('recipients', [])), 2)
|
||||||
|
mocked_delay.assert_called_once()
|
||||||
|
call_kwargs = mocked_delay.call_args.kwargs
|
||||||
|
self.assertEqual(call_kwargs.get('work_order_id'), str(self.work_order.pk))
|
||||||
|
self.assertEqual(call_kwargs.get('requested_by_id'), str(self.user.id))
|
||||||
|
self.assertEqual(call_kwargs.get('month_key'), '2026-07')
|
||||||
|
self.assertEqual(call_kwargs.get('service_note_id'), str(note.pk))
|
||||||
|
|
||||||
|
def test_send_email_keeps_backward_compatible_single_recipient_payload(self):
|
||||||
|
with patch('modules.fleet.views.send_work_order_email_bundle_task.delay') as mocked_delay:
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('workorder-send-email', kwargs={'pk': self.work_order.pk}),
|
||||||
|
data={
|
||||||
|
'recipient': 'single@example.test',
|
||||||
|
'subject': 'Legacy send',
|
||||||
|
},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 202, response.data)
|
||||||
|
self.assertTrue(response.data.get('queued'))
|
||||||
|
self.assertEqual(response.data.get('recipients'), ['single@example.test'])
|
||||||
|
mocked_delay.assert_called_once()
|
||||||
@@ -2,7 +2,11 @@ from rest_framework.routers import DefaultRouter
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from .views import (
|
from .views import (
|
||||||
CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet,
|
CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet,
|
||||||
WorkOrderInvoiceViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth,
|
WorkOrderInvoiceViewSet, ServiceContextNoteViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth,
|
||||||
|
monthly_servicer_report_docx, monthly_costs_report_docx, MonthlyServicerDayEntryViewSet,
|
||||||
|
monthly_service_tasks_archive, monthly_service_tasks_archive_request,
|
||||||
|
monthly_work_orders_archive, monthly_work_orders_archive_request,
|
||||||
|
generated_archive_download,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -10,13 +14,22 @@ router.register(r'vehicles', VehicleViewSet, basename='vehicle')
|
|||||||
router.register(r'cranes', CraneViewSet, basename='crane')
|
router.register(r'cranes', CraneViewSet, basename='crane')
|
||||||
router.register(r'work-orders', WorkOrderViewSet, basename='workorder')
|
router.register(r'work-orders', WorkOrderViewSet, basename='workorder')
|
||||||
router.register(r'work-order-invoices', WorkOrderInvoiceViewSet, basename='work-order-invoice')
|
router.register(r'work-order-invoices', WorkOrderInvoiceViewSet, basename='work-order-invoice')
|
||||||
|
router.register(r'service-notes', ServiceContextNoteViewSet, basename='service-context-note')
|
||||||
router.register(r'service-records', VehicleServiceRecordViewSet, basename='service-record')
|
router.register(r'service-records', VehicleServiceRecordViewSet, basename='service-record')
|
||||||
router.register(r'notifications', VehicleNotificationViewSet, basename='vehicle-notification')
|
router.register(r'notifications', VehicleNotificationViewSet, basename='vehicle-notification')
|
||||||
router.register(r'service-photos', VehicleServicePhotoViewSet, basename='service-photo')
|
router.register(r'service-photos', VehicleServicePhotoViewSet, basename='service-photo')
|
||||||
router.register(r'service-attachments', VehicleServiceAttachmentViewSet, basename='service-attachment')
|
router.register(r'service-attachments', VehicleServiceAttachmentViewSet, basename='service-attachment')
|
||||||
|
router.register(r'monthly-servicer-day-entries', MonthlyServicerDayEntryViewSet, basename='monthly-servicer-day-entry')
|
||||||
|
|
||||||
urlpatterns = router.urls
|
urlpatterns = router.urls
|
||||||
|
|
||||||
urlpatterns += [
|
urlpatterns += [
|
||||||
path('pusher-auth/', pusher_auth, name='pusher-auth'),
|
path('pusher-auth/', pusher_auth, name='pusher-auth'),
|
||||||
|
path('reports/monthly-servicer/', monthly_servicer_report_docx, name='monthly-servicer-report'),
|
||||||
|
path('reports/monthly-costs/', monthly_costs_report_docx, name='monthly-costs-report'),
|
||||||
|
path('reports/monthly-service-tasks-archive/', monthly_service_tasks_archive, name='monthly-service-tasks-archive'),
|
||||||
|
path('reports/monthly-service-tasks-archive-request/', monthly_service_tasks_archive_request, name='monthly-service-tasks-archive-request'),
|
||||||
|
path('reports/monthly-work-orders-archive/', monthly_work_orders_archive, name='monthly-work-orders-archive'),
|
||||||
|
path('reports/monthly-work-orders-archive-request/', monthly_work_orders_archive_request, name='monthly-work-orders-archive-request'),
|
||||||
|
path('reports/generated-archives/<str:archive_id>/download/', generated_archive_download, name='generated-archive-download'),
|
||||||
]
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,69 @@
|
|||||||
# 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, TaskCalendar, 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)
|
||||||
readonly_fields = ('id', 'created_at', 'updated_at')
|
|
||||||
|
|
||||||
|
@admin.register(TaskCalendar)
|
||||||
|
class TaskCalendarAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('scheduled_date', 'title', 'status', 'assigned_to', 'vehicle', 'work_order')
|
||||||
|
list_filter = ('scheduled_date', 'status', 'assigned_to')
|
||||||
|
search_fields = ('title', 'assigned_to__email', 'vehicle__registration_number', 'work_order__display_code')
|
||||||
|
readonly_fields = ('id', 'created_at', 'updated_at')
|
||||||
|
ordering = ('scheduled_date', 'assigned_to__first_name', 'assigned_to__last_name')
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
queryset = super().get_queryset(request)
|
||||||
|
return queryset.filter(is_active=True, scheduled_date__isnull=False)
|
||||||
|
|
||||||
|
|
||||||
|
@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')
|
||||||
|
|||||||
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.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.15 on 2026-07-19 17:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('task_management', '0009_task_scheduled_date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tasktemplate',
|
||||||
|
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,26 @@
|
|||||||
|
# Generated by Django 5.2.15 on 2026-07-21 11:51
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('task_management', '0010_alter_tasktemplate_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TaskCalendar',
|
||||||
|
fields=[
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Kalendar zadatka',
|
||||||
|
'verbose_name_plural': 'Kalendar zadataka',
|
||||||
|
'proxy': True,
|
||||||
|
'indexes': [],
|
||||||
|
'constraints': [],
|
||||||
|
},
|
||||||
|
bases=('task_management.task',),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('task_management', '0011_taskcalendar'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='task',
|
||||||
|
name='service_report_note',
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
verbose_name='Napomena izvještaja servisera',
|
||||||
|
help_text='Tekst napomene za PDF izvještaj servisera za ovaj zadatak.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -23,9 +26,9 @@ class Task(BaseModel):
|
|||||||
default='aktivan'
|
default='aktivan'
|
||||||
)
|
)
|
||||||
assigned_to = models.ForeignKey(
|
assigned_to = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
related_name='tasks'
|
related_name='tasks'
|
||||||
)
|
)
|
||||||
vehicle = models.ForeignKey(
|
vehicle = models.ForeignKey(
|
||||||
@@ -44,6 +47,19 @@ 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."),
|
||||||
|
)
|
||||||
|
service_report_note = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
verbose_name=_("Napomena izvještaja servisera"),
|
||||||
|
help_text=_("Tekst napomene za PDF izvještaj servisera za ovaj zadatak."),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
@@ -53,7 +69,109 @@ 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 TaskCalendar(Task):
|
||||||
|
class Meta:
|
||||||
|
proxy = True
|
||||||
|
verbose_name = _("Kalendar zadatka")
|
||||||
|
verbose_name_plural = _("Kalendar zadataka")
|
||||||
|
|
||||||
|
|
||||||
|
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,50 +1,162 @@
|
|||||||
# 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()
|
||||||
|
vehicle_make = serializers.SerializerMethodField()
|
||||||
|
vehicle_model = serializers.SerializerMethodField()
|
||||||
|
vehicle_owner_name = serializers.SerializerMethodField()
|
||||||
|
vehicle_asset_type = serializers.SerializerMethodField()
|
||||||
|
crane_serial_number = serializers.SerializerMethodField()
|
||||||
|
work_hours_table = 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
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'title', 'description', 'status',
|
'id', 'title', 'description', 'status',
|
||||||
'assigned_to', 'assigned_to_name',
|
'assigned_to', 'assigned_to_name',
|
||||||
'vehicle', 'vehicle_registration',
|
'vehicle', 'vehicle_registration', 'vehicle_make', 'vehicle_model',
|
||||||
|
'vehicle_owner_name', 'vehicle_asset_type', 'crane_serial_number', 'work_hours_table',
|
||||||
'work_order', 'work_order_label',
|
'work_order', 'work_order_label',
|
||||||
|
'scheduled_date',
|
||||||
|
'service_report_note',
|
||||||
|
'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},
|
||||||
|
'service_report_note': {'required': False, 'allow_blank': 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 task has no crane yet, auto-derive it from the work order's crane (backward compat).
|
||||||
|
# Cross-crane assignment is intentionally allowed: a servicer can share one work order
|
||||||
|
# across tasks for multiple cranes on the same day.
|
||||||
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
|
||||||
if work_order and vehicle and work_order.vehicle_id != vehicle.id:
|
|
||||||
raise serializers.ValidationError({
|
|
||||||
'work_order': "Putni nalog mora pripadati istoj dizalici kao task."
|
|
||||||
})
|
|
||||||
|
|
||||||
if status_value == 'neaktivan' and not work_order:
|
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 +165,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:
|
||||||
@@ -72,4 +187,35 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||||||
def get_vehicle_registration(self, obj):
|
def get_vehicle_registration(self, obj):
|
||||||
if not obj.vehicle_id:
|
if not obj.vehicle_id:
|
||||||
return None
|
return None
|
||||||
return obj.vehicle.registration_number
|
return obj.vehicle.registration_number
|
||||||
|
|
||||||
|
def get_vehicle_make(self, obj):
|
||||||
|
if not obj.vehicle_id:
|
||||||
|
return None
|
||||||
|
return obj.vehicle.make or None
|
||||||
|
|
||||||
|
def get_vehicle_model(self, obj):
|
||||||
|
if not obj.vehicle_id:
|
||||||
|
return None
|
||||||
|
return obj.vehicle.model or None
|
||||||
|
|
||||||
|
def get_vehicle_owner_name(self, obj):
|
||||||
|
if not obj.vehicle_id or not obj.vehicle.client_id:
|
||||||
|
return None
|
||||||
|
return obj.vehicle.client.name
|
||||||
|
|
||||||
|
def get_vehicle_asset_type(self, obj):
|
||||||
|
if not obj.vehicle_id:
|
||||||
|
return None
|
||||||
|
return obj.vehicle.asset_type
|
||||||
|
|
||||||
|
def get_crane_serial_number(self, obj):
|
||||||
|
if not obj.vehicle_id:
|
||||||
|
return None
|
||||||
|
return obj.vehicle.crane_serial_number or None
|
||||||
|
|
||||||
|
def get_work_hours_table(self, obj):
|
||||||
|
table = getattr(obj, 'work_hours_table', None)
|
||||||
|
if not table:
|
||||||
|
return None
|
||||||
|
return TaskWorkHoursTableSerializer(table).data
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
# backend/modules/task_management/services.py
|
# backend/modules/task_management/services.py
|
||||||
|
|
||||||
from .models import Task
|
from django.db import transaction, models
|
||||||
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
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
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__)
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def _task_overload_recipients(task):
|
||||||
|
recipients = []
|
||||||
|
if task.assigned_to_id:
|
||||||
|
recipients.append(task.assigned_to)
|
||||||
|
supervisors = User.objects.filter(is_active=True).filter(
|
||||||
|
models.Q(is_staff=True) | (models.Q(is_team_member=True) & models.Q(is_serviser=False))
|
||||||
|
)
|
||||||
|
recipients.extend(supervisors)
|
||||||
|
unique = {}
|
||||||
|
for recipient in recipients:
|
||||||
|
unique[str(recipient.id)] = recipient
|
||||||
|
return list(unique.values())
|
||||||
|
|
||||||
|
|
||||||
class TaskService:
|
class TaskService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -13,14 +34,29 @@ class TaskService:
|
|||||||
"""
|
"""
|
||||||
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()
|
title = (payload.get('title') or '').strip()
|
||||||
if title and len(title) < 3:
|
if title and len(title) < 3:
|
||||||
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
|
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
|
||||||
|
|
||||||
@@ -30,18 +66,138 @@ 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)
|
||||||
entry.save()
|
|
||||||
|
with transaction.atomic():
|
||||||
|
entry.save()
|
||||||
|
TaskService._notify_if_servicer_overloaded(entry)
|
||||||
|
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 _notify_if_servicer_overloaded(entry: Task):
|
||||||
# Validacija za update (npr. samo vlasnik ili admin smije ažurirati)
|
if not entry.assigned_to_id or not entry.scheduled_date:
|
||||||
|
return
|
||||||
|
if not getattr(entry.assigned_to, 'is_serviser', False):
|
||||||
|
return
|
||||||
|
|
||||||
|
task_count = Task.objects.filter(
|
||||||
|
is_active=True,
|
||||||
|
assigned_to=entry.assigned_to,
|
||||||
|
scheduled_date=entry.scheduled_date,
|
||||||
|
status__in=('aktivan', 'servis', 'spreman_za_zavrsetak'),
|
||||||
|
).count()
|
||||||
|
if task_count <= 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
from modules.fleet.services import NotificationService
|
||||||
|
|
||||||
|
schedule_label = entry.scheduled_date.strftime('%d.%m.%Y')
|
||||||
|
title = f"Preopterećenje rasporeda: {entry.assigned_to.get_full_name() or entry.assigned_to.email}"
|
||||||
|
message = (
|
||||||
|
f"Serviseru {entry.assigned_to.get_full_name() or entry.assigned_to.email} "
|
||||||
|
f"je dodijeljeno {task_count} zadatka za {schedule_label}. "
|
||||||
|
f"Sustav je dopustio i dodatni zadatak."
|
||||||
|
)
|
||||||
|
metadata = {
|
||||||
|
"entity_type": "task_overload",
|
||||||
|
"assigned_to_id": str(entry.assigned_to_id),
|
||||||
|
"task_id": str(entry.id),
|
||||||
|
"scheduled_date": entry.scheduled_date.isoformat(),
|
||||||
|
"task_count": task_count,
|
||||||
|
"section": "dashboard",
|
||||||
|
}
|
||||||
|
for recipient in _task_overload_recipients(entry):
|
||||||
|
NotificationService.create_notification(
|
||||||
|
recipient=recipient,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
level='warning',
|
||||||
|
send_email=False,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_work_entries_from_template(*, task: Task, template: TaskTemplate):
|
||||||
|
template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10])
|
||||||
|
for idx, source in enumerate(template_entries, start=1):
|
||||||
|
TaskWorkEntry.objects.create(
|
||||||
|
task=task,
|
||||||
|
template_entry=source,
|
||||||
|
sort_order=idx,
|
||||||
|
title=source.title,
|
||||||
|
work_description=source.work_description,
|
||||||
|
active_faults=source.active_faults,
|
||||||
|
explanation_hr=source.explanation_hr,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_service_records_from_template(*, task: Task, template: TaskTemplate, user):
|
||||||
|
if not user:
|
||||||
|
raise ValidationError({
|
||||||
|
"template_id": "Kreiranje taska iz templatea zahtijeva prijavljenog korisnika za servisne zapise."
|
||||||
|
})
|
||||||
|
if not task.vehicle_id:
|
||||||
|
raise ValidationError({
|
||||||
|
"vehicle": "Task iz templatea mora imati dodijeljenu dizalicu za generiranje servisnih zapisa."
|
||||||
|
})
|
||||||
|
|
||||||
|
template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10])
|
||||||
|
for source in template_entries:
|
||||||
|
description_parts = []
|
||||||
|
if source.work_description:
|
||||||
|
description_parts.append(source.work_description.strip())
|
||||||
|
if source.explanation_hr:
|
||||||
|
description_parts.append(f"Obrazloženje (HR): {source.explanation_hr.strip()}")
|
||||||
|
if source.active_faults:
|
||||||
|
description_parts.append(f"Aktivne greške: {source.active_faults.strip()}")
|
||||||
|
description = "\n\n".join([part for part in description_parts if part]).strip() or source.title
|
||||||
|
|
||||||
|
VehicleServiceRecord.objects.create(
|
||||||
|
vehicle=task.vehicle,
|
||||||
|
task=task,
|
||||||
|
performed_by=user,
|
||||||
|
service_title=source.title,
|
||||||
|
description=description,
|
||||||
|
parts=(source.active_faults or '').strip(),
|
||||||
|
cost=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_task_entry(instance, data, user, auto_close_work_order=False):
|
||||||
if not user.is_staff and instance.assigned_to != user:
|
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:
|
if 'title' in data and len(str(data.get('title') or '').strip()) < 3:
|
||||||
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
|
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
|
||||||
@@ -50,4 +206,4 @@ class TaskService:
|
|||||||
except DjangoValidationError as exc:
|
except DjangoValidationError as 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)
|
||||||
instance.save()
|
instance.save()
|
||||||
return instance
|
return instance
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
@@ -25,7 +79,7 @@ def create_task_from_invoice(invoice_data):
|
|||||||
vehicle = work_order.vehicle
|
vehicle = work_order.vehicle
|
||||||
if vehicle is None and vehicle_id:
|
if vehicle is None and vehicle_id:
|
||||||
vehicle = Vehicle.objects.filter(pk=vehicle_id).first()
|
vehicle = Vehicle.objects.filter(pk=vehicle_id).first()
|
||||||
|
|
||||||
if amount > 1000:
|
if amount > 1000:
|
||||||
Task.objects.create(
|
Task.objects.create(
|
||||||
title=f"Provjera računa: {invoice_data['invoice_number']}",
|
title=f"Provjera računa: {invoice_data['invoice_number']}",
|
||||||
@@ -34,4 +88,4 @@ def create_task_from_invoice(invoice_data):
|
|||||||
status='aktivan',
|
status='aktivan',
|
||||||
vehicle=vehicle,
|
vehicle=vehicle,
|
||||||
work_order=work_order,
|
work_order=work_order,
|
||||||
)
|
)
|
||||||
|
|||||||
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
|
||||||
@@ -2,15 +2,25 @@ from django.test import TestCase
|
|||||||
from modules.task_management.serializers import TaskSerializer
|
from modules.task_management.serializers import TaskSerializer
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from modules.task_management.models import Task
|
from modules.task_management.models import Task, TaskWorkHoursTable
|
||||||
from modules.fleet.models import Vehicle, WorkOrder
|
from modules.fleet.models import Vehicle, WorkOrder
|
||||||
|
from modules.crm.models import Client
|
||||||
|
|
||||||
|
|
||||||
class TaskSerializerTests(TestCase):
|
class TaskSerializerTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
self.user = User.objects.create_user(username="task-user-base", email="task-user-base@example.test", password="pass")
|
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')
|
self.client = Client.objects.create(name="Klijent zadatka")
|
||||||
|
self.vehicle = Vehicle.objects.create(
|
||||||
|
registration_number="ZG-TSK-01",
|
||||||
|
asset_type='crane',
|
||||||
|
make='Liebherr',
|
||||||
|
model='LTM',
|
||||||
|
crane_serial_number='123456',
|
||||||
|
client=self.client,
|
||||||
|
is_company_vehicle=False,
|
||||||
|
)
|
||||||
|
|
||||||
def test_validate_title_too_short_raises(self):
|
def test_validate_title_too_short_raises(self):
|
||||||
serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)})
|
serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)})
|
||||||
@@ -31,4 +41,132 @@ class TaskSerializerTests(TestCase):
|
|||||||
task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle)
|
task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle)
|
||||||
|
|
||||||
serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True)
|
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_vehicle_details_are_included_in_serialized_output(self):
|
||||||
|
task = Task.objects.create(title="Task crane details", assigned_to=self.user, vehicle=self.vehicle)
|
||||||
|
serializer = TaskSerializer(instance=task)
|
||||||
|
assert serializer.data['vehicle_make'] == 'Liebherr'
|
||||||
|
assert serializer.data['vehicle_model'] == 'LTM'
|
||||||
|
assert serializer.data['crane_serial_number'] == '123456'
|
||||||
|
assert serializer.data['vehicle_owner_name'] == 'Klijent zadatka'
|
||||||
|
assert serializer.data['vehicle_asset_type'] == 'crane'
|
||||||
|
|
||||||
|
def test_work_hours_table_is_included_in_serialized_output(self):
|
||||||
|
task = Task.objects.create(title="Task work hours", assigned_to=self.user, vehicle=self.vehicle)
|
||||||
|
TaskWorkHoursTable.objects.create(
|
||||||
|
task=task,
|
||||||
|
data={
|
||||||
|
'rows': [
|
||||||
|
{'date': '12.07.2026', 'work_hours': '8', 'travel_hours': '2'},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = TaskSerializer(instance=task)
|
||||||
|
assert serializer.data['work_hours_table']['data']['rows'][0]['date'] == '12.07.2026'
|
||||||
|
assert serializer.data['work_hours_table']['data']['rows'][0]['work_hours'] == '8'
|
||||||
|
assert serializer.data['work_hours_table']['data']['rows'][0]['travel_hours'] == '2'
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def test_can_assign_work_order_from_different_crane(self):
|
||||||
|
"""Task može dobiti putni nalog koji pripada drugoj dizalici (cross-crane dijeljenje naloga)."""
|
||||||
|
other_vehicle = Vehicle.objects.create(
|
||||||
|
registration_number="ZG-TSK-02",
|
||||||
|
asset_type='crane',
|
||||||
|
make='Manitowoc',
|
||||||
|
model='Grove',
|
||||||
|
client=self.client,
|
||||||
|
is_company_vehicle=False,
|
||||||
|
)
|
||||||
|
work_order = WorkOrder.objects.create(vehicle=other_vehicle, creator=self.user, purpose='kontrola')
|
||||||
|
task = Task.objects.create(title="Cross-crane task", status="servis", assigned_to=self.user, vehicle=self.vehicle)
|
||||||
|
|
||||||
|
serializer = TaskSerializer(instance=task, data={"work_order": str(work_order.id)}, partial=True)
|
||||||
|
assert serializer.is_valid(), serializer.errors
|
||||||
|
updated_task = serializer.save()
|
||||||
|
assert updated_task.work_order_id == work_order.id
|
||||||
|
assert updated_task.vehicle_id == self.vehicle.id # task zadržava svoju dizalicu
|
||||||
|
|
||||||
|
def test_can_close_task_with_cross_crane_work_order(self):
|
||||||
|
"""Task se može zatvoriti putnim nalogom koji pripada drugoj dizalici."""
|
||||||
|
other_vehicle = Vehicle.objects.create(
|
||||||
|
registration_number="ZG-TSK-03",
|
||||||
|
asset_type='crane',
|
||||||
|
make='Tadano',
|
||||||
|
model='GR',
|
||||||
|
client=self.client,
|
||||||
|
is_company_vehicle=False,
|
||||||
|
)
|
||||||
|
work_order = WorkOrder.objects.create(vehicle=other_vehicle, creator=self.user, purpose='popravak')
|
||||||
|
task = Task.objects.create(title="Task for close", 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
|
||||||
|
updated_task = serializer.save()
|
||||||
|
assert updated_task.status == 'zavrsen'
|
||||||
|
assert updated_task.work_order_id == work_order.id
|
||||||
|
|
||||||
|
def test_work_order_auto_derives_vehicle_when_task_has_none(self):
|
||||||
|
"""Ako task nema dizalicu a dobiva WO, dizalica se preuzima iz WO (backward compat)."""
|
||||||
|
work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='defektaza')
|
||||||
|
serializer = TaskSerializer(
|
||||||
|
data={"title": "Auto-vehicle task", "status": "aktivan", "work_order": str(work_order.id)},
|
||||||
|
)
|
||||||
|
# vehicle je obavezan pri kreiranju, ali ako task nije instanca ovo bi trebalo failati
|
||||||
|
# Provjeri da vehicle derivation iz WO radi samo ako task već postoji (partial update)
|
||||||
|
task = Task.objects.create(title="Temp task", status="aktivan", assigned_to=self.user, vehicle=self.vehicle)
|
||||||
|
serializer2 = TaskSerializer(instance=task, data={"work_order": str(work_order.id)}, partial=True)
|
||||||
|
assert serializer2.is_valid(), serializer2.errors
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
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
|
||||||
|
from unittest.mock import patch
|
||||||
|
from datetime import date
|
||||||
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 +45,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,
|
||||||
@@ -50,4 +60,210 @@ class TaskServiceTests(TestCase):
|
|||||||
user=self.user,
|
user=self.user,
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|
||||||
|
@patch('modules.fleet.services.NotificationService.create_notification')
|
||||||
|
def test_create_task_entry_sends_overload_notifications_after_two_tasks(self, mock_notify):
|
||||||
|
servicer = get_user_model().objects.create_user(
|
||||||
|
username='servicer-overload',
|
||||||
|
email='servicer-overload@example.test',
|
||||||
|
password='test1234',
|
||||||
|
is_serviser=True,
|
||||||
|
is_team_member=True,
|
||||||
|
)
|
||||||
|
supervisor = get_user_model().objects.create_user(
|
||||||
|
username='supervisor-overload',
|
||||||
|
email='supervisor-overload@example.test',
|
||||||
|
password='test1234',
|
||||||
|
is_team_member=True,
|
||||||
|
is_serviser=False,
|
||||||
|
)
|
||||||
|
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-OVER1", asset_type='crane')
|
||||||
|
scheduled = date(2026, 7, 21)
|
||||||
|
|
||||||
|
Task.objects.create(title="Postojeći 1", assigned_to=servicer, vehicle=vehicle, status='aktivan', scheduled_date=scheduled)
|
||||||
|
Task.objects.create(title="Postojeći 2", assigned_to=servicer, vehicle=vehicle, status='servis', scheduled_date=scheduled)
|
||||||
|
|
||||||
|
TaskService.create_task_entry(
|
||||||
|
data={
|
||||||
|
"title": "Treći task",
|
||||||
|
"status": "aktivan",
|
||||||
|
"assigned_to": servicer,
|
||||||
|
"vehicle": vehicle,
|
||||||
|
"scheduled_date": scheduled,
|
||||||
|
},
|
||||||
|
user=self.staff,
|
||||||
|
)
|
||||||
|
|
||||||
|
recipients = [call.kwargs.get('recipient') for call in mock_notify.call_args_list]
|
||||||
|
recipient_ids = {str(recipient.id) for recipient in recipients if recipient}
|
||||||
|
self.assertIn(str(servicer.id), recipient_ids)
|
||||||
|
self.assertIn(str(supervisor.id), recipient_ids)
|
||||||
|
|
||||||
|
@patch('modules.fleet.services.NotificationService.create_notification')
|
||||||
|
def test_create_task_entry_does_not_send_overload_notifications_when_two_or_less(self, mock_notify):
|
||||||
|
servicer = get_user_model().objects.create_user(
|
||||||
|
username='servicer-normal-load',
|
||||||
|
email='servicer-normal-load@example.test',
|
||||||
|
password='test1234',
|
||||||
|
is_serviser=True,
|
||||||
|
is_team_member=True,
|
||||||
|
)
|
||||||
|
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-OVER2", asset_type='crane')
|
||||||
|
scheduled = date(2026, 7, 21)
|
||||||
|
|
||||||
|
TaskService.create_task_entry(
|
||||||
|
data={
|
||||||
|
"title": "Prvi task",
|
||||||
|
"status": "aktivan",
|
||||||
|
"assigned_to": servicer,
|
||||||
|
"vehicle": vehicle,
|
||||||
|
"scheduled_date": scheduled,
|
||||||
|
},
|
||||||
|
user=self.staff,
|
||||||
|
)
|
||||||
|
TaskService.create_task_entry(
|
||||||
|
data={
|
||||||
|
"title": "Drugi task",
|
||||||
|
"status": "aktivan",
|
||||||
|
"assigned_to": servicer,
|
||||||
|
"vehicle": vehicle,
|
||||||
|
"scheduled_date": scheduled,
|
||||||
|
},
|
||||||
|
user=self.staff,
|
||||||
|
)
|
||||||
|
self.assertEqual(mock_notify.call_count, 0)
|
||||||
@@ -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,11 @@
|
|||||||
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
|
from modules.fleet.models import Vehicle
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ class TaskViewSetTests(TestCase):
|
|||||||
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.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):
|
||||||
@@ -44,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"
|
||||||
@@ -53,4 +61,32 @@ class TaskViewSetTests(TestCase):
|
|||||||
task = Task.objects.create(title="ToDelete", description="", assigned_to=self.user)
|
task = Task.objects.create(title="ToDelete", description="", assigned_to=self.user)
|
||||||
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,76 @@ 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', 'vehicle__client', '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)
|
||||||
|
work_order_id = self.request.query_params.get('work_order')
|
||||||
|
if work_order_id:
|
||||||
|
qs = qs.filter(work_order_id=work_order_id)
|
||||||
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)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pytesseract==0.3.13
|
|||||||
pytest>=9.1.1
|
pytest>=9.1.1
|
||||||
pytest-django>=4.12.0
|
pytest-django>=4.12.0
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
|
python-docx==1.2.0
|
||||||
python-dotenv==1.2.2
|
python-dotenv==1.2.2
|
||||||
pusher>=3.3.4
|
pusher>=3.3.4
|
||||||
redis==8.0.0
|
redis==8.0.0
|
||||||
|
|||||||
12
backend/scripts/cleanup_tmp_archives.sh
Normal file
12
backend/scripts/cleanup_tmp_archives.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
TARGET_DIR="${TMP_ARCHIVE_DIR:-/tmp/erp-generated-archives}"
|
||||||
|
RETENTION_DAYS="${TMP_RETENTION_DAYS:-1}"
|
||||||
|
|
||||||
|
if [ ! -d "$TARGET_DIR" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
find "$TARGET_DIR" -type f -name '*.zip' -mtime +"$RETENTION_DAYS" -delete
|
||||||
|
find "$TARGET_DIR" -type d -empty -delete
|
||||||
@@ -5,7 +5,7 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
command: python manage.py runserver 0.0.0.0:8000
|
command: python manage.py runserver 0.0.0.0:8000
|
||||||
user: "1001:1001"
|
user: "1000:1000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- media_volume:/app/media
|
- media_volume:/app/media
|
||||||
@@ -22,7 +22,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
command: npm run dev -- --host 0.0.0.0 --force
|
working_dir: /app
|
||||||
|
command: sh -c "rm -rf /app/.astro /app/node_modules/.vite && npm run dev -- --host 0.0.0.0 --force"
|
||||||
ports:
|
ports:
|
||||||
- "4321:4321"
|
- "4321:4321"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -30,13 +31,15 @@ services:
|
|||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PUBLIC_ALLOWED_HOSTS=serviseri-004.captain.mitteworkspace.cloud
|
- CHOKIDAR_USEPOLLING=true
|
||||||
- PUBLIC_HMR_HOST=serviseri-004.captain.mitteworkspace.cloud
|
- CHOKIDAR_INTERVAL=1000
|
||||||
|
- WATCHPACK_POLLING=true
|
||||||
|
- PUBLIC_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
- PUBLIC_HMR_HOST=localhost
|
||||||
- PUBLIC_HMR_PROTOCOL=ws
|
- PUBLIC_HMR_PROTOCOL=ws
|
||||||
- PUBLIC_HMR_CLIENT_PORT=4321
|
- PUBLIC_HMR_CLIENT_PORT=4321
|
||||||
- PUBLIC_HMR_PROTOCOL=wss
|
- PUBLIC_API_URL=http://localhost:4321/api/
|
||||||
- PUBLIC_HMR_CLIENT_PORT=443
|
- PUBLIC_API_PROXY_TARGET=http://backend:8000
|
||||||
- PUBLIC_API_URL=https://api-004.captain.mitteworkspace.cloud/api/
|
|
||||||
|
|
||||||
flower:
|
flower:
|
||||||
container_name: 004erpflower
|
container_name: 004erpflower
|
||||||
@@ -52,6 +55,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
|
||||||
|
|||||||
127
docker-compose.prod.yml
Normal file
127
docker-compose.prod.yml
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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
|
||||||
|
media-permissions:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
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 --concurrency=2 --prefetch-multiplier=1 --max-tasks-per-child=20 --max-memory-per-child=300000
|
||||||
|
volumes:
|
||||||
|
- media_volume:/app/media
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
media-permissions:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
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
|
||||||
|
media-permissions:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
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
|
||||||
@@ -21,13 +21,24 @@ services:
|
|||||||
# i aktiviramo LRU algoritam koji automatski briše najstarije ključeve ako se limit prijeđe.
|
# i aktiviramo LRU algoritam koji automatski briše najstarije ključeve ako se limit prijeđe.
|
||||||
command: redis-server --appendonly yes --maxmemory 100mb --maxmemory-policy allkeys-lru
|
command: redis-server --appendonly yes --maxmemory 100mb --maxmemory-policy allkeys-lru
|
||||||
|
|
||||||
|
media-permissions:
|
||||||
|
image: alpine:3.22
|
||||||
|
container_name: 004erpmediafix
|
||||||
|
command: >
|
||||||
|
sh -c "mkdir -p /app/media/fleet/generated_archives /app/media/fleet/generated_pdfs
|
||||||
|
&& chown -R 1001:1001 /app/media
|
||||||
|
&& chmod -R u+rwX,g+rwX /app/media"
|
||||||
|
volumes:
|
||||||
|
- media_volume:/app/media
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
container_name: 004erpworker
|
container_name: 004erpworker
|
||||||
build:
|
build:
|
||||||
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
|
||||||
@@ -36,6 +47,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db: { condition: service_healthy }
|
db: { condition: service_healthy }
|
||||||
redis: { condition: service_started }
|
redis: { condition: service_started }
|
||||||
|
media-permissions: { condition: service_completed_successfully }
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -44,4 +56,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
media_volume: # Ovo je ključno za tvoj ERP
|
media_volume: # Ovo je ključno za tvoj ERP
|
||||||
|
|||||||
237
docs/production-db-local-setup.md
Normal file
237
docs/production-db-local-setup.md
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# Preuzimanje produkcijske baze podataka na lokalni PC
|
||||||
|
|
||||||
|
Vodič za lokalni razvoj s kopijom produkcijskog PostgreSQL-a bez mijenjanja produkcijske baze.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Preduvjeti
|
||||||
|
|
||||||
|
- SSH pristup produkcijskom serveru
|
||||||
|
- Docker Desktop instaliran lokalno
|
||||||
|
- `scp` dostupan u PowerShell / terminalu
|
||||||
|
- Lokalni `.env` s `DEBUG=True` i **drugačijim** `SECRET_KEY` od produkcije
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Korak 1 — Na produkcijskom serveru: napravi dump
|
||||||
|
|
||||||
|
SSH-aj na produkcijski server, zatim pokreni:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec 004erpdb pg_dump \
|
||||||
|
-U $POSTGRES_USER \
|
||||||
|
-d $POSTGRES_DB \
|
||||||
|
--no-owner \
|
||||||
|
--no-acl \
|
||||||
|
-Fc \
|
||||||
|
-f /tmp/prod_dump.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`-Fc`** — custom format (komprimiran, pogodniji za `pg_restore`)
|
||||||
|
> **`--no-owner --no-acl`** — izostavlja produkcijske usere i privilegije
|
||||||
|
|
||||||
|
Ako env varijable nisu dostupne u shell sesiji, napiši vrijednosti direktno:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec 004erpdb sh -c \
|
||||||
|
"pg_dump -U TVOJ_USER TVOJA_BAZA --no-owner --no-acl -Fc -f /tmp/prod_dump.dump"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ako baza ima desetke gigabajta, pg_dump može opteretiti disk produkcije. U tom slučaju, dumpaj u komprimirani format **--exclude-table-data='log_*'**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec 004erpdb pg_dump \
|
||||||
|
-U $POSTGRES_USER \
|
||||||
|
-d $POSTGRES_DB \
|
||||||
|
--no-owner \
|
||||||
|
--no-acl \
|
||||||
|
--exclude-table-data='log_*' \
|
||||||
|
-Fc \
|
||||||
|
-f /tmp/prod_dump.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec 004erpdb sh -c 'pg_dump
|
||||||
|
-U "$TVOJ_USER"
|
||||||
|
-d "$TVOJA_BAZA"
|
||||||
|
--no-owner
|
||||||
|
--no-acl
|
||||||
|
--exclude-table-data='log_*'
|
||||||
|
-Fc
|
||||||
|
-f /tmp/prod_dump.dump'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Korak 2 — Kopiraj dump na lokalni PC
|
||||||
|
|
||||||
|
Iz docker servera na host server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker cp 004erpdb:/tmp/prod_dump.dump /tmp/prod_dump.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
Iz lokalnog PowerShell terminala:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scp tvoj_user@IP_SERVERA:/tmp/prod_dump.dump C:\Users\mario.tkalac\Downloads\prod_dump.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
S SSH ključem:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scp -i C:\Users\mario.tkalac\.ssh\id_rsa `
|
||||||
|
tvoj_user@IP_SERVERA:/tmp/prod_dump.dump `
|
||||||
|
C:\Users\mario.tkalac\Downloads\prod_dump.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
Brisanje dump-a s produkcijskog servera (opcionalno):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ssh tvoj_user@IP_SERVERA "rm /tmp/prod_dump.dump"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Korak 3 — Pokreni lokalnu bazu
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\mario.tkalac\workspace\aplikacija\004.erp.generic
|
||||||
|
|
||||||
|
docker compose -f docker-compose.yml up -d db
|
||||||
|
```
|
||||||
|
|
||||||
|
Pričekaj dok container postane `healthy`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker ps --filter "name=004erpdb"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Korak 4 — Restoriraj dump u lokalnu bazu
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Kopiraj dump file u container
|
||||||
|
docker cp C:\Users\mario.tkalac\Downloads\prod_dump.dump 004erpdb:/tmp/prod_dump.dump
|
||||||
|
|
||||||
|
# Dropaj lokalnu bazu i kreiraj novu praznu (BRIŠE lokalne podatke!)
|
||||||
|
docker exec 004erpdb dropdb -U TVOJ_USER TVOJA_BAZA --if-exists
|
||||||
|
docker exec 004erpdb createdb -U TVOJ_USER TVOJA_BAZA
|
||||||
|
|
||||||
|
# Restoriraj
|
||||||
|
docker exec 004erpdb sh -c 'pg_restore
|
||||||
|
-U "$TVOJ_USER"
|
||||||
|
-d "$TVOJA_BAZA"
|
||||||
|
--no-owner
|
||||||
|
--no-acl
|
||||||
|
-Fc
|
||||||
|
-f /tmp/prod_dump.dump'
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ Zamijeni `TVOJ_USER` i `TVOJA_BAZA` s vrijednostima iz lokalnog `.env` fajla:
|
||||||
|
> `POSTGRES_USER` i `POSTGRES_DB`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Korak 5 — Primijeni nove migracije
|
||||||
|
|
||||||
|
Pokrenuti odmah nakon restore-a kako bi lokalna shema bila u sinkronizaciji s kodom:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml `
|
||||||
|
run --rm backend python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Migracije na postojećim podacima su sigurne — dodaju tablice/stupce, ne brišu podatke.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Korak 6 — Postavi lokalnog admin korisnika
|
||||||
|
|
||||||
|
Produkcijske lozinke su hashirane i ne mogu se koristiti direktno. Kreiraj novog superusera:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml `
|
||||||
|
run --rm backend python manage.py createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
Ili resetiraj lozinku postojećem accountu:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml `
|
||||||
|
run --rm backend python manage.py changepassword tvoj@email.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Korak 7 — Pokreni cijeli dev stack
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d backend frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Provjeri:
|
||||||
|
- Backend: `http://localhost:8001/api/`
|
||||||
|
- Frontend: `http://localhost:4321/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Napomene o sigurnosti
|
||||||
|
|
||||||
|
| Tema | Akcija |
|
||||||
|
|------|--------|
|
||||||
|
| **Produkcijska baza** | `pg_dump` je read-only operacija — produkcijska baza nije dirnuta |
|
||||||
|
| **SECRET_KEY** | Lokalni `.env` **mora** imati drugačiji `SECRET_KEY` od produkcije |
|
||||||
|
| **DEBUG** | Lokalni `.env` mora imati `DEBUG=True` |
|
||||||
|
| **Media fajlovi** | Dump ne uključuje fajlove iz `media_volume`; kopirati ručno ako su potrebni |
|
||||||
|
| **Osobni podaci** | Baza može sadržavati osobne podatke klijenata — koristiti samo na zaštićenom lokalnom stroju |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Media fajlovi (opcionalno)
|
||||||
|
|
||||||
|
Ako trebaju priloženi fajlovi (slike, PDF-ovi):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Na produkcijskom serveru — arhiviraj media
|
||||||
|
docker run --rm \
|
||||||
|
-v media_volume:/data \
|
||||||
|
alpine tar czf /tmp/media.tar.gz -C /data .
|
||||||
|
|
||||||
|
# Kopiraj lokalno
|
||||||
|
scp tvoj_user@IP_SERVERA:/tmp/media.tar.gz C:\Users\mario.tkalac\Downloads\media.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Raspakiraj u lokalni Docker volume:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Kopiraj arhivu u privremeni container
|
||||||
|
docker run --rm `
|
||||||
|
-v 004erpgeneric_media_volume:/data `
|
||||||
|
-v C:\Users\mario.tkalac\Downloads:/backup `
|
||||||
|
alpine sh -c "tar xzf /backup/media.tar.gz -C /data"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Brzi pregled naredbi (cheat sheet)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Dump na serveru
|
||||||
|
docker exec 004erpdb pg_dump -U USER DB --no-owner --no-acl -Fc -f /tmp/prod_dump.dump
|
||||||
|
|
||||||
|
# 2. Kopiraj lokalno
|
||||||
|
scp user@IP:/tmp/prod_dump.dump ~/Downloads/prod_dump.dump
|
||||||
|
|
||||||
|
# 3. Restore lokalno
|
||||||
|
docker cp ~/Downloads/prod_dump.dump 004erpdb:/tmp/prod_dump.dump
|
||||||
|
docker exec 004erpdb dropdb -U USER DB --if-exists
|
||||||
|
docker exec 004erpdb createdb -U USER DB
|
||||||
|
docker exec 004erpdb pg_restore -U USER -d DB --no-owner --no-acl -Fc /tmp/prod_dump.dump
|
||||||
|
|
||||||
|
# 4. Migracije
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm backend python manage.py migrate
|
||||||
|
```
|
||||||
@@ -19,6 +19,7 @@ const allowedHosts = Array.from(
|
|||||||
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,9 +60,20 @@ 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: 1000,
|
||||||
|
awaitWriteFinish: {
|
||||||
|
stabilityThreshold: 1200,
|
||||||
|
pollInterval: 200,
|
||||||
|
},
|
||||||
|
ignored: ['**/.git/**', '**/node_modules/**', '**/.astro/**', '**/dist/**'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
const CACHE_NAME = 'erp-shell-v2';
|
const CACHE_NAME = 'erp-shell-v3';
|
||||||
const API_CACHE_NAME = 'erp-api-v1';
|
const API_CACHE_NAME = 'erp-api-v1';
|
||||||
const WRITE_QUEUE_DB_NAME = 'erp-write-queue-db';
|
const WRITE_QUEUE_DB_NAME = 'erp-write-queue-db';
|
||||||
const WRITE_QUEUE_STORE = 'requests';
|
const WRITE_QUEUE_STORE = 'requests';
|
||||||
const WRITE_QUEUE_SYNC_TAG = 'erp-write-queue-sync';
|
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 = ['/', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL];
|
||||||
|
|
||||||
const API_CACHE_BLOCKLIST = [
|
const API_CACHE_BLOCKLIST = [
|
||||||
'/api/token/',
|
'/api/token/',
|
||||||
@@ -142,10 +142,26 @@ async function replayQueuedRequests() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
async function precacheShell() {
|
||||||
event.waitUntil(
|
const cache = await caches.open(CACHE_NAME);
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
|
await Promise.all(
|
||||||
|
PRECACHE_URLS.map(async (url) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { cache: 'reload' });
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
console.warn('Skipping precache for non-OK response:', url, response && response.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await cache.put(url, response);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Skipping precache for failed request:', url, error);
|
||||||
|
}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(precacheShell());
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ 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 { hydrateAuthFromStorage } from '../stores/authStore';
|
import { useSpring, animated } from '@react-spring/web';
|
||||||
|
import { hydrateAuthFromStorage, loadCurrentUser, $user, $accessToken } from '../stores/authStore';
|
||||||
|
|
||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', href: '/' },
|
{ id: 'dashboard', label: 'Dashboard', href: '/' },
|
||||||
@@ -13,30 +14,93 @@ const ITEMS = [
|
|||||||
{ id: 'clients', label: 'Klijenti', href: '/clients' },
|
{ id: 'clients', label: 'Klijenti', href: '/clients' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function normalizePath(p) {
|
||||||
|
const value = String(p || '/');
|
||||||
|
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveIndex(path) {
|
||||||
|
// Exact match (e.g. '/' → Dashboard)
|
||||||
|
const exact = ITEMS.findIndex((item) => item.href === path);
|
||||||
|
if (exact >= 0) return exact;
|
||||||
|
// Prefix match for sub-paths (e.g. '/putni-nalozi/racuni' → Putni nalozi)
|
||||||
|
const prefix = ITEMS.findIndex((item) => item.href !== '/' && path.startsWith(item.href));
|
||||||
|
return prefix >= 0 ? prefix : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Navbar({ minimal = false }) {
|
export default function Navbar({ minimal = false }) {
|
||||||
const [pathname, setPathname] = useState('/');
|
// Navbar ostaje montiran (transition:persist) — pratimo promjene pathname preko astro:page-load
|
||||||
const normalizedPath = useMemo(() => {
|
const [pathname, setPathname] = useState(() =>
|
||||||
const value = String(pathname || '/');
|
typeof window !== 'undefined' ? normalizePath(window.location.pathname) : '/'
|
||||||
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
|
|
||||||
return value;
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updatePath = () => setPathname(window.location.pathname || '/');
|
|
||||||
updatePath();
|
|
||||||
window.addEventListener('astro:page-load', updatePath);
|
|
||||||
// Hidratira auth iz storage pri prvi puta učitavanja Navbar-a
|
|
||||||
hydrateAuthFromStorage();
|
|
||||||
return () => window.removeEventListener('astro:page-load', updatePath);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const currentItem = useMemo(
|
|
||||||
() => ITEMS.find((item) => item.href === normalizedPath) || ITEMS[0],
|
|
||||||
[normalizedPath]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
hydrateAuthFromStorage();
|
||||||
|
// Učitaj korisnika ako token postoji ali $user još nije popunjen
|
||||||
|
if (!$user.get() && $accessToken.get()) {
|
||||||
|
loadCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Kad se promijeni activeIndex (nova stranica), animiraj pill na novu poziciju
|
||||||
|
useEffect(() => {
|
||||||
|
if (!skipAnimation) {
|
||||||
|
measureIndicator();
|
||||||
|
}
|
||||||
|
}, [activeIndex]);
|
||||||
|
|
||||||
|
// react-spring: animirani klizač
|
||||||
|
// immediate=true na prvom renderu → pill se ne animira od lijevog ruba, već skače na pravo mjesto
|
||||||
|
const springStyle = useSpring({
|
||||||
|
left: indicatorStyle.left,
|
||||||
|
width: indicatorStyle.width,
|
||||||
|
immediate: skipAnimation,
|
||||||
|
config: { tension: 340, friction: 28 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentItem = ITEMS[activeIndex];
|
||||||
|
|
||||||
return (
|
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 overflow-visible">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
{/* Logo + korisnik */}
|
{/* Logo + korisnik */}
|
||||||
<a href="/" className="flex flex-col hover:opacity-80">
|
<a href="/" className="flex flex-col hover:opacity-80">
|
||||||
@@ -44,16 +108,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,11 +141,13 @@ 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 bg-canvas-base px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep select-none">
|
||||||
{currentItem?.label || 'Izbornik'} ▾
|
☰ {currentItem?.label || 'Izbornik'}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="absolute right-0 mt-2 min-w-44 rounded-lg border border-border-hairline bg-canvas-elevated shadow-lg">
|
{/* Dropdown je positioniran fixed-like kroz visoki z-index kako ne bi bio odsječen */}
|
||||||
|
<div className="absolute left-0 top-full mt-1 z-50 min-w-52 rounded-lg border border-border-hairline bg-canvas-elevated shadow-xl">
|
||||||
<div className="border-b border-border-hairline px-3 py-2 text-[11px] uppercase tracking-wide text-text-muted">
|
<div className="border-b border-border-hairline px-3 py-2 text-[11px] uppercase tracking-wide text-text-muted">
|
||||||
Navigacija
|
Navigacija
|
||||||
</div>
|
</div>
|
||||||
@@ -78,11 +157,12 @@ 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'
|
? 'flex items-center gap-2 bg-indigo-50 px-3 py-2.5 text-sm font-medium text-indigo-700'
|
||||||
: 'block px-3 py-2 text-sm text-text-main hover:bg-canvas-deep'
|
: 'flex items-center gap-2 px-3 py-2.5 text-sm text-text-main hover:bg-canvas-deep'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{pathname === item.href && <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />}
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { showToast } from '../stores/toastStore';
|
|
||||||
|
|
||||||
export default function ToastTrigger() {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => showToast('Sustav je spreman za rad.', 'info', 2500)}
|
|
||||||
className="fixed bottom-4 right-4 z-50 rounded-full border border-border-hairline bg-canvas-elevated px-4 py-2 text-xs font-semibold text-text-main shadow-sm hover:bg-canvas-deep"
|
|
||||||
>
|
|
||||||
Test Toast
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,18 @@
|
|||||||
import { showToast } from '../../stores/toastStore';
|
|
||||||
import ServiceContextSelector from './ServiceContextSelector';
|
import ServiceContextSelector from './ServiceContextSelector';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
|
||||||
{ key: 'dashboard', label: 'Dashboard' },
|
|
||||||
{ key: 'work-orders', label: 'Putni nalozi' },
|
|
||||||
{ key: 'service-records', label: 'Servisni zapisi' },
|
|
||||||
{ key: 'cranes', label: 'Dizalice' },
|
|
||||||
{ key: 'clients', label: 'Klijenti' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function DashboardTopbar({
|
export default function DashboardTopbar({
|
||||||
activeSection,
|
|
||||||
onSectionChange,
|
|
||||||
onOpenWorkOrderModal,
|
onOpenWorkOrderModal,
|
||||||
onOpenTaskModal,
|
onOpenTaskModal,
|
||||||
pendingSyncCount,
|
pendingSyncCount,
|
||||||
onNewServiceRecord,
|
onNewServiceRecord,
|
||||||
showSectionNav = true,
|
|
||||||
}) {
|
}) {
|
||||||
const sectionTitle = NAV_ITEMS.find((item) => item.key === activeSection)?.label || 'Dashboard';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 border-b border-border-hairline bg-canvas-elevated/95 backdrop-blur">
|
<header className="sticky top-0 z-20 border-b border-border-hairline bg-canvas-elevated/95 backdrop-blur">
|
||||||
{/* ── Gornji red ───────────────────────────────────────── */}
|
{/* ── Gornji red ───────────────────────────────────────── */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
|
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-text-muted">Pregled sustava</p>
|
<p className="text-xs uppercase tracking-wide text-text-muted">Pregled sustava</p>
|
||||||
<h2 className="text-xl font-semibold text-text-main">{sectionTitle}</h2>
|
<h2 className="text-xl font-semibold text-text-main">ERP</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{pendingSyncCount > 0 && (
|
{pendingSyncCount > 0 && (
|
||||||
@@ -53,31 +39,6 @@ export default function DashboardTopbar({
|
|||||||
|
|
||||||
{/* ── Selector bar: Kupac + Dizalica ───────────────────── */}
|
{/* ── Selector bar: Kupac + Dizalica ───────────────────── */}
|
||||||
<ServiceContextSelector onNewServiceRecord={onNewServiceRecord} />
|
<ServiceContextSelector onNewServiceRecord={onNewServiceRecord} />
|
||||||
|
|
||||||
{/* ── Navigacijski tabovi ──────────────────────────────── */}
|
|
||||||
{showSectionNav && (
|
|
||||||
<nav className="overflow-x-auto border-t border-border-hairline px-4 sm:px-6">
|
|
||||||
<ul className="flex min-w-max items-center gap-1 py-2 text-sm">
|
|
||||||
{NAV_ITEMS.map((item) => (
|
|
||||||
<li key={item.key}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onSectionChange(item.key);
|
|
||||||
}}
|
|
||||||
className={
|
|
||||||
activeSection === item.key
|
|
||||||
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700'
|
|
||||||
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
995
frontend/src/components/dashboard/TaskCalendarWidget.jsx
Normal file
995
frontend/src/components/dashboard/TaskCalendarWidget.jsx
Normal file
@@ -0,0 +1,995 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
import { getStatusLabel } from '../../stores/taskStore';
|
||||||
|
import {
|
||||||
|
downloadMonthlyServiceTasksArchive,
|
||||||
|
downloadMonthlyCostsReport,
|
||||||
|
downloadMonthlyServiserReport,
|
||||||
|
downloadMonthlyWorkOrdersArchive,
|
||||||
|
fetchMonthlyCostInvoices,
|
||||||
|
fetchMonthlyServicerEntries,
|
||||||
|
upsertMonthlyServicerEntry,
|
||||||
|
} from '../../stores/fleetDashboardStore';
|
||||||
|
import LeftSlideDrawer from '../layout/LeftSlideDrawer';
|
||||||
|
import { getTaskCraneKind, getTaskCraneOwner, getTaskCraneSerial } from '../../lib/taskCraneDisplay';
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS = ['Po', 'Ut', 'Sr', 'Ce', 'Pe', 'Su', 'Ne'];
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'Sijecanj', 'Veljaca', 'Ozujak', 'Travanj', 'Svibanj', 'Lipanj',
|
||||||
|
'Srpanj', 'Kolovoz', 'Rujan', 'Listopad', 'Studeni', 'Prosinac',
|
||||||
|
];
|
||||||
|
const SERVICER_HEADERS = ['DATUM', 'OPIS POSLA', 'BR. DIZALICE', 'KOMITENT', 'MJESTO RADA', 'POCETAK RADA', 'KRAJ RADA', 'REDOVAN RAD (h)', 'PREKOVREMENI (h)', 'RADNI NALOG'];
|
||||||
|
const COST_HEADERS = ['DATUM', 'NAZIV RACUNA', 'LOKACIJA', 'OPIS', 'RADNI NALOG'];
|
||||||
|
|
||||||
|
function toDateKey(value) {
|
||||||
|
const text = String(value || '').trim();
|
||||||
|
if (!text) return null;
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return text;
|
||||||
|
const croatian = text.match(/^(\d{2})\.(\d{2})\.(\d{4})\.?$/);
|
||||||
|
if (croatian) {
|
||||||
|
return `${croatian[3]}-${croatian[2]}-${croatian[1]}`;
|
||||||
|
}
|
||||||
|
const parsed = new Date(text);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null;
|
||||||
|
return `${parsed.getFullYear()}-${String(parsed.getMonth() + 1).padStart(2, '0')}-${String(parsed.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDayLabel(year, month, day) {
|
||||||
|
return `${String(day).padStart(2, '0')}.${String(month + 1).padStart(2, '0')}.${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateFromKey(dateKey) {
|
||||||
|
const [year, month, day] = String(dateKey || '').split('-');
|
||||||
|
if (!year || !month || !day) return '-';
|
||||||
|
return `${day}.${month}.${year}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value) {
|
||||||
|
if (!value) return '-';
|
||||||
|
if (typeof value === 'string' && /^\d{2}:\d{2}/.test(value)) {
|
||||||
|
return value.slice(0, 5);
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return '-';
|
||||||
|
return parsed.toLocaleTimeString('hr-HR', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTimeInputValue(value) {
|
||||||
|
const formatted = formatTime(value);
|
||||||
|
return formatted === '-' ? '' : formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateHours(startTime, endTime) {
|
||||||
|
if (!startTime || !endTime) return null;
|
||||||
|
const [startHour, startMinute] = String(startTime).split(':').map((part) => Number(part));
|
||||||
|
const [endHour, endMinute] = String(endTime).split(':').map((part) => Number(part));
|
||||||
|
if (![startHour, startMinute, endHour, endMinute].every((value) => Number.isFinite(value))) return null;
|
||||||
|
const startTotal = (startHour * 60) + startMinute;
|
||||||
|
const endTotal = (endHour * 60) + endMinute;
|
||||||
|
if (endTotal <= startTotal) return null;
|
||||||
|
return ((endTotal - startTotal) / 60).toFixed(2).replace(/\.?0+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHourValue(value, fallback = '-') {
|
||||||
|
if (value == null || value === '') return fallback;
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) return String(value);
|
||||||
|
if (Number.isInteger(numeric)) return String(numeric);
|
||||||
|
return numeric.toFixed(2).replace(/\.?0+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinUnique(values) {
|
||||||
|
return Array.from(new Set(values.filter(Boolean).map((item) => String(item).trim()).filter(Boolean))).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReportHours(value) {
|
||||||
|
if (value == null || value === '') return 0;
|
||||||
|
const numeric = Number(String(value).replace(',', '.'));
|
||||||
|
return Number.isFinite(numeric) ? numeric : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReportDateTime(dateKey, value) {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return new Date(value.getTime());
|
||||||
|
}
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (!text) return null;
|
||||||
|
const timeMatch = text.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
||||||
|
if (dateKey && timeMatch) {
|
||||||
|
const hours = String(timeMatch[1]).padStart(2, '0');
|
||||||
|
const minutes = timeMatch[2];
|
||||||
|
const seconds = timeMatch[3] || '00';
|
||||||
|
return new Date(`${dateKey}T${hours}:${minutes}:${seconds}`);
|
||||||
|
}
|
||||||
|
const parsed = new Date(text);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskWorkHoursEntries(task, workOrdersById) {
|
||||||
|
const tableData = task?.work_hours_table?.data ?? task?.work_hours_table ?? null;
|
||||||
|
const rows = Array.isArray(tableData?.rows) ? tableData.rows : [];
|
||||||
|
const workOrder = workOrdersById.get(String(task?.work_order || ''));
|
||||||
|
const base = {
|
||||||
|
title: task?.title || '',
|
||||||
|
serial: getTaskCraneSerial(task) || '',
|
||||||
|
client: getTaskCraneOwner(task) || '',
|
||||||
|
location: workOrder?.location || '',
|
||||||
|
workOrderLabel: task?.work_order_label || '',
|
||||||
|
};
|
||||||
|
const entries = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row || typeof row !== 'object') continue;
|
||||||
|
const dateKey = toDateKey(row.date) || toDateKey(task?.scheduled_date);
|
||||||
|
if (!dateKey) continue;
|
||||||
|
const startAt = parseReportDateTime(dateKey, row.work_time_from || row.travel_time_from || '');
|
||||||
|
const endAt = parseReportDateTime(dateKey, row.work_time_to || row.travel_time_to || '');
|
||||||
|
entries.push({
|
||||||
|
...base,
|
||||||
|
dateKey,
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
totalHours: parseReportHours(row.work_hours) + parseReportHours(row.travel_hours),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length > 0) {
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
const travelStart = workOrder?.travel_start_at;
|
||||||
|
const travelEnd = workOrder?.travel_end_at;
|
||||||
|
if (travelStart && travelEnd) {
|
||||||
|
const startAt = parseReportDateTime(null, travelStart);
|
||||||
|
const endAt = parseReportDateTime(null, travelEnd);
|
||||||
|
const dateKey = toDateKey(task?.scheduled_date) || toDateKey(travelStart);
|
||||||
|
const totalHours = startAt && endAt ? Math.max(0, (endAt.getTime() - startAt.getTime()) / 3600000) : 0;
|
||||||
|
if (dateKey) {
|
||||||
|
entries.push({
|
||||||
|
...base,
|
||||||
|
dateKey,
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
totalHours,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders = [], onTaskClick }) {
|
||||||
|
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
|
||||||
|
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
|
||||||
|
const [selectedDay, setSelectedDay] = useState(null);
|
||||||
|
const [reportType, setReportType] = useState(null);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [manualEntries, setManualEntries] = useState([]);
|
||||||
|
const [costInvoices, setCostInvoices] = useState([]);
|
||||||
|
const [loadingManualEntries, setLoadingManualEntries] = useState(false);
|
||||||
|
const [loadingCostInvoices, setLoadingCostInvoices] = useState(false);
|
||||||
|
const [manualEntryTarget, setManualEntryTarget] = useState(null);
|
||||||
|
const [manualEntryDraft, setManualEntryDraft] = useState(null);
|
||||||
|
const [manualEntryError, setManualEntryError] = useState('');
|
||||||
|
const [savingManualEntry, setSavingManualEntry] = useState(false);
|
||||||
|
const [bulkDownloadOpen, setBulkDownloadOpen] = useState(false);
|
||||||
|
const [downloadingAllTasks, setDownloadingAllTasks] = useState(false);
|
||||||
|
const [downloadingAllWorkOrders, setDownloadingAllWorkOrders] = useState(false);
|
||||||
|
|
||||||
|
const reportOpen = reportType !== null;
|
||||||
|
const panelWidth = reportOpen ? 920 : 300;
|
||||||
|
|
||||||
|
const tasksByDate = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const task of tasks) {
|
||||||
|
const key = toDateKey(task.scheduled_date);
|
||||||
|
if (!key) continue;
|
||||||
|
if (!map[key]) map[key] = [];
|
||||||
|
map[key].push(task);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
const notesByDate = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const note of notes) {
|
||||||
|
const key = toDateKey(note.note_date);
|
||||||
|
if (!key) continue;
|
||||||
|
if (!map[key]) map[key] = [];
|
||||||
|
map[key].push(note);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [notes]);
|
||||||
|
|
||||||
|
const workOrdersById = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const workOrder of workOrders) {
|
||||||
|
if (workOrder?.id) {
|
||||||
|
map.set(String(workOrder.id), workOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [workOrders]);
|
||||||
|
|
||||||
|
const manualEntriesByDate = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const entry of manualEntries) {
|
||||||
|
const key = toDateKey(entry.entry_date);
|
||||||
|
if (key) {
|
||||||
|
map[key] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [manualEntries]);
|
||||||
|
|
||||||
|
const calendarDays = useMemo(() => {
|
||||||
|
const firstDay = new Date(viewYear, viewMonth, 1);
|
||||||
|
let startDow = firstDay.getDay();
|
||||||
|
startDow = (startDow + 6) % 7;
|
||||||
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||||
|
const cells = [];
|
||||||
|
for (let i = 0; i < startDow; i += 1) cells.push(null);
|
||||||
|
for (let d = 1; d <= daysInMonth; d += 1) cells.push(d);
|
||||||
|
return cells;
|
||||||
|
}, [viewYear, viewMonth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reportOpen || reportType !== 'servicer') return undefined;
|
||||||
|
let active = true;
|
||||||
|
setLoadingManualEntries(true);
|
||||||
|
fetchMonthlyServicerEntries(viewYear, viewMonth + 1)
|
||||||
|
.then((rows) => {
|
||||||
|
if (!active) return;
|
||||||
|
setManualEntries(Array.isArray(rows) ? rows : []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!active) return;
|
||||||
|
setManualEntries([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!active) return;
|
||||||
|
setLoadingManualEntries(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [reportOpen, reportType, viewYear, viewMonth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reportOpen || reportType !== 'costs') return undefined;
|
||||||
|
let active = true;
|
||||||
|
setLoadingCostInvoices(true);
|
||||||
|
fetchMonthlyCostInvoices(viewYear, viewMonth + 1)
|
||||||
|
.then((rows) => {
|
||||||
|
if (!active) return;
|
||||||
|
setCostInvoices(Array.isArray(rows) ? rows : []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!active) return;
|
||||||
|
setCostInvoices([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!active) return;
|
||||||
|
setLoadingCostInvoices(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [reportOpen, reportType, viewYear, viewMonth]);
|
||||||
|
|
||||||
|
const reportTaskEntriesByDate = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const task of tasks) {
|
||||||
|
for (const entry of getTaskWorkHoursEntries(task, workOrdersById)) {
|
||||||
|
if (!map[entry.dateKey]) map[entry.dateKey] = [];
|
||||||
|
map[entry.dateKey].push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [tasks, workOrdersById]);
|
||||||
|
|
||||||
|
const servicerRows = useMemo(() => {
|
||||||
|
if (reportType !== 'servicer') return [];
|
||||||
|
const rows = [];
|
||||||
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||||
|
const monthPrefix = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day += 1) {
|
||||||
|
const key = `${monthPrefix}-${String(day).padStart(2, '0')}`;
|
||||||
|
const dayEntries = reportTaskEntriesByDate[key] ?? [];
|
||||||
|
const manualEntry = manualEntriesByDate[key];
|
||||||
|
|
||||||
|
if (dayEntries.length > 0) {
|
||||||
|
const titles = [];
|
||||||
|
const serials = [];
|
||||||
|
const clients = [];
|
||||||
|
const locations = [];
|
||||||
|
const workOrderLabels = [];
|
||||||
|
const startTimes = [];
|
||||||
|
const endTimes = [];
|
||||||
|
let totalHours = 0;
|
||||||
|
|
||||||
|
for (const entry of dayEntries) {
|
||||||
|
if (entry.title) titles.push(entry.title);
|
||||||
|
if (entry.serial) serials.push(entry.serial);
|
||||||
|
if (entry.client) clients.push(entry.client);
|
||||||
|
if (entry.location) locations.push(entry.location);
|
||||||
|
if (entry.workOrderLabel) workOrderLabels.push(entry.workOrderLabel);
|
||||||
|
if (entry.startAt) startTimes.push(entry.startAt);
|
||||||
|
if (entry.endAt) endTimes.push(entry.endAt);
|
||||||
|
totalHours += Number(entry.totalHours || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const regularHours = 8;
|
||||||
|
const overtimeHours = Math.max(0, totalHours - regularHours);
|
||||||
|
rows.push({
|
||||||
|
key,
|
||||||
|
clickable: false,
|
||||||
|
source: 'task',
|
||||||
|
cells: [
|
||||||
|
formatDateFromKey(key),
|
||||||
|
joinUnique(titles) || '-',
|
||||||
|
joinUnique(serials) || '-',
|
||||||
|
joinUnique(clients) || '-',
|
||||||
|
joinUnique(locations) || '-',
|
||||||
|
startTimes.length ? formatTime(new Date(Math.min(...startTimes.map((item) => item.getTime())))) : '-',
|
||||||
|
endTimes.length ? formatTime(new Date(Math.max(...endTimes.map((item) => item.getTime())))) : '-',
|
||||||
|
formatHourValue(regularHours, '0'),
|
||||||
|
formatHourValue(overtimeHours, '0'),
|
||||||
|
joinUnique(workOrderLabels) || '-',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manualEntry) {
|
||||||
|
rows.push({
|
||||||
|
key,
|
||||||
|
clickable: true,
|
||||||
|
source: manualEntry.entry_type,
|
||||||
|
cells: [
|
||||||
|
formatDateFromKey(key),
|
||||||
|
manualEntry.description || '-',
|
||||||
|
'-',
|
||||||
|
'-',
|
||||||
|
manualEntry.location || '-',
|
||||||
|
formatTime(manualEntry.start_time),
|
||||||
|
formatTime(manualEntry.end_time),
|
||||||
|
formatHourValue(manualEntry.regular_hours, '0'),
|
||||||
|
formatHourValue(manualEntry.overtime_hours, '0'),
|
||||||
|
'-',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
key,
|
||||||
|
clickable: true,
|
||||||
|
source: 'empty',
|
||||||
|
cells: [formatDateFromKey(key), 'Klikni za unos', '-', '-', '-', '-', '-', '-', '0', '-'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}, [manualEntriesByDate, reportTaskEntriesByDate, reportType, viewMonth, viewYear]);
|
||||||
|
|
||||||
|
const costsRows = useMemo(() => {
|
||||||
|
if (reportType !== 'costs') return [];
|
||||||
|
return costInvoices.map((invoice) => ([
|
||||||
|
invoice?.datum ? new Date(invoice.datum).toLocaleDateString('hr-HR') : '-',
|
||||||
|
invoice?.naziv_racuna || '-',
|
||||||
|
invoice?.lokacija || '-',
|
||||||
|
invoice?.opis || '-',
|
||||||
|
invoice?.work_order_display_code || '-',
|
||||||
|
]));
|
||||||
|
}, [costInvoices, reportType]);
|
||||||
|
|
||||||
|
const selectedDayKey = selectedDay
|
||||||
|
? `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(selectedDay).padStart(2, '0')}`
|
||||||
|
: null;
|
||||||
|
const selectedDayTasks = selectedDayKey ? (tasksByDate[selectedDayKey] ?? []) : [];
|
||||||
|
const selectedDayNotes = selectedDayKey ? (notesByDate[selectedDayKey] ?? []) : [];
|
||||||
|
const today = new Date();
|
||||||
|
const totalScheduled = Object.values(tasksByDate).flat().length;
|
||||||
|
const totalNoteReminders = Object.values(notesByDate).flat().length;
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
setSelectedDay(null);
|
||||||
|
if (viewMonth === 0) {
|
||||||
|
setViewYear((y) => y - 1);
|
||||||
|
setViewMonth(11);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setViewMonth((m) => m - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMonth() {
|
||||||
|
setSelectedDay(null);
|
||||||
|
if (viewMonth === 11) {
|
||||||
|
setViewYear((y) => y + 1);
|
||||||
|
setViewMonth(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setViewMonth((m) => m + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDayClick(day) {
|
||||||
|
setSelectedDay((prev) => (prev === day ? null : day));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskClick(task, closePanel) {
|
||||||
|
onTaskClick?.(task);
|
||||||
|
closePanel?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReport(type) {
|
||||||
|
setReportType((prev) => (prev === type ? null : type));
|
||||||
|
setManualEntryTarget(null);
|
||||||
|
if (type !== 'servicer') {
|
||||||
|
setBulkDownloadOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
if (downloading) return;
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
if (reportType === 'servicer') {
|
||||||
|
await downloadMonthlyServiserReport(viewYear, viewMonth + 1);
|
||||||
|
} else if (reportType === 'costs') {
|
||||||
|
await downloadMonthlyCostsReport(viewYear, viewMonth + 1);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadAllTasksArchive() {
|
||||||
|
if (downloadingAllTasks) return;
|
||||||
|
setBulkDownloadOpen(false);
|
||||||
|
setDownloadingAllTasks(true);
|
||||||
|
try {
|
||||||
|
await downloadMonthlyServiceTasksArchive(viewYear, viewMonth + 1);
|
||||||
|
} finally {
|
||||||
|
setDownloadingAllTasks(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadAllWorkOrdersArchive() {
|
||||||
|
if (downloadingAllWorkOrders) return;
|
||||||
|
setBulkDownloadOpen(false);
|
||||||
|
setDownloadingAllWorkOrders(true);
|
||||||
|
try {
|
||||||
|
await downloadMonthlyWorkOrdersArchive(viewYear, viewMonth + 1);
|
||||||
|
} finally {
|
||||||
|
setDownloadingAllWorkOrders(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleServicerRowClick(row) {
|
||||||
|
if (!row?.clickable) return;
|
||||||
|
setManualEntryTarget(row);
|
||||||
|
setManualEntryDraft(null);
|
||||||
|
setManualEntryError('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function startManualEntry(entryType) {
|
||||||
|
if (!manualEntryTarget) return;
|
||||||
|
const isOffice = entryType === 'office';
|
||||||
|
setManualEntryError('');
|
||||||
|
setManualEntryDraft({
|
||||||
|
entryType,
|
||||||
|
startTime: isOffice ? '08:00' : '',
|
||||||
|
endTime: isOffice ? '16:00' : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveManualEntry() {
|
||||||
|
if (!manualEntryTarget || !manualEntryDraft || savingManualEntry) return;
|
||||||
|
const { entryType, startTime, endTime } = manualEntryDraft;
|
||||||
|
if (startTime && endTime && !calculateHours(startTime, endTime)) {
|
||||||
|
setManualEntryError('Kraj rada mora biti nakon početka rada.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setManualEntryError('');
|
||||||
|
setSavingManualEntry(true);
|
||||||
|
try {
|
||||||
|
const saved = await upsertMonthlyServicerEntry({
|
||||||
|
entry_date: manualEntryTarget.key,
|
||||||
|
entry_type: entryType,
|
||||||
|
start_time: startTime || undefined,
|
||||||
|
end_time: endTime || undefined,
|
||||||
|
});
|
||||||
|
setManualEntries((prev) => {
|
||||||
|
const next = Array.isArray(prev) ? [...prev] : [];
|
||||||
|
const index = next.findIndex((item) => toDateKey(item.entry_date) === toDateKey(saved.entry_date));
|
||||||
|
if (index >= 0) {
|
||||||
|
next[index] = saved;
|
||||||
|
} else {
|
||||||
|
next.push(saved);
|
||||||
|
}
|
||||||
|
return next.sort((a, b) => String(a.entry_date || '').localeCompare(String(b.entry_date || '')));
|
||||||
|
});
|
||||||
|
setManualEntryTarget(null);
|
||||||
|
setManualEntryDraft(null);
|
||||||
|
setManualEntryError('');
|
||||||
|
} finally {
|
||||||
|
setSavingManualEntry(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LeftSlideDrawer
|
||||||
|
drawerId="calendar"
|
||||||
|
title="Kalendar zadataka"
|
||||||
|
tabLabel="Kalendar"
|
||||||
|
tabTitle="Kalendar zadataka"
|
||||||
|
tabTopClassName="top-1/2"
|
||||||
|
backdropClassName="z-[45]"
|
||||||
|
panelClassName="z-[46]"
|
||||||
|
tabClassName="z-[47]"
|
||||||
|
panelWidth={panelWidth}
|
||||||
|
>
|
||||||
|
{({ close }) => (
|
||||||
|
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="flex w-[300px] flex-shrink-0 flex-col overflow-hidden"
|
||||||
|
style={reportOpen ? { borderRight: '1px solid var(--color-border-hairline, #e5e7eb)' } : {}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-1.5 border-b border-border-hairline px-3 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleReport('servicer')}
|
||||||
|
className={[
|
||||||
|
'flex-1 rounded px-2 py-1 text-[11px] font-medium transition-colors',
|
||||||
|
reportType === 'servicer'
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-canvas-deep text-text-main hover:bg-indigo-50 hover:text-indigo-700',
|
||||||
|
].join(' ')}
|
||||||
|
title="Otvori panel za mjesecni izvjestaj servisera"
|
||||||
|
>
|
||||||
|
Izvjestaj servisera
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleReport('costs')}
|
||||||
|
className={[
|
||||||
|
'flex-1 rounded px-2 py-1 text-[11px] font-medium transition-colors',
|
||||||
|
reportType === 'costs'
|
||||||
|
? 'bg-emerald-600 text-white'
|
||||||
|
: 'bg-canvas-deep text-text-main hover:bg-emerald-50 hover:text-emerald-700',
|
||||||
|
].join(' ')}
|
||||||
|
title="Otvori panel za mjesecni izvjestaj troskova"
|
||||||
|
>
|
||||||
|
Izvjestaj troskova
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="Sljedeci mjesec"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 hasNotes = Boolean(notesByDate[key]?.length);
|
||||||
|
const taskCount = tasksByDate[key]?.length ?? 0;
|
||||||
|
const noteCount = notesByDate[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 || hasNotes ? `${taskCount} zadatak/a, ${noteCount} biljeski` : 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 || hasNotes) && (
|
||||||
|
<span className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 items-center gap-0.5">
|
||||||
|
{hasTasks && (
|
||||||
|
<span className={['h-1.5 w-1.5 rounded-full', isSelected ? 'bg-white' : 'bg-indigo-500'].join(' ')} />
|
||||||
|
)}
|
||||||
|
{hasNotes && (
|
||||||
|
<span className={['h-1.5 w-1.5 rounded-full', isSelected ? 'bg-violet-200' : 'bg-violet-500'].join(' ')} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 border-t border-border-hairline" />
|
||||||
|
|
||||||
|
<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 && selectedDayNotes.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted">Nema dogadjaja za odabrani datum.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{selectedDayNotes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-violet-700">Biljeske</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{selectedDayNotes.map((note) => (
|
||||||
|
<li key={note.id} className="rounded-lg border border-violet-200 bg-violet-50 px-3 py-2 text-sm text-violet-900">
|
||||||
|
<span className="block">{note.note}</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-violet-700">
|
||||||
|
{note.task_title || note.work_order_label || 'Servisni kontekst'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedDayTasks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-indigo-700">Zadaci</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{selectedDayTasks.map((task) => (
|
||||||
|
<li key={task.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTaskClick(task, close)}
|
||||||
|
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-left text-sm transition-colors hover:border-indigo-200 hover:bg-indigo-50"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-text-muted">
|
||||||
|
{getTaskCraneSerial(task)} | {getTaskCraneKind(task)} | {getTaskCraneOwner(task)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{totalNoteReminders > 0 && (
|
||||||
|
<p className="mt-1 text-xs font-medium text-violet-600">
|
||||||
|
{totalNoteReminders} biljeski/podsjetnika u kalendaru.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{totalScheduled === 0 && (
|
||||||
|
<p className="mt-1.5 text-[11px] text-text-muted">
|
||||||
|
Nijedan zadatak nema postavljen planirani datum.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reportOpen && (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-semibold text-text-main">
|
||||||
|
{reportType === 'servicer' ? 'Mjesecni izvjestaj servisera' : 'Mjesecni izvjestaj troskova'}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-xs text-text-muted">
|
||||||
|
{MONTH_NAMES[viewMonth]} {viewYear}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={downloading}
|
||||||
|
className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{downloading ? 'Preuzimanje...' : reportType === 'servicer' ? 'Preuzmi mjesečni izvještaj' : 'Preuzmi DOCX'}
|
||||||
|
</button>
|
||||||
|
{reportType === 'servicer' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBulkDownloadOpen(true)}
|
||||||
|
className="rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep"
|
||||||
|
>
|
||||||
|
Preuzmi ZIP
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="border-b border-border-hairline bg-canvas-deep px-4 py-1.5 text-[11px] text-text-muted">
|
||||||
|
{reportType === 'servicer'
|
||||||
|
? 'Tablica prikazuje sve dane u mjesecu. Klikni na red bez putnog naloga i odaberi status dana.'
|
||||||
|
: 'Pregled troskova iz racuna vezanih uz putne naloge u odabranom mjesecu.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{reportType === 'servicer' && (
|
||||||
|
loadingManualEntries ? (
|
||||||
|
<p className="px-4 py-8 text-center text-sm text-text-muted">Ucitam dnevne unose...</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full min-w-[980px] border-collapse text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-canvas-deep">
|
||||||
|
{SERVICER_HEADERS.map((header) => (
|
||||||
|
<th key={header} className="border border-border-hairline px-2 py-1.5 text-left font-semibold text-text-muted whitespace-nowrap">
|
||||||
|
{header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{servicerRows.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={row.key}
|
||||||
|
onClick={() => handleServicerRowClick(row)}
|
||||||
|
className={[
|
||||||
|
index % 2 === 0 ? 'bg-canvas-base' : 'bg-canvas-deep',
|
||||||
|
row.clickable ? 'cursor-pointer hover:bg-indigo-50' : '',
|
||||||
|
].join(' ')}
|
||||||
|
title={row.clickable ? 'Klikni za unos ili izmjenu dana bez putnog naloga.' : undefined}
|
||||||
|
>
|
||||||
|
{row.cells.map((cell, cellIndex) => (
|
||||||
|
<td key={`${row.key}-${cellIndex}`} className="border border-border-hairline px-2 py-1 text-text-main whitespace-nowrap">
|
||||||
|
{cell}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reportType === 'costs' && (
|
||||||
|
loadingCostInvoices ? (
|
||||||
|
<p className="px-4 py-8 text-center text-sm text-text-muted">Ucitam troskove...</p>
|
||||||
|
) : costsRows.length === 0 ? (
|
||||||
|
<p className="px-4 py-8 text-center text-sm text-text-muted">
|
||||||
|
Nema troskova za {MONTH_NAMES[viewMonth]} {viewYear}.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full min-w-[640px] border-collapse text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-canvas-deep">
|
||||||
|
{COST_HEADERS.map((header) => (
|
||||||
|
<th key={header} className="border border-border-hairline px-2 py-1.5 text-left font-semibold text-text-muted whitespace-nowrap">
|
||||||
|
{header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{costsRows.map((row, index) => (
|
||||||
|
<tr key={`${row[0]}-${row[1]}-${index}`} className={index % 2 === 0 ? 'bg-canvas-base' : 'bg-canvas-deep'}>
|
||||||
|
{row.map((cell, cellIndex) => (
|
||||||
|
<td key={cellIndex} className="border border-border-hairline px-2 py-1 text-text-main whitespace-nowrap">
|
||||||
|
{cell}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{manualEntryTarget && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/20">
|
||||||
|
<div className="w-full max-w-sm rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-2xl">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-text-main">
|
||||||
|
{manualEntryDraft ? 'Unos radnog dana' : 'Odaberi vrstu unosa'}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">
|
||||||
|
{formatDateFromKey(manualEntryTarget.key)} nema putni nalog. Odaberi status dana.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setManualEntryTarget(null);
|
||||||
|
setManualEntryDraft(null);
|
||||||
|
setManualEntryError('');
|
||||||
|
}}
|
||||||
|
className="rounded p-1 text-text-muted hover:bg-canvas-deep"
|
||||||
|
aria-label="Zatvori"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!manualEntryDraft ? (
|
||||||
|
<div className="mt-4 grid gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingManualEntry}
|
||||||
|
onClick={() => startManualEntry('office')}
|
||||||
|
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
|
||||||
|
>
|
||||||
|
<span className="block font-medium text-text-main">Rad u uredu</span>
|
||||||
|
<span className="block text-xs text-text-muted">08:00 - 16:00, 8h, Zagreb (ured)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingManualEntry}
|
||||||
|
onClick={() => startManualEntry('sick_leave')}
|
||||||
|
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
|
||||||
|
>
|
||||||
|
<span className="block font-medium text-text-main">Bolovanje</span>
|
||||||
|
<span className="block text-xs text-text-muted">Unesi vrijeme po potrebi</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingManualEntry}
|
||||||
|
onClick={() => startManualEntry('vacation')}
|
||||||
|
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
|
||||||
|
>
|
||||||
|
<span className="block font-medium text-text-main">Godisnji</span>
|
||||||
|
<span className="block text-xs text-text-muted">Unesi vrijeme po potrebi</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-text-muted">Početak rada</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={manualEntryDraft.startTime}
|
||||||
|
onChange={(event) => setManualEntryDraft((prev) => ({ ...prev, startTime: event.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-text-muted">Kraj rada</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={manualEntryDraft.endTime}
|
||||||
|
onChange={(event) => setManualEntryDraft((prev) => ({ ...prev, endTime: event.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-canvas-deep px-3 py-2 text-xs text-text-muted">
|
||||||
|
Redovan rad (h): {calculateHours(manualEntryDraft.startTime, manualEntryDraft.endTime) || '0'}
|
||||||
|
</div>
|
||||||
|
{manualEntryError && (
|
||||||
|
<p className="text-xs text-red-600">{manualEntryError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingManualEntry}
|
||||||
|
onClick={() => {
|
||||||
|
setManualEntryDraft(null);
|
||||||
|
setManualEntryError('');
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded-lg border border-border-hairline px-3 py-2 text-sm hover:bg-canvas-deep"
|
||||||
|
>
|
||||||
|
Nazad
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingManualEntry}
|
||||||
|
onClick={handleSaveManualEntry}
|
||||||
|
className="flex-1 rounded-lg bg-indigo-600 px-3 py-2 text-sm text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{savingManualEntry ? 'Spremam...' : 'Spremi'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bulkDownloadOpen && reportType === 'servicer' && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/30 px-4">
|
||||||
|
<div className="w-full max-w-md rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-text-main">Preuzmi ZIP arhivu</h3>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">
|
||||||
|
{MONTH_NAMES[viewMonth]} {viewYear}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBulkDownloadOpen(false)}
|
||||||
|
className="rounded p-1 text-text-muted hover:bg-canvas-deep"
|
||||||
|
aria-label="Zatvori"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={downloadingAllTasks || downloadingAllWorkOrders}
|
||||||
|
onClick={handleDownloadAllTasksArchive}
|
||||||
|
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{downloadingAllTasks ? 'Generiranje...' : 'Servisni taskovi (SN)'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={downloadingAllTasks || downloadingAllWorkOrders}
|
||||||
|
onClick={handleDownloadAllWorkOrdersArchive}
|
||||||
|
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{downloadingAllWorkOrders ? 'Generiranje...' : 'Putni nalozi + računi (PN)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</LeftSlideDrawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,143 @@
|
|||||||
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';
|
||||||
|
import { updateVehicle } from '../../stores/fleetDashboardStore';
|
||||||
|
|
||||||
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: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function todayIso() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWODate(dateStr) {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleDateString('hr-HR');
|
||||||
|
}
|
||||||
|
|
||||||
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 [craneDataModalOpen, setCraneDataModalOpen] = useState(false);
|
||||||
|
const [craneDataForm, setCraneDataForm] = useState({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
|
||||||
|
const [savingCraneData, setSavingCraneData] = useState(false);
|
||||||
|
const [craneDataError, setCraneDataError] = 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);
|
||||||
|
setCraneDataModalOpen(false);
|
||||||
|
setCraneDataForm({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
|
||||||
|
setSavingCraneData(false);
|
||||||
|
setCraneDataError('');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [open]);
|
// Auto-pre-select today's open work order if exactly one exists for this crane
|
||||||
|
const today = todayIso();
|
||||||
|
const todayOpen = workOrders.filter(
|
||||||
|
(wo) => String(wo?.status || '').toLowerCase() !== 'closed'
|
||||||
|
&& String(wo?.date || '').slice(0, 10) === today
|
||||||
|
);
|
||||||
|
if (todayOpen.length === 1) {
|
||||||
|
setForm((prev) => ({ ...prev, work_order: String(todayOpen[0].id) }));
|
||||||
|
}
|
||||||
|
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
function openCraneDataModal() {
|
||||||
|
if (!contextCrane?.id) {
|
||||||
|
setCraneDataError('Dizalica nije odabrana u servisnom kontekstu.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCraneDataForm({
|
||||||
|
superstructure_working_hours: String(contextCrane?.superstructure_working_hours ?? ''),
|
||||||
|
chassis_working_hours: String(contextCrane?.chassis_working_hours ?? ''),
|
||||||
|
current_mileage: String(contextCrane?.current_mileage ?? ''),
|
||||||
|
});
|
||||||
|
setCraneDataError('');
|
||||||
|
setCraneDataModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveCraneData() {
|
||||||
|
if (!contextCrane?.id) {
|
||||||
|
setCraneDataError('Dizalica nije odabrana u servisnom kontekstu.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const superstructure = craneDataForm.superstructure_working_hours === '' ? undefined : Number(craneDataForm.superstructure_working_hours);
|
||||||
|
const chassis = craneDataForm.chassis_working_hours === '' ? undefined : Number(craneDataForm.chassis_working_hours);
|
||||||
|
const mileage = craneDataForm.current_mileage === '' ? undefined : Number(craneDataForm.current_mileage);
|
||||||
|
if (superstructure !== undefined && (Number.isNaN(superstructure) || superstructure < 0)) {
|
||||||
|
setCraneDataError('Radni sati nadogradnje moraju biti pozitivan broj.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chassis !== undefined && (Number.isNaN(chassis) || chassis < 0)) {
|
||||||
|
setCraneDataError('Radni sati podvozja moraju biti pozitivan broj.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mileage !== undefined && (Number.isNaN(mileage) || mileage < 0)) {
|
||||||
|
setCraneDataError('Kilometraza mora biti pozitivan broj.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {};
|
||||||
|
if (superstructure !== undefined) payload.superstructure_working_hours = superstructure;
|
||||||
|
if (chassis !== undefined) payload.chassis_working_hours = chassis;
|
||||||
|
if (mileage !== undefined) payload.current_mileage = mileage;
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
setCraneDataError('Unesite barem jednu vrijednost za azuriranje.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingCraneData(true);
|
||||||
|
setCraneDataError('');
|
||||||
|
try {
|
||||||
|
await updateVehicle(contextCrane.id, payload);
|
||||||
|
setCraneDataModalOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
setCraneDataError(err?.message || 'Azuriranje podataka dizalice nije uspjelo.');
|
||||||
|
} finally {
|
||||||
|
setSavingCraneData(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -30,33 +145,70 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
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 +219,14 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className="fixed inset-0 z-50 overflow-y-auto bg-slate-950/15 p-4 pt-20 backdrop-blur-[2px]"
|
<ModalShell
|
||||||
onClick={onClose}
|
onClose={onClose}
|
||||||
|
overlayClassName="z-50 overflow-y-auto p-4 pt-20"
|
||||||
|
contentClassName="flex items-start justify-center min-h-full"
|
||||||
|
panelClassName="w-full max-w-2xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-2rem)]"
|
||||||
>
|
>
|
||||||
<div className="flex min-h-full items-start justify-center">
|
<div className="w-full flex flex-col">
|
||||||
<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="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,26 +234,93 @@ 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>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={onClose}
|
type="button"
|
||||||
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
onClick={enableTemplateMode}
|
||||||
aria-label="Zatvori modal"
|
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"
|
||||||
✕
|
>
|
||||||
</button>
|
{templateMode ? 'Ručni unos' : 'Unos iz templatea'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
||||||
|
aria-label="Zatvori modal"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{contextCrane && (
|
||||||
|
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-text-main">Podaci odabrane dizalice</p>
|
||||||
|
<p className="text-xs text-text-muted">{contextCrane.registration_number || '-'} / {contextCrane.make || '-'} {contextCrane.model || ''}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCraneDataModal}
|
||||||
|
disabled={submitting}
|
||||||
|
className="rounded border border-border-hairline px-3 py-1 text-xs font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Uredi podatke dizalice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<dl className="mt-3 grid gap-2 text-sm sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-text-muted">Radni sati nadogradnje</dt>
|
||||||
|
<dd className="text-text-main">{contextCrane.superstructure_working_hours ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-text-muted">Radni sati podvozja</dt>
|
||||||
|
<dd className="text-text-main">{contextCrane.chassis_working_hours ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-text-muted">Kilometraza</dt>
|
||||||
|
<dd className="text-text-main">{contextCrane.current_mileage ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<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 +330,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 +341,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 +353,43 @@ 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)}>
|
const isToday = String(workOrder.date || '').slice(0, 10) === todayIso();
|
||||||
{formatEntityCode('PN', workOrder.id)} • {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} • {formatPurposeLabel(workOrder.purpose) || '-'}
|
return (
|
||||||
</option>
|
<option key={workOrder.id} value={String(workOrder.id)}>
|
||||||
))}
|
{isToday ? '★ ' : ''}{formatWorkOrderDisplayCode(workOrder)} • {formatWODate(workOrder.date)} • {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} • {formatPurposeLabel(workOrder.purpose) || '-'}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
{workOrders.length === 0 && (
|
<span className="text-[11px] text-text-muted">
|
||||||
|
Više zadataka može dijeliti isti putni nalog. Danas kreiran nalog označen je s ★.
|
||||||
|
</span>
|
||||||
|
{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 +415,92 @@ 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>
|
||||||
</div>
|
</ModalShell>
|
||||||
|
|
||||||
|
{craneDataModalOpen && (
|
||||||
|
<ModalShell
|
||||||
|
onClose={() => setCraneDataModalOpen(false)}
|
||||||
|
overlayClassName="z-[60] bg-black/40 p-4"
|
||||||
|
contentClassName="flex min-h-full items-center justify-center"
|
||||||
|
panelClassName="w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-base font-semibold text-text-main">Izmijeni podatke dizalice</h4>
|
||||||
|
<p className="mt-1 text-sm text-text-muted">
|
||||||
|
Azurirajte radne sate i kilometrazu za dizalicu: <strong>{contextCrane?.registration_number || "-"}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="text-xs text-text-muted">Radni sati nadogradnje</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={craneDataForm.superstructure_working_hours}
|
||||||
|
onInput={(event) => setCraneDataForm((prev) => ({ ...prev, superstructure_working_hours: event.currentTarget.value }))}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-base 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">Radni sati podvozja</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={craneDataForm.chassis_working_hours}
|
||||||
|
onInput={(event) => setCraneDataForm((prev) => ({ ...prev, chassis_working_hours: event.currentTarget.value }))}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-base 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">Kilometraza (km)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={craneDataForm.current_mileage}
|
||||||
|
onInput={(event) => setCraneDataForm((prev) => ({ ...prev, current_mileage: event.currentTarget.value }))}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{craneDataError && (
|
||||||
|
<div className="rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{craneDataError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCraneDataModalOpen(false)}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Odustani
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveCraneData}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{savingCraneData ? "Spremanje..." : "Spremi podatke"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
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';
|
||||||
|
import { getTaskCraneDisplay, getTaskCraneOwner } from '../../lib/taskCraneDisplay';
|
||||||
|
import { updateVehicle, $vehicles } from '../../stores/fleetDashboardStore';
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = ['aktivan', 'servis', 'spreman_za_zavrsetak', 'zavrsen', 'neaktivan'];
|
||||||
|
|
||||||
|
function todayIso() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
@@ -9,6 +18,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 +39,27 @@ export default function TaskServiceRecordsModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onOpenServiceRecord,
|
onOpenServiceRecord,
|
||||||
onAssignWorkOrder,
|
onAssignWorkOrder,
|
||||||
|
onUpdateTask,
|
||||||
onCreateServiceRecord,
|
onCreateServiceRecord,
|
||||||
|
onSetServiceContext,
|
||||||
}) {
|
}) {
|
||||||
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: '',
|
||||||
|
});
|
||||||
|
const [craneDataModalOpen, setCraneDataModalOpen] = useState(false);
|
||||||
|
const [craneDataForm, setCraneDataForm] = useState({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
|
||||||
|
const [savingCraneData, setSavingCraneData] = useState(false);
|
||||||
|
const [craneDataError, setCraneDataError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || typeof window === 'undefined') return;
|
if (!open || typeof window === 'undefined') return;
|
||||||
@@ -41,17 +72,90 @@ 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 || ''),
|
||||||
|
});
|
||||||
|
setCraneDataModalOpen(false);
|
||||||
|
setCraneDataError('');
|
||||||
|
setSavingCraneData(false);
|
||||||
}, [task?.id, task?.work_order]);
|
}, [task?.id, task?.work_order]);
|
||||||
|
|
||||||
|
const openCraneDataModal = () => {
|
||||||
|
const craneVehicleId = task?.vehicle;
|
||||||
|
if (!craneVehicleId) return;
|
||||||
|
const craneVehicle = ($vehicles?.get?.() || []).find((v) => String(v.id) === String(craneVehicleId)) || null;
|
||||||
|
setCraneDataForm({
|
||||||
|
superstructure_working_hours: String(craneVehicle?.superstructure_working_hours ?? ''),
|
||||||
|
chassis_working_hours: String(craneVehicle?.chassis_working_hours ?? ''),
|
||||||
|
current_mileage: String(craneVehicle?.current_mileage ?? ''),
|
||||||
|
});
|
||||||
|
setCraneDataError('');
|
||||||
|
setCraneDataModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCraneData = async () => {
|
||||||
|
const craneVehicleId = task?.vehicle;
|
||||||
|
if (!craneVehicleId) {
|
||||||
|
setCraneDataError('Dizalica nije pronađena.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const superstructure = craneDataForm.superstructure_working_hours === '' ? undefined : Number(craneDataForm.superstructure_working_hours);
|
||||||
|
const chassis = craneDataForm.chassis_working_hours === '' ? undefined : Number(craneDataForm.chassis_working_hours);
|
||||||
|
const mileage = craneDataForm.current_mileage === '' ? undefined : Number(craneDataForm.current_mileage);
|
||||||
|
if (superstructure !== undefined && (Number.isNaN(superstructure) || superstructure < 0)) {
|
||||||
|
setCraneDataError('Radni sati superstrukture moraju biti pozitivan broj.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chassis !== undefined && (Number.isNaN(chassis) || chassis < 0)) {
|
||||||
|
setCraneDataError('Radni sati podvozja moraju biti pozitivan broj.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mileage !== undefined && (Number.isNaN(mileage) || mileage < 0)) {
|
||||||
|
setCraneDataError('Kilometraža mora biti pozitivan broj.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {};
|
||||||
|
if (superstructure !== undefined) payload.superstructure_working_hours = superstructure;
|
||||||
|
if (chassis !== undefined) payload.chassis_working_hours = chassis;
|
||||||
|
if (mileage !== undefined) payload.current_mileage = mileage;
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
setCraneDataError('Nije unesena niti jedna vrijednost za ažuriranje.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingCraneData(true);
|
||||||
|
setCraneDataError('');
|
||||||
|
try {
|
||||||
|
await updateVehicle(craneVehicleId, payload);
|
||||||
|
setCraneDataModalOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
setCraneDataError(err?.message || 'Ažuriranje podataka dizalice nije uspjelo.');
|
||||||
|
} finally {
|
||||||
|
setSavingCraneData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const currentWorkOrder = useMemo(
|
const currentWorkOrder = useMemo(
|
||||||
() => workOrders.find((item) => String(item.id) === String(task?.work_order || selectedWorkOrderId)) || null,
|
() => workOrders.find((item) => String(item.id) === String(task?.work_order || selectedWorkOrderId)) || null,
|
||||||
[workOrders, task?.work_order, selectedWorkOrderId]
|
[workOrders, task?.work_order, selectedWorkOrderId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortedWorkOrders = useMemo(
|
const sortedWorkOrders = useMemo(() => {
|
||||||
() => [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))),
|
const today = todayIso();
|
||||||
[workOrders]
|
return [...workOrders].sort((a, b) => {
|
||||||
);
|
const aToday = String(a.date || '').slice(0, 10) === today ? 1 : 0;
|
||||||
|
const bToday = String(b.date || '').slice(0, 10) === today ? 1 : 0;
|
||||||
|
if (bToday !== aToday) return bToday - aToday;
|
||||||
|
return String(b.date || '').localeCompare(String(a.date || ''));
|
||||||
|
});
|
||||||
|
}, [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 || '');
|
||||||
@@ -68,9 +172,63 @@ 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 (
|
||||||
|
<>
|
||||||
<ModalShell
|
<ModalShell
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
overlayClassName="z-50 overflow-y-auto p-4 pt-2"
|
overlayClassName="z-50 overflow-y-auto p-4 pt-2"
|
||||||
@@ -82,27 +240,215 @@ 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
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={onClose}
|
type="button"
|
||||||
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
onClick={() => onSetServiceContext?.(task)}
|
||||||
aria-label="Zatvori"
|
className="btn-erp rounded-md bg-emerald-600 px-3 py-1 text-xs font-semibold normal-case tracking-normal text-white hover:bg-emerald-700"
|
||||||
>
|
>
|
||||||
✕
|
Postavi servisni kontekst
|
||||||
</button>
|
</button>
|
||||||
|
{task?.vehicle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCraneDataModal}
|
||||||
|
className="rounded-md border border-border-hairline px-3 py-1 text-xs font-semibold text-text-main hover:bg-canvas-base"
|
||||||
|
>
|
||||||
|
Uredi podatke dizalice
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
||||||
|
aria-label="Zatvori"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">Vlasnik</dt>
|
||||||
|
<dd className="text-text-main">{getTaskCraneOwner(task)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-text-muted">Dizalica</dt>
|
||||||
|
<dd className="flex items-center gap-2 text-text-main">
|
||||||
|
<span>{getTaskCraneDisplay(task)}</span>
|
||||||
|
{task?.vehicle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCraneDataModal}
|
||||||
|
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
||||||
|
title="Izmijeni radne sate i kilometrazu dizalice"
|
||||||
|
>
|
||||||
|
Uredi podatke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="text-xs text-text-muted">Vlasnik</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getTaskCraneOwner(task)}
|
||||||
|
disabled
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-deep px-3 py-2 text-text-main disabled:opacity-80"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="text-xs text-text-muted">Dizalica</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getTaskCraneDisplay(task)}
|
||||||
|
disabled
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-deep px-3 py-2 text-text-main disabled:opacity-80"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<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)} • {formatDate(currentWorkOrder.date)} • {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} • {currentWorkOrder.status || '-'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -121,14 +467,19 @@ export default function TaskServiceRecordsModal({
|
|||||||
className="w-full rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main disabled:opacity-60"
|
className="w-full rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<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)}>
|
const isToday = String(workOrder.date || '').slice(0, 10) === todayIso();
|
||||||
{formatEntityCode('PN', workOrder.id)} • {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} • {workOrder.status || '-'}
|
return (
|
||||||
</option>
|
<option key={workOrder.id} value={String(workOrder.id)}>
|
||||||
))}
|
{isToday ? '★ ' : ''}{formatWorkOrderDisplayCode(workOrder)} • {formatDate(workOrder.date)} • {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} • {workOrder.status || '-'}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
{assigningWorkOrder ? 'Spremanje odabira putnog naloga...' : 'Odabir se automatski sprema u bazu.'}
|
{assigningWorkOrder
|
||||||
|
? 'Spremanje odabira putnog naloga...'
|
||||||
|
: 'Isti putni nalog možete dodijeliti više zadataka. Danas (★) su prikazani prvo.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -195,5 +546,81 @@ export default function TaskServiceRecordsModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalShell>
|
</ModalShell>
|
||||||
|
|
||||||
|
{craneDataModalOpen && (
|
||||||
|
<ModalShell
|
||||||
|
onClose={() => setCraneDataModalOpen(false)}
|
||||||
|
overlayClassName="z-[60] bg-black/40 p-4"
|
||||||
|
contentClassName="flex min-h-full items-center justify-center"
|
||||||
|
panelClassName="w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
Ažurirajte radne sate i kilometražu za dizalicu: <strong>{getTaskCraneDisplay(task)}</strong>
|
||||||
|
</p>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="text-xs text-text-muted">Radni sati superstrukture</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={craneDataForm.superstructure_working_hours}
|
||||||
|
onInput={(e) => setCraneDataForm((p) => ({ ...p, superstructure_working_hours: e.currentTarget.value }))}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
|
||||||
|
placeholder="npr. 1250.5"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="text-xs text-text-muted">Radni sati podvozja</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={craneDataForm.chassis_working_hours}
|
||||||
|
onInput={(e) => setCraneDataForm((p) => ({ ...p, chassis_working_hours: e.currentTarget.value }))}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
|
||||||
|
placeholder="npr. 980.0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="text-xs text-text-muted">Trenutna kilometraža (km)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={craneDataForm.current_mileage}
|
||||||
|
onInput={(e) => setCraneDataForm((p) => ({ ...p, current_mileage: e.currentTarget.value }))}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
|
||||||
|
placeholder="npr. 45000"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{craneDataError && (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{craneDataError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCraneDataModalOpen(false)}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg border border-border-hairline px-4 py-2 text-sm text-text-main hover:bg-canvas-base disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Odustani
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveCraneData}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingCraneData ? 'Spremam…' : 'Spremi podatke'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
536
frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx
Normal file
536
frontend/src/components/dashboard/TaskWorkHoursTableModal.jsx
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
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 DAY_LABELS_BY_INDEX = ['NED', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB'];
|
||||||
|
|
||||||
|
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,
|
||||||
|
workOrder = null,
|
||||||
|
defaultLocation = '',
|
||||||
|
saving = false,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
|
||||||
|
const getDefaultDayDate = (fallbackToToday = false) => {
|
||||||
|
const raw = String(task?.scheduled_date || '').trim();
|
||||||
|
const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (match) {
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const day = Number(match[3]);
|
||||||
|
const date = new Date(year, month - 1, day);
|
||||||
|
if (!Number.isNaN(date.getTime())) {
|
||||||
|
return {
|
||||||
|
day: DAY_LABELS_BY_INDEX[date.getDay()] || '',
|
||||||
|
date: `${String(day).padStart(2, '0')}.${String(month).padStart(2, '0')}.${year}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!fallbackToToday) {
|
||||||
|
return { day: '', date: '' };
|
||||||
|
}
|
||||||
|
const today = new Date();
|
||||||
|
return {
|
||||||
|
day: DAY_LABELS_BY_INDEX[today.getDay()] || '',
|
||||||
|
date: `${String(today.getDate()).padStart(2, '0')}.${String(today.getMonth() + 1).padStart(2, '0')}.${today.getFullYear()}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) { setRows([]); return; }
|
||||||
|
const incoming = Array.isArray(task?.work_hours_table?.rows) ? task.work_hours_table.rows : [];
|
||||||
|
const defaultDayDate = getDefaultDayDate(false);
|
||||||
|
setRows(
|
||||||
|
incoming.length > 0
|
||||||
|
? incoming.map((r) => ({ ...EMPTY_ROW, ...(r || {}) }))
|
||||||
|
: [
|
||||||
|
{ ...EMPTY_ROW, ...defaultDayDate },
|
||||||
|
{ ...EMPTY_ROW, ...defaultDayDate },
|
||||||
|
{ ...EMPTY_ROW, ...defaultDayDate },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [open, task?.id, task?.scheduled_date, 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 defaults = getDefaultDayDate(true);
|
||||||
|
const gid = newGroupId();
|
||||||
|
const loc = defaultLocation || '';
|
||||||
|
|
||||||
|
setRows((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ ...EMPTY_ROW, day: defaults.day, date: defaults.date, departure_place: 'Zagreb', arrival_place: loc, _groupId: gid, _role: 'departure' },
|
||||||
|
{ ...EMPTY_ROW, day: defaults.day, date: defaults.date, _groupId: gid, _role: 'work' },
|
||||||
|
{ ...EMPTY_ROW, day: defaults.day, date: defaults.date, 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">
|
||||||
|
|
||||||
|
{/* ── read-only work order travel data ── */}
|
||||||
|
{workOrder && (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-xs dark:border-amber-800 dark:bg-amber-900/20">
|
||||||
|
<p className="mb-1.5 font-semibold text-amber-800 dark:text-amber-300">Podaci iz putnog naloga (samo za prikaz)</p>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-amber-900 dark:text-amber-200 sm:grid-cols-3">
|
||||||
|
{workOrder.origin_location && (
|
||||||
|
<div><span className="font-medium">Ishodište:</span> {workOrder.origin_location}</div>
|
||||||
|
)}
|
||||||
|
{workOrder.location && (
|
||||||
|
<div><span className="font-medium">Odredište:</span> {workOrder.location}</div>
|
||||||
|
)}
|
||||||
|
{workOrder.travel_start_at && (
|
||||||
|
<div><span className="font-medium">Polazak:</span> {new Date(workOrder.travel_start_at).toLocaleString('hr-HR', { dateStyle: 'short', timeStyle: 'short' })}</div>
|
||||||
|
)}
|
||||||
|
{workOrder.travel_end_at && (
|
||||||
|
<div><span className="font-medium">Povratak:</span> {new Date(workOrder.travel_end_at).toLocaleString('hr-HR', { dateStyle: 'short', timeStyle: 'short' })}</div>
|
||||||
|
)}
|
||||||
|
{workOrder.start_mileage != null && (
|
||||||
|
<div><span className="font-medium">KM početak:</span> {workOrder.start_mileage}</div>
|
||||||
|
)}
|
||||||
|
{workOrder.end_mileage != null && (
|
||||||
|
<div><span className="font-medium">KM kraj:</span> {workOrder.end_mileage}</div>
|
||||||
|
)}
|
||||||
|
{workOrder.distance != null && (
|
||||||
|
<div><span className="font-medium">Prijeđeno km:</span> {workOrder.distance}</div>
|
||||||
|
)}
|
||||||
|
{workOrder.servicer_vehicle_registration && (
|
||||||
|
<div><span className="font-medium">Vozilo servisera:</span> {workOrder.servicer_vehicle_registration} {workOrder.servicer_vehicle_make_model || ''}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
import ModalShell from '../ui/ModalShell';
|
||||||
|
|
||||||
|
const EMPTY_ROW = {
|
||||||
|
naziv: '',
|
||||||
|
broj_racuna: '',
|
||||||
|
ukupan_iznos: '',
|
||||||
|
prilog: '',
|
||||||
|
source_invoice_id: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseAmount(value) {
|
||||||
|
const normalized = String(value || '').trim().replace('€', '').replace(/\s+/g, '').replace(',', '.');
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkOrderAdditionalCostsModal({
|
||||||
|
open,
|
||||||
|
workOrder,
|
||||||
|
tableData,
|
||||||
|
saving = false,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setRows([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const incoming = Array.isArray(tableData?.data?.rows) ? tableData.data.rows : [];
|
||||||
|
setRows(incoming.length > 0 ? incoming.map((row) => ({ ...EMPTY_ROW, ...(row || {}) })) : [{ ...EMPTY_ROW }]);
|
||||||
|
}, [open, tableData]);
|
||||||
|
|
||||||
|
const total = useMemo(() => {
|
||||||
|
const value = rows.reduce((sum, row) => sum + parseAmount(row.ukupan_iznos), 0);
|
||||||
|
return value.toFixed(2);
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const updateRowField = (index, field, value) => {
|
||||||
|
setRows((prev) => prev.map((row, i) => (i === index ? { ...row, [field]: value } : row)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]);
|
||||||
|
const removeRow = (index) => setRows((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const payloadRows = rows.map((row) => ({
|
||||||
|
naziv: String(row.naziv || '').trim(),
|
||||||
|
broj_racuna: String(row.broj_racuna || '').trim(),
|
||||||
|
ukupan_iznos: String(row.ukupan_iznos || '').trim(),
|
||||||
|
prilog: String(row.prilog || '').trim(),
|
||||||
|
source_invoice_id: String(row.source_invoice_id || '').trim(),
|
||||||
|
}));
|
||||||
|
await onSave?.({ rows: payloadRows });
|
||||||
|
};
|
||||||
|
|
||||||
|
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-5xl 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">
|
||||||
|
<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">DODATNI TROŠKOVI</h3>
|
||||||
|
<p className="text-xs text-text-muted">{workOrder?.display_code || workOrder?.id || '-'}</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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-auto rounded-xl border border-gray-200 shadow-sm dark:border-gray-700">
|
||||||
|
<table className="min-w-[900px] 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">#</th>
|
||||||
|
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">NAZIV</th>
|
||||||
|
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Broj računa</th>
|
||||||
|
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Ukupan iznos</th>
|
||||||
|
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Prilog</th>
|
||||||
|
<th className="px-2 py-2.5 text-gray-400">Akcija</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr key={index} className="border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<td className="px-2 py-1.5 text-center text-[11px] text-gray-400">{index + 1}</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={row.naziv}
|
||||||
|
onInput={(event) => updateRowField(index, 'naziv', event.currentTarget.value)}
|
||||||
|
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={row.broj_racuna}
|
||||||
|
onInput={(event) => updateRowField(index, 'broj_racuna', event.currentTarget.value)}
|
||||||
|
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={row.ukupan_iznos}
|
||||||
|
onInput={(event) => updateRowField(index, 'ukupan_iznos', event.currentTarget.value)}
|
||||||
|
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={row.prilog}
|
||||||
|
onInput={(event) => updateRowField(index, 'prilog', event.currentTarget.value)}
|
||||||
|
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeRow(index)}
|
||||||
|
className="rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Obriši
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<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={3} className="px-3 py-2 text-gray-700 dark:text-gray-200">UKUPNO ZA ISPLATU</td>
|
||||||
|
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100">{total} €</td>
|
||||||
|
<td colSpan={2} />
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{saving ? 'Spremam…' : 'Spremi dodatne troškove'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { useStore } from '@nanostores/preact';
|
import { useStore } from '@nanostores/preact';
|
||||||
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
|
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
|
||||||
|
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
|
||||||
|
import TaskCreateModal from './TaskCreateModal';
|
||||||
import ModalShell from '../ui/ModalShell';
|
import ModalShell from '../ui/ModalShell';
|
||||||
import {
|
import {
|
||||||
createWorkOrderInvoice,
|
createWorkOrderInvoice,
|
||||||
fetchVehicleById,
|
fetchTasksByWorkOrder,
|
||||||
fetchTeamMembers,
|
fetchTeamMembers,
|
||||||
updateVehicle,
|
|
||||||
fetchWorkOrderInvoices,
|
fetchWorkOrderInvoices,
|
||||||
fetchWorkOrderImages,
|
fetchWorkOrderImages,
|
||||||
openWorkOrderInvoicesPdfPage,
|
openWorkOrderInvoicesPdfPage,
|
||||||
sendWorkOrderEmail,
|
sendWorkOrderEmail,
|
||||||
|
updateVehicle,
|
||||||
} from '../../stores/fleetDashboardStore';
|
} from '../../stores/fleetDashboardStore';
|
||||||
|
import { getStatusLabel } from '../../stores/taskStore';
|
||||||
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';
|
import { $serviceNotes, $serviceNotesLoading, fetchServiceNotes } from '../../stores/serviceNotesStore';
|
||||||
|
|
||||||
const PURPOSE_OPTIONS = [
|
const PURPOSE_OPTIONS = [
|
||||||
{ value: 'defektaza', label: 'Defektaža' },
|
{ value: 'defektaza', label: 'Defektaža' },
|
||||||
@@ -23,21 +26,26 @@ const PURPOSE_OPTIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmit }) {
|
export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmit }) {
|
||||||
const serialByVehicle = useStore($craneSerialByVehicle);
|
const serviceNotes = useStore($serviceNotes);
|
||||||
|
const serviceNotesLoading = useStore($serviceNotesLoading);
|
||||||
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: '',
|
||||||
|
origin_location: 'Zagreb',
|
||||||
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);
|
||||||
@@ -52,9 +60,17 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
const [emailPayload, setEmailPayload] = useState({
|
const [emailPayload, setEmailPayload] = useState({
|
||||||
subject: '',
|
subject: '',
|
||||||
message: '',
|
message: '',
|
||||||
|
includeWorkOrderPdf: true,
|
||||||
|
includeServiceRecordsPdf: false,
|
||||||
|
includeInvoicesPdf: false,
|
||||||
|
includeImages: false,
|
||||||
|
includeMonthlyTasks: false,
|
||||||
|
month: new Date().toISOString().slice(0, 7),
|
||||||
|
serviceNoteId: '',
|
||||||
});
|
});
|
||||||
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,22 +79,18 @@ 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([]);
|
||||||
const [selectedLocation, setSelectedLocation] = useState(null);
|
const [selectedLocation, setSelectedLocation] = useState(null);
|
||||||
const [searchingLocation, setSearchingLocation] = useState(false);
|
const [searchingLocation, setSearchingLocation] = useState(false);
|
||||||
const [mapError, setMapError] = useState('');
|
const [mapError, setMapError] = useState('');
|
||||||
|
const [workOrderTasks, setWorkOrderTasks] = useState([]);
|
||||||
const targetCraneId = useMemo(
|
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false);
|
||||||
() => workOrder?.crane || workOrder?.vehicle || null,
|
const [craneDataModalOpen, setCraneDataModalOpen] = useState(false);
|
||||||
[workOrder?.crane, workOrder?.vehicle]
|
const [craneDataForm, setCraneDataForm] = useState({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
|
||||||
);
|
const [savingCraneData, setSavingCraneData] = useState(false);
|
||||||
const cachedCraneSerial = useMemo(
|
const [craneDataError, setCraneDataError] = useState('');
|
||||||
() => getCachedCraneSerial(targetCraneId),
|
|
||||||
[serialByVehicle, targetCraneId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const craneLabel = useMemo(() => {
|
const craneLabel = useMemo(() => {
|
||||||
if (!workOrder) return '-';
|
if (!workOrder) return '-';
|
||||||
@@ -92,6 +104,16 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
}
|
}
|
||||||
return PURPOSE_OPTIONS;
|
return PURPOSE_OPTIONS;
|
||||||
}, [form.purpose]);
|
}, [form.purpose]);
|
||||||
|
const selectableServiceNotes = useMemo(() => {
|
||||||
|
if (!workOrder?.id) return [];
|
||||||
|
return serviceNotes.filter((note) => (
|
||||||
|
!note?.is_closed
|
||||||
|
&& (
|
||||||
|
!note?.work_order
|
||||||
|
|| String(note.work_order) === String(workOrder.id)
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}, [serviceNotes, workOrder?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || typeof window === 'undefined') return;
|
if (!open || typeof window === 'undefined') return;
|
||||||
@@ -101,22 +123,23 @@ 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.origin_location || 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),
|
||||||
|
origin_location: workOrder.origin_location || 'Zagreb',
|
||||||
location: workOrder.location || '',
|
location: workOrder.location || '',
|
||||||
travel_start_at: workOrder.travel_start_at ? String(workOrder.travel_start_at).slice(0, 16) : '',
|
travel_start_at: workOrder.travel_start_at ? String(workOrder.travel_start_at).slice(0, 16) : '',
|
||||||
travel_end_at: workOrder.travel_end_at ? String(workOrder.travel_end_at).slice(0, 16) : '',
|
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('');
|
||||||
@@ -130,9 +153,17 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
setEmailPayload({
|
setEmailPayload({
|
||||||
subject: '',
|
subject: '',
|
||||||
message: '',
|
message: '',
|
||||||
|
includeWorkOrderPdf: true,
|
||||||
|
includeServiceRecordsPdf: false,
|
||||||
|
includeInvoicesPdf: false,
|
||||||
|
includeImages: false,
|
||||||
|
includeMonthlyTasks: false,
|
||||||
|
month: new Date().toISOString().slice(0, 7),
|
||||||
|
serviceNoteId: '',
|
||||||
});
|
});
|
||||||
setInvoices([]);
|
setInvoices([]);
|
||||||
setInvoiceModalOpen(false);
|
setInvoiceModalOpen(false);
|
||||||
|
setPhotoUploadOpen(false);
|
||||||
setInvoiceSaving(false);
|
setInvoiceSaving(false);
|
||||||
setInvoiceForm({
|
setInvoiceForm({
|
||||||
naziv_racuna: '',
|
naziv_racuna: '',
|
||||||
@@ -147,38 +178,13 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
setSelectedLocation(null);
|
setSelectedLocation(null);
|
||||||
setSearchingLocation(false);
|
setSearchingLocation(false);
|
||||||
setMapError('');
|
setMapError('');
|
||||||
}, [open, workOrder, cachedCraneSerial, targetCraneId]);
|
setWorkOrderTasks([]);
|
||||||
|
setAddTaskModalOpen(false);
|
||||||
useEffect(() => {
|
setCraneDataModalOpen(false);
|
||||||
if (!open || !targetCraneId) {
|
setCraneDataForm({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
|
||||||
return;
|
setSavingCraneData(false);
|
||||||
}
|
setCraneDataError('');
|
||||||
let cancelled = false;
|
}, [open, workOrder]);
|
||||||
(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;
|
||||||
@@ -186,7 +192,10 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
setLoadingTeamMembers(true);
|
setLoadingTeamMembers(true);
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const members = await fetchTeamMembers();
|
const [members] = await Promise.all([
|
||||||
|
fetchTeamMembers(),
|
||||||
|
fetchServiceNotes(),
|
||||||
|
]);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setTeamMembers(members);
|
setTeamMembers(members);
|
||||||
}
|
}
|
||||||
@@ -258,6 +267,23 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
};
|
};
|
||||||
}, [open, workOrder?.id]);
|
}, [open, workOrder?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !workOrder?.id) {
|
||||||
|
setWorkOrderTasks([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const tasks = await fetchTasksByWorkOrder(workOrder.id);
|
||||||
|
if (!cancelled) setWorkOrderTasks(tasks);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setWorkOrderTasks([]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [open, workOrder?.id]);
|
||||||
|
|
||||||
const selectedMapEmbedUrl = (() => {
|
const selectedMapEmbedUrl = (() => {
|
||||||
if (!selectedLocation) return null;
|
if (!selectedLocation) return null;
|
||||||
const lat = Number(selectedLocation.lat);
|
const lat = Number(selectedLocation.lat);
|
||||||
@@ -272,6 +298,60 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const craneVehicleId = workOrder.vehicle || workOrder.crane;
|
||||||
|
const craneVehicle = workOrder.craneInfo || null;
|
||||||
|
|
||||||
|
const openCraneDataModal = () => {
|
||||||
|
setCraneDataForm({
|
||||||
|
superstructure_working_hours: String(craneVehicle?.superstructure_working_hours ?? ''),
|
||||||
|
chassis_working_hours: String(craneVehicle?.chassis_working_hours ?? ''),
|
||||||
|
current_mileage: String(craneVehicle?.current_mileage ?? ''),
|
||||||
|
});
|
||||||
|
setCraneDataError('');
|
||||||
|
setCraneDataModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCraneData = async () => {
|
||||||
|
if (!craneVehicleId) {
|
||||||
|
setCraneDataError('Dizalica nije pronađena.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const superstructure = craneDataForm.superstructure_working_hours === '' ? undefined : Number(craneDataForm.superstructure_working_hours);
|
||||||
|
const chassis = craneDataForm.chassis_working_hours === '' ? undefined : Number(craneDataForm.chassis_working_hours);
|
||||||
|
const mileage = craneDataForm.current_mileage === '' ? undefined : Number(craneDataForm.current_mileage);
|
||||||
|
if (superstructure !== undefined && (Number.isNaN(superstructure) || superstructure < 0)) {
|
||||||
|
setCraneDataError('Radni sati superstrukture moraju biti pozitivan broj.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chassis !== undefined && (Number.isNaN(chassis) || chassis < 0)) {
|
||||||
|
setCraneDataError('Radni sati podvozja moraju biti pozitivan broj.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mileage !== undefined && (Number.isNaN(mileage) || mileage < 0)) {
|
||||||
|
setCraneDataError('Kilometraža mora biti pozitivan broj.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {};
|
||||||
|
if (superstructure !== undefined) payload.superstructure_working_hours = superstructure;
|
||||||
|
if (chassis !== undefined) payload.chassis_working_hours = chassis;
|
||||||
|
if (mileage !== undefined) payload.current_mileage = mileage;
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
setCraneDataError('Nije unesena niti jedna vrijednost za ažuriranje.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingCraneData(true);
|
||||||
|
setCraneDataError('');
|
||||||
|
try {
|
||||||
|
await updateVehicle(craneVehicleId, payload);
|
||||||
|
showToast('Podaci dizalice su uspješno ažurirani.', 'success');
|
||||||
|
setCraneDataModalOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
setCraneDataError(err?.message || 'Ažuriranje podataka dizalice nije uspjelo.');
|
||||||
|
} finally {
|
||||||
|
setSavingCraneData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const searchLocation = async () => {
|
const searchLocation = async () => {
|
||||||
const query = locationQuery.trim();
|
const query = locationQuery.trim();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
@@ -310,13 +390,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 +406,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 +430,29 @@ 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),
|
||||||
|
origin_location: form.origin_location.trim() || 'Zagreb',
|
||||||
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,
|
||||||
@@ -481,29 +521,34 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
setWidgetError('Odaberite barem jednog člana tima ili uključite slanje svima.');
|
setWidgetError('Odaberite barem jednog člana tima ili uključite slanje svima.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
let failed = 0;
|
!emailPayload.includeWorkOrderPdf
|
||||||
for (const recipient of recipients) {
|
&& !emailPayload.includeServiceRecordsPdf
|
||||||
try {
|
&& !emailPayload.includeInvoicesPdf
|
||||||
await sendWorkOrderEmail(
|
&& !emailPayload.includeImages
|
||||||
workOrder.id,
|
&& !emailPayload.includeMonthlyTasks
|
||||||
{
|
) {
|
||||||
recipient,
|
setWidgetError('Odaberite barem jedan PDF/prilog za slanje.');
|
||||||
subject: emailPayload.subject.trim() || undefined,
|
|
||||||
message: emailPayload.message.trim() || undefined,
|
|
||||||
},
|
|
||||||
{ toast: false }
|
|
||||||
);
|
|
||||||
showToast(`Email je poslan: ${recipient}`, 'success');
|
|
||||||
} catch {
|
|
||||||
failed += 1;
|
|
||||||
showToast(`Neuspješno slanje: ${recipient}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (failed > 0) {
|
|
||||||
setWidgetError(`Slanje nije uspjelo za ${failed} primatelja.`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await sendWorkOrderEmail(
|
||||||
|
workOrder.id,
|
||||||
|
{
|
||||||
|
recipients,
|
||||||
|
subject: emailPayload.subject.trim() || undefined,
|
||||||
|
message: emailPayload.message.trim() || undefined,
|
||||||
|
include_work_order_pdf: emailPayload.includeWorkOrderPdf,
|
||||||
|
include_service_records_pdf: emailPayload.includeServiceRecordsPdf,
|
||||||
|
include_invoices_pdf: emailPayload.includeInvoicesPdf,
|
||||||
|
include_images: emailPayload.includeImages,
|
||||||
|
include_monthly_tasks: emailPayload.includeMonthlyTasks,
|
||||||
|
month: emailPayload.includeMonthlyTasks ? emailPayload.month : undefined,
|
||||||
|
service_note_id: emailPayload.serviceNoteId || undefined,
|
||||||
|
},
|
||||||
|
{ toast: false }
|
||||||
|
);
|
||||||
|
showToast(`Slanje je pokrenuto u pozadini za ${recipients.length} primatelja. Rezultat je u Notifikacijama.`, 'info');
|
||||||
setEmailModalOpen(false);
|
setEmailModalOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setWidgetError(err?.message || 'Neuspješno slanje emaila za putni nalog.');
|
setWidgetError(err?.message || 'Neuspješno slanje emaila za putni nalog.');
|
||||||
@@ -523,7 +568,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
|
||||||
@@ -582,8 +627,49 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-border-hairline bg-canvas-base p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold text-text-main">Zadaci putnog naloga ({workOrderTasks.length})</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddTaskModalOpen(true)}
|
||||||
|
className="rounded-md border border-border-hairline px-2 py-1 text-xs font-medium text-text-main hover:bg-canvas-deep"
|
||||||
|
>
|
||||||
|
+ Dodaj zadatak
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{workOrderTasks.length === 0 ? (
|
||||||
|
<p className="text-xs text-text-muted">Nema zadataka za ovaj putni nalog.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{workOrderTasks.map((task) => (
|
||||||
|
<li key={task.id} className="flex items-center justify-between rounded-lg bg-canvas-deep px-3 py-2 text-sm">
|
||||||
|
<span className="font-medium text-text-main">{task.title}</span>
|
||||||
|
<span className="rounded-full bg-canvas-base px-2 py-0.5 text-[11px] text-text-muted border border-border-hairline">
|
||||||
|
{getStatusLabel(task.status)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<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,91 +693,75 @@ 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
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
|
||||||
<span className="font-medium text-text-main">Marka/Model</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={workOrder?.craneInfo?.display_name || ''}
|
|
||||||
className="rounded-lg border border-border-hairline bg-canvas-deep px-3 py-2 text-text-muted"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
|
||||||
<span className="font-medium text-text-main">Početna kilometraža {form.has_travel_order ? '*' : ''}</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={form.start_mileage}
|
|
||||||
onInput={(event) => setForm({ ...form, start_mileage: event.currentTarget.value })}
|
|
||||||
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">Završna kilometraža</span>
|
<span className="font-medium text-text-main">Marka/Model vozila servisera</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
min="0"
|
value={form.servicer_vehicle_make_model}
|
||||||
value={form.end_mileage}
|
onInput={(event) => setForm({ ...form, servicer_vehicle_make_model: event.currentTarget.value })}
|
||||||
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>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium text-text-main">Početna km vozila servisera</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={form.servicer_vehicle_start_mileage}
|
||||||
|
onInput={(event) => setForm({ ...form, servicer_vehicle_start_mileage: event.currentTarget.value })}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium text-text-main">Završna km vozila servisera</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={form.servicer_vehicle_end_mileage}
|
||||||
|
onInput={(event) => setForm({ ...form, servicer_vehicle_end_mileage: event.currentTarget.value })}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
||||||
|
disabled={!isEditing}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium text-text-main">Trošak goriva (EUR)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={form.servicer_vehicle_fuel_cost}
|
||||||
|
onInput={(event) => setForm({ ...form, servicer_vehicle_fuel_cost: event.currentTarget.value })}
|
||||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
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>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-border-hairline bg-canvas-base p-3">
|
|
||||||
<h4 className="mb-2 text-sm font-semibold text-text-main">Podaci dizalice</h4>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
|
||||||
<span className="font-medium text-text-main">Trenutna km dizalice</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={form.crane_current_mileage}
|
|
||||||
onInput={(event) => setForm({ ...form, crane_current_mileage: event.currentTarget.value })}
|
|
||||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
|
||||||
disabled={!isEditing}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
|
||||||
<span className="font-medium text-text-main">Radni sati nadogradnje</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={form.crane_superstructure_working_hours}
|
|
||||||
onInput={(event) => setForm({ ...form, crane_superstructure_working_hours: event.currentTarget.value })}
|
|
||||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
|
||||||
disabled={!isEditing}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
|
||||||
<span className="font-medium text-text-main">Radni sati podvozja</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={form.crane_chassis_working_hours}
|
|
||||||
onInput={(event) => setForm({ ...form, crane_chassis_working_hours: event.currentTarget.value })}
|
|
||||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
|
||||||
disabled={!isEditing}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">Putni podaci</h4>
|
<h4 className="mb-2 text-sm font-semibold text-text-main">Putni podaci</h4>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="sm:col-span-2 flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium text-text-main">Lokacija ishodišta</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.origin_location}
|
||||||
|
onInput={(event) => setForm({ ...form, origin_location: 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="sm:col-span-2 flex flex-col gap-1 text-sm">
|
<label className="sm:col-span-2 flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-text-main">Lokacija odredišta {form.has_travel_order ? '*' : ''}</span>
|
<span className="font-medium text-text-main">Lokacija odredišta {form.has_travel_order ? '*' : ''}</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -745,12 +815,14 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
<label className="mt-3 flex items-center gap-2 text-sm text-text-main">
|
<label className="mt-3 flex items-center gap-2 text-sm text-text-main">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
className="erp-checkbox"
|
||||||
checked={form.has_travel_order}
|
checked={form.has_travel_order}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const checked = event.currentTarget.checked;
|
const checked = event.currentTarget.checked;
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
has_travel_order: checked,
|
has_travel_order: checked,
|
||||||
|
origin_location: checked ? (prev.origin_location || 'Zagreb') : 'Zagreb',
|
||||||
location: checked ? prev.location : '',
|
location: checked ? prev.location : '',
|
||||||
travel_start_at: checked ? prev.travel_start_at : '',
|
travel_start_at: checked ? prev.travel_start_at : '',
|
||||||
travel_end_at: checked ? prev.travel_end_at : '',
|
travel_end_at: checked ? prev.travel_end_at : '',
|
||||||
@@ -811,14 +883,23 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
Izmijeni
|
Izmijeni
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
onClick={submit}
|
type="button"
|
||||||
disabled={submitting}
|
onClick={() => setPhotoUploadOpen(true)}
|
||||||
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 border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
|
||||||
>
|
>
|
||||||
{submitting ? 'Spremam...' : 'Spremi izmjene'}
|
📷 Dodaj fotografije
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={submitting}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{submitting ? 'Spremam...' : 'Spremi izmjene'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -945,6 +1026,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
<label className="flex items-center gap-2 text-xs text-text-muted">
|
<label className="flex items-center gap-2 text-xs text-text-muted">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
className="erp-checkbox"
|
||||||
checked={sendToAllTeamMembers}
|
checked={sendToAllTeamMembers}
|
||||||
onChange={(event) => setSendToAllTeamMembers(event.currentTarget.checked)}
|
onChange={(event) => setSendToAllTeamMembers(event.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
@@ -987,6 +1069,79 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
placeholder="Poruka (opcionalno)"
|
placeholder="Poruka (opcionalno)"
|
||||||
className="min-h-24 w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
|
className="min-h-24 w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
|
||||||
/>
|
/>
|
||||||
|
<div className="grid gap-2 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main sm:grid-cols-2">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="erp-checkbox"
|
||||||
|
checked={emailPayload.includeWorkOrderPdf}
|
||||||
|
onChange={(event) => setEmailPayload({ ...emailPayload, includeWorkOrderPdf: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
PDF putnog naloga
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="erp-checkbox"
|
||||||
|
checked={emailPayload.includeServiceRecordsPdf}
|
||||||
|
onChange={(event) => setEmailPayload({ ...emailPayload, includeServiceRecordsPdf: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
PDF servisnih zapisa
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="erp-checkbox"
|
||||||
|
checked={emailPayload.includeInvoicesPdf}
|
||||||
|
onChange={(event) => setEmailPayload({ ...emailPayload, includeInvoicesPdf: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
PDF računa
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="erp-checkbox"
|
||||||
|
checked={emailPayload.includeImages}
|
||||||
|
onChange={(event) => setEmailPayload({ ...emailPayload, includeImages: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
Dodaj slike
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 sm:col-span-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="erp-checkbox"
|
||||||
|
checked={emailPayload.includeMonthlyTasks}
|
||||||
|
onChange={(event) => setEmailPayload({ ...emailPayload, includeMonthlyTasks: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
Pošalji popis mjesečnih taskova
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 sm:col-span-2">
|
||||||
|
<span className="text-xs text-text-muted">Mjesec (YYYY-MM)</span>
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
value={emailPayload.month}
|
||||||
|
disabled={!emailPayload.includeMonthlyTasks}
|
||||||
|
onInput={(event) => setEmailPayload({ ...emailPayload, month: event.currentTarget.value })}
|
||||||
|
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main disabled:bg-canvas-deep"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 sm:col-span-2">
|
||||||
|
<span className="text-xs text-text-muted">Odaberi kreiranu bilješku (opcionalno)</span>
|
||||||
|
<select
|
||||||
|
value={emailPayload.serviceNoteId}
|
||||||
|
onChange={(event) => setEmailPayload({ ...emailPayload, serviceNoteId: event.currentTarget.value })}
|
||||||
|
disabled={serviceNotesLoading}
|
||||||
|
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main disabled:bg-canvas-deep"
|
||||||
|
>
|
||||||
|
<option value="">Bez bilješke</option>
|
||||||
|
{selectableServiceNotes.map((note) => (
|
||||||
|
<option key={note.id} value={String(note.id)}>
|
||||||
|
{(note.note_date || '-') + ' • ' + (note.note || '').slice(0, 70)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 border-t border-border-hairline px-4 py-3">
|
<div className="flex justify-end gap-2 border-t border-border-hairline px-4 py-3">
|
||||||
<button
|
<button
|
||||||
@@ -1064,10 +1219,10 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
|
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||||
<span className="font-medium text-text-main">slika_računa</span>
|
<span className="font-medium text-text-main">slika_računa / pdf_računa</span>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*,.pdf,application/pdf"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const file = event.currentTarget.files && event.currentTarget.files[0] ? event.currentTarget.files[0] : null;
|
const file = event.currentTarget.files && event.currentTarget.files[0] ? event.currentTarget.files[0] : null;
|
||||||
setInvoiceForm((prev) => ({ ...prev, imageFile: file }));
|
setInvoiceForm((prev) => ({ ...prev, imageFile: file }));
|
||||||
@@ -1110,6 +1265,113 @@ 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(() => {});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TaskCreateModal
|
||||||
|
open={addTaskModalOpen}
|
||||||
|
workOrders={[workOrder]}
|
||||||
|
contextCrane={craneVehicle || { id: craneVehicleId, registration_number: craneLabel }}
|
||||||
|
onClose={() => setAddTaskModalOpen(false)}
|
||||||
|
onSuccess={async () => {
|
||||||
|
const tasks = await fetchTasksByWorkOrder(workOrder.id).catch(() => []);
|
||||||
|
setWorkOrderTasks(tasks);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{craneDataModalOpen && (
|
||||||
|
<ModalShell
|
||||||
|
onClose={() => setCraneDataModalOpen(false)}
|
||||||
|
overlayClassName="z-[60] overflow-y-auto p-4 pt-16"
|
||||||
|
contentClassName="flex items-start justify-center min-h-full"
|
||||||
|
panelClassName="w-full max-w-md rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-text-main">Izmijeni podatke dizalice</h4>
|
||||||
|
<p className="text-xs text-text-muted">{craneLabel}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCraneDataModalOpen(false)}
|
||||||
|
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
|
||||||
|
aria-label="Zatvori"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 px-4 py-4">
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium text-text-main">Radni sati superstrukture</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={craneDataForm.superstructure_working_hours}
|
||||||
|
onInput={(e) => setCraneDataForm((p) => ({ ...p, superstructure_working_hours: e.currentTarget.value }))}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
|
||||||
|
placeholder={String(craneVehicle?.superstructure_working_hours ?? '0')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium text-text-main">Radni sati podvozja</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={craneDataForm.chassis_working_hours}
|
||||||
|
onInput={(e) => setCraneDataForm((p) => ({ ...p, chassis_working_hours: e.currentTarget.value }))}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
|
||||||
|
placeholder={String(craneVehicle?.chassis_working_hours ?? '0')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium text-text-main">Prijeđeni kilometri dizalice</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={craneDataForm.current_mileage}
|
||||||
|
onInput={(e) => setCraneDataForm((p) => ({ ...p, current_mileage: e.currentTarget.value }))}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
|
||||||
|
placeholder={String(craneVehicle?.current_mileage ?? '0')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{craneDataError && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{craneDataError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-border-hairline px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCraneDataModalOpen(false)}
|
||||||
|
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
|
||||||
|
disabled={savingCraneData}
|
||||||
|
>
|
||||||
|
Odustani
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveCraneData}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-md bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{savingCraneData ? 'Spremam…' : 'Spremi podatke'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,9 @@ export function useAuthenticatedMediaSources(mediaPaths = []) {
|
|||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
if (isProtectedMediaUrl(resolved)) {
|
||||||
|
return authenticatedSources[resolved] || '';
|
||||||
|
}
|
||||||
return authenticatedSources[resolved] || resolved;
|
return authenticatedSources[resolved] || resolved;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { useStore } from '@nanostores/preact';
|
import { useStore } from '@nanostores/preact';
|
||||||
import {
|
import {
|
||||||
|
downloadWorkOrderDocx,
|
||||||
downloadWorkOrderPdf,
|
downloadWorkOrderPdf,
|
||||||
|
downloadWorkOrderInvoicesDocx,
|
||||||
downloadWorkOrderInvoicesPdf,
|
downloadWorkOrderInvoicesPdf,
|
||||||
|
downloadWorkOrderServiceRecordsDocx,
|
||||||
downloadWorkOrderServiceRecordsPdf,
|
downloadWorkOrderServiceRecordsPdf,
|
||||||
|
fetchWorkOrderAdditionalCostsTable,
|
||||||
fetchWorkOrderById,
|
fetchWorkOrderById,
|
||||||
fetchWorkOrderInvoices,
|
fetchWorkOrderInvoices,
|
||||||
|
fetchWorkOrderTravelExpensesTable,
|
||||||
fetchWorkOrderTaskServiceContext,
|
fetchWorkOrderTaskServiceContext,
|
||||||
|
updateTaskWorkHoursTable,
|
||||||
|
updateTaskServiceReportNote,
|
||||||
|
updateWorkOrderAdditionalCostsTable,
|
||||||
|
updateWorkOrderTravelExpensesTable,
|
||||||
} 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, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
import { useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
||||||
|
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
|
||||||
|
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
|
||||||
|
import WorkOrderTravelExpensesModal from './WorkOrderTravelExpensesModal';
|
||||||
|
import WorkOrderServiceNotesModal from './WorkOrderServiceNotesModal';
|
||||||
|
|
||||||
function readWorkOrderIdFromQuery() {
|
function readWorkOrderIdFromQuery() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -21,8 +34,32 @@ function readWorkOrderIdFromQuery() {
|
|||||||
return params.get('workOrderId') || '';
|
return params.get('workOrderId') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function InvoiceImagePreview({ imageUrl, alt }) {
|
function InvoiceImagePreview({ imageUrl, rawUrl, alt, contentType, onClick }) {
|
||||||
const [broken, setBroken] = useState(false);
|
const [broken, setBroken] = useState(false);
|
||||||
|
const isPdf = String(contentType || '').toLowerCase() === 'application/pdf';
|
||||||
|
const isProtected = typeof rawUrl === 'string' && /\/api\/fleet\//i.test(rawUrl);
|
||||||
|
const isLoading = isProtected && !imageUrl && !broken;
|
||||||
|
|
||||||
|
if (isPdf && imageUrl) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={imageUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex h-28 w-28 items-center justify-center rounded-lg border border-border-hairline bg-canvas-deep text-center text-[11px] text-text-main hover:bg-canvas-base"
|
||||||
|
>
|
||||||
|
Otvori PDF
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-28 w-28 items-center justify-center rounded-lg border border-border-hairline bg-canvas-deep text-center text-[11px] text-text-muted">
|
||||||
|
<span className="animate-pulse">Učitavanje…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!imageUrl || broken) {
|
if (!imageUrl || broken) {
|
||||||
return (
|
return (
|
||||||
@@ -33,14 +70,76 @@ function InvoiceImagePreview({ imageUrl, alt }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href={imageUrl} target="_blank" rel="noopener noreferrer" className="inline-flex">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex cursor-pointer focus:outline-none"
|
||||||
|
aria-label={`Otvori pregled: ${alt}`}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
onError={() => setBroken(true)}
|
onError={() => setBroken(true)}
|
||||||
className="h-28 w-28 rounded-lg border border-border-hairline object-cover"
|
className="h-28 w-28 rounded-lg border border-border-hairline object-cover hover:opacity-90 transition-opacity"
|
||||||
/>
|
/>
|
||||||
</a>
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvoiceDetailModal({ invoice, imageUrl, contentType, onClose }) {
|
||||||
|
const isPdf = String(contentType || '').toLowerCase() === 'application/pdf';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', handleKey);
|
||||||
|
return () => window.removeEventListener('keydown', handleKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
|
<div className="relative flex max-h-[95vh] w-full max-w-3xl flex-col overflow-auto rounded-xl bg-canvas-base shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-3">
|
||||||
|
<h2 className="text-base font-semibold text-text-main">{invoice.naziv_racuna || 'Račun'}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded p-1 text-text-muted hover:text-text-main focus:outline-none"
|
||||||
|
aria-label="Zatvori"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 px-5 py-3 text-sm text-text-main border-b border-border-hairline">
|
||||||
|
<div><span className="font-semibold">Naziv:</span> {invoice.naziv_racuna || '-'}</div>
|
||||||
|
<div><span className="font-semibold">Lokacija:</span> {invoice.lokacija || '-'}</div>
|
||||||
|
<div><span className="font-semibold">Datum:</span> {invoice.datum || '-'}</div>
|
||||||
|
<div><span className="font-semibold">Opis:</span> {invoice.opis || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center p-5">
|
||||||
|
{isPdf && imageUrl ? (
|
||||||
|
<a
|
||||||
|
href={imageUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Otvori PDF u novom tabu
|
||||||
|
</a>
|
||||||
|
) : imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={invoice.naziv_racuna}
|
||||||
|
className="max-h-[60vh] max-w-full rounded-lg object-contain shadow"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-text-muted">Slika nije dostupna.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +150,20 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
const [workOrder, setWorkOrder] = useState(null);
|
const [workOrder, setWorkOrder] = useState(null);
|
||||||
const [invoices, setInvoices] = useState([]);
|
const [invoices, setInvoices] = useState([]);
|
||||||
const [taskContext, setTaskContext] = useState({ tasks: [] });
|
const [taskContext, setTaskContext] = useState({ tasks: [] });
|
||||||
|
const [travelExpensesTable, setTravelExpensesTable] = useState({ broj_sati: '0.00', kolicina_dnevnica: '0.00', iznos_dnevnica: '30.00', daily_rate_country: 'HR', total_for_payout: '0.00' });
|
||||||
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);
|
||||||
|
const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' });
|
||||||
|
const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false);
|
||||||
|
const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false);
|
||||||
|
const [editingTravelExpenses, setEditingTravelExpenses] = useState(false);
|
||||||
|
const [savingTravelExpenses, setSavingTravelExpenses] = useState(false);
|
||||||
|
const [editingTaskNote, setEditingTaskNote] = useState(null);
|
||||||
|
const [savingTaskNote, setSavingTaskNote] = useState(false);
|
||||||
|
const [taskNoteError, setTaskNoteError] = useState('');
|
||||||
|
const [invoiceDetailModal, setInvoiceDetailModal] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hydrateAuthFromStorage();
|
hydrateAuthFromStorage();
|
||||||
@@ -87,10 +198,12 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [contextPayload, order, invoiceItems] = await Promise.all([
|
const [contextPayload, order, invoiceItems, additionalCostsPayload, travelExpensesPayload] = await Promise.all([
|
||||||
fetchWorkOrderTaskServiceContext(workOrderId),
|
fetchWorkOrderTaskServiceContext(workOrderId),
|
||||||
fetchWorkOrderById(workOrderId),
|
fetchWorkOrderById(workOrderId),
|
||||||
fetchWorkOrderInvoices(workOrderId),
|
fetchWorkOrderInvoices(workOrderId),
|
||||||
|
fetchWorkOrderAdditionalCostsTable(workOrderId),
|
||||||
|
fetchWorkOrderTravelExpensesTable(workOrderId),
|
||||||
]);
|
]);
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
@@ -98,6 +211,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
setTaskContext(contextPayload);
|
setTaskContext(contextPayload);
|
||||||
setWorkOrder(order);
|
setWorkOrder(order);
|
||||||
setInvoices(invoiceItems);
|
setInvoices(invoiceItems);
|
||||||
|
setAdditionalCostsTable(additionalCostsPayload || contextPayload?.additional_costs_table || { data: { rows: [] }, total_for_payout: '0.00' });
|
||||||
|
setTravelExpensesTable(travelExpensesPayload || contextPayload?.travel_expenses_table || {
|
||||||
|
broj_sati: '0.00',
|
||||||
|
kolicina_dnevnica: '0.00',
|
||||||
|
iznos_dnevnica: '30.00',
|
||||||
|
daily_rate_country: 'HR',
|
||||||
|
total_for_payout: '0.00',
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.');
|
setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.');
|
||||||
@@ -118,7 +239,7 @@ 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 mediaPaths = useMemo(() => {
|
||||||
@@ -139,8 +260,9 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
}
|
}
|
||||||
if (Array.isArray(invoices)) {
|
if (Array.isArray(invoices)) {
|
||||||
invoices.forEach((invoice) => {
|
invoices.forEach((invoice) => {
|
||||||
if (invoice?.image) {
|
const invoiceImage = invoice?.image_url || invoice?.image;
|
||||||
paths.push(invoice.image);
|
if (invoiceImage) {
|
||||||
|
paths.push(invoiceImage);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -149,6 +271,78 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
|
|
||||||
const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(mediaPaths);
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTaskServiceReportNote = async (note) => {
|
||||||
|
if (!editingTaskNote?.id) return;
|
||||||
|
setSavingTaskNote(true);
|
||||||
|
setTaskNoteError('');
|
||||||
|
try {
|
||||||
|
const updated = await updateTaskServiceReportNote(editingTaskNote.id, note);
|
||||||
|
setTaskContext((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tasks: (Array.isArray(prev.tasks) ? prev.tasks : []).map((task) => (
|
||||||
|
String(task.id) === String(editingTaskNote.id)
|
||||||
|
? { ...task, service_report_note: updated?.service_report_note ?? note }
|
||||||
|
: task
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
setEditingTaskNote(null);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err?.message || 'Spremanje napomene nije uspjelo.';
|
||||||
|
setTaskNoteError(message);
|
||||||
|
} finally {
|
||||||
|
setSavingTaskNote(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAdditionalCostsTable = async (tableData) => {
|
||||||
|
if (!workOrderId) return;
|
||||||
|
setSavingAdditionalCosts(true);
|
||||||
|
try {
|
||||||
|
const response = await updateWorkOrderAdditionalCostsTable(workOrderId, tableData);
|
||||||
|
setAdditionalCostsTable(response || { data: { rows: [] }, total_for_payout: '0.00' });
|
||||||
|
setEditingAdditionalCosts(false);
|
||||||
|
} finally {
|
||||||
|
setSavingAdditionalCosts(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTravelExpensesTable = async (tableData) => {
|
||||||
|
if (!workOrderId) return;
|
||||||
|
setSavingTravelExpenses(true);
|
||||||
|
try {
|
||||||
|
const response = await updateWorkOrderTravelExpensesTable(workOrderId, tableData);
|
||||||
|
setTravelExpensesTable(response || {
|
||||||
|
broj_sati: '0.00',
|
||||||
|
kolicina_dnevnica: '0.00',
|
||||||
|
iznos_dnevnica: '30.00',
|
||||||
|
daily_rate_country: 'HR',
|
||||||
|
total_for_payout: '0.00',
|
||||||
|
});
|
||||||
|
setEditingTravelExpenses(false);
|
||||||
|
} finally {
|
||||||
|
setSavingTravelExpenses(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">
|
||||||
@@ -159,28 +353,56 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
{workOrder ? `Dizalica: ${workOrder.crane_label || workOrder.crane || '-'}` : 'Pregled računa prije kreiranja PDF-a.'}
|
{workOrder ? `Dizalica: ${workOrder.crane_label || workOrder.crane || '-'}` : 'Pregled računa prije kreiranja PDF-a.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => downloadWorkOrderPdf(workOrderId)}
|
type="button"
|
||||||
disabled={!workOrderId}
|
onClick={() => downloadWorkOrderPdf(workOrderId)}
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
disabled={!workOrderId}
|
||||||
>
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
Preuzmi PDF putnog naloga
|
>
|
||||||
</button>
|
Preuzmi PDF putnog naloga
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadWorkOrderDocx(workOrderId)}
|
||||||
|
disabled={!workOrderId}
|
||||||
|
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Preuzmi DOCX putnog naloga
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingTravelExpenses(true)}
|
||||||
|
disabled={!workOrderId}
|
||||||
|
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Uredi obračun putnih troškova
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">Servisni zapisi</h2>
|
<h2 className="text-lg font-semibold text-text-main">Servisni zapisi</h2>
|
||||||
<button
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
|
type="button"
|
||||||
disabled={!workOrderId}
|
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
disabled={!workOrderId}
|
||||||
>
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
Preuzmi PDF servisnih zapisa
|
>
|
||||||
</button>
|
Preuzmi PDF svih servisnih zapisa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadWorkOrderServiceRecordsDocx(workOrderId)}
|
||||||
|
disabled={!workOrderId}
|
||||||
|
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Preuzmi DOCX svih servisnih zapisa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loading && <p className="text-sm text-text-muted">Učitavanje task konteksta...</p>}
|
{loading && <p className="text-sm text-text-muted">Učitavanje task konteksta...</p>}
|
||||||
{!loading && !error && (!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0) && (
|
{!loading && !error && (!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0) && (
|
||||||
@@ -190,13 +412,66 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{taskContext.tasks.map((task) => (
|
{taskContext.tasks.map((task) => (
|
||||||
<article key={task.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
<article key={task.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||||
<div className="space-y-1 text-sm text-text-main">
|
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||||
<div><span className="font-semibold">Radni zadatak:</span> {task.title || '-'}</div>
|
<div className="space-y-1 text-sm text-text-main">
|
||||||
<div><span className="font-semibold">Status:</span> {task.status || '-'}</div>
|
<div><span className="font-semibold">Radni zadatak:</span> {task.title || '-'}</div>
|
||||||
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
|
<div><span className="font-semibold">Status:</span> {task.status || '-'}</div>
|
||||||
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
|
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
|
||||||
|
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
|
||||||
|
{task.service_report_note && (
|
||||||
|
<div className="mt-1 rounded bg-canvas-deep px-2 py-1 text-xs text-text-muted">
|
||||||
|
<span className="font-semibold">Napomena:</span> {task.service_report_note}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingTask(task)}
|
||||||
|
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep"
|
||||||
|
>
|
||||||
|
Uredi tablicu radnih sati
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setTaskNoteError(''); setEditingTaskNote(task); }}
|
||||||
|
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep"
|
||||||
|
>
|
||||||
|
Uredi tekst napomene
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadWorkOrderServiceRecordsPdf(
|
||||||
|
workOrderId,
|
||||||
|
task.id,
|
||||||
|
{
|
||||||
|
workOrderDisplayCode: workOrder?.display_code,
|
||||||
|
taskTitle: task.title,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
disabled={!workOrderId || !task.id}
|
||||||
|
className="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
PDF za ovaj task
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadWorkOrderServiceRecordsDocx(
|
||||||
|
workOrderId,
|
||||||
|
task.id,
|
||||||
|
{
|
||||||
|
workOrderDisplayCode: workOrder?.display_code,
|
||||||
|
taskTitle: task.title,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
disabled={!workOrderId || !task.id}
|
||||||
|
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
DOCX za ovaj task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="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>
|
||||||
) : (
|
) : (
|
||||||
@@ -242,15 +517,38 @@ 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">Računi</h2>
|
<h2 className="text-lg font-semibold text-text-main">Računi</h2>
|
||||||
<button
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => downloadWorkOrderInvoicesPdf(workOrderId)}
|
type="button"
|
||||||
disabled={!workOrderId}
|
onClick={() => setEditingAdditionalCosts(true)}
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
disabled={!workOrderId}
|
||||||
>
|
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
Preuzmi račune putnog naloga
|
>
|
||||||
</button>
|
Uredi dodatne troškove
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadWorkOrderInvoicesPdf(workOrderId)}
|
||||||
|
disabled={!workOrderId}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Preuzmi račune putnog naloga
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadWorkOrderInvoicesDocx(workOrderId)}
|
||||||
|
disabled={!workOrderId}
|
||||||
|
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Preuzmi DOCX računa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!loading && !error && (
|
||||||
|
<p className="mb-3 text-sm text-text-muted">
|
||||||
|
Ukupno dodatni troškovi: <span className="font-semibold text-text-main">{additionalCostsTable?.total_for_payout || '0.00'} €</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{loading && <p className="text-sm text-text-muted">Učitavanje računa...</p>}
|
{loading && <p className="text-sm text-text-muted">Učitavanje računa...</p>}
|
||||||
{!loading && error && (
|
{!loading && error && (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
@@ -262,25 +560,83 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
)}
|
)}
|
||||||
{!loading && !error && invoices.length > 0 && (
|
{!loading && !error && invoices.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{invoices.map((invoice) => (
|
{invoices.map((invoice) => {
|
||||||
<article key={invoice.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
const rawUrl = invoice.image_url || invoice.image || '';
|
||||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-start">
|
const blobUrl = getAuthenticatedMediaSrc(rawUrl);
|
||||||
<div className="space-y-1 text-sm text-text-main">
|
return (
|
||||||
<div><span className="font-semibold">naziv_računa:</span> {invoice.naziv_racuna}</div>
|
<article
|
||||||
<div><span className="font-semibold">lokacija:</span> {invoice.lokacija || '-'}</div>
|
key={invoice.id}
|
||||||
<div><span className="font-semibold">datum:</span> {invoice.datum || '-'}</div>
|
className="cursor-pointer rounded-lg border border-border-hairline bg-canvas-base p-3 hover:bg-canvas-deep transition-colors"
|
||||||
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
|
onClick={() => setInvoiceDetailModal(invoice)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setInvoiceDetailModal(invoice); }}
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-start">
|
||||||
|
<div className="space-y-1 text-sm text-text-main">
|
||||||
|
<div><span className="font-semibold">naziv_računa:</span> {invoice.naziv_racuna}</div>
|
||||||
|
<div><span className="font-semibold">lokacija:</span> {invoice.lokacija || '-'}</div>
|
||||||
|
<div><span className="font-semibold">datum:</span> {invoice.datum || '-'}</div>
|
||||||
|
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<InvoiceImagePreview
|
||||||
|
imageUrl={blobUrl}
|
||||||
|
rawUrl={rawUrl}
|
||||||
|
alt={`Račun ${invoice.naziv_racuna}`}
|
||||||
|
contentType={invoice.image_content_type}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setInvoiceDetailModal(invoice); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<InvoiceImagePreview
|
)}
|
||||||
imageUrl={getAuthenticatedMediaSrc(invoice.image) || resolveMediaUrl(invoice.image)}
|
|
||||||
alt={`Račun ${invoice.naziv_racuna}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<TaskWorkHoursTableModal
|
||||||
|
open={!!editingTask}
|
||||||
|
task={editingTask}
|
||||||
|
workOrder={editingTask?.task_work_order ?? null}
|
||||||
|
defaultLocation={editingTask?.task_work_order?.location ?? ''}
|
||||||
|
saving={savingWorkHours}
|
||||||
|
onClose={() => setEditingTask(null)}
|
||||||
|
onSave={handleSaveWorkHoursTable}
|
||||||
|
/>
|
||||||
|
<WorkOrderAdditionalCostsModal
|
||||||
|
open={editingAdditionalCosts}
|
||||||
|
workOrder={workOrder}
|
||||||
|
tableData={additionalCostsTable}
|
||||||
|
saving={savingAdditionalCosts}
|
||||||
|
onClose={() => setEditingAdditionalCosts(false)}
|
||||||
|
onSave={handleSaveAdditionalCostsTable}
|
||||||
|
/>
|
||||||
|
<WorkOrderTravelExpensesModal
|
||||||
|
open={editingTravelExpenses}
|
||||||
|
workOrder={workOrder}
|
||||||
|
tableData={travelExpensesTable}
|
||||||
|
saving={savingTravelExpenses}
|
||||||
|
onClose={() => setEditingTravelExpenses(false)}
|
||||||
|
onSave={handleSaveTravelExpensesTable}
|
||||||
|
/>
|
||||||
|
<WorkOrderServiceNotesModal
|
||||||
|
open={!!editingTaskNote}
|
||||||
|
task={editingTaskNote}
|
||||||
|
saving={savingTaskNote}
|
||||||
|
error={taskNoteError}
|
||||||
|
onClose={() => {
|
||||||
|
setEditingTaskNote(null);
|
||||||
|
setTaskNoteError('');
|
||||||
|
}}
|
||||||
|
onSave={handleSaveTaskServiceReportNote}
|
||||||
|
/>
|
||||||
|
{invoiceDetailModal && (
|
||||||
|
<InvoiceDetailModal
|
||||||
|
invoice={invoiceDetailModal}
|
||||||
|
imageUrl={getAuthenticatedMediaSrc(invoiceDetailModal.image_url || invoiceDetailModal.image || '')}
|
||||||
|
contentType={invoiceDetailModal.image_content_type}
|
||||||
|
onClose={() => setInvoiceDetailModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</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: '',
|
||||||
@@ -18,6 +20,7 @@ const INITIAL_FORM = {
|
|||||||
servicer_vehicle_start_mileage: '',
|
servicer_vehicle_start_mileage: '',
|
||||||
servicer_vehicle_end_mileage: '',
|
servicer_vehicle_end_mileage: '',
|
||||||
servicer_vehicle_fuel_cost: '',
|
servicer_vehicle_fuel_cost: '',
|
||||||
|
origin_location: 'Zagreb',
|
||||||
location: '',
|
location: '',
|
||||||
travel_start_at: '',
|
travel_start_at: '',
|
||||||
travel_end_at: '',
|
travel_end_at: '',
|
||||||
@@ -54,6 +57,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 +120,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 +204,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(),
|
||||||
@@ -198,6 +212,7 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
|
|||||||
servicer_vehicle_start_mileage: servicerStartMileage,
|
servicer_vehicle_start_mileage: servicerStartMileage,
|
||||||
servicer_vehicle_end_mileage: servicerEndMileage,
|
servicer_vehicle_end_mileage: servicerEndMileage,
|
||||||
servicer_vehicle_fuel_cost: servicerFuelCost,
|
servicer_vehicle_fuel_cost: servicerFuelCost,
|
||||||
|
origin_location: form.origin_location.trim() || 'Zagreb',
|
||||||
location: form.has_travel_order ? form.location.trim() : '',
|
location: form.has_travel_order ? 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,
|
||||||
@@ -233,6 +248,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
|
||||||
@@ -366,6 +395,18 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<label className="sm:col-span-2 flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium text-text-main">Lokacija ishodišta</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.origin_location}
|
||||||
|
onInput={(event) => setForm({ ...form, origin_location: event.currentTarget.value })}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
|
||||||
|
placeholder="Npr. Zagreb"
|
||||||
|
disabled={!form.has_travel_order}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="sm:col-span-2 flex flex-col gap-1 text-sm">
|
<label className="sm:col-span-2 flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-text-main">Lokacija odredišta {form.has_travel_order ? '*' : ''}</span>
|
<span className="font-medium text-text-main">Lokacija odredišta {form.has_travel_order ? '*' : ''}</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -431,6 +472,7 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
|
|||||||
has_travel_order: checked,
|
has_travel_order: checked,
|
||||||
start_mileage: checked ? (prev.start_mileage || String(selectedCrane?.current_mileage ?? '')) : '',
|
start_mileage: checked ? (prev.start_mileage || String(selectedCrane?.current_mileage ?? '')) : '',
|
||||||
end_mileage: checked ? prev.end_mileage : '',
|
end_mileage: checked ? prev.end_mileage : '',
|
||||||
|
origin_location: checked ? (prev.origin_location || 'Zagreb') : 'Zagreb',
|
||||||
location: checked ? prev.location : '',
|
location: checked ? prev.location : '',
|
||||||
travel_start_at: checked ? prev.travel_start_at : '',
|
travel_start_at: checked ? prev.travel_start_at : '',
|
||||||
travel_end_at: checked ? prev.travel_end_at : '',
|
travel_end_at: checked ? prev.travel_end_at : '',
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
import ModalShell from '../ui/ModalShell';
|
||||||
|
|
||||||
|
export default function WorkOrderServiceNotesModal({
|
||||||
|
open,
|
||||||
|
task,
|
||||||
|
saving = false,
|
||||||
|
error = '',
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}) {
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setNotes('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNotes(String(task?.service_report_note || ''));
|
||||||
|
}, [open, task?.id, task?.service_report_note]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await onSave?.(notes);
|
||||||
|
};
|
||||||
|
|
||||||
|
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-2xl 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">
|
||||||
|
<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">UREDI TEKST NAPOMENE</h3>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
{task?.title
|
||||||
|
? `Zadatak: ${task.title}`
|
||||||
|
: 'Tekst se prikazuje u PDF-u "Izvještaj servisera".'}
|
||||||
|
</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-2 px-5 py-4">
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<label htmlFor="task-service-report-note" className="text-sm font-medium text-text-main">
|
||||||
|
Napomena
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="task-service-report-note"
|
||||||
|
value={notes}
|
||||||
|
onInput={(event) => setNotes(event.currentTarget.value)}
|
||||||
|
rows={8}
|
||||||
|
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Unesite tekst napomene za PDF izvještaj servisera..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{saving ? 'Spremam…' : 'Spremi napomenu'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
import ModalShell from '../ui/ModalShell';
|
||||||
|
|
||||||
|
const RATE_OPTIONS = [
|
||||||
|
{ value: 'HR', label: 'Hrvatska', amount: '30.00' },
|
||||||
|
{ value: 'BIH', label: 'BiH', amount: '50.00' },
|
||||||
|
{ value: 'SI', label: 'Slovenija', amount: '80.00' },
|
||||||
|
{ value: 'CG', label: 'Crna Gora', amount: '50.00' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_ROW = {
|
||||||
|
broj_sati: '0.00',
|
||||||
|
kolicina_dnevnica: '0.00',
|
||||||
|
iznos_dnevnica: '30.00',
|
||||||
|
daily_rate_country: 'HR',
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseAmount(value) {
|
||||||
|
const normalized = String(value || '').trim().replace('€', '').replace(/\s+/g, '').replace(',', '.');
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDisplayValue(value) {
|
||||||
|
if (value == null || value === '') return '0.00';
|
||||||
|
const numeric = Number(String(value).replace(',', '.'));
|
||||||
|
return Number.isFinite(numeric) ? numeric.toFixed(2) : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRateAmount(country) {
|
||||||
|
const option = RATE_OPTIONS.find((item) => item.value === country);
|
||||||
|
return option ? option.amount : DEFAULT_ROW.iznos_dnevnica;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkOrderTravelExpensesModal({
|
||||||
|
open,
|
||||||
|
workOrder,
|
||||||
|
tableData,
|
||||||
|
saving = false,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}) {
|
||||||
|
const [row, setRow] = useState(DEFAULT_ROW);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setRow(DEFAULT_ROW);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const incoming = tableData && typeof tableData === 'object' ? tableData : {};
|
||||||
|
const country = String(incoming.daily_rate_country || DEFAULT_ROW.daily_rate_country).toUpperCase();
|
||||||
|
const amount = incoming.iznos_dnevnica != null && incoming.iznos_dnevnica !== ''
|
||||||
|
? incoming.iznos_dnevnica
|
||||||
|
: getRateAmount(country);
|
||||||
|
setRow({
|
||||||
|
broj_sati: toDisplayValue(incoming.broj_sati),
|
||||||
|
kolicina_dnevnica: toDisplayValue(incoming.kolicina_dnevnica),
|
||||||
|
iznos_dnevnica: toDisplayValue(amount),
|
||||||
|
daily_rate_country: country,
|
||||||
|
});
|
||||||
|
}, [open, tableData]);
|
||||||
|
|
||||||
|
const total = useMemo(() => (
|
||||||
|
(parseAmount(row.kolicina_dnevnica) * parseAmount(row.iznos_dnevnica)).toFixed(2)
|
||||||
|
), [row]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const updateField = (field, value) => {
|
||||||
|
setRow((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCountry = (country) => {
|
||||||
|
setRow((prev) => ({
|
||||||
|
...prev,
|
||||||
|
daily_rate_country: country,
|
||||||
|
iznos_dnevnica: getRateAmount(country),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await onSave?.({
|
||||||
|
broj_sati: String(row.broj_sati || '').trim(),
|
||||||
|
kolicina_dnevnica: String(row.kolicina_dnevnica || '').trim(),
|
||||||
|
iznos_dnevnica: String(row.iznos_dnevnica || '').trim(),
|
||||||
|
daily_rate_country: String(row.daily_rate_country || '').trim(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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-4xl 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">
|
||||||
|
<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">OBRAČUN PUTNIH TROŠKOVA</h3>
|
||||||
|
<p className="text-xs text-text-muted">{workOrder?.display_code || workOrder?.id || '-'}</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">
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-4 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
||||||
|
<p className="font-semibold text-gray-800 dark:text-gray-100">Brzi odabir dnevnice</p>
|
||||||
|
<p>Odabir države automatski postavlja iznos dnevnice. Po potrebi iznos možete ručno izmijeniti.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-auto rounded-xl border border-gray-200 shadow-sm dark:border-gray-700">
|
||||||
|
<table className="min-w-[900px] 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">#</th>
|
||||||
|
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Broj sati</th>
|
||||||
|
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Količina dnevnica</th>
|
||||||
|
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Država</th>
|
||||||
|
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Iznos dnevnice</th>
|
||||||
|
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Ukupan iznos</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<td className="px-2 py-1.5 text-center text-[11px] text-gray-400">1</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={row.broj_sati}
|
||||||
|
onInput={(event) => updateField('broj_sati', event.currentTarget.value)}
|
||||||
|
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={row.kolicina_dnevnica}
|
||||||
|
onInput={(event) => updateField('kolicina_dnevnica', event.currentTarget.value)}
|
||||||
|
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<select
|
||||||
|
value={row.daily_rate_country}
|
||||||
|
onChange={(event) => updateCountry(event.currentTarget.value)}
|
||||||
|
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
|
||||||
|
>
|
||||||
|
{RATE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={row.iznos_dnevnica}
|
||||||
|
onInput={(event) => updateField('iznos_dnevnica', event.currentTarget.value)}
|
||||||
|
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<div className="rounded-lg border border-gray-300 bg-gray-100 px-2.5 py-2 text-right text-xs font-semibold text-gray-800">
|
||||||
|
{total} €
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{saving ? 'Spremam…' : 'Spremi obračun'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -172,31 +174,24 @@ export default function ServiceRecordDetailModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let failed = 0;
|
await sendServiceRecordEmail(
|
||||||
for (const recipient of recipients) {
|
serviceRecord.id,
|
||||||
try {
|
{
|
||||||
await sendServiceRecordEmail(
|
recipients,
|
||||||
serviceRecord.id,
|
subject: emailPayload.subject.trim() || undefined,
|
||||||
{
|
message: emailPayload.message.trim() || undefined,
|
||||||
recipient,
|
},
|
||||||
subject: emailPayload.subject.trim() || undefined,
|
{ toast: false }
|
||||||
message: emailPayload.message.trim() || undefined,
|
);
|
||||||
},
|
showToast(`Email je poslan za ${recipients.length} primatelja.`, 'success');
|
||||||
{ toast: false }
|
|
||||||
);
|
|
||||||
showToast(`Email je poslan: ${recipient}`, 'success');
|
|
||||||
} catch {
|
|
||||||
failed += 1;
|
|
||||||
showToast(`Neuspješno slanje: ${recipient}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (failed > 0) {
|
|
||||||
setWidgetError(`Slanje nije uspjelo za ${failed} primatelja.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setEmailModalOpen(false);
|
setEmailModalOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setWidgetError(err?.message || 'Neuspješno slanje emaila za servisni zapis.');
|
const failedCount = Array.isArray(err?.failures) ? err.failures.length : 0;
|
||||||
|
if (failedCount > 0) {
|
||||||
|
setWidgetError(`Slanje nije uspjelo za ${failedCount} primatelja.`);
|
||||||
|
} else {
|
||||||
|
setWidgetError(err?.message || 'Neuspješno slanje emaila za servisni zapis.');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setEmailSending(false);
|
setEmailSending(false);
|
||||||
}
|
}
|
||||||
@@ -411,6 +406,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={() => {
|
||||||
@@ -469,6 +472,7 @@ export default function ServiceRecordDetailModal({
|
|||||||
<label className="flex items-center gap-2 text-xs text-text-muted">
|
<label className="flex items-center gap-2 text-xs text-text-muted">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
className="erp-checkbox"
|
||||||
checked={sendToAllTeamMembers}
|
checked={sendToAllTeamMembers}
|
||||||
onChange={(event) => setSendToAllTeamMembers(event.currentTarget.checked)}
|
onChange={(event) => setSendToAllTeamMembers(event.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user