19 Commits

Author SHA1 Message Date
mariomitte
61bc772054 fix: reduce memory usage for monthly ZIP archive flow
Replace generated archive download response with FileResponse streaming so ZIP files are not fully loaded into process memory.

Move monthly archive task output to a temporary file and upload that file to storage, and write invoice attachments into ZIP archives in chunks.

Add Celery memory guard settings, a memory-aware base task hook, and periodic /tmp cleanup for prefixed archive files older than one day.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-05 08:15:30 +02:00
mariomitte
b985c285f9 fix: apply EXIF orientation to service record images in PDF/DOCX
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Smartphone photos often have EXIF orientation metadata (tags 6, 8, 3)
that rotates the display but doesn't transform the pixel data. When
PDFs/DOCX embedded images without applying this metadata, they appear
rotated 90/180/270 degrees.

Use ImageOps.exif_transpose(img) before resize/convert in both
_compress_image_for_pdf() and _compress_image_for_docx() to read EXIF
orientation and transpose the actual pixel data accordingly. This is
a standard Pillow function and is a no-op for images without EXIF.

Add regression test test_compress_image_for_docx_handles_exif_orientation
to verify images with EXIF tag 0x0112 (orientation=6) are transposed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 11:08:16 +02:00
mariomitte
aa690d4ac4 fix: increase image megapixel limit from 8 to 30 MP to include standard phone photos
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Previous limit of 8 MP was rejecting standard smartphone photos (12-20 MP),
causing service record PDFs/DOCX documents to be generated without photos.

This was a regression from the image timeout fix: the megapixel guard was
designed to prevent processing of pathologically large files (preventing
worker timeouts), but the threshold was set too aggressively.

Increase limit to 30 MP to allow standard device cameras while still
rejecting extreme outliers that would cause timeout/OOM.

Add regression test test_compress_image_for_docx_keeps_standard_phone_photos
to verify 12 MP photos are accepted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 10:54:40 +02:00
mariomitte
52408bc5a9 fix: BILINEAR+BaseException catch to fix image worker timeout in PDF/DOCX
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Root cause was WORKER TIMEOUT, not OOM:
- Gunicorn sends SIGABRT to workers that exceed 60s timeout
- SIGABRT handler calls sys.exit(1) raising SystemExit(BaseException)
- except Exception does NOT catch SystemExit, so the worker crashes

Two fixes:
1. except BaseException — catches SystemExit so the worker survives
   and gracefully skips the image (returns None) instead of dying
2. Image.BILINEAR instead of LANCZOS — orders of magnitude faster
   for large images, ensures processing completes well within 60s
3. Reduce max_width 1600->800, megapixel limit 20->8 MP, optimize=True removed
   (these reduce processing time further)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 10:43:27 +02:00
mariomitte
8e9d23fcfa fix: thumbnail-first + megapixel guard to prevent image OOM in PDF/DOCX
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Previous draft()+convert()+thumbnail() ordering still caused OOM for
non-JPEG formats (PNG/HEIC) because draft() is a no-op for those formats,
and convert('RGB') forces a full pixel decode regardless.

Replace with thumbnail()-first ordering:
- thumbnail() internally calls draft() for JPEG before decoding
- thumbnail() performs in-place resize without allocating a second
  full-resolution buffer
- convert('RGB') is then called on the already-small image (safe for
  any format)

Add _COMPRESS_IMAGE_MAX_MEGAPIXELS guard (20 MP): read image dimensions
from headers only (no pixel decode) and return None for images that
exceed the limit. This prevents OOM even for pathologically large files
where draft() provides no benefit (e.g. PNG, TIFF, HEIC).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 10:10:01 +02:00
mariomitte
a7220d27f5 fix: use draft()+thumbnail() to prevent OOM kill on large image resize
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
PIL img.resize() on high-resolution photos was exhausting worker RAM,
causing Gunicorn to SIGKILL the worker mid-request (production OOM crash).

Replace the explicit resize() path in both _compress_image_for_pdf() and
_compress_image_for_docx() with:
  - Image.draft() — hints the JPEG decoder to decode at a lower resolution
  - Image.thumbnail() — in-place resize that avoids allocating a second
    full-resolution buffer

This keeps peak memory proportional to the output size instead of the
original file size, preventing the worker from being killed when
processing multi-megapixel service-record photos.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 09:51:21 +02:00
mariomitte
1e68c663f2 fix: scope inferred work-order tasks to the current work order
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Prevent service-record inference from pulling tasks from other work orders
that share the same vehicle. This keeps task-service-context, service
reports, and related work-order widgets isolated to the selected nalog.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 08:32:41 +02:00
mariomitte
2826ee9bab fix: invalidate stale service records PDF cache on service record changes
Ensure admin/API deletes and updates of service records, photos, and
attachments invalidate the cached service-records PDF. Also harden cached
PDF lookup/download paths so a missing file is treated as stale cache
instead of a 500.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 07:32:05 +02:00
mariomitte
9bd5f53675 fix: normalize task datetimes before travel expense min/max
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Prevent 500 errors on travel-expenses-table when work-hour entries mix
naive and timezone-aware datetimes. Normalize all candidate datetimes to
a consistent timezone before min/max comparisons.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-10 08:17:49 +02:00
mariomitte
d2dc37aef9 fix: repair service-worker.js duplicate install listener causing script eval failure
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
apply_patch previously duplicated the install event listener, causing a
ServiceWorker script evaluation error on load. Deduplicate and hoist
precacheShell() before the install listener.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-10 07:53:09 +02:00
mariomitte
e3b3ff90a9 manifest manifest.webmanifest
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-08-10 07:45:08 +02:00
mariomitte
024bf59733 fix: add 0037 migration to align WorkOrderTravelExpensesTable BaseModel fields
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
BaseModel defines id with uuid4 default, help_text, and verbose_name on
created_at/updated_at/is_active. Migration 0036 omitted these, causing
Django to detect pending model changes and refuse to migrate on the server.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-08 05:41:20 +02:00
mariomitte
8577aaa01b fix: add editable work order travel expenses
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Introduce a dedicated travel-expenses table for work orders so Broj sati, Količina dnevnica, and Iznos dnevnice can be reviewed and edited before PDF generation. The PDF now uses the stored travel-expenses values, with default HR rate and cache invalidation on updates.
2026-08-08 03:52:41 +02:00
mariomitte
a9ab83c202 fix: apply travel-day quantity rules
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Use business-rule based daily quantity for travel expense calculations and cover it with regression tests.
2026-08-08 03:09:24 +02:00
mariomitte
681f73d85a fix: calculate PREKOVREMENI as total hours minus regular 8h
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
PREKOVREMENI (overtime hours) is now correctly calculated as the difference between total work+travel hours and the standard 8-hour workday.
2026-08-08 01:50:11 +02:00
mariomitte
181cab1f45 fix: sync multi-day work hours accounting
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Aggregate task work-hour rows into travel-cost calculations and monthly servicer reporting, and keep the frontend calendar aligned with the task work-hours source.
2026-08-08 01:32:17 +02:00
mariomitte
38f1786469 predzadnji unstagaeni dijelovi
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-08-07 10:27:16 +02:00
mariomitte
f37d130bc7 Reapply "predzadnji unstagaeni dijelovi"
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
This reverts commit fe4ba3b5dd.
2026-08-07 10:20:32 +02:00
mariomitte
fe4ba3b5dd Revert "predzadnji unstagaeni dijelovi"
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
This reverts commit 1f6c0d6086.
2026-08-07 10:15:35 +02:00
22 changed files with 1948 additions and 230 deletions

