Compare commits
68 Commits
42fcd836ed
...
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 |
22
README.md
22
README.md
@@ -1 +1,23 @@
|
||||
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
|
||||
from celery import Celery
|
||||
from celery import Task
|
||||
import gc
|
||||
import logging
|
||||
|
||||
# Postavi Django settings modul
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
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_'
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
|
||||
@@ -154,6 +154,10 @@ REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
||||
|
||||
CELERY_BROKER_URL = 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)
|
||||
# Za ovo ti je potreban paket 'django-redis' u requirements.txt
|
||||
@@ -175,6 +179,14 @@ CELERY_BEAT_SCHEDULE = {
|
||||
'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),
|
||||
|
||||
@@ -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')
|
||||
fieldsets = (
|
||||
(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')}),
|
||||
('Važni datumi', {'fields': ('last_login', 'date_joined')}),
|
||||
)
|
||||
@@ -18,7 +18,7 @@ class CustomUserAdmin(UserAdmin):
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
'classes': ('wide',),
|
||||
'fields': ('email', 'password', 'is_team_member'),
|
||||
'fields': ('email', 'password', 'first_name', 'last_name', 'telefon', 'occupation', 'signature', 'is_team_member'),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
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 .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):
|
||||
# Email koristimo za login, pa mora biti jedinstven
|
||||
username=None
|
||||
@@ -19,6 +30,7 @@ class CustomUser(AbstractUser):
|
||||
occupation = models.CharField(max_length=120, blank=True, default='', verbose_name="Zanimanje")
|
||||
residence = models.CharField(max_length=255, blank=True, default='', verbose_name="Prebivalište")
|
||||
work_position = models.CharField(max_length=120, blank=True, default='', verbose_name="Na radnom mjestu")
|
||||
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")
|
||||
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:
|
||||
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):
|
||||
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()
|
||||
super().save(*args, **kwargs)
|
||||
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"
|
||||
@@ -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.admin import ModelAdmin
|
||||
from django.utils.html import format_html
|
||||
@@ -10,6 +11,7 @@ from .models import (
|
||||
WorkOrderAdditionalCostsTable,
|
||||
ServiceContextNote,
|
||||
GeneratedWorkOrderPdf,
|
||||
EmailDispatchLog,
|
||||
VehicleServiceRecord,
|
||||
VehicleServicePhoto,
|
||||
VehicleServiceAttachment,
|
||||
@@ -60,8 +62,27 @@ class ServiceRecordInline(admin.TabularInline):
|
||||
class WorkOrderInvoiceInline(admin.TabularInline):
|
||||
model = WorkOrderInvoice
|
||||
extra = 0
|
||||
fields = ('naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active')
|
||||
readonly_fields = ('created_at',)
|
||||
fields = ('thumbnail_preview', 'naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active')
|
||||
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):
|
||||
@@ -192,10 +213,30 @@ class WorkOrderAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(WorkOrderInvoice)
|
||||
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')
|
||||
search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id')
|
||||
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)
|
||||
@@ -245,3 +286,49 @@ class VehicleServiceRecordAdmin(admin.ModelAdmin):
|
||||
if obj.description:
|
||||
return obj.description[:60] + ('…' if len(obj.description) > 60 else '')
|
||||
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/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 re
|
||||
from datetime import time
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils import timezone
|
||||
from django.dispatch import receiver
|
||||
from core.base_models import BaseModel
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -64,6 +67,16 @@ def _generated_work_order_pdf_upload_to(instance, 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):
|
||||
"""
|
||||
Fotografije kvarova / radova povezane s VehicleServiceRecord.
|
||||
@@ -131,6 +144,67 @@ class VehicleServiceAttachment(BaseModel):
|
||||
def __str__(self):
|
||||
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):
|
||||
ASSET_TYPE_CHOICES = [
|
||||
('vehicle', _("Vozilo")),
|
||||
@@ -406,6 +480,63 @@ class WorkOrderAdditionalCostsTable(BaseModel):
|
||||
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,
|
||||
@@ -479,6 +610,59 @@ class GeneratedWorkOrderPdf(BaseModel):
|
||||
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):
|
||||
"""
|
||||
Zapisi o servisima/popravcima na vozilu.
|
||||
@@ -568,6 +752,67 @@ class VehicleNotification(BaseModel):
|
||||
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'),
|
||||
@@ -634,3 +879,105 @@ class ServiceContextNote(BaseModel):
|
||||
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)
|
||||
@@ -10,9 +10,11 @@ from .models import (
|
||||
Crane,
|
||||
Vehicle,
|
||||
ServiceContextNote,
|
||||
MonthlyServicerDayEntry,
|
||||
VehicleNotification,
|
||||
WorkOrder,
|
||||
WorkOrderAdditionalCostsTable,
|
||||
WorkOrderTravelExpensesTable,
|
||||
WorkOrderPhoto,
|
||||
WorkOrderInvoice,
|
||||
VehicleServiceRecord,
|
||||
@@ -38,7 +40,7 @@ class VehicleSerializer(serializers.ModelSerializer):
|
||||
'is_company_vehicle', 'client', 'client_name',
|
||||
'assigned_servicer', 'is_active',
|
||||
]
|
||||
read_only_fields = ['id', 'current_mileage', 'client_name']
|
||||
read_only_fields = ['id', 'client_name']
|
||||
|
||||
def get_client_name(self, obj):
|
||||
if obj.client_id:
|
||||
@@ -154,12 +156,12 @@ class ServiceContextNoteCreateSerializer(serializers.Serializer):
|
||||
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),
|
||||
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)),
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(is_active=True, is_team_member=True)),
|
||||
required=False,
|
||||
allow_empty=False,
|
||||
)
|
||||
@@ -202,12 +204,12 @@ class ServiceContextNoteCreateSerializer(serializers.Serializer):
|
||||
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 servisera.'})
|
||||
if target_user and not getattr(target_user, 'is_serviser', False):
|
||||
raise serializers.ValidationError({'target_user': 'Bilješku je moguće poslati samo serviseru.'})
|
||||
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_serviser', False):
|
||||
raise serializers.ValidationError({'target_users': 'Bilješku je moguće poslati samo serviserima.'})
|
||||
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':
|
||||
@@ -228,6 +230,33 @@ class ServiceContextNoteCreateSerializer(serializers.Serializer):
|
||||
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):
|
||||
crane = serializers.SerializerMethodField()
|
||||
crane_label = serializers.SerializerMethodField()
|
||||
@@ -349,12 +378,14 @@ class WorkOrderSerializer(serializers.ModelSerializer):
|
||||
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
||||
image_url = serializers.SerializerMethodField()
|
||||
image_content_type = serializers.SerializerMethodField()
|
||||
work_order_display_code = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = WorkOrderInvoice
|
||||
fields = [
|
||||
'id',
|
||||
'work_order',
|
||||
'work_order_display_code',
|
||||
'naziv_racuna',
|
||||
'lokacija',
|
||||
'datum',
|
||||
@@ -373,9 +404,13 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
||||
return None
|
||||
if request is None:
|
||||
return None
|
||||
return request.build_absolute_uri(
|
||||
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:
|
||||
@@ -393,6 +428,15 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
||||
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
|
||||
@@ -458,6 +502,105 @@ class WorkOrderAdditionalCostsTableSerializer(serializers.ModelSerializer):
|
||||
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
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
# backend/modules/fleet/tasks.py
|
||||
from celery import shared_task
|
||||
from core.celery import ResourceAwareTask
|
||||
from django.core.mail import send_mail
|
||||
from django.core.mail import EmailMessage
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.core.files import File
|
||||
from django.core.files.base import ContentFile
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
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 django.db import transaction
|
||||
from pypdf import PdfReader
|
||||
@@ -19,8 +29,9 @@ from pytesseract import TesseractNotFoundError
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfgen import canvas
|
||||
from .models import VehicleNotification, GeneratedWorkOrderPdf
|
||||
from .models import VehicleNotification, GeneratedWorkOrderPdf, GeneratedFleetArchive
|
||||
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
|
||||
from .email_utils import append_user_signature
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
register_unicode_fonts()
|
||||
@@ -93,6 +104,451 @@ def send_email_task(recipient_email, subject, message, notif_id):
|
||||
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):
|
||||
buffer = BytesIO()
|
||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||
@@ -191,10 +647,19 @@ def _build_work_order_invoices_pdf(work_order):
|
||||
y = content_top
|
||||
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)
|
||||
draw_width = max(1, int(image.width * ratio))
|
||||
draw_height = max(1, int(image.height * ratio))
|
||||
image_reader = ImageReader(image)
|
||||
image_reader = ImageReader(buf)
|
||||
pdf.drawImage(
|
||||
image_reader,
|
||||
margin,
|
||||
@@ -372,6 +837,107 @@ def process_work_order_invoice_ocr(invoice_id):
|
||||
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()
|
||||
|
||||
@@ -41,6 +41,13 @@ class ServiceContextNotesApiTests(TestCase):
|
||||
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',
|
||||
@@ -132,3 +139,31 @@ class ServiceContextNotesApiTests(TestCase):
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -6,10 +7,31 @@ from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from datetime import timedelta
|
||||
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
|
||||
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'):
|
||||
@@ -48,16 +70,32 @@ class WorkOrderImagesEndpointTests(TestCase):
|
||||
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',
|
||||
@@ -203,6 +241,58 @@ class WorkOrderImagesEndpointTests(TestCase):
|
||||
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'),
|
||||
@@ -240,3 +330,661 @@ class WorkOrderImagesEndpointTests(TestCase):
|
||||
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()
|
||||
@@ -3,6 +3,10 @@ from django.urls import path
|
||||
from .views import (
|
||||
CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet,
|
||||
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()
|
||||
@@ -15,9 +19,17 @@ router.register(r'service-records', VehicleServiceRecordViewSet, basename='servi
|
||||
router.register(r'notifications', VehicleNotificationViewSet, basename='vehicle-notification')
|
||||
router.register(r'service-photos', VehicleServicePhotoViewSet, basename='service-photo')
|
||||
router.register(r'service-attachments', VehicleServiceAttachmentViewSet, basename='service-attachment')
|
||||
router.register(r'monthly-servicer-day-entries', MonthlyServicerDayEntryViewSet, basename='monthly-servicer-day-entry')
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
||||
urlpatterns += [
|
||||
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
@@ -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.',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -54,6 +54,12 @@ class Task(BaseModel):
|
||||
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:
|
||||
ordering = ['-created_at']
|
||||
|
||||
@@ -91,6 +91,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
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)
|
||||
|
||||
@@ -100,9 +101,10 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
'id', 'title', 'description', 'status',
|
||||
'assigned_to', 'assigned_to_name',
|
||||
'vehicle', 'vehicle_registration', 'vehicle_make', 'vehicle_model',
|
||||
'vehicle_owner_name', 'vehicle_asset_type', 'crane_serial_number',
|
||||
'vehicle_owner_name', 'vehicle_asset_type', 'crane_serial_number', 'work_hours_table',
|
||||
'work_order', 'work_order_label',
|
||||
'scheduled_date',
|
||||
'service_report_note',
|
||||
'template_id', 'auto_close_work_order',
|
||||
'created_at',
|
||||
]
|
||||
@@ -114,6 +116,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
'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):
|
||||
@@ -146,13 +149,12 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
'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:
|
||||
vehicle = work_order.vehicle
|
||||
attrs['vehicle'] = vehicle
|
||||
if work_order and vehicle and work_order.vehicle_id != vehicle.id:
|
||||
raise serializers.ValidationError({
|
||||
'work_order': "Putni nalog mora pripadati istoj dizalici kao task."
|
||||
})
|
||||
|
||||
if status_value in {'neaktivan', 'zavrsen'} and not work_order:
|
||||
raise serializers.ValidationError({
|
||||
@@ -211,3 +213,9 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.test import TestCase
|
||||
from modules.task_management.serializers import TaskSerializer
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from django.contrib.auth import get_user_model
|
||||
from modules.task_management.models import Task
|
||||
from modules.task_management.models import Task, TaskWorkHoursTable
|
||||
from modules.fleet.models import Vehicle, WorkOrder
|
||||
from modules.crm.models import Client
|
||||
|
||||
@@ -86,6 +86,22 @@ class TaskSerializerTests(TestCase):
|
||||
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",
|
||||
@@ -100,3 +116,57 @@ class TaskSerializerTests(TestCase):
|
||||
)
|
||||
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
|
||||
@@ -30,6 +30,9 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
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
|
||||
|
||||
def perform_create(self, serializer):
|
||||
|
||||
@@ -50,6 +50,7 @@ pytesseract==0.3.13
|
||||
pytest>=9.1.1
|
||||
pytest-django>=4.12.0
|
||||
python-dateutil==2.9.0.post0
|
||||
python-docx==1.2.0
|
||||
python-dotenv==1.2.2
|
||||
pusher>=3.3.4
|
||||
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
|
||||
@@ -22,7 +22,8 @@ services:
|
||||
build:
|
||||
context: ./frontend
|
||||
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:
|
||||
- "4321:4321"
|
||||
volumes:
|
||||
@@ -30,6 +31,9 @@ services:
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
- CHOKIDAR_INTERVAL=1000
|
||||
- WATCHPACK_POLLING=true
|
||||
- PUBLIC_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
- PUBLIC_HMR_HOST=localhost
|
||||
- PUBLIC_HMR_PROTOCOL=ws
|
||||
|
||||
@@ -25,6 +25,8 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
media-permissions:
|
||||
condition: service_completed_successfully
|
||||
|
||||
worker:
|
||||
build:
|
||||
@@ -37,7 +39,7 @@ services:
|
||||
environment:
|
||||
DEBUG: "False"
|
||||
DJANGO_SETTINGS_MODULE: core.settings.production
|
||||
command: celery -A core worker --loglevel=info
|
||||
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:
|
||||
@@ -45,6 +47,8 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
media-permissions:
|
||||
condition: service_completed_successfully
|
||||
|
||||
beat:
|
||||
build:
|
||||
@@ -65,6 +69,8 @@ services:
|
||||
condition: service_started
|
||||
worker:
|
||||
condition: service_started
|
||||
media-permissions:
|
||||
condition: service_completed_successfully
|
||||
|
||||
flower:
|
||||
build:
|
||||
|
||||
@@ -21,6 +21,17 @@ services:
|
||||
# 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
|
||||
|
||||
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:
|
||||
container_name: 004erpworker
|
||||
build:
|
||||
@@ -36,6 +47,7 @@ services:
|
||||
depends_on:
|
||||
db: { condition: service_healthy }
|
||||
redis: { condition: service_started }
|
||||
media-permissions: { condition: service_completed_successfully }
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
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
|
||||
```
|
||||
@@ -68,7 +68,12 @@ export default defineConfig({
|
||||
},
|
||||
watch: {
|
||||
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 WRITE_QUEUE_DB_NAME = 'erp-write-queue-db';
|
||||
const WRITE_QUEUE_STORE = 'requests';
|
||||
const WRITE_QUEUE_SYNC_TAG = 'erp-write-queue-sync';
|
||||
const OFFLINE_URL = '/offline.html';
|
||||
const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL];
|
||||
const PRECACHE_URLS = ['/', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL];
|
||||
|
||||
const API_CACHE_BLOCKLIST = [
|
||||
'/api/token/',
|
||||
@@ -142,10 +142,26 @@ async function replayQueuedRequests() {
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
async function precacheShell() {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import AuthWidget from './AuthWidget';
|
||||
import UserDisplay from './ui/UserDisplay';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useSpring, animated } from '@react-spring/web';
|
||||
import { hydrateAuthFromStorage } from '../stores/authStore';
|
||||
import { hydrateAuthFromStorage, loadCurrentUser, $user, $accessToken } from '../stores/authStore';
|
||||
|
||||
const ITEMS = [
|
||||
{ id: 'dashboard', label: 'Dashboard', href: '/' },
|
||||
@@ -21,8 +21,12 @@ function normalizePath(p) {
|
||||
}
|
||||
|
||||
function getActiveIndex(path) {
|
||||
const idx = ITEMS.findIndex((item) => item.href === path);
|
||||
return idx >= 0 ? idx : 0;
|
||||
// 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 }) {
|
||||
@@ -55,6 +59,10 @@ export default function Navbar({ minimal = false }) {
|
||||
// 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();
|
||||
@@ -92,7 +100,7 @@ export default function Navbar({ minimal = false }) {
|
||||
const currentItem = ITEMS[activeIndex];
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-30 mb-4 rounded-lg border border-border-hairline bg-canvas-elevated/95 px-4 py-3 backdrop-blur">
|
||||
<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">
|
||||
{/* Logo + korisnik */}
|
||||
<a href="/" className="flex flex-col hover:opacity-80">
|
||||
@@ -135,10 +143,11 @@ export default function Navbar({ minimal = false }) {
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mobilni dropdown */}
|
||||
<details className="relative sm:hidden">
|
||||
<summary className="list-none cursor-pointer rounded-lg border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-deep">
|
||||
{currentItem?.label || 'Izbornik'} ▾
|
||||
<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'}
|
||||
</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">
|
||||
Navigacija
|
||||
</div>
|
||||
@@ -149,10 +158,11 @@ export default function Navbar({ minimal = false }) {
|
||||
href={item.href}
|
||||
className={
|
||||
pathname === item.href
|
||||
? 'block bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700'
|
||||
: 'block px-3 py-2 text-sm text-text-main hover:bg-canvas-deep'
|
||||
? 'flex items-center gap-2 bg-indigo-50 px-3 py-2.5 text-sm font-medium text-indigo-700'
|
||||
: '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}
|
||||
</a>
|
||||
</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';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'work-orders', label: 'Putni nalozi' },
|
||||
{ key: 'service-records', label: 'Servisni zapisi' },
|
||||
{ key: 'cranes', label: 'Dizalice' },
|
||||
{ key: 'clients', label: 'Klijenti' },
|
||||
];
|
||||
|
||||
export default function DashboardTopbar({
|
||||
activeSection,
|
||||
onSectionChange,
|
||||
onOpenWorkOrderModal,
|
||||
onOpenTaskModal,
|
||||
pendingSyncCount,
|
||||
onNewServiceRecord,
|
||||
showSectionNav = true,
|
||||
}) {
|
||||
const sectionTitle = NAV_ITEMS.find((item) => item.key === activeSection)?.label || 'Dashboard';
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b border-border-hairline bg-canvas-elevated/95 backdrop-blur">
|
||||
{/* ── Gornji red ───────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
|
||||
<div>
|
||||
<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 className="flex items-center gap-3">
|
||||
{pendingSyncCount > 0 && (
|
||||
@@ -53,31 +39,6 @@ export default function DashboardTopbar({
|
||||
|
||||
{/* ── Selector bar: Kupac + Dizalica ───────────────────── */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { animated, useSpring, useTransition } from '@react-spring/web';
|
||||
import DashboardTopbar from './DashboardTopbar';
|
||||
@@ -136,17 +136,7 @@ function SortableCodeLabel({ title, direction, onToggle }) {
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_MODE_TO_SECTION = {
|
||||
dashboard: 'dashboard',
|
||||
'work-orders': 'work-orders',
|
||||
'service-records': 'service-records',
|
||||
vehicles: 'cranes',
|
||||
clients: 'clients',
|
||||
};
|
||||
|
||||
export default function FleetDashboardShell({ initialSection = 'dashboard', pageMode = 'dashboard' }) {
|
||||
const resolvedInitialSection = initialSection || PAGE_MODE_TO_SECTION[pageMode] || 'dashboard';
|
||||
const [activeSection, setActiveSection] = useState(resolvedInitialSection);
|
||||
export default function FleetDashboardShell({ pageMode = 'dashboard' }) {
|
||||
const [isWorkOrderModalOpen, setIsWorkOrderModalOpen] = useState(false);
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [selectedWorkOrder, setSelectedWorkOrder] = useState(null);
|
||||
@@ -175,6 +165,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
const selectedClientId = useStore($selectedClientId);
|
||||
const selectedVehicleId = useStore($selectedVehicleId);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [serviceRecordsPage, setServiceRecordsPage] = useState(1);
|
||||
const [taskFilter, setTaskFilter] = useState('active');
|
||||
const [taskScope, setTaskScope] = useState('all');
|
||||
const [supervisorServicers, setSupervisorServicers] = useState([]);
|
||||
@@ -185,6 +176,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
const [selectedCraneRowId, setSelectedCraneRowId] = useState(null);
|
||||
const [expandedServiceTaskId, setExpandedServiceTaskId] = useState(null);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
const [taskCraneFilter, setTaskCraneFilter] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
@@ -270,26 +262,9 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
};
|
||||
}, [authReady]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleNavbarNavigate(event) {
|
||||
const section = event?.detail;
|
||||
if (typeof section === 'string' && section.length > 0) {
|
||||
setActiveSection(section);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('navbar:navigate', handleNavbarNavigate);
|
||||
return () => window.removeEventListener('navbar:navigate', handleNavbarNavigate);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleNotificationOpenEntity(event) {
|
||||
const detail = event?.detail || {};
|
||||
const section = detail.section;
|
||||
if (typeof section === 'string' && section.length > 0) {
|
||||
setActiveSection(section);
|
||||
}
|
||||
|
||||
if (detail.entityType === 'work_order' && detail.entityId) {
|
||||
if (detail.vehicleId) {
|
||||
const targetCrane = cranes.find((item) => String(item.id) === String(detail.craneId || detail.vehicleId));
|
||||
@@ -352,6 +327,51 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
if (serial) return `SN ${serial}`;
|
||||
return hydratedContextCrane.registration_number || '-';
|
||||
}, [hydratedContextCrane]);
|
||||
// KPI: context-aware counts (null kad kontekst nije postavljen)
|
||||
const contextStats = useMemo(() => {
|
||||
if (!hydratedSelectedVehicleId) return null;
|
||||
const ctxWoIds = new Set(
|
||||
tasks
|
||||
.filter((t) => String(t.vehicle || '') === String(hydratedSelectedVehicleId) && t.work_order)
|
||||
.map((t) => String(t.work_order))
|
||||
);
|
||||
const ctxWorkOrders = workOrders.filter(
|
||||
(o) => String(o.vehicle) === String(hydratedSelectedVehicleId) || ctxWoIds.has(String(o.id))
|
||||
);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return {
|
||||
openWorkOrders: ctxWorkOrders.filter((o) => o.status !== 'closed').length,
|
||||
servicesToday: tasks.filter(
|
||||
(t) => String(t.vehicle || '') === String(hydratedSelectedVehicleId)
|
||||
&& t.scheduled_date === today
|
||||
&& (t.status === 'aktivan' || t.status === 'servis')
|
||||
).length,
|
||||
};
|
||||
}, [tasks, workOrders, hydratedSelectedVehicleId]);
|
||||
// KPI: moji zadaci planirani danas
|
||||
const myTasksToday = useMemo(() => {
|
||||
if (!user?.id || !hasMounted) return 0;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return tasks.filter(
|
||||
(t) => String(t.assigned_to || '') === String(user.id)
|
||||
&& t.scheduled_date === today
|
||||
&& isTaskActive(t)
|
||||
).length;
|
||||
}, [tasks, user?.id, hasMounted]);
|
||||
// Zadnji servisni zapisi za fallback prikaz (kad kontekst nije postavljen)
|
||||
const recentServiceRecords = useMemo(() => (
|
||||
[...serviceRecords]
|
||||
.sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || '')))
|
||||
), [serviceRecords]);
|
||||
const serviceRecordsPageSize = 10;
|
||||
const serviceRecordsTotalPages = Math.max(1, Math.ceil(recentServiceRecords.length / serviceRecordsPageSize));
|
||||
const paginatedRecentServiceRecords = useMemo(
|
||||
() => recentServiceRecords.slice(
|
||||
(serviceRecordsPage - 1) * serviceRecordsPageSize,
|
||||
serviceRecordsPage * serviceRecordsPageSize
|
||||
),
|
||||
[recentServiceRecords, serviceRecordsPage]
|
||||
);
|
||||
const serviceRecordsForContext = useMemo(() => {
|
||||
if (!selectedVehicleId) return [];
|
||||
return [...serviceRecords]
|
||||
@@ -423,19 +443,44 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
if (!hydratedSelectedVehicleId) {
|
||||
return [];
|
||||
}
|
||||
return codeSorted.filter((order) => String(order.vehicle) === String(hydratedSelectedVehicleId));
|
||||
}, [workOrders, workOrderScope, hydratedSelectedVehicleId, user?.id, user?.is_serviser, user?.is_team_member, workOrderCodeSortDirection]);
|
||||
// Collect work order IDs that are directly assigned to tasks of the context crane.
|
||||
// This covers cross-crane shared work orders: a WO created for crane A can be
|
||||
// assigned to a task of crane B — it should still appear in crane B's context view.
|
||||
const contextTaskWorkOrderIds = new Set(
|
||||
tasks
|
||||
.filter((t) => String(t.vehicle || '') === String(hydratedSelectedVehicleId) && t.work_order)
|
||||
.map((t) => String(t.work_order))
|
||||
);
|
||||
return codeSorted.filter(
|
||||
(order) => String(order.vehicle) === String(hydratedSelectedVehicleId)
|
||||
|| contextTaskWorkOrderIds.has(String(order.id))
|
||||
);
|
||||
}, [workOrders, tasks, workOrderScope, hydratedSelectedVehicleId, user?.id, user?.is_serviser, user?.is_team_member, workOrderCodeSortDirection]);
|
||||
const pageSize = 10;
|
||||
const totalPages = Math.max(1, Math.ceil(scopedWorkOrders.length / pageSize));
|
||||
const latestWorkOrders = useMemo(
|
||||
() => scopedWorkOrders.slice((currentPage - 1) * pageSize, currentPage * pageSize),
|
||||
[scopedWorkOrders, currentPage]
|
||||
);
|
||||
|
||||
// Map: workOrderId → array of unique crane objects (own + cross-crane task links)
|
||||
const workOrderSharedCranes = useMemo(() => {
|
||||
const craneById = new Map(cranes.map((c) => [String(c.id), c]));
|
||||
const map = new Map();
|
||||
tasks.forEach((task) => {
|
||||
if (!task.work_order || !task.vehicle) return;
|
||||
const woId = String(task.work_order);
|
||||
if (!map.has(woId)) map.set(woId, new Map());
|
||||
const craneObj = craneById.get(String(task.vehicle));
|
||||
if (craneObj) map.get(woId).set(String(task.vehicle), craneObj);
|
||||
});
|
||||
return map;
|
||||
}, [tasks, cranes]);
|
||||
const serviceRecordWorkOrders = useMemo(() => {
|
||||
const craneId = serviceRecordCrane?.id || contextCrane?.id;
|
||||
if (!craneId) return [];
|
||||
return workOrders
|
||||
.filter((order) => String(order.vehicle) === String(craneId))
|
||||
// Show ALL open work orders so a task can be linked to any work order regardless of crane.
|
||||
// Cross-crane sharing is intentional: a servicer may use one travel order for multiple cranes.
|
||||
return [...workOrders]
|
||||
.filter((order) => String(order.status || '').toLowerCase() !== 'closed')
|
||||
.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
||||
}, [workOrders, serviceRecordCrane, contextCrane]);
|
||||
const serviceRecordTasks = useMemo(() => {
|
||||
@@ -525,14 +570,23 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
return filteredByServicer;
|
||||
})();
|
||||
|
||||
return [...filteredByStatus].sort((left, right) => (
|
||||
const filteredByCrane = (() => {
|
||||
if (taskCraneFilter !== 'context' || !hydratedSelectedVehicleId) {
|
||||
return filteredByStatus;
|
||||
}
|
||||
return filteredByStatus.filter(
|
||||
(task) => String(task.vehicle || '') === String(hydratedSelectedVehicleId)
|
||||
);
|
||||
})();
|
||||
|
||||
return [...filteredByCrane].sort((left, right) => (
|
||||
compareWorkOrderCodesByDate(
|
||||
left?.work_order_label || '',
|
||||
right?.work_order_label || '',
|
||||
taskWorkOrderSortDirection
|
||||
)
|
||||
));
|
||||
}, [tasks, taskFilter, canViewAllTasks, canToggleTaskScope, taskScope, user?.id, isSupervisorNotServiser, selectedServicerId, taskWorkOrderSortDirection]);
|
||||
}, [tasks, taskFilter, canViewAllTasks, canToggleTaskScope, taskScope, user?.id, isSupervisorNotServiser, selectedServicerId, taskWorkOrderSortDirection, taskCraneFilter, hydratedSelectedVehicleId]);
|
||||
const selectedTaskServiceRecords = useMemo(() => {
|
||||
if (!selectedTask?.id) return [];
|
||||
return [...serviceRecords]
|
||||
@@ -541,10 +595,18 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
}, [serviceRecords, selectedTask?.id]);
|
||||
const selectedTaskWorkOrders = useMemo(() => {
|
||||
if (!selectedTask) return [];
|
||||
const byVehicle = selectedTask.vehicle
|
||||
? workOrders.filter((order) => String(order.vehicle) === String(selectedTask.vehicle))
|
||||
: workOrders;
|
||||
return [...byVehicle].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
||||
// Keep dropdown focused on open work orders, but preserve the task's already assigned
|
||||
// work order even when it is closed so the reference remains visible/editable in task details.
|
||||
// Cross-crane sharing allowed: no vehicle filter.
|
||||
const selectedWorkOrderId = String(selectedTask.work_order || '');
|
||||
return [...workOrders]
|
||||
.filter((order) => {
|
||||
const orderId = String(order.id || '');
|
||||
const isClosed = String(order.status || '').toLowerCase() === 'closed';
|
||||
if (!isClosed) return true;
|
||||
return selectedWorkOrderId && orderId === selectedWorkOrderId;
|
||||
})
|
||||
.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
||||
}, [selectedTask, workOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -553,10 +615,20 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
}
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serviceRecordsPage > serviceRecordsTotalPages) {
|
||||
setServiceRecordsPage(serviceRecordsTotalPages);
|
||||
}
|
||||
}, [serviceRecordsPage, serviceRecordsTotalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [workOrderScope, hydratedSelectedVehicleId]);
|
||||
|
||||
useEffect(() => {
|
||||
setServiceRecordsPage(1);
|
||||
}, [hydratedSelectedVehicleId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandedServiceTaskId) return;
|
||||
const exists = serviceTaskGroups.some((group) => String(group.id) === String(expandedServiceTaskId));
|
||||
@@ -588,7 +660,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
await updateWorkOrder(workOrder.id, changes);
|
||||
showToast('Nalog je uspješno ažuriran.', 'success');
|
||||
await fetchFleetDashboardData({ silent: true });
|
||||
await fetchTasks();
|
||||
await fetchTasks({ force: true });
|
||||
} catch (error) {
|
||||
showToast(error?.message || 'Ažuriranje naloga nije uspjelo.', 'error');
|
||||
}
|
||||
@@ -701,9 +773,6 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
<AnimatedPage className="min-h-screen">
|
||||
<main className="min-w-0">
|
||||
<DashboardTopbar
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
showSectionNav={false}
|
||||
onOpenWorkOrderModal={async () => {
|
||||
try {
|
||||
const availableCranes = await ensureVehiclesCatalog();
|
||||
@@ -736,30 +805,92 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
)}
|
||||
|
||||
{isDashboardPage && (
|
||||
<div id="section-dashboard" className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
||||
<p className="text-sm text-text-muted">Otvoreni nalozi</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-text-main">{stats.openWorkOrders}</p>
|
||||
<div id="section-dashboard" className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
|
||||
{/* Otvoreni nalozi */}
|
||||
<a
|
||||
href="/putni-nalozi"
|
||||
className="group flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm transition-all hover:border-indigo-300 hover:bg-indigo-50/60 hover:shadow-md"
|
||||
>
|
||||
<p className="text-xs text-text-muted group-hover:text-indigo-700">Otvoreni nalozi</p>
|
||||
<p className="text-xl font-semibold text-text-main">{stats.openWorkOrders}</p>
|
||||
{hydratedSelectedVehicleId && contextStats !== null && (
|
||||
<p className="text-[10px] text-indigo-600">{contextStats.openWorkOrders} u kontekstu</p>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* Servisi danas */}
|
||||
<a
|
||||
href="/servisni-zapisi"
|
||||
className="group flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm transition-all hover:border-indigo-300 hover:bg-indigo-50/60 hover:shadow-md"
|
||||
>
|
||||
<p className="text-xs text-text-muted group-hover:text-indigo-700">Servisi danas</p>
|
||||
<p className="text-xl font-semibold text-text-main">{stats.servicesToday}</p>
|
||||
{hydratedSelectedVehicleId && contextStats !== null && (
|
||||
<p className="text-[10px] text-indigo-600">{contextStats.servicesToday} u kontekstu</p>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* Bez putnog naloga */}
|
||||
<article className="flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm">
|
||||
<p className="text-xs text-text-muted">Bez put. naloga</p>
|
||||
<p className={`text-xl font-semibold ${stats.tasksWithoutWorkOrder > 0 ? 'text-amber-600' : 'text-text-main'}`}>
|
||||
{stats.tasksWithoutWorkOrder}
|
||||
</p>
|
||||
<p className="text-[10px] text-text-muted">aktivnih zadataka</p>
|
||||
</article>
|
||||
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
||||
<p className="text-sm text-text-muted">Servisi danas</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-text-main">{stats.servicesToday}</p>
|
||||
|
||||
{/* Dovršeni nalozi */}
|
||||
<article className="flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm">
|
||||
<p className="text-xs text-text-muted">Dovršeni nalozi</p>
|
||||
<p className="text-xl font-semibold text-emerald-600">{stats.closedWorkOrders}</p>
|
||||
{stats.totalWorkOrders > 0 && (
|
||||
<p className="text-[10px] text-text-muted">
|
||||
{Math.round((stats.closedWorkOrders / stats.totalWorkOrders) * 100)}% / {stats.totalWorkOrders} ukupno
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
||||
<p className="text-sm text-text-muted">Upozorenja</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-amber-600">{stats.warnings}</p>
|
||||
</article>
|
||||
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
||||
<p className="text-sm text-text-muted">Dovršeni nalozi</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-emerald-600">{stats.closedWorkOrders}</p>
|
||||
|
||||
{/* Moji zadaci danas */}
|
||||
<article className="flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm">
|
||||
<p className="text-xs text-text-muted">Moji danas</p>
|
||||
<p className={`text-xl font-semibold ${myTasksToday > 0 ? 'text-indigo-600' : 'text-text-main'}`}>
|
||||
{hasMounted ? myTasksToday : '—'}
|
||||
</p>
|
||||
<p className="text-[10px] text-text-muted">planirani na mene</p>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
{isDashboardPage && (
|
||||
<article className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border-hairline px-4 py-3">
|
||||
<h3 className="font-semibold text-text-main">Radni zadaci</h3>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{/* Crane context filter */}
|
||||
{hasMounted && hydratedSelectedVehicleId && (
|
||||
<>
|
||||
<span className="text-text-muted">|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTaskCraneFilter('all')}
|
||||
className={taskCraneFilter === 'all'
|
||||
? 'rounded-md bg-emerald-50 px-2.5 py-1 font-semibold text-emerald-700'
|
||||
: 'rounded-md px-2.5 py-1 text-text-main hover:bg-canvas-deep'}
|
||||
>
|
||||
Sve dizalice
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTaskCraneFilter('context')}
|
||||
className={taskCraneFilter === 'context'
|
||||
? 'rounded-md bg-emerald-50 px-2.5 py-1 font-semibold text-emerald-700'
|
||||
: 'rounded-md px-2.5 py-1 text-text-main hover:bg-canvas-deep'}
|
||||
title={contextCraneLabel ?? undefined}
|
||||
>
|
||||
Odabrani kontekst
|
||||
</button>
|
||||
<span className="text-text-muted">|</span>
|
||||
</>
|
||||
)}
|
||||
{hasMounted && isSupervisorNotServiser && (
|
||||
<label className="flex items-center gap-2 text-text-main">
|
||||
<span>Serviser</span>
|
||||
@@ -884,9 +1015,15 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
</article>
|
||||
)}
|
||||
{isDashboardPage && (
|
||||
<div>
|
||||
<details className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
|
||||
<summary className="flex cursor-pointer select-none items-center justify-between px-4 py-3 text-sm font-medium text-text-muted hover:bg-canvas-deep">
|
||||
<span>Sync log</span>
|
||||
<span className="text-xs opacity-60">▾</span>
|
||||
</summary>
|
||||
<div className="border-t border-border-hairline">
|
||||
<SyncLogPanel />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{showWorkOrders && (
|
||||
@@ -947,7 +1084,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
workOrderScope === 'context'
|
||||
? (!hydratedSelectedVehicleId
|
||||
? 'Odaberite dizalicu za prikaz putnih naloga iz servisnog konteksta.'
|
||||
: 'Nema dostupnih putnih naloga za odabrani servisni kontekst.')
|
||||
: 'Nema dostupnih putnih naloga za odabrani servisni kontekst. Možete prebaciti na prikaz svih naloga.')
|
||||
: 'Nema dostupnih putnih naloga.'
|
||||
}
|
||||
renderRow={(workOrder) => (
|
||||
@@ -961,10 +1098,51 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
<td
|
||||
className="px-4 py-3"
|
||||
>
|
||||
{[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ') || workOrder.craneLabel || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`}
|
||||
{(() => {
|
||||
const sharedCranes = workOrderSharedCranes.get(String(workOrder.id));
|
||||
if (sharedCranes && sharedCranes.size > 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{[...sharedCranes.values()].map((c) => {
|
||||
const craneName = [c.make, c.model].filter(Boolean).join(' ') || c.registration_number || `#${c.id}`;
|
||||
return (
|
||||
<span key={c.id}>
|
||||
{craneName}
|
||||
{c.client_name && (
|
||||
<span className="text-text-muted text-xs ml-1">({c.client_name})</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
{[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ')
|
||||
|| workOrder.craneLabel
|
||||
|| workOrder.craneInfo?.registration_number
|
||||
|| `#${workOrder.crane || workOrder.vehicle}`}
|
||||
{workOrder.craneInfo?.client_name && (
|
||||
<span className="text-text-muted text-xs ml-1">({workOrder.craneInfo.client_name})</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-text-muted">
|
||||
{workOrder.craneInfo?.crane_serial_number || '-'}
|
||||
{(() => {
|
||||
const sharedCranes = workOrderSharedCranes.get(String(workOrder.id));
|
||||
if (sharedCranes && sharedCranes.size > 0) {
|
||||
const sns = [...sharedCranes.values()].map((c) => c.crane_serial_number || '-');
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{sns.map((sn, i) => <span key={i}>{sn}</span>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return workOrder.craneInfo?.crane_serial_number || '-';
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{formatDate(workOrder.date)}
|
||||
@@ -988,7 +1166,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
className="rounded px-2 py-1 text-xs font-medium text-text-muted hover:bg-canvas-deep hover:text-text-main"
|
||||
title="Upload fotografija putnog naloga"
|
||||
>
|
||||
📷
|
||||
Foto
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
@@ -1057,9 +1235,80 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
</div>
|
||||
</div>
|
||||
{!hydratedSelectedVehicleId && (
|
||||
<p className="px-4 py-6 text-center text-sm text-text-muted">
|
||||
Odaberite dizalicu za prikaz servisnih zapisa.
|
||||
<div>
|
||||
<p className="border-b border-border-hairline px-4 py-3 text-xs text-text-muted">
|
||||
Odaberite dizalicu u servisnom kontekstu za prikaz zapisa po radnim zadacima.
|
||||
Ispod su prikazani svi servisni zapisi.
|
||||
</p>
|
||||
{recentServiceRecords.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-text-muted">Nema servisnih zapisa.</p>
|
||||
) : (
|
||||
<>
|
||||
<AnimatedDataTable
|
||||
columns={[
|
||||
{ key: 'date', label: 'Datum', className: 'px-4 py-3' },
|
||||
{ key: 'crane', label: 'Dizalica', className: 'px-4 py-3' },
|
||||
{ key: 'description', label: 'Opis', className: 'px-4 py-3' },
|
||||
{ key: 'cost', label: 'Trošak', className: 'px-4 py-3' },
|
||||
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
|
||||
]}
|
||||
rows={paginatedRecentServiceRecords}
|
||||
rowKey={(r) => r.id}
|
||||
rowClassName="hover:bg-canvas-deep"
|
||||
renderRow={(record) => (
|
||||
<>
|
||||
<td className="px-4 py-3">{formatDate(record.service_date)}</td>
|
||||
<td className="px-4 py-3">{formatServiceRecordCraneLabel(record, cranes)}</td>
|
||||
<td className="max-w-xs truncate px-4 py-3" title={record.description || ''}>
|
||||
{record.description || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">{formatCost(record.cost)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setServiceRecordBackTask(null);
|
||||
setSelectedServiceRecord(record);
|
||||
}}
|
||||
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
||||
>
|
||||
Detalji
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-between border-t border-border-hairline px-4 py-3 text-sm">
|
||||
<span className="text-text-muted">
|
||||
Prikaz {recentServiceRecords.length ? (serviceRecordsPage - 1) * serviceRecordsPageSize + 1 : 0}
|
||||
-
|
||||
{recentServiceRecords.length ? Math.min(serviceRecordsPage * serviceRecordsPageSize, recentServiceRecords.length) : 0} od {recentServiceRecords.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setServiceRecordsPage((page) => Math.max(1, page - 1))}
|
||||
disabled={serviceRecordsPage <= 1}
|
||||
className="rounded border border-border-hairline px-2 py-1 text-text-main hover:bg-canvas-deep disabled:opacity-50"
|
||||
>
|
||||
Prethodna
|
||||
</button>
|
||||
<span className="text-text-muted">
|
||||
{serviceRecordsPage}/{serviceRecordsTotalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setServiceRecordsPage((page) => Math.min(serviceRecordsTotalPages, page + 1))}
|
||||
disabled={serviceRecordsPage >= serviceRecordsTotalPages}
|
||||
className="rounded border border-border-hairline px-2 py-1 text-text-main hover:bg-canvas-deep disabled:opacity-50"
|
||||
>
|
||||
Sljedeća
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hydratedSelectedVehicleId && loading && (
|
||||
<p className="px-4 py-6 text-center text-sm text-text-muted">
|
||||
@@ -1077,12 +1326,13 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
const isOpen = String(expandedServiceTaskId || '') === String(group.id);
|
||||
return (
|
||||
<animated.div key={group.id} style={style}>
|
||||
<div className="flex items-center gap-3 px-4 py-3 hover:bg-canvas-deep">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedServiceTaskId(isOpen ? null : group.id)}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-canvas-deep"
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-3 text-left"
|
||||
>
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-text-main">{group.title}</span>
|
||||
<span className="ml-2 text-xs text-text-muted">{group.status}</span>
|
||||
</div>
|
||||
@@ -1090,9 +1340,19 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
<span className="rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700">
|
||||
{group.records.length} servisnih zapisa
|
||||
</span>
|
||||
<span className="text-text-muted">{isOpen ? '▲' : '▼'}</span>
|
||||
<span className="text-text-muted">{isOpen ? "^" : "v"}</span>
|
||||
</div>
|
||||
</button>
|
||||
{group.task && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedTask(group.task)}
|
||||
className="shrink-0 rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
||||
>
|
||||
Detalji taska
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="border-t border-border-hairline bg-canvas-base/50 px-4 py-3">
|
||||
{group.records.length === 0 ? (
|
||||
@@ -1140,7 +1400,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
onClick={() => setPhotoUpload({ open: true, serviceRecordId: record.id })}
|
||||
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
||||
>
|
||||
📎 Dodaj datoteke
|
||||
Dodaj datoteke
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
@@ -1159,7 +1419,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
|
||||
{showClients && (
|
||||
<>
|
||||
{/* ── Tablica klijenata i njihovih dizalica ───────── */}
|
||||
{/* Tablica klijenata i njihovih dizalica */}
|
||||
<div id="section-clients">
|
||||
<ClientsSection
|
||||
onSetServiceContext={handleSetServiceContextForCraneId}
|
||||
|
||||
@@ -1,34 +1,188 @@
|
||||
import { useMemo, useState } from 'preact/hooks';
|
||||
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', 'Če', 'Pe', 'Su', 'Ne'];
|
||||
const WEEKDAY_LABELS = ['Po', 'Ut', 'Sr', 'Ce', 'Pe', 'Su', 'Ne'];
|
||||
const MONTH_NAMES = [
|
||||
'Siječanj', 'Veljača', 'Ožujak', 'Travanj', 'Svibanj', 'Lipanj',
|
||||
'Sijecanj', 'Veljaca', 'Ozujak', 'Travanj', 'Svibanj', 'Lipanj',
|
||||
'Srpanj', 'Kolovoz', 'Rujan', 'Listopad', 'Studeni', 'Prosinac',
|
||||
];
|
||||
function toIsoDateKey(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
const d = new Date(dateStr);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
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}`;
|
||||
}
|
||||
|
||||
export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick }) {
|
||||
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;
|
||||
|
||||
// Map: "YYYY-MM-DD" -> task[]
|
||||
const tasksByDate = useMemo(() => {
|
||||
const map = {};
|
||||
for (const task of tasks) {
|
||||
const key = toIsoDateKey(task.scheduled_date);
|
||||
const key = toDateKey(task.scheduled_date);
|
||||
if (!key) continue;
|
||||
if (!map[key]) map[key] = [];
|
||||
map[key].push(task);
|
||||
@@ -39,7 +193,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick
|
||||
const notesByDate = useMemo(() => {
|
||||
const map = {};
|
||||
for (const note of notes) {
|
||||
const key = toIsoDateKey(note.note_date);
|
||||
const key = toDateKey(note.note_date);
|
||||
if (!key) continue;
|
||||
if (!map[key]) map[key] = [];
|
||||
map[key].push(note);
|
||||
@@ -47,34 +201,217 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick
|
||||
return map;
|
||||
}, [notes]);
|
||||
|
||||
// Calendar grid cells for current month
|
||||
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(); // 0=Sun
|
||||
startDow = (startDow + 6) % 7; // shift to Mon=0
|
||||
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++) cells.push(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
|
||||
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); }
|
||||
else setViewMonth((m) => m - 1);
|
||||
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); }
|
||||
else setViewMonth((m) => m + 1);
|
||||
if (viewMonth === 11) {
|
||||
setViewYear((y) => y + 1);
|
||||
setViewMonth(0);
|
||||
return;
|
||||
}
|
||||
setViewMonth((m) => m + 1);
|
||||
}
|
||||
|
||||
function handleDayClick(day) {
|
||||
@@ -86,24 +423,149 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick
|
||||
closePanel?.();
|
||||
}
|
||||
|
||||
const totalScheduled = Object.values(tasksByDate).flat().length;
|
||||
const totalNoteReminders = Object.values(notesByDate).flat().length;
|
||||
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"
|
||||
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>
|
||||
|
||||
{/* Month navigation */}
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -111,7 +573,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick
|
||||
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}
|
||||
@@ -120,13 +582,12 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick
|
||||
type="button"
|
||||
onClick={nextMonth}
|
||||
className="rounded p-1 text-sm text-text-main hover:bg-canvas-deep"
|
||||
aria-label="Sljedeći mjesec"
|
||||
aria-label="Sljedeci mjesec"
|
||||
>
|
||||
▶
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="px-3">
|
||||
<div className="grid grid-cols-7 text-center">
|
||||
{WEEKDAY_LABELS.map((label) => (
|
||||
@@ -144,20 +605,16 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick
|
||||
const taskCount = tasksByDate[key]?.length ?? 0;
|
||||
const noteCount = notesByDate[key]?.length ?? 0;
|
||||
const isToday =
|
||||
today.getFullYear() === viewYear &&
|
||||
today.getMonth() === viewMonth &&
|
||||
today.getDate() === day;
|
||||
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} bilješki`
|
||||
: undefined
|
||||
}
|
||||
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
|
||||
@@ -173,20 +630,10 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick
|
||||
{(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(' ')}
|
||||
/>
|
||||
<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 className={['h-1.5 w-1.5 rounded-full', isSelected ? 'bg-violet-200' : 'bg-violet-500'].join(' ')} />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
@@ -196,10 +643,8 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mt-2 border-t border-border-hairline" />
|
||||
|
||||
{/* Task list for selected day OR hint */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{selectedDay ? (
|
||||
<>
|
||||
@@ -207,12 +652,12 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick
|
||||
{formatDayLabel(viewYear, viewMonth, selectedDay)}
|
||||
</p>
|
||||
{selectedDayTasks.length === 0 && selectedDayNotes.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">Nema događaja za odabrani datum.</p>
|
||||
<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">Bilješke</p>
|
||||
<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">
|
||||
@@ -234,17 +679,16 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick
|
||||
<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 hover:bg-indigo-50 hover:border-indigo-200 transition-colors"
|
||||
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 || '-')}
|
||||
{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)}
|
||||
{getTaskCraneSerial(task)} | {getTaskCraneKind(task)} | {getTaskCraneOwner(task)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -267,7 +711,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick
|
||||
)}
|
||||
{totalNoteReminders > 0 && (
|
||||
<p className="mt-1 text-xs font-medium text-violet-600">
|
||||
{totalNoteReminders} bilješki/podsjetnika u kalendaru.
|
||||
{totalNoteReminders} biljeski/podsjetnika u kalendaru.
|
||||
</p>
|
||||
)}
|
||||
{totalScheduled === 0 && (
|
||||
@@ -278,7 +722,273 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], onTaskClick
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { createTask, fetchTaskTemplates, getStatusLabel } from '../../stores/taskStore';
|
||||
import { formatPurposeLabel, formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
import { updateVehicle } from '../../stores/fleetDashboardStore';
|
||||
|
||||
const STATUS_OPTIONS = ['aktivan', 'servis', 'zavrsen', 'neaktivan'];
|
||||
|
||||
@@ -13,6 +14,17 @@ const INITIAL_FORM = {
|
||||
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 }) {
|
||||
const [form, setForm] = useState(INITIAL_FORM);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -21,6 +33,10 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
||||
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,
|
||||
@@ -40,8 +56,22 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
||||
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;
|
||||
@@ -55,6 +85,60 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
||||
}));
|
||||
}, [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;
|
||||
|
||||
function setField(name, value) {
|
||||
@@ -135,6 +219,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell
|
||||
onClose={onClose}
|
||||
overlayClassName="z-50 overflow-y-auto p-4 pt-20"
|
||||
@@ -194,6 +279,39 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
||||
</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">
|
||||
<span className="font-medium text-text-main">Naslov zadatka *</span>
|
||||
<input
|
||||
@@ -254,12 +372,18 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
||||
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>
|
||||
{availableWorkOrders.map((workOrder) => (
|
||||
{availableWorkOrders.map((workOrder) => {
|
||||
const isToday = String(workOrder.date || '').slice(0, 10) === todayIso();
|
||||
return (
|
||||
<option key={workOrder.id} value={String(workOrder.id)}>
|
||||
{formatWorkOrderDisplayCode(workOrder)} • {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} • {formatPurposeLabel(workOrder.purpose) || '-'}
|
||||
{isToday ? '★ ' : ''}{formatWorkOrderDisplayCode(workOrder)} • {formatWODate(workOrder.date)} • {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} • {formatPurposeLabel(workOrder.purpose) || '-'}
|
||||
</option>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<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.
|
||||
@@ -299,5 +423,84 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
||||
</form>
|
||||
</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,11 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
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) {
|
||||
if (!value) return '-';
|
||||
const parsed = new Date(value);
|
||||
@@ -51,6 +56,10 @@ export default function TaskServiceRecordsModal({
|
||||
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(() => {
|
||||
if (!open || typeof window === 'undefined') return;
|
||||
@@ -72,17 +81,79 @@ export default function TaskServiceRecordsModal({
|
||||
status: String(task.status || 'aktivan'),
|
||||
scheduled_date: String(task.scheduled_date || ''),
|
||||
});
|
||||
setCraneDataModalOpen(false);
|
||||
setCraneDataError('');
|
||||
setSavingCraneData(false);
|
||||
}, [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(
|
||||
() => workOrders.find((item) => String(item.id) === String(task?.work_order || selectedWorkOrderId)) || null,
|
||||
[workOrders, task?.work_order, selectedWorkOrderId]
|
||||
);
|
||||
|
||||
const sortedWorkOrders = useMemo(
|
||||
() => [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))),
|
||||
[workOrders]
|
||||
);
|
||||
const sortedWorkOrders = useMemo(() => {
|
||||
const today = todayIso();
|
||||
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 || '');
|
||||
|
||||
@@ -157,6 +228,7 @@ export default function TaskServiceRecordsModal({
|
||||
if (!open || !task) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell
|
||||
onClose={onClose}
|
||||
overlayClassName="z-50 overflow-y-auto p-4 pt-2"
|
||||
@@ -179,6 +251,15 @@ export default function TaskServiceRecordsModal({
|
||||
>
|
||||
Postavi servisni kontekst
|
||||
</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}
|
||||
@@ -228,7 +309,19 @@ export default function TaskServiceRecordsModal({
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-text-muted">Dizalica</dt>
|
||||
<dd className="text-text-main">{getTaskCraneDisplay(task)}</dd>
|
||||
<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>
|
||||
@@ -355,7 +448,7 @@ export default function TaskServiceRecordsModal({
|
||||
{currentWorkOrder && !showWorkOrderEditor ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded border border-border-hairline bg-canvas-elevated px-3 py-2">
|
||||
<p className="text-sm text-text-main">
|
||||
{formatWorkOrderDisplayCode(currentWorkOrder)} • {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} • {currentWorkOrder.status || '-'}
|
||||
{formatWorkOrderDisplayCode(currentWorkOrder)} • {formatDate(currentWorkOrder.date)} • {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} • {currentWorkOrder.status || '-'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -374,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"
|
||||
>
|
||||
<option value="">— Odaberi putni nalog —</option>
|
||||
{sortedWorkOrders.map((workOrder) => (
|
||||
{sortedWorkOrders.map((workOrder) => {
|
||||
const isToday = String(workOrder.date || '').slice(0, 10) === todayIso();
|
||||
return (
|
||||
<option key={workOrder.id} value={String(workOrder.id)}>
|
||||
{formatWorkOrderDisplayCode(workOrder)} • {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} • {workOrder.status || '-'}
|
||||
{isToday ? '★ ' : ''}{formatWorkOrderDisplayCode(workOrder)} • {formatDate(workOrder.date)} • {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} • {workOrder.status || '-'}
|
||||
</option>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -448,5 +546,81 @@ export default function TaskServiceRecordsModal({
|
||||
</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="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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ const FIELD_CONFIGS = [
|
||||
];
|
||||
|
||||
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',
|
||||
@@ -137,6 +138,7 @@ const FB_MUTED = [
|
||||
export default function TaskWorkHoursTableModal({
|
||||
open,
|
||||
task,
|
||||
workOrder = null,
|
||||
defaultLocation = '',
|
||||
saving = false,
|
||||
onClose,
|
||||
@@ -144,15 +146,45 @@ export default function TaskWorkHoursTableModal({
|
||||
}) {
|
||||
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 }, { ...EMPTY_ROW }, { ...EMPTY_ROW }],
|
||||
: [
|
||||
{ ...EMPTY_ROW, ...defaultDayDate },
|
||||
{ ...EMPTY_ROW, ...defaultDayDate },
|
||||
{ ...EMPTY_ROW, ...defaultDayDate },
|
||||
],
|
||||
);
|
||||
}, [open, task?.id, task?.work_hours_table]);
|
||||
}, [open, task?.id, task?.scheduled_date, task?.work_hours_table]);
|
||||
|
||||
if (!open || !task) return null;
|
||||
|
||||
@@ -251,20 +283,15 @@ export default function TaskWorkHoursTableModal({
|
||||
const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]);
|
||||
|
||||
const addThreeRows = () => {
|
||||
const today = new Date();
|
||||
const dd = String(today.getDate()).padStart(2, '0');
|
||||
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const dateStr = `${dd}.${mm}.${today.getFullYear()}`;
|
||||
const dayLabels = ['NED', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB'];
|
||||
const dayLabel = dayLabels[today.getDay()];
|
||||
const defaults = getDefaultDayDate(true);
|
||||
const gid = newGroupId();
|
||||
const loc = defaultLocation || '';
|
||||
|
||||
setRows((prev) => [
|
||||
...prev,
|
||||
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: 'Zagreb', arrival_place: loc, _groupId: gid, _role: 'departure' },
|
||||
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, _groupId: gid, _role: 'work' },
|
||||
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: loc, arrival_place: 'Zagreb', _groupId: gid, _role: 'return' },
|
||||
{ ...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' },
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -337,6 +364,39 @@ export default function TaskWorkHoursTableModal({
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
|
||||
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
|
||||
import TaskCreateModal from './TaskCreateModal';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
import {
|
||||
createWorkOrderInvoice,
|
||||
fetchTasksByWorkOrder,
|
||||
fetchTeamMembers,
|
||||
fetchWorkOrderInvoices,
|
||||
fetchWorkOrderImages,
|
||||
openWorkOrderInvoicesPdfPage,
|
||||
sendWorkOrderEmail,
|
||||
updateVehicle,
|
||||
} from '../../stores/fleetDashboardStore';
|
||||
import { getStatusLabel } from '../../stores/taskStore';
|
||||
import { showToast } from '../../stores/toastStore';
|
||||
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||
import { $serviceNotes, $serviceNotesLoading, fetchServiceNotes } from '../../stores/serviceNotesStore';
|
||||
|
||||
const PURPOSE_OPTIONS = [
|
||||
{ value: 'defektaza', label: 'Defektaža' },
|
||||
@@ -20,6 +26,8 @@ const PURPOSE_OPTIONS = [
|
||||
];
|
||||
|
||||
export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmit }) {
|
||||
const serviceNotes = useStore($serviceNotes);
|
||||
const serviceNotesLoading = useStore($serviceNotesLoading);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
has_travel_order: false,
|
||||
@@ -52,6 +60,13 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
const [emailPayload, setEmailPayload] = useState({
|
||||
subject: '',
|
||||
message: '',
|
||||
includeWorkOrderPdf: true,
|
||||
includeServiceRecordsPdf: false,
|
||||
includeInvoicesPdf: false,
|
||||
includeImages: false,
|
||||
includeMonthlyTasks: false,
|
||||
month: new Date().toISOString().slice(0, 7),
|
||||
serviceNoteId: '',
|
||||
});
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
|
||||
@@ -70,6 +85,12 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
const [selectedLocation, setSelectedLocation] = useState(null);
|
||||
const [searchingLocation, setSearchingLocation] = useState(false);
|
||||
const [mapError, setMapError] = useState('');
|
||||
const [workOrderTasks, setWorkOrderTasks] = useState([]);
|
||||
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false);
|
||||
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 craneLabel = useMemo(() => {
|
||||
if (!workOrder) return '-';
|
||||
@@ -83,6 +104,16 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
}
|
||||
return PURPOSE_OPTIONS;
|
||||
}, [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(() => {
|
||||
if (!open || typeof window === 'undefined') return;
|
||||
@@ -122,6 +153,13 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
setEmailPayload({
|
||||
subject: '',
|
||||
message: '',
|
||||
includeWorkOrderPdf: true,
|
||||
includeServiceRecordsPdf: false,
|
||||
includeInvoicesPdf: false,
|
||||
includeImages: false,
|
||||
includeMonthlyTasks: false,
|
||||
month: new Date().toISOString().slice(0, 7),
|
||||
serviceNoteId: '',
|
||||
});
|
||||
setInvoices([]);
|
||||
setInvoiceModalOpen(false);
|
||||
@@ -140,6 +178,12 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
setSelectedLocation(null);
|
||||
setSearchingLocation(false);
|
||||
setMapError('');
|
||||
setWorkOrderTasks([]);
|
||||
setAddTaskModalOpen(false);
|
||||
setCraneDataModalOpen(false);
|
||||
setCraneDataForm({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
|
||||
setSavingCraneData(false);
|
||||
setCraneDataError('');
|
||||
}, [open, workOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -148,7 +192,10 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
setLoadingTeamMembers(true);
|
||||
(async () => {
|
||||
try {
|
||||
const members = await fetchTeamMembers();
|
||||
const [members] = await Promise.all([
|
||||
fetchTeamMembers(),
|
||||
fetchServiceNotes(),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setTeamMembers(members);
|
||||
}
|
||||
@@ -220,6 +267,23 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
};
|
||||
}, [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 = (() => {
|
||||
if (!selectedLocation) return null;
|
||||
const lat = Number(selectedLocation.lat);
|
||||
@@ -234,6 +298,60 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
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 query = locationQuery.trim();
|
||||
if (!query) {
|
||||
@@ -403,29 +521,34 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
setWidgetError('Odaberite barem jednog člana tima ili uključite slanje svima.');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!emailPayload.includeWorkOrderPdf
|
||||
&& !emailPayload.includeServiceRecordsPdf
|
||||
&& !emailPayload.includeInvoicesPdf
|
||||
&& !emailPayload.includeImages
|
||||
&& !emailPayload.includeMonthlyTasks
|
||||
) {
|
||||
setWidgetError('Odaberite barem jedan PDF/prilog za slanje.');
|
||||
return;
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
for (const recipient of recipients) {
|
||||
try {
|
||||
await sendWorkOrderEmail(
|
||||
workOrder.id,
|
||||
{
|
||||
recipient,
|
||||
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(`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;
|
||||
}
|
||||
showToast(`Slanje je pokrenuto u pozadini za ${recipients.length} primatelja. Rezultat je u Notifikacijama.`, 'info');
|
||||
setEmailModalOpen(false);
|
||||
} catch (err) {
|
||||
setWidgetError(err?.message || 'Neuspješno slanje emaila za putni nalog.');
|
||||
@@ -504,6 +627,33 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
)}
|
||||
</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">
|
||||
<h4 className="mb-2 text-sm font-semibold text-text-main">Status</h4>
|
||||
<label className="mb-3 flex flex-col gap-1 text-sm">
|
||||
@@ -665,6 +815,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
<label className="mt-3 flex items-center gap-2 text-sm text-text-main">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="erp-checkbox"
|
||||
checked={form.has_travel_order}
|
||||
onChange={(event) => {
|
||||
const checked = event.currentTarget.checked;
|
||||
@@ -875,6 +1026,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
<label className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="erp-checkbox"
|
||||
checked={sendToAllTeamMembers}
|
||||
onChange={(event) => setSendToAllTeamMembers(event.currentTarget.checked)}
|
||||
/>
|
||||
@@ -917,6 +1069,79 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
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"
|
||||
/>
|
||||
<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 className="flex justify-end gap-2 border-t border-border-hairline px-4 py-3">
|
||||
<button
|
||||
@@ -1054,6 +1279,99 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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) {
|
||||
return '';
|
||||
}
|
||||
if (isProtectedMediaUrl(resolved)) {
|
||||
return authenticatedSources[resolved] || '';
|
||||
}
|
||||
return authenticatedSources[resolved] || resolved;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import {
|
||||
downloadWorkOrderDocx,
|
||||
downloadWorkOrderPdf,
|
||||
downloadWorkOrderInvoicesDocx,
|
||||
downloadWorkOrderInvoicesPdf,
|
||||
downloadWorkOrderServiceRecordsDocx,
|
||||
downloadWorkOrderServiceRecordsPdf,
|
||||
fetchWorkOrderAdditionalCostsTable,
|
||||
fetchWorkOrderById,
|
||||
fetchWorkOrderInvoices,
|
||||
fetchWorkOrderTravelExpensesTable,
|
||||
fetchWorkOrderTaskServiceContext,
|
||||
updateTaskWorkHoursTable,
|
||||
updateTaskServiceReportNote,
|
||||
updateWorkOrderAdditionalCostsTable,
|
||||
updateWorkOrderTravelExpensesTable,
|
||||
} from '../../stores/fleetDashboardStore';
|
||||
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
|
||||
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
||||
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() {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -26,9 +34,11 @@ function readWorkOrderIdFromQuery() {
|
||||
return params.get('workOrderId') || '';
|
||||
}
|
||||
|
||||
function InvoiceImagePreview({ imageUrl, alt, contentType }) {
|
||||
function InvoiceImagePreview({ imageUrl, rawUrl, alt, contentType, onClick }) {
|
||||
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 (
|
||||
@@ -43,6 +53,14 @@ function InvoiceImagePreview({ imageUrl, alt, contentType }) {
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
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">
|
||||
@@ -52,14 +70,76 @@ function InvoiceImagePreview({ imageUrl, alt, contentType }) {
|
||||
}
|
||||
|
||||
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
|
||||
src={imageUrl}
|
||||
alt={alt}
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +150,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
const [workOrder, setWorkOrder] = useState(null);
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
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 [error, setError] = useState('');
|
||||
const [editingTask, setEditingTask] = useState(null);
|
||||
@@ -77,6 +158,12 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
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(() => {
|
||||
hydrateAuthFromStorage();
|
||||
@@ -111,11 +198,12 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [contextPayload, order, invoiceItems, additionalCostsPayload] = await Promise.all([
|
||||
const [contextPayload, order, invoiceItems, additionalCostsPayload, travelExpensesPayload] = await Promise.all([
|
||||
fetchWorkOrderTaskServiceContext(workOrderId),
|
||||
fetchWorkOrderById(workOrderId),
|
||||
fetchWorkOrderInvoices(workOrderId),
|
||||
fetchWorkOrderAdditionalCostsTable(workOrderId),
|
||||
fetchWorkOrderTravelExpensesTable(workOrderId),
|
||||
]);
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -124,6 +212,13 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
setWorkOrder(order);
|
||||
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) {
|
||||
if (!cancelled) {
|
||||
setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.');
|
||||
@@ -195,6 +290,29 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -207,6 +325,24 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<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">
|
||||
@@ -217,6 +353,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
{workOrder ? `Dizalica: ${workOrder.crane_label || workOrder.crane || '-'}` : 'Pregled računa prije kreiranja PDF-a.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadWorkOrderPdf(workOrderId)}
|
||||
@@ -225,6 +362,23 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
>
|
||||
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>
|
||||
|
||||
@@ -232,25 +386,21 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
<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>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const firstTask = Array.isArray(taskContext.tasks) ? taskContext.tasks[0] : null;
|
||||
if (!firstTask) return;
|
||||
setEditingTask(firstTask);
|
||||
}}
|
||||
disabled={!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0}
|
||||
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 tablicu radnih sati
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadWorkOrderServiceRecordsPdf(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 PDF servisnih zapisa
|
||||
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>
|
||||
@@ -262,13 +412,66 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
<div className="space-y-4">
|
||||
{taskContext.tasks.map((task) => (
|
||||
<article key={task.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="space-y-1 text-sm text-text-main">
|
||||
<div><span className="font-semibold">Radni zadatak:</span> {task.title || '-'}</div>
|
||||
<div><span className="font-semibold">Status:</span> {task.status || '-'}</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 className="mt-3 space-y-3">
|
||||
)}
|
||||
</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 className="space-y-3">
|
||||
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">Nema povezanih servisnih zapisa.</p>
|
||||
) : (
|
||||
@@ -331,6 +534,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
>
|
||||
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>
|
||||
{!loading && !error && (
|
||||
@@ -349,8 +560,18 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
)}
|
||||
{!loading && !error && invoices.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{invoices.map((invoice) => (
|
||||
<article key={invoice.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
{invoices.map((invoice) => {
|
||||
const rawUrl = invoice.image_url || invoice.image || '';
|
||||
const blobUrl = getAuthenticatedMediaSrc(rawUrl);
|
||||
return (
|
||||
<article
|
||||
key={invoice.id}
|
||||
className="cursor-pointer rounded-lg border border-border-hairline bg-canvas-base p-3 hover:bg-canvas-deep transition-colors"
|
||||
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>
|
||||
@@ -359,20 +580,24 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
|
||||
</div>
|
||||
<InvoiceImagePreview
|
||||
imageUrl={getAuthenticatedMediaSrc(invoice.image_url || invoice.image) || resolveMediaUrl(invoice.image_url || invoice.image)}
|
||||
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>
|
||||
<TaskWorkHoursTableModal
|
||||
open={!!editingTask}
|
||||
task={editingTask}
|
||||
defaultLocation={workOrder?.location || ''}
|
||||
workOrder={editingTask?.task_work_order ?? null}
|
||||
defaultLocation={editingTask?.task_work_order?.location ?? ''}
|
||||
saving={savingWorkHours}
|
||||
onClose={() => setEditingTask(null)}
|
||||
onSave={handleSaveWorkHoursTable}
|
||||
@@ -385,6 +610,33 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -174,31 +174,24 @@ export default function ServiceRecordDetailModal({
|
||||
return;
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
for (const recipient of recipients) {
|
||||
try {
|
||||
await sendServiceRecordEmail(
|
||||
serviceRecord.id,
|
||||
{
|
||||
recipient,
|
||||
recipients,
|
||||
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;
|
||||
}
|
||||
showToast(`Email je poslan za ${recipients.length} primatelja.`, 'success');
|
||||
setEmailModalOpen(false);
|
||||
} catch (err) {
|
||||
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 {
|
||||
setEmailSending(false);
|
||||
}
|
||||
@@ -479,6 +472,7 @@ export default function ServiceRecordDetailModal({
|
||||
<label className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="erp-checkbox"
|
||||
checked={sendToAllTeamMembers}
|
||||
onChange={(event) => setSendToAllTeamMembers(event.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function ServiceNotesWidget({
|
||||
canManageRecipients = false,
|
||||
activeTasks = [],
|
||||
activeWorkOrders = [],
|
||||
servicers = [],
|
||||
teamMembers = [],
|
||||
onCreate,
|
||||
onClose,
|
||||
}) {
|
||||
@@ -156,7 +156,7 @@ export default function ServiceNotesWidget({
|
||||
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
|
||||
>
|
||||
<option value="self">Notifikacija samo meni</option>
|
||||
{canManageRecipients && <option value="member">Notifikacija serviserima</option>}
|
||||
{canManageRecipients && <option value="member">Notifikacija odabranim članovima tima</option>}
|
||||
{canManageRecipients && <option value="team">Notifikacija svim članovima tima</option>}
|
||||
</select>
|
||||
{canManageRecipients && audience === 'member' && (
|
||||
@@ -167,10 +167,10 @@ export default function ServiceNotesWidget({
|
||||
const selected = Array.from(event.currentTarget.selectedOptions).map((option) => option.value);
|
||||
setTargetUsers(selected);
|
||||
}}
|
||||
size={Math.min(6, Math.max(3, servicers.length || 3))}
|
||||
size={Math.min(6, Math.max(3, teamMembers.length || 3))}
|
||||
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
|
||||
>
|
||||
{servicers.map((member) => (
|
||||
{teamMembers.map((member) => (
|
||||
<option key={member.id} value={member.id}>
|
||||
{member.full_name || member.email}
|
||||
</option>
|
||||
@@ -179,7 +179,7 @@ export default function ServiceNotesWidget({
|
||||
)}
|
||||
{canManageRecipients && audience === 'member' && (
|
||||
<p className="text-[10px] text-text-muted">
|
||||
Držite Ctrl (ili Cmd) za odabir više servisera.
|
||||
Držite Ctrl (ili Cmd) za odabir više članova tima.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $tasks } from '../../stores/taskStore';
|
||||
import { $user } from '../../stores/authStore';
|
||||
import { $workOrdersWithVehicle, fetchServicers } from '../../stores/fleetDashboardStore';
|
||||
import { $workOrdersWithVehicle, fetchTeamMembers } from '../../stores/fleetDashboardStore';
|
||||
import {
|
||||
$serviceNotes,
|
||||
$serviceNotesLoading,
|
||||
@@ -30,7 +30,7 @@ export default function TaskCalendarPortal() {
|
||||
const notes = useStore($serviceNotes);
|
||||
const notesLoading = useStore($serviceNotesLoading);
|
||||
const user = useStore($user);
|
||||
const [servicers, setServicers] = useState([]);
|
||||
const [teamMembers, setTeamMembers] = useState([]);
|
||||
|
||||
const isServiser = Boolean(user?.is_serviser);
|
||||
const isTeamMember = Boolean(user?.is_team_member);
|
||||
@@ -64,6 +64,17 @@ export default function TaskCalendarPortal() {
|
||||
));
|
||||
}, [workOrders, isSupervisorNotServiser, user?.id]);
|
||||
|
||||
const calendarWorkOrders = useMemo(() => {
|
||||
const base = Array.isArray(workOrders) ? workOrders : [];
|
||||
if (isSupervisorNotServiser) {
|
||||
return base;
|
||||
}
|
||||
return base.filter((item) => (
|
||||
String(item.creator || '') === String(user?.id || '')
|
||||
|| String(item.craneInfo?.assigned_servicer || '') === String(user?.id || '')
|
||||
));
|
||||
}, [workOrders, isSupervisorNotServiser, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
fetchServiceNotes().catch(() => {});
|
||||
@@ -71,18 +82,18 @@ export default function TaskCalendarPortal() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!canManageRecipients) {
|
||||
setServicers([]);
|
||||
setTeamMembers([]);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
fetchServicers()
|
||||
fetchTeamMembers()
|
||||
.then((rows) => {
|
||||
if (!active) return;
|
||||
setServicers(Array.isArray(rows) ? rows : []);
|
||||
setTeamMembers(Array.isArray(rows) ? rows : []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setServicers([]);
|
||||
setTeamMembers([]);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
@@ -103,6 +114,7 @@ export default function TaskCalendarPortal() {
|
||||
<TaskCalendarWidget
|
||||
tasks={tasks}
|
||||
notes={notes}
|
||||
workOrders={calendarWorkOrders}
|
||||
onTaskClick={handleTaskClick}
|
||||
/>
|
||||
<ServiceNotesWidget
|
||||
@@ -111,7 +123,7 @@ export default function TaskCalendarPortal() {
|
||||
canManageRecipients={canManageRecipients}
|
||||
activeTasks={activeTasks}
|
||||
activeWorkOrders={activeWorkOrders}
|
||||
servicers={servicers}
|
||||
teamMembers={teamMembers}
|
||||
onCreate={createServiceNote}
|
||||
onClose={closeServiceNote}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
import {
|
||||
downloadGeneratedArchiveByUrl,
|
||||
downloadGeneratedPdfByUrl,
|
||||
downloadWorkOrderInvoicesPdf,
|
||||
downloadWorkOrderPdf,
|
||||
@@ -21,9 +22,11 @@ function formatTimestamp(value) {
|
||||
}
|
||||
|
||||
function detectNotificationKind(notification) {
|
||||
if (notification?.metadata?.entity_type === 'fleet_archive') return 'fleet_archive';
|
||||
const title = String(notification?.title || '').toLowerCase();
|
||||
if (title.includes('servisni kontekst')) return 'service_context';
|
||||
if (title.includes('pdf')) return 'work_order_pdf';
|
||||
if (title.includes('email')) return 'work_order_email';
|
||||
if (title.includes('putni nalog kreiran')) return 'work_order_created';
|
||||
if (title.includes('novi putni nalog')) return 'work_order_assigned';
|
||||
if (title.includes('putni nalog zatvoren')) return 'work_order_closed';
|
||||
@@ -78,6 +81,28 @@ function getEntityAction(notification, meta) {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (metadata.entity_type === 'fleet_archive') {
|
||||
const archiveType = metadata.archive_type === 'work_orders' ? 'work_order' : 'service_tasks';
|
||||
if (metadata.stage === 'completed' && metadata.download_url) {
|
||||
return {
|
||||
label: archiveType === 'work_order'
|
||||
? 'Preuzmi ZIP putnih naloga + računa'
|
||||
: 'Preuzmi ZIP servisnih taskova',
|
||||
run() {
|
||||
return downloadGeneratedArchiveByUrl(
|
||||
metadata.download_url,
|
||||
metadata.filename || `MT-${String(metadata.month || '').padStart(2, '0')}-${metadata.year || ''}-${archiveType === 'work_order' ? 'PN' : 'SN'}.zip`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: 'Otvori servisne zapise',
|
||||
run() {
|
||||
window.dispatchEvent(new CustomEvent('navbar:navigate', { detail: 'service-records' }));
|
||||
},
|
||||
};
|
||||
}
|
||||
if (metadata.entity_type === 'work_order' && metadata.work_order_id) {
|
||||
return {
|
||||
label: 'Otvori detalje naloga',
|
||||
@@ -124,6 +149,13 @@ function getNotificationTypeMeta(kind) {
|
||||
recommendation: 'Možete odmah preuzeti generirani PDF dokument.',
|
||||
};
|
||||
}
|
||||
if (kind === 'fleet_archive') {
|
||||
return {
|
||||
category: 'ZIP arhive',
|
||||
section: 'service-records',
|
||||
recommendation: 'Pričekajte završetak generiranja, zatim preuzmite ZIP iz notifikacije.',
|
||||
};
|
||||
}
|
||||
if (kind.startsWith('work_order')) {
|
||||
return {
|
||||
category: 'Putni nalozi',
|
||||
@@ -167,6 +199,14 @@ export default function NotificationDetailModal({ open, notification, onClose })
|
||||
notification?.metadata?.stage === 'completed' &&
|
||||
notification?.metadata?.work_order_id
|
||||
);
|
||||
const showArchiveLink = Boolean(
|
||||
notification?.metadata?.entity_type === 'fleet_archive' &&
|
||||
notification?.metadata?.stage === 'completed' &&
|
||||
notification?.metadata?.download_url
|
||||
);
|
||||
const failedEmailCachedPdfs = Array.isArray(notification?.metadata?.cached_pdfs)
|
||||
? notification.metadata.cached_pdfs
|
||||
: [];
|
||||
|
||||
if (!open || !notification) {
|
||||
return null;
|
||||
@@ -208,7 +248,7 @@ export default function NotificationDetailModal({ open, notification, onClose })
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
<p className="text-sm font-semibold text-text-main">{notification.title || 'Obavijest'}</p>
|
||||
<p className="mt-2 text-sm text-text-muted">{notification.message || '-'}</p>
|
||||
{showPdfLink && (
|
||||
{(showPdfLink || showArchiveLink) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => action.run()}
|
||||
@@ -217,6 +257,23 @@ export default function NotificationDetailModal({ open, notification, onClose })
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
{failedEmailCachedPdfs.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
Cached PDF datoteke
|
||||
</p>
|
||||
{failedEmailCachedPdfs.map((item, index) => (
|
||||
<button
|
||||
key={`${item.generated_pdf_id || index}-${item.filename || 'pdf'}`}
|
||||
type="button"
|
||||
onClick={() => downloadGeneratedPdfByUrl(item.download_url, item.filename || `work-order-${index + 1}.pdf`)}
|
||||
className="block text-left text-sm font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-700"
|
||||
>
|
||||
{item.filename || `PDF ${index + 1}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
function SkeletonRows({ columns, rows = 4 }) {
|
||||
return Array.from({ length: rows }).map((_, rowIdx) => (
|
||||
<tr key={rowIdx} className="animate-pulse">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={col.className}>
|
||||
<div className="h-3 rounded bg-canvas-deep" style={{ width: `${55 + ((rowIdx * 17 + col.key.length * 7) % 30)}%` }} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
));
|
||||
}
|
||||
|
||||
export default function AnimatedDataTable({
|
||||
columns = [],
|
||||
@@ -14,17 +26,43 @@ export default function AnimatedDataTable({
|
||||
bodyClassName = 'divide-y divide-border-hairline',
|
||||
rowClassName = 'hover:bg-canvas-deep',
|
||||
isRowSelected = () => false,
|
||||
wrapperClassName = 'overflow-x-auto',
|
||||
wrapperClassName = '',
|
||||
trail = 35,
|
||||
ariaLabel,
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const scrollRef = useRef(null);
|
||||
const [scrollState, setScrollState] = useState({ left: false, right: false });
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Prati scroll poziciju da bi se prikazali fade indikatori s lijeva/desna
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
function updateScrollState() {
|
||||
setScrollState({
|
||||
left: el.scrollLeft > 4,
|
||||
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 4,
|
||||
});
|
||||
}
|
||||
|
||||
updateScrollState();
|
||||
el.addEventListener('scroll', updateScrollState, { passive: true });
|
||||
const ro = new ResizeObserver(updateScrollState);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', updateScrollState);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [mounted, rows.length]);
|
||||
|
||||
const transitions = useTransition(rows, {
|
||||
keys: (row) => rowKey(row),
|
||||
from: { opacity: 0, transform: 'translate3d(0,8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||
from: { opacity: 0, transform: 'translate3d(0,6px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||
enter: (row) => ({
|
||||
opacity: 1,
|
||||
transform: 'translate3d(0,0,0)',
|
||||
@@ -37,43 +75,55 @@ export default function AnimatedDataTable({
|
||||
? 'inset 0 0 0 2px rgba(16,185,129,0.75)'
|
||||
: 'inset 0 0 0 0px rgba(16,185,129,0)',
|
||||
}),
|
||||
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||
leave: { opacity: 0, transform: 'translate3d(0,-4px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||
trail,
|
||||
config: { tension: 230, friction: 26 },
|
||||
config: { tension: 260, friction: 28 },
|
||||
});
|
||||
|
||||
const colSpan = Math.max(1, columns.length);
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<table className={tableClassName}>
|
||||
<div className={`relative ${wrapperClassName}`}>
|
||||
{/* Fade gradijent — lijeva strana (kad se može scrollati ulijevo) */}
|
||||
{scrollState.left && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-canvas-elevated to-transparent"
|
||||
/>
|
||||
)}
|
||||
{/* Fade gradijent — desna strana (kad postoji sadržaj desno) */}
|
||||
{scrollState.right && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-canvas-elevated to-transparent"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-x-auto"
|
||||
style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'thin' }}
|
||||
>
|
||||
<table
|
||||
className={tableClassName}
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={loading ? 'true' : undefined}
|
||||
>
|
||||
<thead className={headClassName}>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} className={column.className}>
|
||||
<th key={column.key} scope="col" className={column.className}>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={bodyClassName}>
|
||||
{!mounted && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
|
||||
{loadingMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{mounted && loading && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
|
||||
{loadingMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!mounted && <SkeletonRows columns={columns} rows={3} />}
|
||||
{mounted && loading && <SkeletonRows columns={columns} rows={3} />}
|
||||
{mounted && !loading && rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
|
||||
<td colSpan={colSpan} className="px-4 py-8 text-center text-sm text-text-muted">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -90,5 +140,6 @@ export default function AnimatedDataTable({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import '../styles/global.css';
|
||||
import ThemeToggle from '../components/ui/ThemeToggle.jsx';
|
||||
import ButtonDisplayCounter from '../components/atom/ButtonDisplayCounter.jsx';
|
||||
import Toast from '../components/ui/Toast.jsx';
|
||||
import ToastTrigger from '../components/ToastTrigger.jsx';
|
||||
import NetworkGuard from '../components/ui/NetworkGuard.jsx';
|
||||
import Navbar from '../components/Navbar.jsx';
|
||||
import TaskCalendarPortal from '../components/layout/TaskCalendarPortal.jsx';
|
||||
@@ -18,7 +17,6 @@ interface Props {
|
||||
|
||||
const { title, isAuthPage = false, minimalNav = false } = Astro.props as Props;
|
||||
const isProd = import.meta.env.PROD;
|
||||
const isDev = import.meta.env.DEV;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
@@ -57,7 +55,6 @@ const isDev = import.meta.env.DEV;
|
||||
|
||||
{!isAuthPage && <NetworkGuard client:only="preact" transition:persist="network-guard" />}
|
||||
<Toast client:only="preact" transition:persist="toast" />
|
||||
{!isAuthPage && isDev && <ToastTrigger client:load />}
|
||||
{!isAuthPage && <Navbar client:only="preact" transition:persist="navbar" minimal={minimalNav} />}
|
||||
{!isAuthPage && <TaskCalendarPortal client:only="preact" transition:persist="calendar-portal" />}
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ const DASHBOARD_FETCH_TTL_MS = 30_000; // 30 sekundi
|
||||
let dbPromise = null;
|
||||
let syncListenerStarted = false;
|
||||
let isSyncInProgress = false;
|
||||
const archiveNotificationPollers = new Map();
|
||||
const ARCHIVE_NOTIFICATION_POLL_INTERVAL_MS = 10_000;
|
||||
const ARCHIVE_NOTIFICATION_POLL_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
function isBrowser() {
|
||||
return typeof window !== 'undefined';
|
||||
@@ -257,6 +260,16 @@ function replaceWorkOrderItem(workOrderId, nextValue) {
|
||||
);
|
||||
}
|
||||
|
||||
function upsertWorkOrderItem(workOrderId, nextValue) {
|
||||
const current = $workOrders.get();
|
||||
const exists = current.some((item) => String(item.id) === String(workOrderId));
|
||||
if (exists) {
|
||||
replaceWorkOrderItem(workOrderId, nextValue);
|
||||
return;
|
||||
}
|
||||
$workOrders.set([nextValue, ...current]);
|
||||
}
|
||||
|
||||
function appendLocalDraft(entity, payload, tempId) {
|
||||
const base = {
|
||||
...payload,
|
||||
@@ -547,15 +560,12 @@ export async function createWorkOrder(data) {
|
||||
|
||||
export async function updateWorkOrder(workOrderId, changes) {
|
||||
const existing = $workOrders.get().find((item) => String(item.id) === String(workOrderId));
|
||||
if (!existing) {
|
||||
throw new Error('Work order nije pronađen u lokalnom stanju.');
|
||||
}
|
||||
|
||||
const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get();
|
||||
if (canSendNow) {
|
||||
try {
|
||||
const response = await api.patch(`fleet/work-orders/${workOrderId}/`, changes);
|
||||
replaceWorkOrderItem(workOrderId, response);
|
||||
upsertWorkOrderItem(workOrderId, response);
|
||||
await persistDashboardCache();
|
||||
await logSync(
|
||||
{ entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' },
|
||||
@@ -575,6 +585,10 @@ export async function updateWorkOrder(workOrderId, changes) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
throw new Error('Work order nije dostupan offline. Otvorite dashboard ili pokušajte ponovno kada ste online.');
|
||||
}
|
||||
|
||||
const optimistic = {
|
||||
...existing,
|
||||
...changes,
|
||||
@@ -710,12 +724,22 @@ export async function fetchWorkOrderById(workOrderId) {
|
||||
return await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/`);
|
||||
}
|
||||
|
||||
export async function fetchTasksByWorkOrder(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
return [];
|
||||
}
|
||||
const data = await api.get(`tasks/tasks/?work_order=${encodeURIComponent(workOrderId)}`);
|
||||
return Array.isArray(data) ? data : (data?.results ?? []);
|
||||
}
|
||||
|
||||
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
return { tasks: [], additional_costs_table: null };
|
||||
return { tasks: [], additional_costs_table: null, travel_expenses_table: null };
|
||||
}
|
||||
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/task-service-context/`);
|
||||
return payload && typeof payload === 'object' ? payload : { tasks: [], additional_costs_table: null };
|
||||
return payload && typeof payload === 'object'
|
||||
? payload
|
||||
: { tasks: [], additional_costs_table: null, travel_expenses_table: null };
|
||||
}
|
||||
|
||||
export async function fetchWorkOrderAdditionalCostsTable(workOrderId) {
|
||||
@@ -740,6 +764,42 @@ export async function updateWorkOrderAdditionalCostsTable(workOrderId, data) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchWorkOrderTravelExpensesTable(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
return {
|
||||
work_order: null,
|
||||
broj_sati: '0.00',
|
||||
kolicina_dnevnica: '0.00',
|
||||
iznos_dnevnica: '30.00',
|
||||
daily_rate_country: 'HR',
|
||||
total_for_payout: '0.00',
|
||||
};
|
||||
}
|
||||
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/travel-expenses-table/`);
|
||||
return payload && typeof payload === 'object'
|
||||
? payload
|
||||
: {
|
||||
work_order: null,
|
||||
broj_sati: '0.00',
|
||||
kolicina_dnevnica: '0.00',
|
||||
iznos_dnevnica: '30.00',
|
||||
daily_rate_country: 'HR',
|
||||
total_for_payout: '0.00',
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateWorkOrderTravelExpensesTable(workOrderId, data) {
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
const payload = await api.put(
|
||||
`fleet/work-orders/${encodeURIComponent(workOrderId)}/travel-expenses-table/`,
|
||||
data ?? {},
|
||||
);
|
||||
showToast('Obračun putnih troškova je uspješno spremljen.', 'success');
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function updateTaskWorkHoursTable(taskId, data) {
|
||||
if (!taskId) {
|
||||
throw new Error('Task ID je obavezan.');
|
||||
@@ -749,6 +809,17 @@ export async function updateTaskWorkHoursTable(taskId, data) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function updateTaskServiceReportNote(taskId, note) {
|
||||
if (!taskId) {
|
||||
throw new Error('Task ID je obavezan.');
|
||||
}
|
||||
const payload = await api.patch(`tasks/tasks/${encodeURIComponent(taskId)}/`, {
|
||||
service_report_note: String(note ?? ''),
|
||||
});
|
||||
showToast('Napomena servisnog izvještaja je uspješno spremljena.', 'success');
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function createWorkOrderInvoice(workOrderId, data = {}, signal = null) {
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
@@ -798,6 +869,70 @@ function _schedulePdfNotificationPolling() {
|
||||
});
|
||||
}
|
||||
|
||||
function _findCompletedArchiveNotification(notifications, generatedArchiveId) {
|
||||
if (!generatedArchiveId || !Array.isArray(notifications)) {
|
||||
return null;
|
||||
}
|
||||
return notifications.find((notification) => {
|
||||
const metadata = notification?.metadata || {};
|
||||
return metadata.entity_type === 'fleet_archive'
|
||||
&& String(metadata.generated_archive_id || '') === String(generatedArchiveId)
|
||||
&& ['completed', 'failed'].includes(String(metadata.stage || ''));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function _clearArchiveNotificationPoller(generatedArchiveId) {
|
||||
const key = String(generatedArchiveId || '');
|
||||
const handles = archiveNotificationPollers.get(key);
|
||||
if (!handles) {
|
||||
return;
|
||||
}
|
||||
window.clearInterval(handles.intervalId);
|
||||
window.clearTimeout(handles.timeoutId);
|
||||
archiveNotificationPollers.delete(key);
|
||||
}
|
||||
|
||||
function _scheduleArchiveNotificationPolling(generatedArchiveId = null) {
|
||||
if (!isBrowser()) return;
|
||||
if (!generatedArchiveId) {
|
||||
[5000, 20000, 60000].forEach((delay) => {
|
||||
setTimeout(() => _refreshNotificationsAsync(), delay);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = String(generatedArchiveId);
|
||||
if (archiveNotificationPollers.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pollOnce = async () => {
|
||||
try {
|
||||
const notificationModule = await import('./notificationStore.js');
|
||||
await notificationModule.fetchNotifications();
|
||||
const resolvedNotification = _findCompletedArchiveNotification(
|
||||
notificationModule.$notifications.get(),
|
||||
key
|
||||
);
|
||||
if (resolvedNotification) {
|
||||
_clearArchiveNotificationPoller(key);
|
||||
}
|
||||
} catch (_) {
|
||||
// silent — korisnik će i dalje vidjeti toast ili ručno osvježiti notifikacije
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void pollOnce();
|
||||
}, ARCHIVE_NOTIFICATION_POLL_INTERVAL_MS);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
_clearArchiveNotificationPoller(key);
|
||||
}, ARCHIVE_NOTIFICATION_POLL_TIMEOUT_MS);
|
||||
|
||||
archiveNotificationPollers.set(key, { intervalId, timeoutId });
|
||||
void pollOnce();
|
||||
}
|
||||
|
||||
export async function downloadWorkOrderPdf(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
@@ -849,6 +984,144 @@ export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function downloadWorkOrderDocx(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
try {
|
||||
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/docx/`, { responseType: 'blob' });
|
||||
saveBlobToFile(blob, `${workOrderId}.work-order.docx`);
|
||||
showToast('DOCX putnog naloga je preuzet.', 'success');
|
||||
} catch (err) {
|
||||
const msg = err?.message || 'Preuzimanje DOCX putnog naloga nije uspjelo.';
|
||||
showToast(msg, 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadWorkOrderServiceRecordsDocx(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
try {
|
||||
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/`, { responseType: 'blob' });
|
||||
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
|
||||
showToast('DOCX servisnih zapisa je preuzet.', 'success');
|
||||
} catch (err) {
|
||||
const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.';
|
||||
showToast(msg, 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadWorkOrderInvoicesDocx(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
try {
|
||||
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-docx/`, { responseType: 'blob' });
|
||||
saveBlobToFile(blob, `${workOrderId}.work-order-invoices.docx`);
|
||||
showToast('DOCX računa putnog naloga je preuzet.', 'success');
|
||||
} catch (err) {
|
||||
const msg = err?.message || 'Preuzimanje DOCX računa nije uspjelo.';
|
||||
showToast(msg, 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMonthlyServicerEntries(year, month) {
|
||||
const payload = await api.get(
|
||||
`fleet/monthly-servicer-day-entries/?year=${year}&month=${month}`
|
||||
);
|
||||
return Array.isArray(payload) ? payload : (payload?.results ?? []);
|
||||
}
|
||||
|
||||
export async function fetchMonthlyCostInvoices(year, month) {
|
||||
const payload = await api.get(
|
||||
`fleet/work-order-invoices/?year=${year}&month=${month}`
|
||||
);
|
||||
return Array.isArray(payload) ? payload : (payload?.results ?? []);
|
||||
}
|
||||
|
||||
export async function upsertMonthlyServicerEntry(data = {}) {
|
||||
if (!data.entry_date) {
|
||||
throw new Error('Datum dnevnog unosa je obavezan.');
|
||||
}
|
||||
if (!data.entry_type) {
|
||||
throw new Error('Vrsta dnevnog unosa je obavezna.');
|
||||
}
|
||||
try {
|
||||
const response = await api.post('fleet/monthly-servicer-day-entries/', data);
|
||||
showToast('Dnevni unos je uspjesno spremljen.', 'success');
|
||||
return response;
|
||||
} catch (err) {
|
||||
showToast(err?.message || 'Spremanje dnevnog unosa nije uspjelo.', 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadMonthlyServiserReport(year, month) {
|
||||
try {
|
||||
const blob = await api.get(
|
||||
`fleet/reports/monthly-servicer/?year=${year}&month=${month}`,
|
||||
{ responseType: 'blob' },
|
||||
);
|
||||
saveBlobToFile(blob, `izvjestaj-servisera-${year}-${String(month).padStart(2, '0')}.docx`);
|
||||
showToast('Izvještaj servisera je preuzet.', 'success');
|
||||
} catch (err) {
|
||||
showToast(err?.message || 'Preuzimanje izvještaja servisera nije uspjelo.', 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadMonthlyCostsReport(year, month) {
|
||||
try {
|
||||
const blob = await api.get(
|
||||
`fleet/reports/monthly-costs/?year=${year}&month=${month}`,
|
||||
{ responseType: 'blob' },
|
||||
);
|
||||
saveBlobToFile(blob, `troskovi-servisera-${year}-${String(month).padStart(2, '0')}.docx`);
|
||||
showToast('Izvještaj troškova je preuzet.', 'success');
|
||||
} catch (err) {
|
||||
showToast(err?.message || 'Preuzimanje izvještaja troškova nije uspjelo.', 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadMonthlyServiceTasksArchive(year, month) {
|
||||
try {
|
||||
const payload = await api.post('fleet/reports/monthly-service-tasks-archive-request/', { year, month });
|
||||
await _refreshNotificationsAsync();
|
||||
if (payload?.status === 'ready' && payload?.download_url) {
|
||||
showToast('ZIP arhiva servisnih taskova je spremna. Preuzmite je kroz notifikaciju.', 'success');
|
||||
return payload;
|
||||
}
|
||||
showToast('ZIP arhiva servisnih taskova se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
|
||||
_scheduleArchiveNotificationPolling(payload?.generated_archive_id || null);
|
||||
return payload;
|
||||
} catch (err) {
|
||||
showToast(err?.message || 'Pokretanje generiranja ZIP arhive servisnih taskova nije uspjelo.', 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadMonthlyWorkOrdersArchive(year, month) {
|
||||
try {
|
||||
const payload = await api.post('fleet/reports/monthly-work-orders-archive-request/', { year, month });
|
||||
await _refreshNotificationsAsync();
|
||||
if (payload?.status === 'ready' && payload?.download_url) {
|
||||
showToast('ZIP arhiva putnih naloga je spremna. Preuzmite je kroz notifikaciju.', 'success');
|
||||
return payload;
|
||||
}
|
||||
showToast('ZIP arhiva putnih naloga se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
|
||||
_scheduleArchiveNotificationPolling(payload?.generated_archive_id || null);
|
||||
return payload;
|
||||
} catch (err) {
|
||||
showToast(err?.message || 'Pokretanje generiranja ZIP arhive putnih naloga nije uspjelo.', 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') {
|
||||
if (!downloadUrl) {
|
||||
showToast('Nedostaje URL za preuzimanje PDF-a.', 'error');
|
||||
@@ -865,6 +1138,22 @@ export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'documen
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadGeneratedArchiveByUrl(downloadUrl, filename = 'archive.zip') {
|
||||
if (!downloadUrl) {
|
||||
showToast('Nedostaje URL za preuzimanje ZIP arhive.', 'error');
|
||||
throw new Error('Nedostaje URL za preuzimanje ZIP arhive.');
|
||||
}
|
||||
try {
|
||||
const blob = await api.get(downloadUrl, { responseType: 'blob' });
|
||||
saveBlobToFile(blob, filename);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = err?.message || 'Preuzimanje ZIP arhive nije uspjelo.';
|
||||
showToast(msg, 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function openWorkOrderInvoicesPdfPage(workOrderId) {
|
||||
if (!isBrowser() || !workOrderId) {
|
||||
return;
|
||||
@@ -887,9 +1176,19 @@ export async function sendWorkOrderEmail(workOrderId, payload = {}, options = {}
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/send-email/`, payload);
|
||||
const recipients = Array.isArray(payload?.recipients)
|
||||
? payload.recipients.filter(Boolean)
|
||||
: [];
|
||||
const normalizedPayload = {
|
||||
...payload,
|
||||
recipients: recipients.length > 0 ? recipients : undefined,
|
||||
};
|
||||
if (!normalizedPayload.recipients || normalizedPayload.recipients.length === 0) {
|
||||
delete normalizedPayload.recipients;
|
||||
}
|
||||
const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/send-email/`, normalizedPayload);
|
||||
if (options.toast !== false) {
|
||||
showToast('Email za putni nalog je poslan.', 'success');
|
||||
showToast('Zahtjev za slanje emaila je pokrenut u pozadini.', 'info');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -907,6 +1206,34 @@ export async function sendServiceRecordEmail(serviceRecordId, payload = {}, opti
|
||||
if (!serviceRecordId) {
|
||||
throw new Error('Service record ID je obavezan.');
|
||||
}
|
||||
const recipients = Array.isArray(payload?.recipients)
|
||||
? payload.recipients.filter(Boolean)
|
||||
: [];
|
||||
if (recipients.length > 0) {
|
||||
const failures = [];
|
||||
for (const recipient of recipients) {
|
||||
try {
|
||||
await api.post(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/send-email/`, {
|
||||
...payload,
|
||||
recipient,
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push({ recipient, error });
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
const firstError = failures[0]?.error;
|
||||
const message = firstError?.message || `Neuspješno slanje za ${failures.length} primatelja.`;
|
||||
const batchError = new Error(message);
|
||||
batchError.failures = failures;
|
||||
throw batchError;
|
||||
}
|
||||
if (options.toast !== false) {
|
||||
showToast('Email za servisni zapis je poslan.', 'success');
|
||||
}
|
||||
return { sent: true, recipients };
|
||||
}
|
||||
|
||||
const response = await api.post(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/send-email/`, payload);
|
||||
if (options.toast !== false) {
|
||||
showToast('Email za servisni zapis je poslan.', 'success');
|
||||
|
||||
@@ -3,6 +3,7 @@ import Pusher from 'pusher-js';
|
||||
import { api } from '../services/apiClient';
|
||||
import { $accessToken, $user } from './authStore';
|
||||
import { showToast } from './toastStore';
|
||||
import { fetchServiceNotes } from './serviceNotesStore';
|
||||
|
||||
export const $notifications = atom([]);
|
||||
export const $isRealtimeConnected = atom(false);
|
||||
@@ -84,6 +85,9 @@ function attachChannelHandlers(channel) {
|
||||
}
|
||||
|
||||
$notifications.set([next, ...existing]);
|
||||
if (next?.metadata?.entity_type === 'service_note') {
|
||||
fetchServiceNotes().catch(() => {});
|
||||
}
|
||||
showToast(next.title, next.level === 'warning' || next.level === 'critical' ? 'error' : 'success');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function fetchServiceNotes() {
|
||||
export async function createServiceNote(data) {
|
||||
const payload = await api.post('fleet/service-notes/', data ?? {});
|
||||
const nextNotes = Array.isArray(payload?.notes) ? payload.notes : null;
|
||||
if (nextNotes) {
|
||||
if (nextNotes && nextNotes.length > 0) {
|
||||
$serviceNotes.set(nextNotes);
|
||||
} else {
|
||||
await fetchServiceNotes();
|
||||
|
||||
@@ -72,3 +72,45 @@ body.modal-open #main-content {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Checkbox readability across light/dark themes */
|
||||
input[type="checkbox"].erp-checkbox {
|
||||
appearance: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 1px solid var(--app-border-hairline);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--app-canvas-base);
|
||||
display: inline-grid;
|
||||
place-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="checkbox"].erp-checkbox::before {
|
||||
content: "";
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
transform: scale(0);
|
||||
transition: transform 120ms ease-in-out;
|
||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0, 43% 62%);
|
||||
box-shadow: inset 1em 1em #ffffff;
|
||||
}
|
||||
|
||||
input[type="checkbox"].erp-checkbox:checked {
|
||||
background: var(--app-brand-primary);
|
||||
border-color: var(--app-brand-primary);
|
||||
}
|
||||
|
||||
input[type="checkbox"].erp-checkbox:checked::before {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
input[type="checkbox"].erp-checkbox:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input[type="checkbox"].erp-checkbox:focus-visible {
|
||||
outline: 2px solid var(--app-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
Reference in New Issue
Block a user