View File

@@ -7,7 +7,7 @@ Nakon što je gitea webhook i testirana skripta za automatsko povlačenje promje
```bash
cd /opt/erp/app
git fetch origin
git reset --hard origin/main
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

View File

@@ -1,7 +1,12 @@
# backend/core/celery.py
import os
import gc
import logging
from celery import Celery
from celery import Task
logger = logging.getLogger(__name__)
# Postavi Django settings modul
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
@@ -11,5 +16,42 @@ app = Celery('core')
# Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_'
app.config_from_object('django.conf:settings', namespace='CELERY')
def _rss_memory_mb():
try:
import resource
usage_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
# Na Linuxu je ru_maxrss u KiB.
return round(float(usage_kb) / 1024.0, 2)
except (ImportError, AttributeError, OSError, ValueError):
return None
class MemoryAwareTask(Task):
abstract = True
def __call__(self, *args, **kwargs):
gc.collect()
return super().__call__(*args, **kwargs)
def after_return(self, status, retval, task_id, args, kwargs, einfo):
memory_before_gc = _rss_memory_mb()
gc.collect()
memory_after_gc = _rss_memory_mb()
if memory_before_gc is not None and memory_after_gc is not None:
logger.info(
"Task %s (%s) status=%s RSS prije/poslije GC: %.2fMB -> %.2fMB",
self.name,
task_id,
status,
memory_before_gc,
memory_after_gc,
)
return super().after_return(status, retval, task_id, args, kwargs, einfo)
app.Task = MemoryAwareTask
# Automatski pronalazi taskove u svim 'modules.*'
app.autodiscover_tasks()

View File

@@ -154,6 +154,11 @@ 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 = int(os.environ.get('CELERY_WORKER_PREFETCH_MULTIPLIER', '1'))
CELERY_WORKER_MAX_TASKS_PER_CHILD = int(os.environ.get('CELERY_WORKER_MAX_TASKS_PER_CHILD', '20'))
CELERY_WORKER_MAX_MEMORY_PER_CHILD = int(os.environ.get('CELERY_WORKER_MAX_MEMORY_PER_CHILD', '350000'))
CELERY_TASK_ACKS_LATE = True
CELERY_TASK_REJECT_ON_WORKER_LOST = True
# 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
@@ -171,10 +176,18 @@ CACHES = {
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_BEAT_SCHEDULE = {
'cleanup-expired-generated-archives-hourly': {
'task': 'modules.fleet.tasks.cleanup_expired_generated_archives_task',
'schedule': crontab(minute=5),
},
'cleanup-expired-generated-pdfs-hourly': {
'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task',
'schedule': crontab(minute=0),
},
'cleanup-app-tmp-daily': {
'task': 'modules.fleet.tasks.cleanup_tmp_files_task',
'schedule': crontab(hour=3, minute=15),
},
'notify-upcoming-tasks-daily': {
'task': 'modules.task_management.tasks.notify_upcoming_tasks',
'schedule': crontab(hour=8, minute=0),
@@ -190,6 +203,13 @@ STATIC_URL = '/static/'
# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente)
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
APP_TMP_CLEANUP_DIR = os.environ.get('APP_TMP_CLEANUP_DIR', '/tmp')
APP_TMP_CLEANUP_MAX_AGE_HOURS = int(os.environ.get('APP_TMP_CLEANUP_MAX_AGE_HOURS', '24'))
APP_TMP_CLEANUP_PREFIXES = [
prefix.strip()
for prefix in os.environ.get('APP_TMP_CLEANUP_PREFIXES', 'erp-fleet-archive-').split(',')
if prefix.strip()
]
# ==============================================================================

View File

@@ -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'],
},
),
]

View File

@@ -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'),
),
]

View File

@@ -2,10 +2,12 @@ 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
@@ -142,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")),
@@ -417,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,

View File

@@ -14,6 +14,7 @@ from .models import (
VehicleNotification,
WorkOrder,
WorkOrderAdditionalCostsTable,
WorkOrderTravelExpensesTable,
WorkOrderPhoto,
WorkOrderInvoice,
VehicleServiceRecord,
@@ -501,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

View File

@@ -5,6 +5,7 @@ from django.core.mail import EmailMessage
from django.conf import settings
from django.utils import timezone
from django.core.files.base import ContentFile
from django.core.files import File
import logging
from io import BytesIO
from io import StringIO
@@ -13,6 +14,9 @@ import base64
import re
import csv
import mimetypes
import os
import tempfile
import zipfile
from decimal import Decimal, InvalidOperation
from smtplib import SMTPSenderRefused
from datetime import timedelta
@@ -838,10 +842,9 @@ def process_work_order_invoice_ocr(invoice_id):
@shared_task
def build_monthly_archive_cached_task(generated_archive_id):
from .models import GeneratedFleetArchive
from .services import NotificationService
from .views import (
_build_monthly_service_tasks_archive_content,
_build_monthly_work_orders_archive_content,
_write_monthly_service_tasks_archive_entries,
_write_monthly_work_orders_archive_entries,
_notify_monthly_archive_request,
)
@@ -854,25 +857,39 @@ def build_monthly_archive_cached_task(generated_archive_id):
if generated is None:
return {"status": "failed", "error": "Generated archive record not found"}
tmp_dir = str(getattr(settings, 'APP_TMP_CLEANUP_DIR', '/tmp'))
with tempfile.NamedTemporaryFile(
mode='w+b',
delete=False,
dir=tmp_dir if os.path.isdir(tmp_dir) else None,
prefix='erp-fleet-archive-',
suffix='.zip',
) as temp_zip:
temp_zip_path = temp_zip.name
try:
if generated.archive_type == 'service_tasks':
archive_content = _build_monthly_service_tasks_archive_content(
user=generated.requested_by,
year=generated.year,
month=generated.month,
)
else:
archive_content = _build_monthly_work_orders_archive_content(
user=generated.requested_by,
year=generated.year,
month=generated.month,
)
if not archive_content:
with zipfile.ZipFile(temp_zip_path, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
if generated.archive_type == 'service_tasks':
entries_written = _write_monthly_service_tasks_archive_entries(
archive,
user=generated.requested_by,
year=generated.year,
month=generated.month,
)
else:
entries_written = _write_monthly_work_orders_archive_entries(
archive,
user=generated.requested_by,
year=generated.year,
month=generated.month,
)
if entries_written == 0:
raise ValueError('ZIP arhiva je prazna.')
if os.path.getsize(temp_zip_path) <= 0:
raise ValueError('ZIP arhiva je prazna.')
filename = generated.filename or f"{generated.archive_type}-{generated.year}-{generated.month}.zip"
generated.file.save(filename, ContentFile(archive_content), save=False)
with open(temp_zip_path, 'rb') as temp_file:
generated.file.save(filename, File(temp_file), save=False)
generated.status = 'ready'
generated.error_message = ''
generated.save(update_fields=['file', 'status', 'error_message', 'updated_at'])
@@ -902,6 +919,12 @@ def build_monthly_archive_cached_task(generated_archive_id):
)
logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc)
return {"status": "failed", "error": str(exc)}
finally:
try:
if os.path.exists(temp_zip_path):
os.remove(temp_zip_path)
except OSError:
logger.warning("Ne mogu obrisati privremenu ZIP datoteku: %s", temp_zip_path)
@shared_task
@@ -944,6 +967,46 @@ def cleanup_expired_generated_pdfs_task():
return {"deleted": deleted}
@shared_task
def cleanup_tmp_files_task():
tmp_dir = str(getattr(settings, 'APP_TMP_CLEANUP_DIR', '/tmp'))
max_age_hours = int(getattr(settings, 'APP_TMP_CLEANUP_MAX_AGE_HOURS', 24))
prefixes = tuple(getattr(settings, 'APP_TMP_CLEANUP_PREFIXES', ['erp-fleet-archive-']))
if max_age_hours <= 0:
raise ValueError('APP_TMP_CLEANUP_MAX_AGE_HOURS mora biti > 0.')
if not prefixes:
raise ValueError('APP_TMP_CLEANUP_PREFIXES ne smije biti prazan.')
if not os.path.isdir(tmp_dir):
return {'deleted': 0, 'tmp_dir': tmp_dir, 'reason': 'tmp-dir-not-found'}
now = timezone.now().timestamp()
cutoff = now - (max_age_hours * 3600)
deleted = 0
for name in os.listdir(tmp_dir):
if not any(name.startswith(prefix) for prefix in prefixes):
continue
full_path = os.path.join(tmp_dir, name)
try:
stat_info = os.stat(full_path)
except FileNotFoundError:
continue
if stat_info.st_mtime > cutoff:
continue
if os.path.isdir(full_path):
try:
os.rmdir(full_path)
except OSError:
logger.warning("Preskačem %s: direktorij nije prazan ili je nedostupan.", full_path)
continue
else:
os.remove(full_path)
deleted += 1
return {'deleted': deleted, 'tmp_dir': tmp_dir, 'max_age_hours': max_age_hours}
@shared_task
def build_work_order_pdf_cached_task(generated_pdf_id):
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf

View File

@@ -24,8 +24,14 @@ from modules.fleet.models import (
GeneratedFleetArchive,
VehicleNotification,
)
from modules.task_management.models import Task
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'):
@@ -235,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'),
@@ -273,6 +331,76 @@ class WorkOrderImagesEndpointTests(TestCase):
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,
@@ -467,6 +595,127 @@ class WorkOrderImagesEndpointTests(TestCase):
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/',

View File

@@ -9,12 +9,12 @@ import threading
import zipfile
import re
from collections import OrderedDict
from datetime import timedelta
from datetime import datetime, timedelta
from pathlib import Path
from decimal import Decimal, InvalidOperation
from django.contrib.auth import get_user_model
from PIL import Image, UnidentifiedImageError
from PIL import Image, ImageOps, UnidentifiedImageError
from django.conf import settings
from django.core.mail import EmailMessage
from django.core.exceptions import ValidationError as DjangoValidationError
@@ -53,6 +53,7 @@ from .models import (
GeneratedFleetArchive,
WorkOrderInvoice,
WorkOrderAdditionalCostsTable,
WorkOrderTravelExpensesTable,
VehicleServiceRecord,
VehicleServicePhoto,
VehicleServiceAttachment,
@@ -63,6 +64,7 @@ from .serializers import (
WorkOrderSerializer,
WorkOrderInvoiceSerializer,
WorkOrderAdditionalCostsTableSerializer,
WorkOrderTravelExpensesTableSerializer,
WorkOrderPhotoSerializer,
VehicleServiceRecordSerializer,
VehicleNotificationSerializer,
@@ -153,6 +155,7 @@ def _work_order_related_tasks_queryset(work_order):
vehicle_id=work_order.vehicle_id,
task_id__isnull=False,
task__is_active=True,
task__work_order=work_order,
).values_list('task_id', flat=True)
)
task_ids = list({*direct_task_ids, *inferred_task_ids})
@@ -162,6 +165,215 @@ def _work_order_related_tasks_queryset(work_order):
'assigned_to', 'vehicle', 'work_order', 'work_hours_table'
).order_by('-created_at')
def _parse_report_date_value(value):
text = str(value or '').strip()
if not text:
return None
for fmt in ('%Y-%m-%d', '%d.%m.%Y.', '%d.%m.%Y'):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return None
def _parse_report_time_value(value):
text = str(value or '').strip()
if not text:
return None
for fmt in ('%H:%M', '%H:%M:%S'):
try:
return datetime.strptime(text, fmt).time()
except ValueError:
continue
return None
def _parse_report_decimal_value(value):
if value in (None, ''):
return Decimal('0.00')
try:
return Decimal(str(value).strip().replace(',', '.'))
except (InvalidOperation, TypeError, ValueError):
return Decimal('0.00')
def _calculate_daily_quantity_from_hours(total_hours):
numeric = Decimal(str(total_hours)) if total_hours not in (None, '') else Decimal('0.00')
if numeric <= 0:
return 0.0
if numeric < Decimal('8'):
return 0.0
if numeric < Decimal('12'):
return 0.5
return 1.0
def _task_work_hours_entries(task):
table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None)
rows = table_data.get('rows', []) if isinstance(table_data, dict) else []
if not isinstance(rows, list):
rows = []
entries = []
work_order = getattr(task, 'work_order', None)
fallback_date = getattr(task, 'scheduled_date', None)
task_vehicle = getattr(task, 'vehicle', None)
task_client = getattr(task_vehicle, 'client', None)
for raw_row in rows:
if not isinstance(raw_row, dict):
continue
row_date = _parse_report_date_value(raw_row.get('date')) or fallback_date
if row_date is None:
continue
start_text = str(raw_row.get('work_time_from') or raw_row.get('travel_time_from') or '').strip()
end_text = str(raw_row.get('work_time_to') or raw_row.get('travel_time_to') or '').strip()
start_time = _parse_report_time_value(start_text)
end_time = _parse_report_time_value(end_text)
start_dt = datetime.combine(row_date, start_time) if start_time else None
end_dt = datetime.combine(row_date, end_time) if end_time else None
if start_dt and end_dt and end_dt <= start_dt:
end_dt += timedelta(days=1)
work_hours = _parse_report_decimal_value(raw_row.get('work_hours'))
travel_hours = _parse_report_decimal_value(raw_row.get('travel_hours'))
entries.append({
'date': row_date,
'date_label': row_date.strftime('%d.%m.%Y.'),
'start_dt': start_dt,
'end_dt': end_dt,
'work_hours': work_hours,
'travel_hours': travel_hours,
'total_hours': work_hours + travel_hours,
'title': str(getattr(task, 'title', '') or '').strip(),
'serial': str(getattr(task_vehicle, 'crane_serial_number', '') or '').strip(),
'client': str(getattr(task_client, 'name', '') or '').strip(),
'location': str(getattr(work_order, 'location', '') or '').strip(),
'work_order_label': str(getattr(work_order, 'display_code', '') or '').strip(),
})
if entries:
return entries
if work_order and work_order.travel_start_at and work_order.travel_end_at and work_order.travel_end_at > work_order.travel_start_at:
start_dt = timezone.localtime(work_order.travel_start_at)
end_dt = timezone.localtime(work_order.travel_end_at)
fallback_date = fallback_date or start_dt.date()
travel_hours = Decimal(str((end_dt - start_dt).total_seconds() / 3600.0)).quantize(Decimal('0.01'))
return [{
'date': fallback_date,
'date_label': fallback_date.strftime('%d.%m.%Y.') if fallback_date else '-',
'start_dt': start_dt,
'end_dt': end_dt,
'work_hours': Decimal('0.00'),
'travel_hours': travel_hours,
'total_hours': travel_hours,
'title': str(getattr(task, 'title', '') or '').strip(),
'serial': str(getattr(task_vehicle, 'crane_serial_number', '') or '').strip(),
'client': str(getattr(task_client, 'name', '') or '').strip(),
'location': str(getattr(work_order, 'location', '') or '').strip(),
'work_order_label': str(getattr(work_order, 'display_code', '') or '').strip(),
}]
return []
return []
def _format_decimal_display(value, *, default='0'):
if value in (None, ''):
return default
try:
normalized = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return str(value)
text = format(normalized.normalize(), 'f')
if '.' in text:
text = text.rstrip('0').rstrip('.')
return text.replace('.', ',') or default
def _format_decimal_fixed(value, *, default='0.00', places=2):
if value in (None, ''):
return default
try:
normalized = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return str(value)
quantizer = Decimal('1').scaleb(-places)
return format(normalized.quantize(quantizer), 'f')
def _normalize_datetime_for_compare(value):
if not isinstance(value, datetime):
return value
if timezone.is_naive(value):
return timezone.make_aware(value, timezone.get_current_timezone())
return timezone.localtime(value)
def _work_order_travel_expenses_context(work_order):
related_tasks = list(_work_order_related_tasks_queryset(work_order))
trip_entries = []
for task in related_tasks:
trip_entries.extend(_task_work_hours_entries(task))
travel_start = getattr(work_order, 'travel_start_at', None)
travel_end = getattr(work_order, 'travel_end_at', None)
trip_start_date = travel_start.date() if travel_start else getattr(work_order, 'date', None)
trip_end_date = travel_end.date() if travel_end else getattr(work_order, 'date', None)
total_hours = sum((entry['total_hours'] for entry in trip_entries), Decimal('0.00'))
if trip_entries:
entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')]
start_candidates = [
_normalize_datetime_for_compare(entry.get('start_dt'))
for entry in trip_entries
if entry.get('start_dt')
]
end_candidates = [
_normalize_datetime_for_compare(entry.get('end_dt'))
for entry in trip_entries
if entry.get('end_dt')
]
if entry_dates:
trip_start_date = min(entry_dates)
trip_end_date = max(entry_dates)
if start_candidates:
travel_start = min(start_candidates)
if end_candidates:
travel_end = max(end_candidates)
table = getattr(work_order, 'travel_expenses_table', None)
if table:
broj_sati = Decimal(str(table.broj_sati or '0'))
kolicina_dnevnica = Decimal(str(table.kolicina_dnevnica or '0'))
iznos_dnevnica = Decimal(str(table.iznos_dnevnica or '0'))
daily_rate_country = str(table.daily_rate_country or WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY)
total_for_payout = Decimal(str(table.total_for_payout or '0')).quantize(Decimal('0.01'))
else:
broj_sati = total_hours.quantize(Decimal('0.01'))
kolicina_dnevnica = Decimal(str(_calculate_daily_quantity_from_hours(broj_sati)))
daily_rate_country = WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY
iznos_dnevnica = WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT
total_for_payout = (kolicina_dnevnica * iznos_dnevnica).quantize(Decimal('0.01'))
return {
'related_tasks': related_tasks,
'trip_entries': trip_entries,
'travel_start': travel_start,
'travel_end': travel_end,
'trip_start_date': trip_start_date,
'trip_end_date': trip_end_date,
'broj_sati': broj_sati,
'kolicina_dnevnica': kolicina_dnevnica,
'iznos_dnevnica': iznos_dnevnica,
'daily_rate_country': daily_rate_country,
'total_for_payout': total_for_payout,
}
def _can_access_service_record(user, service_record):
service_record_id = getattr(service_record, 'pk', service_record)
return _service_records_queryset_for_user(user).filter(pk=service_record_id).exists()
@@ -252,24 +464,32 @@ def _resolve_service_report_tasks(work_order, task_id):
return [selected_task], selected_task
def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
_COMPRESS_IMAGE_MAX_MEGAPIXELS = 30 # preskači tek vrlo velike slike (>30 MP); standardni telefoni 1220 MP ostaju uključeni
def _compress_image_for_pdf(image_field, max_width=800, quality=75):
"""
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna.
BILINEAR umjesto LANCZOS: višestruko brže za velike slike (izbjeći Gunicorn timeout).
Megapixel guard čita samo header i preskače slike > 8 MP bez dekodiranja piksela.
except BaseException hvata i SystemExit koji Gunicorn diže na SIGABRT (worker timeout).
"""
try:
image_field.open('rb')
with Image.open(image_field) as src:
img = src.convert('RGB')
if img.width > max_width:
ratio = max_width / float(img.width)
new_h = max(1, int(img.height * ratio))
img = img.resize((max_width, new_h), Image.LANCZOS)
with Image.open(image_field) as img:
img = ImageOps.exif_transpose(img)
w, h = img.size
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
return None
img.thumbnail((max_width, max_width * 2), Image.BILINEAR)
rgb = img.convert('RGB')
buf = BytesIO()
img.save(buf, format='JPEG', quality=quality, optimize=True)
rgb.save(buf, format='JPEG', quality=quality)
buf.seek(0)
return ImageReader(buf)
except Exception:
except BaseException:
return None
finally:
try:
@@ -278,23 +498,28 @@ def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
pass
def _compress_image_for_docx(image_field, max_width=1600, quality=80):
def _compress_image_for_docx(image_field, max_width=800, quality=80):
"""
Pripremi sliku za python-docx kao JPEG stream razumne veličine.
BILINEAR umjesto LANCZOS: višestruko brže za velike slike (izbjeći Gunicorn timeout).
Megapixel guard čita samo header i preskače slike > 8 MP bez dekodiranja piksela.
except BaseException hvata i SystemExit koji Gunicorn diže na SIGABRT (worker timeout).
"""
try:
image_field.open('rb')
with Image.open(image_field) as src:
img = src.convert('RGB')
if img.width > max_width:
ratio = max_width / float(img.width)
new_h = max(1, int(img.height * ratio))
img = img.resize((max_width, new_h), Image.LANCZOS)
with Image.open(image_field) as img:
img = ImageOps.exif_transpose(img)
w, h = img.size
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
return None
img.thumbnail((max_width, max_width * 2), Image.BILINEAR)
rgb = img.convert('RGB')
buf = BytesIO()
img.save(buf, format='JPEG', quality=quality, optimize=True)
rgb.save(buf, format='JPEG', quality=quality)
buf.seek(0)
return buf
except Exception:
except BaseException:
return None
finally:
try:
@@ -321,10 +546,30 @@ def _get_cached_pdf(work_order, pdf_type):
)
for candidate in candidates:
if str(candidate.filename or '').strip() == expected_filename:
return candidate
file_name = getattr(candidate.file, 'name', '')
storage = getattr(candidate.file, 'storage', None)
if file_name and storage:
try:
if storage.exists(file_name):
return candidate
except (FileNotFoundError, OSError, ValueError):
pass
candidate.file = None
candidate.is_active = False
candidate.status = 'failed'
candidate.error_message = 'PDF datoteka nije dostupna.'
candidate.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
return None
def _mark_generated_pdf_failed(generated_pdf, error_message):
generated_pdf.file = None
generated_pdf.is_active = False
generated_pdf.status = 'failed'
generated_pdf.error_message = error_message
generated_pdf.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
def _cleanup_expired_generated_pdfs():
now = timezone.now()
expired = GeneratedWorkOrderPdf.objects.filter(
@@ -335,10 +580,11 @@ def _cleanup_expired_generated_pdfs():
for item in expired:
if item.file:
item.file.delete(save=False)
item.file = None
item.is_active = False
item.status = 'failed'
item.error_message = 'PDF cache istekao.'
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
item.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
def _parse_amount_decimal(value):
@@ -361,10 +607,11 @@ def _invalidate_work_order_pdf_cache(work_order, *, pdf_types=None):
for cached in cache_qs:
if cached.file:
cached.file.delete(save=False)
cached.file = None
cached.is_active = False
cached.status = 'failed'
cached.error_message = 'PDF cache invalidiran zbog promjene podataka.'
cached.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
cached.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
def _upsert_additional_cost_row_from_invoice(invoice):
@@ -396,7 +643,11 @@ def _upsert_additional_cost_row_from_invoice(invoice):
def _cached_pdf_file_response(generated_pdf, *, default_filename):
generated_pdf.file.open('rb')
try:
generated_pdf.file.open('rb')
except (FileNotFoundError, OSError, ValueError):
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
filename = generated_pdf.filename or default_filename
response = FileResponse(generated_pdf.file, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
@@ -600,13 +851,18 @@ def _build_work_order_pdf(work_order):
)
creator_residence = (getattr(creator, 'residence', None) or '').strip() or "-"
creator_work_position = (getattr(creator, 'work_position', None) or '').strip() or creator_occupation
travel_start = work_order.travel_start_at
travel_end = work_order.travel_end_at
travel_hours = _hours_between(travel_start, travel_end)
daily_qty = round(travel_hours / 8.0, 1) if travel_hours > 0 else 0.0
daily_rate = 0.0
daily_total = daily_qty * daily_rate
transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0)
travel_context = _work_order_travel_expenses_context(work_order)
related_tasks = travel_context['related_tasks']
trip_entries = travel_context['trip_entries']
travel_start = travel_context['travel_start']
travel_end = travel_context['travel_end']
trip_start_date = travel_context['trip_start_date']
trip_end_date = travel_context['trip_end_date']
travel_hours = travel_context['broj_sati']
daily_qty = travel_context['kolicina_dnevnica']
daily_rate = travel_context['iznos_dnevnica']
daily_total = travel_context['total_for_payout']
transport_total = _parse_decimal(work_order.servicer_vehicle_fuel_cost)
place_label = (work_order.location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
origin_label = (work_order.origin_location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
additional_table = getattr(work_order, 'additional_costs_table', None)
@@ -615,7 +871,7 @@ def _build_work_order_pdf(work_order):
if additional_table and isinstance(additional_table.data, dict):
additional_rows_data = additional_table.data.get('rows', []) if isinstance(additional_table.data.get('rows', []), list) else []
additional_total_decimal = _parse_decimal(additional_table.total_for_payout)
grand_total = daily_total + transport_total + float(additional_total_decimal)
grand_total = daily_total + transport_total + additional_total_decimal
attachment_names = [
str(row.get('prilog', '')).strip()
@@ -785,12 +1041,12 @@ def _build_work_order_pdf(work_order):
["OBRAČUN PUTNIH TROŠKOVA", "", "", "", "", "", "", ""],
["ODLAZAK Datum", "ODLAZAK Vrijeme", "POVRATAK Datum", "POVRATAK Vrijeme", "Broj sati", "Količina dnevnica", "Iznos dnevnice", "Ukupan iznos"],
[
_fmt_date(travel_start.date() if travel_start else work_order.date),
_fmt_date(trip_start_date or work_order.date),
_fmt_time(travel_start),
_fmt_date(travel_end.date() if travel_end else work_order.date),
_fmt_date(trip_end_date or work_order.date),
_fmt_time(travel_end),
str(travel_hours).replace('.', ','),
str(daily_qty).replace('.', ','),
_format_decimal_display(travel_hours),
_format_decimal_display(daily_qty),
_fmt_eur(daily_rate),
_fmt_eur(daily_total),
],
@@ -2194,6 +2450,26 @@ def _file_attachment(file_field, fallback_name):
return (filename, content, _guess_content_type(filename))
def _write_file_field_to_zip(archive, *, file_field, entry_name, chunk_size=64 * 1024):
if not file_field:
return False
file_field.open('rb')
try:
first_chunk = file_field.read(chunk_size)
if not first_chunk:
return False
with archive.open(entry_name, mode='w') as destination:
destination.write(first_chunk)
while True:
chunk = file_field.read(chunk_size)
if not chunk:
break
destination.write(chunk)
finally:
file_field.close()
return True
def _build_image_attachments_for_work_order(work_order):
attachments = []
photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
@@ -2348,6 +2624,7 @@ def _build_monthly_servicer_report_rows(user, year, month):
'work_order',
'work_order__vehicle',
'work_order__vehicle__client',
'work_hours_table',
)
.order_by('scheduled_date', 'created_at')
)
@@ -2358,9 +2635,10 @@ def _build_monthly_servicer_report_rows(user, year, month):
entry_date__month=month,
).order_by('entry_date')
tasks_by_date = OrderedDict()
entries_by_date = OrderedDict()
for task in tasks_qs:
tasks_by_date.setdefault(task.scheduled_date, []).append(task)
for entry in _task_work_hours_entries(task):
entries_by_date.setdefault(entry['date'], []).append(entry)
manual_by_date = {
entry.entry_date: entry
@@ -2371,8 +2649,8 @@ def _build_monthly_servicer_report_rows(user, year, month):
days_in_month = monthrange(year, month)[1]
for day in range(1, days_in_month + 1):
current_date = _date(year, month, day)
day_tasks = tasks_by_date.get(current_date, [])
if day_tasks:
day_entries = entries_by_date.get(current_date, [])
if day_entries:
titles = OrderedDict()
serials = OrderedDict()
clients = OrderedDict()
@@ -2380,53 +2658,31 @@ def _build_monthly_servicer_report_rows(user, year, month):
work_orders = OrderedDict()
start_values = []
end_values = []
counted_work_order_ids = set()
total_hours = 0.0
total_hours = Decimal('0.00')
for task in day_tasks:
title = str(task.title or '').strip()
if title:
titles[title] = title
vehicle = getattr(task, 'vehicle', None)
work_order = getattr(task, 'work_order', None)
if work_order and getattr(work_order, 'vehicle', None):
vehicle = work_order.vehicle
serial_value = str(getattr(vehicle, 'crane_serial_number', '') or '').strip()
if serial_value:
serials[serial_value] = serial_value
client_name = str(getattr(getattr(vehicle, 'client', None), 'name', '') or '').strip()
if client_name:
clients[client_name] = client_name
location = str(getattr(work_order, 'location', '') or '').strip()
if location:
locations[location] = location
display_code = str(getattr(work_order, 'display_code', '') or '').strip()
if display_code:
work_orders[display_code] = display_code
if work_order and work_order.travel_start_at and work_order.travel_end_at and work_order.travel_end_at > work_order.travel_start_at and str(work_order.id) not in counted_work_order_ids:
counted_work_order_ids.add(str(work_order.id))
start_values.append((work_order.id, timezone.localtime(work_order.travel_start_at)))
end_values.append((work_order.id, timezone.localtime(work_order.travel_end_at)))
total_hours += (work_order.travel_end_at - work_order.travel_start_at).total_seconds() / 3600.0
for entry in day_entries:
if entry.get('title'):
titles[entry['title']] = entry['title']
if entry.get('serial'):
serials[entry['serial']] = entry['serial']
if entry.get('client'):
clients[entry['client']] = entry['client']
if entry.get('location'):
locations[entry['location']] = entry['location']
if entry.get('work_order_label'):
work_orders[entry['work_order_label']] = entry['work_order_label']
if entry.get('start_dt'):
start_values.append(entry['start_dt'])
if entry.get('end_dt'):
end_values.append(entry['end_dt'])
total_hours += entry.get('total_hours', Decimal('0.00'))
start_label = '-'
end_label = '-'
if start_values:
start_label = min(value for _, value in start_values).strftime('%H:%M')
start_label = min(start_values).strftime('%H:%M')
if end_values:
end_label = max(value for _, value in end_values).strftime('%H:%M')
regular_hours = '-'
overtime_hours = '0'
if total_hours > 0:
regular_hours = _format_report_hours(min(total_hours, 8.0), default='0')
overtime_hours = _format_report_hours(max(total_hours - 8.0, 0.0), default='0')
end_label = max(end_values).strftime('%H:%M')
rows.append({
'date': current_date,
@@ -2437,8 +2693,8 @@ def _build_monthly_servicer_report_rows(user, year, month):
'mjesto_rada': ', '.join(locations.values()) or '-',
'pocetak_rada': start_label,
'kraj_rada': end_label,
'redovan_rad': regular_hours,
'prekovremeni': overtime_hours,
'redovan_rad': '8',
'prekovremeni': _format_report_hours(total_hours, default='0'),
'radni_nalog': ', '.join(value for value in work_orders.values() if value) or '-',
'source': 'task',
})
@@ -2471,7 +2727,7 @@ def _build_monthly_servicer_report_rows(user, year, month):
'mjesto_rada': '-',
'pocetak_rada': '-',
'kraj_rada': '-',
'redovan_rad': '-',
'redovan_rad': '0',
'prekovremeni': '0',
'radni_nalog': '-',
'source': 'empty',
@@ -2772,7 +3028,7 @@ def _parse_year_month_params(request):
return year, month
def _build_monthly_service_tasks_archive_content(*, user, year, month):
def _write_monthly_service_tasks_archive_entries(archive, *, user, year, month):
from modules.task_management.models import Task
tasks = list(
@@ -2792,24 +3048,37 @@ def _build_monthly_service_tasks_archive_content(*, user, year, month):
raise DRFValidationError({'detail': 'Nema servisnih taskova za odabrani mjesec.'})
used_names = set()
entries_written = 0
for task in tasks:
work_order = task.work_order
if work_order is None:
continue
docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=[task])
base_name = _service_records_docx_filename(work_order, task)
entry_name = _unique_zip_entry_name(base_name, used_names)
archive.writestr(entry_name, docx_bytes)
entries_written += 1
if entries_written == 0:
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
return entries_written
def _build_monthly_service_tasks_archive_content(*, user, year, month):
archive_buffer = BytesIO()
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
for task in tasks:
work_order = task.work_order
if work_order is None:
continue
docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=[task])
base_name = _service_records_docx_filename(work_order, task)
entry_name = _unique_zip_entry_name(base_name, used_names)
archive.writestr(entry_name, docx_bytes)
_write_monthly_service_tasks_archive_entries(
archive,
user=user,
year=year,
month=month,
)
archive_content = archive_buffer.getvalue()
if not archive_content:
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
return archive_content
def _build_monthly_work_orders_archive_content(*, user, year, month):
def _write_monthly_work_orders_archive_entries(archive, *, user, year, month):
from modules.task_management.models import Task
monthly_tasks = (
@@ -2848,23 +3117,34 @@ def _build_monthly_work_orders_archive_content(*, user, year, month):
raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'})
used_names = set()
entries_written = 0
for work_order in work_orders:
pdf_bytes = _build_work_order_pdf(work_order)
work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names)
archive.writestr(work_order_pdf_name, pdf_bytes)
entries_written += 1
display_code = _work_order_display_code(work_order)
invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at')
for index, invoice in enumerate(invoices, start=1):
file_name = Path(str(getattr(getattr(invoice, 'image', None), 'name', '') or f"invoice-{index}.bin")).name
archive_path = _unique_zip_entry_name(f"Racuni/{display_code}/{file_name}", used_names)
if _write_file_field_to_zip(archive, file_field=invoice.image, entry_name=archive_path):
entries_written += 1
if entries_written == 0:
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
return entries_written
def _build_monthly_work_orders_archive_content(*, user, year, month):
archive_buffer = BytesIO()
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
for work_order in work_orders:
pdf_bytes = _build_work_order_pdf(work_order)
work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names)
archive.writestr(work_order_pdf_name, pdf_bytes)
display_code = _work_order_display_code(work_order)
invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at')
for index, invoice in enumerate(invoices, start=1):
attachment = _file_attachment(invoice.image, fallback_name=f"invoice-{index}.bin")
if not attachment:
continue
invoice_filename, content, _content_type = attachment
archive_path = f"Racuni/{display_code}/{invoice_filename}"
archive.writestr(_unique_zip_entry_name(archive_path, used_names), content)
_write_monthly_work_orders_archive_entries(
archive,
user=user,
year=year,
month=month,
)
archive_content = archive_buffer.getvalue()
if not archive_content:
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
@@ -3055,10 +3335,17 @@ def _request_monthly_archive_generation(*, request, archive_type):
.first()
)
if existing_pending:
return {
'status': 'processing',
'generated_archive_id': str(existing_pending.pk),
}
stale_pending_threshold = timezone.now() - timedelta(minutes=3)
if existing_pending.created_at and existing_pending.created_at < stale_pending_threshold:
existing_pending.is_active = False
existing_pending.status = 'failed'
existing_pending.error_message = 'ZIP zahtjev je ostao predugo u pending statusu; pokrece se novi zahtjev.'
existing_pending.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
else:
return {
'status': 'processing',
'generated_archive_id': str(existing_pending.pk),
}
generated_archive = GeneratedFleetArchive.objects.create(
requested_by=request.user,
@@ -3080,6 +3367,7 @@ def _request_monthly_archive_generation(*, request, archive_type):
stage='requested',
year=year,
month=month,
generated_archive=generated_archive,
)
try:
@@ -3167,12 +3455,18 @@ def generated_archive_download(request, archive_id):
if generated_archive is None:
raise DRFValidationError({'detail': 'ZIP arhiva nije dostupna ili je istekla.'})
generated_archive.file.open('rb')
file_name = getattr(generated_archive.file, 'name', '')
storage = getattr(generated_archive.file, 'storage', None)
if not file_name or storage is None:
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
try:
archive_bytes = generated_archive.file.read()
finally:
generated_archive.file.close()
if not archive_bytes:
if not storage.exists(file_name):
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
except (FileNotFoundError, OSError, ValueError):
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
try:
generated_archive.file.open('rb')
except (FileNotFoundError, OSError, ValueError):
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
filename = generated_archive.filename or _generated_archive_filename_for_user(
request.user,
@@ -3180,10 +3474,13 @@ def generated_archive_download(request, archive_id):
month=generated_archive.month,
archive_type=generated_archive.archive_type,
)
response = HttpResponse(archive_bytes, content_type='application/zip')
response = FileResponse(generated_archive.file, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
response['Cache-Control'] = 'private, max-age=3600'
response['Content-Length'] = str(len(archive_bytes))
try:
response['Content-Length'] = str(generated_archive.file.size)
except (OSError, ValueError, TypeError):
pass
return response
@@ -3462,6 +3759,18 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
).exclude(file='').exclude(file__isnull=True).first()
if generated_pdf is None:
raise DRFValidationError({"detail": "PDF nije dostupan ili je istekao."})
file_name = getattr(generated_pdf.file, 'name', '')
storage = getattr(generated_pdf.file, 'storage', None)
if not file_name or storage is None:
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
try:
if not storage.exists(file_name):
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
except (FileNotFoundError, OSError, ValueError):
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
return _cached_pdf_file_response(generated_pdf, default_filename=_pdf_filename(work_order, generated_pdf.pdf_type))
@action(detail=True, methods=['get'], url_path='pdf-preview')
@@ -3595,11 +3904,26 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
if additional_costs_table
else {'work_order': str(work_order.pk), 'data': {'rows': []}, 'total_for_payout': '0.00'}
)
travel_expenses_context = _work_order_travel_expenses_context(work_order)
travel_expenses_table = getattr(work_order, 'travel_expenses_table', None)
travel_expenses_payload = (
WorkOrderTravelExpensesTableSerializer(travel_expenses_table).data
if travel_expenses_table
else {
'work_order': str(work_order.pk),
'broj_sati': _format_decimal_fixed(travel_expenses_context['broj_sati']),
'kolicina_dnevnica': _format_decimal_fixed(travel_expenses_context['kolicina_dnevnica']),
'iznos_dnevnica': _format_decimal_fixed(travel_expenses_context['iznos_dnevnica']),
'daily_rate_country': WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY,
'total_for_payout': _format_decimal_fixed(travel_expenses_context['total_for_payout']),
}
)
return Response({
'work_order_id': work_order.pk,
'tasks': payload,
'additional_costs_table': additional_costs_payload,
'travel_expenses_table': travel_expenses_payload,
}, status=status.HTTP_200_OK)
@action(detail=True, methods=['get', 'put', 'patch'], url_path='additional-costs-table')
@@ -3632,6 +3956,40 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'invoices'])
return Response(WorkOrderAdditionalCostsTableSerializer(instance).data, status=status.HTTP_200_OK)
@action(detail=True, methods=['get', 'put', 'patch'], url_path='travel-expenses-table')
def travel_expenses_table(self, request, pk=None):
work_order = self.get_object()
table = getattr(work_order, 'travel_expenses_table', None)
fallback = _work_order_travel_expenses_context(work_order)
if request.method.lower() == 'get':
if table:
return Response(WorkOrderTravelExpensesTableSerializer(table).data, status=status.HTTP_200_OK)
return Response({
'work_order': str(work_order.pk),
'broj_sati': _format_decimal_fixed(fallback['broj_sati']),
'kolicina_dnevnica': _format_decimal_fixed(fallback['kolicina_dnevnica']),
'iznos_dnevnica': _format_decimal_fixed(fallback['iznos_dnevnica']),
'daily_rate_country': fallback['daily_rate_country'],
'total_for_payout': _format_decimal_fixed(fallback['total_for_payout']),
}, status=status.HTTP_200_OK)
serializer = WorkOrderTravelExpensesTableSerializer(
table,
data={
'work_order': str(work_order.pk),
'broj_sati': request.data.get('broj_sati', fallback['broj_sati']),
'kolicina_dnevnica': request.data.get('kolicina_dnevnica', fallback['kolicina_dnevnica']),
'iznos_dnevnica': request.data.get('iznos_dnevnica', fallback['iznos_dnevnica']),
'daily_rate_country': request.data.get('daily_rate_country', fallback['daily_rate_country']),
},
partial=bool(table),
)
serializer.is_valid(raise_exception=True)
instance = serializer.save(work_order=work_order)
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order'])
return Response(WorkOrderTravelExpensesTableSerializer(instance).data, status=status.HTTP_200_OK)
@action(detail=True, methods=['post'], url_path='send-email')
def send_email(self, request, pk=None):
work_order = self.get_object()

View File

@@ -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,7 +101,7 @@ 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',
@@ -212,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

View File

@@ -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",

View File

@@ -0,0 +1,24 @@
#!/bin/sh
set -eu
TMP_DIR="${APP_TMP_CLEANUP_DIR:-/tmp}"
MAX_AGE_DAYS="${APP_TMP_CLEANUP_MAX_AGE_DAYS:-1}"
PREFIXES="${APP_TMP_CLEANUP_PREFIXES:-erp-fleet-archive-}"
if [ ! -d "$TMP_DIR" ]; then
echo "TMP dir ne postoji: $TMP_DIR"
exit 0
fi
OLD_IFS="$IFS"
IFS=','
for prefix in $PREFIXES; do
prefix_trimmed="$(echo "$prefix" | xargs)"
if [ -z "$prefix_trimmed" ]; then
continue
fi
find "$TMP_DIR" -maxdepth 1 -mindepth 1 -name "${prefix_trimmed}*" -mtime "+${MAX_AGE_DAYS}" -print -delete
done
IFS="$OLD_IFS"
echo "TMP cleanup dovršen za $TMP_DIR (older than ${MAX_AGE_DAYS} day(s))."

View File

@@ -37,7 +37,13 @@ services:
environment:
DEBUG: "False"
DJANGO_SETTINGS_MODULE: core.settings.production
command: celery -A core worker --loglevel=info
CELERY_WORKER_PREFETCH_MULTIPLIER: "1"
CELERY_WORKER_MAX_TASKS_PER_CHILD: "20"
CELERY_WORKER_MAX_MEMORY_PER_CHILD: "350000"
APP_TMP_CLEANUP_DIR: "/tmp"
APP_TMP_CLEANUP_MAX_AGE_HOURS: "24"
APP_TMP_CLEANUP_PREFIXES: "erp-fleet-archive-"
command: celery -A core worker --loglevel=info --concurrency=${CELERY_WORKER_CONCURRENCY:-2}
volumes:
- media_volume:/app/media
depends_on:

View File

@@ -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();
});

View File

@@ -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();

View File

@@ -24,6 +24,10 @@ 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')}`;
@@ -77,6 +81,83 @@ 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());
@@ -196,6 +277,17 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
};
}, [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 = [];
@@ -204,44 +296,32 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
for (let day = 1; day <= daysInMonth; day += 1) {
const key = `${monthPrefix}-${String(day).padStart(2, '0')}`;
const dayTasks = tasksByDate[key] ?? [];
const dayEntries = reportTaskEntriesByDate[key] ?? [];
const manualEntry = manualEntriesByDate[key];
if (dayTasks.length > 0) {
if (dayEntries.length > 0) {
const titles = [];
const serials = [];
const clients = [];
const locations = [];
const workOrderLabels = [];
const workStartTimes = [];
const workEndTimes = [];
const countedWorkOrders = new Set();
const startTimes = [];
const endTimes = [];
let totalHours = 0;
for (const task of dayTasks) {
if (task?.title) titles.push(task.title);
serials.push(getTaskCraneSerial(task));
clients.push(getTaskCraneOwner(task));
if (task?.work_order_label) workOrderLabels.push(task.work_order_label);
const workOrder = workOrdersById.get(String(task.work_order || ''));
if (workOrder?.location) locations.push(workOrder.location);
if (
workOrder?.travel_start_at
&& workOrder?.travel_end_at
&& !countedWorkOrders.has(String(workOrder.id))
) {
const startAt = new Date(workOrder.travel_start_at);
const endAt = new Date(workOrder.travel_end_at);
if (!Number.isNaN(startAt.getTime()) && !Number.isNaN(endAt.getTime()) && endAt > startAt) {
countedWorkOrders.add(String(workOrder.id));
workStartTimes.push(startAt);
workEndTimes.push(endAt);
totalHours += (endAt.getTime() - startAt.getTime()) / 3600000;
}
}
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,
@@ -252,10 +332,10 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
joinUnique(serials) || '-',
joinUnique(clients) || '-',
joinUnique(locations) || '-',
workStartTimes.length ? formatTime(new Date(Math.min(...workStartTimes.map((item) => item.getTime()))).toISOString()) : '-',
workEndTimes.length ? formatTime(new Date(Math.max(...workEndTimes.map((item) => item.getTime()))).toISOString()) : '-',
totalHours > 0 ? formatHourValue(Math.min(totalHours, 8), '0') : '-',
totalHours > 8 ? formatHourValue(totalHours - 8, '0') : '0',
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) || '-',
],
});
@@ -292,7 +372,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
}
return rows;
}, [manualEntriesByDate, reportType, tasksByDate, viewMonth, viewYear, workOrdersById]);
}, [manualEntriesByDate, reportTaskEntriesByDate, reportType, viewMonth, viewYear]);
const costsRows = useMemo(() => {
if (reportType !== 'costs') return [];
@@ -367,10 +447,10 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
async function handleDownloadAllTasksArchive() {
if (downloadingAllTasks) return;
setBulkDownloadOpen(false);
setDownloadingAllTasks(true);
try {
await downloadMonthlyServiceTasksArchive(viewYear, viewMonth + 1);
setBulkDownloadOpen(false);
} finally {
setDownloadingAllTasks(false);
}
@@ -378,10 +458,10 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
async function handleDownloadAllWorkOrdersArchive() {
if (downloadingAllWorkOrders) return;
setBulkDownloadOpen(false);
setDownloadingAllWorkOrders(true);
try {
await downloadMonthlyWorkOrdersArchive(viewYear, viewMonth + 1);
setBulkDownloadOpen(false);
} finally {
setDownloadingAllWorkOrders(false);
}
@@ -655,23 +735,25 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
{MONTH_NAMES[viewMonth]} {viewYear}
</span>
</div>
<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' && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setBulkDownloadOpen(true)}
className="ml-2 rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep"
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"
>
Preuzmi ZIP
{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">

View File

@@ -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'];
@@ -32,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,
@@ -51,6 +56,10 @@ 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;
}
// Auto-pre-select today's open work order if exactly one exists for this crane
@@ -76,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) {
@@ -156,6 +219,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
}
return (
<>
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto p-4 pt-20"
@@ -215,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
@@ -326,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>
)}
</>
);
}

View File

@@ -251,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}
@@ -306,10 +315,10 @@ export default function TaskServiceRecordsModal({
<button
type="button"
onClick={openCraneDataModal}
className="rounded border border-border-hairline px-1.5 py-0.5 text-[10px] text-text-muted hover:bg-canvas-base"
title="Izmijeni radne sate i kilometražu dizalice"
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>
@@ -539,7 +548,12 @@ export default function TaskServiceRecordsModal({
</ModalShell>
{craneDataModalOpen && (
<ModalShell open={craneDataModalOpen} onClose={() => setCraneDataModalOpen(false)} title="Izmijeni podatke dizalice">
<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>

View File

@@ -10,10 +10,12 @@ import {
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';
@@ -21,6 +23,7 @@ import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
import WorkOrderTravelExpensesModal from './WorkOrderTravelExpensesModal';
import WorkOrderServiceNotesModal from './WorkOrderServiceNotesModal';
function readWorkOrderIdFromQuery() {
@@ -147,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);
@@ -154,6 +158,8 @@ 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('');
@@ -192,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;
@@ -205,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.');
@@ -311,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">
@@ -321,22 +353,32 @@ export default function WorkOrderInvoicesPdfPage() {
{workOrder ? `Dizalica: ${workOrder.crane_label || workOrder.crane || '-'}` : 'Pregled računa prije kreiranja PDF-a.'}
</p>
</div>
<button
type="button"
onClick={() => downloadWorkOrderPdf(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 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>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => downloadWorkOrderPdf(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 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>
@@ -568,6 +610,14 @@ 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}

View File

@@ -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>
);
}

View File

@@ -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';
@@ -734,10 +737,12 @@ export async function fetchTasksByWorkOrder(workOrderId) {
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) {
@@ -762,6 +767,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.');
@@ -831,11 +872,68 @@ function _schedulePdfNotificationPolling() {
});
}
function _scheduleArchiveNotificationPolling() {
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;
[5000, 20000, 60000].forEach((delay) => {
setTimeout(() => _refreshNotificationsAsync(), delay);
});
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) {
@@ -1030,13 +1128,13 @@ export async function downloadMonthlyCostsReport(year, month) {
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');
_refreshNotificationsAsync();
return payload;
}
showToast('ZIP arhiva servisnih taskova se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
_scheduleArchiveNotificationPolling();
_scheduleArchiveNotificationPolling(payload?.generated_archive_id || null);
return payload;
} catch (err) {
showToast(err?.message || 'Pokretanje generiranja ZIP arhive servisnih taskova nije uspjelo.', 'error');
@@ -1047,13 +1145,13 @@ export async function downloadMonthlyServiceTasksArchive(year, month) {
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');
_refreshNotificationsAsync();
return payload;
}
showToast('ZIP arhiva putnih naloga se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
_scheduleArchiveNotificationPolling();
_scheduleArchiveNotificationPolling(payload?.generated_archive_id || null);
return payload;
} catch (err) {
showToast(err?.message || 'Pokretanje generiranja ZIP arhive putnih naloga nije uspjelo.', 'error');