Compare commits
23 Commits
7053ec32f8
...
agents/out
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61bc772054 | ||
|
|
b985c285f9 | ||
|
|
aa690d4ac4 | ||
|
|
52408bc5a9 | ||
|
|
8e9d23fcfa | ||
|
|
a7220d27f5 | ||
|
|
1e68c663f2 | ||
|
|
2826ee9bab | ||
|
|
9bd5f53675 | ||
|
|
d2dc37aef9 | ||
|
|
e3b3ff90a9 | ||
|
|
024bf59733 | ||
|
|
8577aaa01b | ||
|
|
a9ab83c202 | ||
|
|
681f73d85a | ||
|
|
181cab1f45 | ||
|
|
38f1786469 | ||
|
|
f37d130bc7 | ||
|
|
fe4ba3b5dd | ||
|
|
1f6c0d6086 | ||
|
|
3aecbdd6ea | ||
|
|
ee18af7877 | ||
|
|
27b7c31c13 |
@@ -7,7 +7,7 @@ Nakon što je gitea webhook i testirana skripta za automatsko povlačenje promje
|
|||||||
```bash
|
```bash
|
||||||
cd /opt/erp/app
|
cd /opt/erp/app
|
||||||
git fetch origin
|
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
|
# rebuild backend + worker + beat + frontend
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
# backend/core/celery.py
|
# backend/core/celery.py
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import gc
|
||||||
|
import logging
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
from celery import Task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Postavi Django settings modul
|
# Postavi Django settings modul
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
@@ -11,5 +16,42 @@ app = Celery('core')
|
|||||||
# Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_'
|
# Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_'
|
||||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||||
|
|
||||||
|
|
||||||
|
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.*'
|
# Automatski pronalazi taskove u svim 'modules.*'
|
||||||
app.autodiscover_tasks()
|
app.autodiscover_tasks()
|
||||||
@@ -154,6 +154,11 @@ REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
|||||||
|
|
||||||
CELERY_BROKER_URL = REDIS_URL
|
CELERY_BROKER_URL = REDIS_URL
|
||||||
CELERY_RESULT_BACKEND = REDIS_URL
|
CELERY_RESULT_BACKEND = REDIS_URL
|
||||||
|
CELERY_WORKER_PREFETCH_MULTIPLIER = 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)
|
# Ako želiš koristiti Redis kao brzi cache sustav unutar Djanga (izvrsno za ERP performanse)
|
||||||
# Za ovo ti je potreban paket 'django-redis' u requirements.txt
|
# Za ovo ti je potreban paket 'django-redis' u requirements.txt
|
||||||
@@ -171,10 +176,18 @@ CACHES = {
|
|||||||
CELERY_ACCEPT_CONTENT = ['json']
|
CELERY_ACCEPT_CONTENT = ['json']
|
||||||
CELERY_TASK_SERIALIZER = 'json'
|
CELERY_TASK_SERIALIZER = 'json'
|
||||||
CELERY_BEAT_SCHEDULE = {
|
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': {
|
'cleanup-expired-generated-pdfs-hourly': {
|
||||||
'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task',
|
'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task',
|
||||||
'schedule': crontab(minute=0),
|
'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': {
|
'notify-upcoming-tasks-daily': {
|
||||||
'task': 'modules.task_management.tasks.notify_upcoming_tasks',
|
'task': 'modules.task_management.tasks.notify_upcoming_tasks',
|
||||||
'schedule': crontab(hour=8, minute=0),
|
'schedule': crontab(hour=8, minute=0),
|
||||||
@@ -190,6 +203,13 @@ STATIC_URL = '/static/'
|
|||||||
# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente)
|
# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente)
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
MEDIA_ROOT = BASE_DIR / '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()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fleet', '0035_generatedfleetarchive'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='WorkOrderTravelExpensesTable',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('broj_sati', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='Broj sati')),
|
||||||
|
('kolicina_dnevnica', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='Količina dnevnica')),
|
||||||
|
('iznos_dnevnica', models.DecimalField(decimal_places=2, default=Decimal('30.00'), max_digits=10, verbose_name='Iznos dnevnice')),
|
||||||
|
('daily_rate_country', models.CharField(choices=[('HR', 'Hrvatska'), ('BIH', 'BiH'), ('SI', 'Slovenija'), ('CG', 'Crna Gora')], default='HR', max_length=8, verbose_name='Država dnevnice')),
|
||||||
|
('total_for_payout', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12, verbose_name='Ukupan iznos')),
|
||||||
|
('work_order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='travel_expenses_table', to='fleet.workorder', verbose_name='Putni nalog')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Tablica obračuna putnih troškova',
|
||||||
|
'verbose_name_plural': 'Tablice obračuna putnih troškova',
|
||||||
|
'ordering': ['-updated_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fleet', '0036_workordertravelexpenses_table'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workordertravelexpensestable',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workordertravelexpensestable',
|
||||||
|
name='id',
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workordertravelexpensestable',
|
||||||
|
name='is_active',
|
||||||
|
field=models.BooleanField(default=True, verbose_name='Aktivan zapis'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workordertravelexpensestable',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,10 +2,12 @@ import uuid
|
|||||||
import re
|
import re
|
||||||
from datetime import time
|
from datetime import time
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_save, pre_delete
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.dispatch import receiver
|
||||||
from core.base_models import BaseModel
|
from core.base_models import BaseModel
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -142,6 +144,67 @@ class VehicleServiceAttachment(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Attachment {self.pk} for {self.service_record} ({self.file.name if self.file else 'no-file'})"
|
return f"Attachment {self.pk} for {self.service_record} ({self.file.name if self.file else 'no-file'})"
|
||||||
|
|
||||||
|
|
||||||
|
def _service_record_work_order_id(instance):
|
||||||
|
if isinstance(instance, VehicleServiceRecord):
|
||||||
|
task = getattr(instance, 'task', None)
|
||||||
|
if task and getattr(task, 'work_order_id', None):
|
||||||
|
return task.work_order_id
|
||||||
|
if getattr(instance, 'task_id', None):
|
||||||
|
return VehicleServiceRecord.objects.filter(pk=instance.pk).values_list('task__work_order_id', flat=True).first()
|
||||||
|
return None
|
||||||
|
|
||||||
|
service_record = getattr(instance, 'service_record', None)
|
||||||
|
if service_record and getattr(service_record, 'task', None) and getattr(service_record.task, 'work_order_id', None):
|
||||||
|
return service_record.task.work_order_id
|
||||||
|
|
||||||
|
service_record_id = getattr(instance, 'service_record_id', None)
|
||||||
|
if not service_record_id:
|
||||||
|
return None
|
||||||
|
return VehicleServiceRecord.objects.filter(pk=service_record_id).values_list('task__work_order_id', flat=True).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate_service_records_pdf_cache(work_order_id):
|
||||||
|
if not work_order_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
cached_pdfs = GeneratedWorkOrderPdf.objects.filter(
|
||||||
|
is_active=True,
|
||||||
|
work_order_id=work_order_id,
|
||||||
|
pdf_type='service_records',
|
||||||
|
)
|
||||||
|
for cached in cached_pdfs:
|
||||||
|
file_name = getattr(cached.file, 'name', '')
|
||||||
|
if file_name:
|
||||||
|
try:
|
||||||
|
if cached.file.storage.exists(file_name):
|
||||||
|
cached.file.delete(save=False)
|
||||||
|
except (FileNotFoundError, OSError, ValueError):
|
||||||
|
pass
|
||||||
|
cached.file = None
|
||||||
|
cached.is_active = False
|
||||||
|
cached.status = 'failed'
|
||||||
|
cached.error_message = 'PDF cache invalidiran zbog promjene servisnih zapisa.'
|
||||||
|
cached.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender='fleet.VehicleServiceRecord')
|
||||||
|
@receiver(pre_delete, sender='fleet.VehicleServiceRecord')
|
||||||
|
def _invalidate_service_records_pdf_for_service_record(sender, instance, **kwargs):
|
||||||
|
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender='fleet.VehicleServicePhoto')
|
||||||
|
@receiver(pre_delete, sender='fleet.VehicleServicePhoto')
|
||||||
|
def _invalidate_service_records_pdf_for_service_photo(sender, instance, **kwargs):
|
||||||
|
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender='fleet.VehicleServiceAttachment')
|
||||||
|
@receiver(pre_delete, sender='fleet.VehicleServiceAttachment')
|
||||||
|
def _invalidate_service_records_pdf_for_service_attachment(sender, instance, **kwargs):
|
||||||
|
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
|
||||||
|
|
||||||
class Vehicle(BaseModel):
|
class Vehicle(BaseModel):
|
||||||
ASSET_TYPE_CHOICES = [
|
ASSET_TYPE_CHOICES = [
|
||||||
('vehicle', _("Vozilo")),
|
('vehicle', _("Vozilo")),
|
||||||
@@ -417,6 +480,63 @@ class WorkOrderAdditionalCostsTable(BaseModel):
|
|||||||
return f"Additional costs table for work order {self.work_order_id}"
|
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):
|
class WorkOrderPhoto(BaseModel):
|
||||||
work_order = models.ForeignKey(
|
work_order = models.ForeignKey(
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from .models import (
|
|||||||
VehicleNotification,
|
VehicleNotification,
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
WorkOrderAdditionalCostsTable,
|
WorkOrderAdditionalCostsTable,
|
||||||
|
WorkOrderTravelExpensesTable,
|
||||||
WorkOrderPhoto,
|
WorkOrderPhoto,
|
||||||
WorkOrderInvoice,
|
WorkOrderInvoice,
|
||||||
VehicleServiceRecord,
|
VehicleServiceRecord,
|
||||||
@@ -501,6 +502,105 @@ class WorkOrderAdditionalCostsTableSerializer(serializers.ModelSerializer):
|
|||||||
return super().update(instance, validated_data)
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkOrderTravelExpensesTableSerializer(serializers.ModelSerializer):
|
||||||
|
RATE_DEFAULTS = {
|
||||||
|
'HR': Decimal('30.00'),
|
||||||
|
'BIH': Decimal('50.00'),
|
||||||
|
'SI': Decimal('80.00'),
|
||||||
|
'CG': Decimal('50.00'),
|
||||||
|
}
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = WorkOrderTravelExpensesTable
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'work_order',
|
||||||
|
'broj_sati',
|
||||||
|
'kolicina_dnevnica',
|
||||||
|
'iznos_dnevnica',
|
||||||
|
'daily_rate_country',
|
||||||
|
'total_for_payout',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'total_for_payout', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_decimal(value):
|
||||||
|
if value in (None, ''):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.')
|
||||||
|
return Decimal(normalized)
|
||||||
|
except (InvalidOperation, ValueError, TypeError):
|
||||||
|
raise serializers.ValidationError('Neispravna decimalna vrijednost.')
|
||||||
|
|
||||||
|
def validate_daily_rate_country(self, value):
|
||||||
|
normalized = str(value or '').strip().upper()
|
||||||
|
if not normalized:
|
||||||
|
return WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY
|
||||||
|
valid_choices = {choice for choice, _label in WorkOrderTravelExpensesTable.RATE_CHOICES}
|
||||||
|
if normalized not in valid_choices:
|
||||||
|
raise serializers.ValidationError('Neispravna država dnevnice.')
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
instance = getattr(self, 'instance', None)
|
||||||
|
country = attrs.get(
|
||||||
|
'daily_rate_country',
|
||||||
|
getattr(instance, 'daily_rate_country', WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY),
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'iznos_dnevnica' not in attrs or attrs.get('iznos_dnevnica') in (None, ''):
|
||||||
|
attrs['iznos_dnevnica'] = self.RATE_DEFAULTS.get(
|
||||||
|
country,
|
||||||
|
WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'broj_sati' in attrs:
|
||||||
|
attrs['broj_sati'] = self._parse_decimal(attrs['broj_sati'])
|
||||||
|
if 'kolicina_dnevnica' in attrs:
|
||||||
|
attrs['kolicina_dnevnica'] = self._parse_decimal(attrs['kolicina_dnevnica'])
|
||||||
|
if 'iznos_dnevnica' in attrs:
|
||||||
|
attrs['iznos_dnevnica'] = self._parse_decimal(attrs['iznos_dnevnica'])
|
||||||
|
|
||||||
|
attrs['broj_sati'] = attrs.get(
|
||||||
|
'broj_sati',
|
||||||
|
getattr(instance, 'broj_sati', Decimal('0.00')),
|
||||||
|
) or Decimal('0.00')
|
||||||
|
attrs['kolicina_dnevnica'] = attrs.get(
|
||||||
|
'kolicina_dnevnica',
|
||||||
|
getattr(instance, 'kolicina_dnevnica', Decimal('0.00')),
|
||||||
|
) or Decimal('0.00')
|
||||||
|
attrs['iznos_dnevnica'] = attrs.get(
|
||||||
|
'iznos_dnevnica',
|
||||||
|
getattr(instance, 'iznos_dnevnica', self.RATE_DEFAULTS.get(country, WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT)),
|
||||||
|
) or self.RATE_DEFAULTS.get(country, WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT)
|
||||||
|
attrs['daily_rate_country'] = country
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def _calculate_total_for_payout(self, validated_data):
|
||||||
|
quantity = validated_data.get('kolicina_dnevnica', Decimal('0.00'))
|
||||||
|
rate = validated_data.get('iznos_dnevnica', Decimal('0.00'))
|
||||||
|
if not isinstance(quantity, Decimal):
|
||||||
|
quantity = self._parse_decimal(quantity) or Decimal('0.00')
|
||||||
|
if not isinstance(rate, Decimal):
|
||||||
|
rate = self._parse_decimal(rate) or Decimal('0.00')
|
||||||
|
return (quantity * rate).quantize(Decimal('0.01'))
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data['total_for_payout'] = self._calculate_total_for_payout(validated_data)
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
merged = {
|
||||||
|
'kolicina_dnevnica': validated_data.get('kolicina_dnevnica', instance.kolicina_dnevnica),
|
||||||
|
'iznos_dnevnica': validated_data.get('iznos_dnevnica', instance.iznos_dnevnica),
|
||||||
|
}
|
||||||
|
validated_data['total_for_payout'] = self._calculate_total_for_payout(merged)
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
class WorkOrderPhotoSerializer(serializers.ModelSerializer):
|
class WorkOrderPhotoSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WorkOrderPhoto
|
model = WorkOrderPhoto
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from django.core.mail import EmailMessage
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.files import File
|
||||||
import logging
|
import logging
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
@@ -13,6 +14,9 @@ import base64
|
|||||||
import re
|
import re
|
||||||
import csv
|
import csv
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from smtplib import SMTPSenderRefused
|
from smtplib import SMTPSenderRefused
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -838,10 +842,9 @@ def process_work_order_invoice_ocr(invoice_id):
|
|||||||
@shared_task
|
@shared_task
|
||||||
def build_monthly_archive_cached_task(generated_archive_id):
|
def build_monthly_archive_cached_task(generated_archive_id):
|
||||||
from .models import GeneratedFleetArchive
|
from .models import GeneratedFleetArchive
|
||||||
from .services import NotificationService
|
|
||||||
from .views import (
|
from .views import (
|
||||||
_build_monthly_service_tasks_archive_content,
|
_write_monthly_service_tasks_archive_entries,
|
||||||
_build_monthly_work_orders_archive_content,
|
_write_monthly_work_orders_archive_entries,
|
||||||
_notify_monthly_archive_request,
|
_notify_monthly_archive_request,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -854,25 +857,39 @@ def build_monthly_archive_cached_task(generated_archive_id):
|
|||||||
if generated is None:
|
if generated is None:
|
||||||
return {"status": "failed", "error": "Generated archive record not found"}
|
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:
|
try:
|
||||||
if generated.archive_type == 'service_tasks':
|
with zipfile.ZipFile(temp_zip_path, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
archive_content = _build_monthly_service_tasks_archive_content(
|
if generated.archive_type == 'service_tasks':
|
||||||
user=generated.requested_by,
|
entries_written = _write_monthly_service_tasks_archive_entries(
|
||||||
year=generated.year,
|
archive,
|
||||||
month=generated.month,
|
user=generated.requested_by,
|
||||||
)
|
year=generated.year,
|
||||||
else:
|
month=generated.month,
|
||||||
archive_content = _build_monthly_work_orders_archive_content(
|
)
|
||||||
user=generated.requested_by,
|
else:
|
||||||
year=generated.year,
|
entries_written = _write_monthly_work_orders_archive_entries(
|
||||||
month=generated.month,
|
archive,
|
||||||
)
|
user=generated.requested_by,
|
||||||
|
year=generated.year,
|
||||||
if not archive_content:
|
month=generated.month,
|
||||||
|
)
|
||||||
|
if entries_written == 0:
|
||||||
raise ValueError('ZIP arhiva je prazna.')
|
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"
|
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.status = 'ready'
|
||||||
generated.error_message = ''
|
generated.error_message = ''
|
||||||
generated.save(update_fields=['file', 'status', 'error_message', 'updated_at'])
|
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)
|
logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc)
|
||||||
return {"status": "failed", "error": str(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
|
@shared_task
|
||||||
@@ -944,6 +967,46 @@ def cleanup_expired_generated_pdfs_task():
|
|||||||
return {"deleted": deleted}
|
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
|
@shared_task
|
||||||
def build_work_order_pdf_cached_task(generated_pdf_id):
|
def build_work_order_pdf_cached_task(generated_pdf_id):
|
||||||
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf
|
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf
|
||||||
|
|||||||
@@ -24,8 +24,14 @@ from modules.fleet.models import (
|
|||||||
GeneratedFleetArchive,
|
GeneratedFleetArchive,
|
||||||
VehicleNotification,
|
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.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'):
|
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.assertIn('additional_costs_table', payload)
|
||||||
self.assertEqual(payload['additional_costs_table']['total_for_payout'], '5.00')
|
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):
|
def test_invoice_upload_immediately_appears_in_additional_costs_table(self):
|
||||||
create_response = self.client.post(
|
create_response = self.client.post(
|
||||||
reverse('work-order-invoice-list'),
|
reverse('work-order-invoice-list'),
|
||||||
@@ -273,6 +331,76 @@ class WorkOrderImagesEndpointTests(TestCase):
|
|||||||
self.assertFalse(cached.is_active)
|
self.assertFalse(cached.is_active)
|
||||||
self.assertEqual(cached.status, 'failed')
|
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):
|
def test_service_records_docx_contains_embedded_service_photos(self):
|
||||||
VehicleServicePhoto.objects.create(
|
VehicleServicePhoto.objects.create(
|
||||||
service_record=self.service_record,
|
service_record=self.service_record,
|
||||||
@@ -467,6 +595,127 @@ class WorkOrderImagesEndpointTests(TestCase):
|
|||||||
self.assertIn('MT150726.work-order.pdf', names)
|
self.assertIn('MT150726.work-order.pdf', names)
|
||||||
self.assertTrue(any(name.startswith('Racuni/MT150726/') for name in names), 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):
|
def test_monthly_service_tasks_archive_request_creates_ready_download_with_notification(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/api/fleet/reports/monthly-service-tasks-archive-request/',
|
'/api/fleet/reports/monthly-service-tasks-archive-request/',
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import threading
|
|||||||
import zipfile
|
import zipfile
|
||||||
import re
|
import re
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from django.contrib.auth import get_user_model
|
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.conf import settings
|
||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
@@ -53,6 +53,7 @@ from .models import (
|
|||||||
GeneratedFleetArchive,
|
GeneratedFleetArchive,
|
||||||
WorkOrderInvoice,
|
WorkOrderInvoice,
|
||||||
WorkOrderAdditionalCostsTable,
|
WorkOrderAdditionalCostsTable,
|
||||||
|
WorkOrderTravelExpensesTable,
|
||||||
VehicleServiceRecord,
|
VehicleServiceRecord,
|
||||||
VehicleServicePhoto,
|
VehicleServicePhoto,
|
||||||
VehicleServiceAttachment,
|
VehicleServiceAttachment,
|
||||||
@@ -63,6 +64,7 @@ from .serializers import (
|
|||||||
WorkOrderSerializer,
|
WorkOrderSerializer,
|
||||||
WorkOrderInvoiceSerializer,
|
WorkOrderInvoiceSerializer,
|
||||||
WorkOrderAdditionalCostsTableSerializer,
|
WorkOrderAdditionalCostsTableSerializer,
|
||||||
|
WorkOrderTravelExpensesTableSerializer,
|
||||||
WorkOrderPhotoSerializer,
|
WorkOrderPhotoSerializer,
|
||||||
VehicleServiceRecordSerializer,
|
VehicleServiceRecordSerializer,
|
||||||
VehicleNotificationSerializer,
|
VehicleNotificationSerializer,
|
||||||
@@ -153,6 +155,7 @@ def _work_order_related_tasks_queryset(work_order):
|
|||||||
vehicle_id=work_order.vehicle_id,
|
vehicle_id=work_order.vehicle_id,
|
||||||
task_id__isnull=False,
|
task_id__isnull=False,
|
||||||
task__is_active=True,
|
task__is_active=True,
|
||||||
|
task__work_order=work_order,
|
||||||
).values_list('task_id', flat=True)
|
).values_list('task_id', flat=True)
|
||||||
)
|
)
|
||||||
task_ids = list({*direct_task_ids, *inferred_task_ids})
|
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'
|
'assigned_to', 'vehicle', 'work_order', 'work_hours_table'
|
||||||
).order_by('-created_at')
|
).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):
|
def _can_access_service_record(user, service_record):
|
||||||
service_record_id = getattr(service_record, 'pk', service_record)
|
service_record_id = getattr(service_record, 'pk', service_record)
|
||||||
return _service_records_queryset_for_user(user).filter(pk=service_record_id).exists()
|
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
|
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 12–20 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,
|
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
|
||||||
vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna.
|
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:
|
try:
|
||||||
image_field.open('rb')
|
image_field.open('rb')
|
||||||
with Image.open(image_field) as src:
|
with Image.open(image_field) as img:
|
||||||
img = src.convert('RGB')
|
img = ImageOps.exif_transpose(img)
|
||||||
if img.width > max_width:
|
w, h = img.size
|
||||||
ratio = max_width / float(img.width)
|
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
|
||||||
new_h = max(1, int(img.height * ratio))
|
return None
|
||||||
img = img.resize((max_width, new_h), Image.LANCZOS)
|
img.thumbnail((max_width, max_width * 2), Image.BILINEAR)
|
||||||
|
rgb = img.convert('RGB')
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
rgb.save(buf, format='JPEG', quality=quality)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
return ImageReader(buf)
|
return ImageReader(buf)
|
||||||
except Exception:
|
except BaseException:
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -278,23 +498,28 @@ def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
|
|||||||
pass
|
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.
|
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:
|
try:
|
||||||
image_field.open('rb')
|
image_field.open('rb')
|
||||||
with Image.open(image_field) as src:
|
with Image.open(image_field) as img:
|
||||||
img = src.convert('RGB')
|
img = ImageOps.exif_transpose(img)
|
||||||
if img.width > max_width:
|
w, h = img.size
|
||||||
ratio = max_width / float(img.width)
|
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
|
||||||
new_h = max(1, int(img.height * ratio))
|
return None
|
||||||
img = img.resize((max_width, new_h), Image.LANCZOS)
|
img.thumbnail((max_width, max_width * 2), Image.BILINEAR)
|
||||||
|
rgb = img.convert('RGB')
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
rgb.save(buf, format='JPEG', quality=quality)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
return buf
|
return buf
|
||||||
except Exception:
|
except BaseException:
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -321,10 +546,30 @@ def _get_cached_pdf(work_order, pdf_type):
|
|||||||
)
|
)
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if str(candidate.filename or '').strip() == expected_filename:
|
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
|
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():
|
def _cleanup_expired_generated_pdfs():
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
expired = GeneratedWorkOrderPdf.objects.filter(
|
expired = GeneratedWorkOrderPdf.objects.filter(
|
||||||
@@ -335,10 +580,11 @@ def _cleanup_expired_generated_pdfs():
|
|||||||
for item in expired:
|
for item in expired:
|
||||||
if item.file:
|
if item.file:
|
||||||
item.file.delete(save=False)
|
item.file.delete(save=False)
|
||||||
|
item.file = None
|
||||||
item.is_active = False
|
item.is_active = False
|
||||||
item.status = 'failed'
|
item.status = 'failed'
|
||||||
item.error_message = 'PDF cache istekao.'
|
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):
|
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:
|
for cached in cache_qs:
|
||||||
if cached.file:
|
if cached.file:
|
||||||
cached.file.delete(save=False)
|
cached.file.delete(save=False)
|
||||||
|
cached.file = None
|
||||||
cached.is_active = False
|
cached.is_active = False
|
||||||
cached.status = 'failed'
|
cached.status = 'failed'
|
||||||
cached.error_message = 'PDF cache invalidiran zbog promjene podataka.'
|
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):
|
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):
|
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
|
filename = generated_pdf.filename or default_filename
|
||||||
response = FileResponse(generated_pdf.file, content_type='application/pdf')
|
response = FileResponse(generated_pdf.file, content_type='application/pdf')
|
||||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
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_residence = (getattr(creator, 'residence', None) or '').strip() or "-"
|
||||||
creator_work_position = (getattr(creator, 'work_position', None) or '').strip() or creator_occupation
|
creator_work_position = (getattr(creator, 'work_position', None) or '').strip() or creator_occupation
|
||||||
travel_start = work_order.travel_start_at
|
travel_context = _work_order_travel_expenses_context(work_order)
|
||||||
travel_end = work_order.travel_end_at
|
related_tasks = travel_context['related_tasks']
|
||||||
travel_hours = _hours_between(travel_start, travel_end)
|
trip_entries = travel_context['trip_entries']
|
||||||
daily_qty = round(travel_hours / 8.0, 1) if travel_hours > 0 else 0.0
|
travel_start = travel_context['travel_start']
|
||||||
daily_rate = 0.0
|
travel_end = travel_context['travel_end']
|
||||||
daily_total = daily_qty * daily_rate
|
trip_start_date = travel_context['trip_start_date']
|
||||||
transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0)
|
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'
|
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'
|
origin_label = (work_order.origin_location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
|
||||||
additional_table = getattr(work_order, 'additional_costs_table', None)
|
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):
|
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_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)
|
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 = [
|
attachment_names = [
|
||||||
str(row.get('prilog', '')).strip()
|
str(row.get('prilog', '')).strip()
|
||||||
@@ -785,12 +1041,12 @@ def _build_work_order_pdf(work_order):
|
|||||||
["OBRAČUN PUTNIH TROŠKOVA", "", "", "", "", "", "", ""],
|
["OBRAČUN PUTNIH TROŠKOVA", "", "", "", "", "", "", ""],
|
||||||
["ODLAZAK Datum", "ODLAZAK Vrijeme", "POVRATAK Datum", "POVRATAK Vrijeme", "Broj sati", "Količina dnevnica", "Iznos dnevnice", "Ukupan iznos"],
|
["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_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),
|
_fmt_time(travel_end),
|
||||||
str(travel_hours).replace('.', ','),
|
_format_decimal_display(travel_hours),
|
||||||
str(daily_qty).replace('.', ','),
|
_format_decimal_display(daily_qty),
|
||||||
_fmt_eur(daily_rate),
|
_fmt_eur(daily_rate),
|
||||||
_fmt_eur(daily_total),
|
_fmt_eur(daily_total),
|
||||||
],
|
],
|
||||||
@@ -2194,6 +2450,26 @@ def _file_attachment(file_field, fallback_name):
|
|||||||
return (filename, content, _guess_content_type(filename))
|
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):
|
def _build_image_attachments_for_work_order(work_order):
|
||||||
attachments = []
|
attachments = []
|
||||||
photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
|
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',
|
||||||
'work_order__vehicle',
|
'work_order__vehicle',
|
||||||
'work_order__vehicle__client',
|
'work_order__vehicle__client',
|
||||||
|
'work_hours_table',
|
||||||
)
|
)
|
||||||
.order_by('scheduled_date', 'created_at')
|
.order_by('scheduled_date', 'created_at')
|
||||||
)
|
)
|
||||||
@@ -2358,9 +2635,10 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
|||||||
entry_date__month=month,
|
entry_date__month=month,
|
||||||
).order_by('entry_date')
|
).order_by('entry_date')
|
||||||
|
|
||||||
tasks_by_date = OrderedDict()
|
entries_by_date = OrderedDict()
|
||||||
for task in tasks_qs:
|
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 = {
|
manual_by_date = {
|
||||||
entry.entry_date: entry
|
entry.entry_date: entry
|
||||||
@@ -2371,8 +2649,8 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
|||||||
days_in_month = monthrange(year, month)[1]
|
days_in_month = monthrange(year, month)[1]
|
||||||
for day in range(1, days_in_month + 1):
|
for day in range(1, days_in_month + 1):
|
||||||
current_date = _date(year, month, day)
|
current_date = _date(year, month, day)
|
||||||
day_tasks = tasks_by_date.get(current_date, [])
|
day_entries = entries_by_date.get(current_date, [])
|
||||||
if day_tasks:
|
if day_entries:
|
||||||
titles = OrderedDict()
|
titles = OrderedDict()
|
||||||
serials = OrderedDict()
|
serials = OrderedDict()
|
||||||
clients = OrderedDict()
|
clients = OrderedDict()
|
||||||
@@ -2380,53 +2658,31 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
|||||||
work_orders = OrderedDict()
|
work_orders = OrderedDict()
|
||||||
start_values = []
|
start_values = []
|
||||||
end_values = []
|
end_values = []
|
||||||
counted_work_order_ids = set()
|
total_hours = Decimal('0.00')
|
||||||
total_hours = 0.0
|
|
||||||
|
|
||||||
for task in day_tasks:
|
for entry in day_entries:
|
||||||
title = str(task.title or '').strip()
|
if entry.get('title'):
|
||||||
if title:
|
titles[entry['title']] = entry['title']
|
||||||
titles[title] = title
|
if entry.get('serial'):
|
||||||
|
serials[entry['serial']] = entry['serial']
|
||||||
vehicle = getattr(task, 'vehicle', None)
|
if entry.get('client'):
|
||||||
work_order = getattr(task, 'work_order', None)
|
clients[entry['client']] = entry['client']
|
||||||
if work_order and getattr(work_order, 'vehicle', None):
|
if entry.get('location'):
|
||||||
vehicle = work_order.vehicle
|
locations[entry['location']] = entry['location']
|
||||||
|
if entry.get('work_order_label'):
|
||||||
serial_value = str(getattr(vehicle, 'crane_serial_number', '') or '').strip()
|
work_orders[entry['work_order_label']] = entry['work_order_label']
|
||||||
if serial_value:
|
if entry.get('start_dt'):
|
||||||
serials[serial_value] = serial_value
|
start_values.append(entry['start_dt'])
|
||||||
|
if entry.get('end_dt'):
|
||||||
client_name = str(getattr(getattr(vehicle, 'client', None), 'name', '') or '').strip()
|
end_values.append(entry['end_dt'])
|
||||||
if client_name:
|
total_hours += entry.get('total_hours', Decimal('0.00'))
|
||||||
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
|
|
||||||
|
|
||||||
start_label = '-'
|
start_label = '-'
|
||||||
end_label = '-'
|
end_label = '-'
|
||||||
if start_values:
|
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:
|
if end_values:
|
||||||
end_label = max(value for _, value in end_values).strftime('%H:%M')
|
end_label = max(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')
|
|
||||||
|
|
||||||
rows.append({
|
rows.append({
|
||||||
'date': current_date,
|
'date': current_date,
|
||||||
@@ -2437,8 +2693,8 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
|||||||
'mjesto_rada': ', '.join(locations.values()) or '-',
|
'mjesto_rada': ', '.join(locations.values()) or '-',
|
||||||
'pocetak_rada': start_label,
|
'pocetak_rada': start_label,
|
||||||
'kraj_rada': end_label,
|
'kraj_rada': end_label,
|
||||||
'redovan_rad': regular_hours,
|
'redovan_rad': '8',
|
||||||
'prekovremeni': overtime_hours,
|
'prekovremeni': _format_report_hours(total_hours, default='0'),
|
||||||
'radni_nalog': ', '.join(value for value in work_orders.values() if value) or '-',
|
'radni_nalog': ', '.join(value for value in work_orders.values() if value) or '-',
|
||||||
'source': 'task',
|
'source': 'task',
|
||||||
})
|
})
|
||||||
@@ -2471,7 +2727,7 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
|||||||
'mjesto_rada': '-',
|
'mjesto_rada': '-',
|
||||||
'pocetak_rada': '-',
|
'pocetak_rada': '-',
|
||||||
'kraj_rada': '-',
|
'kraj_rada': '-',
|
||||||
'redovan_rad': '-',
|
'redovan_rad': '0',
|
||||||
'prekovremeni': '0',
|
'prekovremeni': '0',
|
||||||
'radni_nalog': '-',
|
'radni_nalog': '-',
|
||||||
'source': 'empty',
|
'source': 'empty',
|
||||||
@@ -2772,7 +3028,7 @@ def _parse_year_month_params(request):
|
|||||||
return year, month
|
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
|
from modules.task_management.models import Task
|
||||||
|
|
||||||
tasks = list(
|
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.'})
|
raise DRFValidationError({'detail': 'Nema servisnih taskova za odabrani mjesec.'})
|
||||||
|
|
||||||
used_names = set()
|
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()
|
archive_buffer = BytesIO()
|
||||||
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
for task in tasks:
|
_write_monthly_service_tasks_archive_entries(
|
||||||
work_order = task.work_order
|
archive,
|
||||||
if work_order is None:
|
user=user,
|
||||||
continue
|
year=year,
|
||||||
docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=[task])
|
month=month,
|
||||||
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)
|
|
||||||
|
|
||||||
archive_content = archive_buffer.getvalue()
|
archive_content = archive_buffer.getvalue()
|
||||||
if not archive_content:
|
if not archive_content:
|
||||||
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
||||||
return archive_content
|
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
|
from modules.task_management.models import Task
|
||||||
|
|
||||||
monthly_tasks = (
|
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.'})
|
raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'})
|
||||||
|
|
||||||
used_names = set()
|
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()
|
archive_buffer = BytesIO()
|
||||||
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
for work_order in work_orders:
|
_write_monthly_work_orders_archive_entries(
|
||||||
pdf_bytes = _build_work_order_pdf(work_order)
|
archive,
|
||||||
work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names)
|
user=user,
|
||||||
archive.writestr(work_order_pdf_name, pdf_bytes)
|
year=year,
|
||||||
|
month=month,
|
||||||
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)
|
|
||||||
|
|
||||||
archive_content = archive_buffer.getvalue()
|
archive_content = archive_buffer.getvalue()
|
||||||
if not archive_content:
|
if not archive_content:
|
||||||
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
||||||
@@ -3175,12 +3455,18 @@ def generated_archive_download(request, archive_id):
|
|||||||
if generated_archive is None:
|
if generated_archive is None:
|
||||||
raise DRFValidationError({'detail': 'ZIP arhiva nije dostupna ili je istekla.'})
|
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:
|
try:
|
||||||
archive_bytes = generated_archive.file.read()
|
if not storage.exists(file_name):
|
||||||
finally:
|
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
|
||||||
generated_archive.file.close()
|
except (FileNotFoundError, OSError, ValueError):
|
||||||
if not archive_bytes:
|
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.'})
|
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
|
||||||
filename = generated_archive.filename or _generated_archive_filename_for_user(
|
filename = generated_archive.filename or _generated_archive_filename_for_user(
|
||||||
request.user,
|
request.user,
|
||||||
@@ -3188,10 +3474,13 @@ def generated_archive_download(request, archive_id):
|
|||||||
month=generated_archive.month,
|
month=generated_archive.month,
|
||||||
archive_type=generated_archive.archive_type,
|
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['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||||
response['Cache-Control'] = 'private, max-age=3600'
|
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
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -3470,6 +3759,18 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
|||||||
).exclude(file='').exclude(file__isnull=True).first()
|
).exclude(file='').exclude(file__isnull=True).first()
|
||||||
if generated_pdf is None:
|
if generated_pdf is None:
|
||||||
raise DRFValidationError({"detail": "PDF nije dostupan ili je istekao."})
|
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))
|
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')
|
@action(detail=True, methods=['get'], url_path='pdf-preview')
|
||||||
@@ -3603,11 +3904,26 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
|||||||
if additional_costs_table
|
if additional_costs_table
|
||||||
else {'work_order': str(work_order.pk), 'data': {'rows': []}, 'total_for_payout': '0.00'}
|
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({
|
return Response({
|
||||||
'work_order_id': work_order.pk,
|
'work_order_id': work_order.pk,
|
||||||
'tasks': payload,
|
'tasks': payload,
|
||||||
'additional_costs_table': additional_costs_payload,
|
'additional_costs_table': additional_costs_payload,
|
||||||
|
'travel_expenses_table': travel_expenses_payload,
|
||||||
}, status=status.HTTP_200_OK)
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@action(detail=True, methods=['get', 'put', 'patch'], url_path='additional-costs-table')
|
@action(detail=True, methods=['get', 'put', 'patch'], url_path='additional-costs-table')
|
||||||
@@ -3640,6 +3956,40 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
|||||||
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'invoices'])
|
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'invoices'])
|
||||||
return Response(WorkOrderAdditionalCostsTableSerializer(instance).data, status=status.HTTP_200_OK)
|
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')
|
@action(detail=True, methods=['post'], url_path='send-email')
|
||||||
def send_email(self, request, pk=None):
|
def send_email(self, request, pk=None):
|
||||||
work_order = self.get_object()
|
work_order = self.get_object()
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||||||
vehicle_owner_name = serializers.SerializerMethodField()
|
vehicle_owner_name = serializers.SerializerMethodField()
|
||||||
vehicle_asset_type = serializers.SerializerMethodField()
|
vehicle_asset_type = serializers.SerializerMethodField()
|
||||||
crane_serial_number = serializers.SerializerMethodField()
|
crane_serial_number = serializers.SerializerMethodField()
|
||||||
|
work_hours_table = serializers.SerializerMethodField()
|
||||||
template_id = serializers.UUIDField(write_only=True, required=False, allow_null=True)
|
template_id = serializers.UUIDField(write_only=True, required=False, allow_null=True)
|
||||||
auto_close_work_order = serializers.BooleanField(write_only=True, required=False)
|
auto_close_work_order = serializers.BooleanField(write_only=True, required=False)
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||||||
'id', 'title', 'description', 'status',
|
'id', 'title', 'description', 'status',
|
||||||
'assigned_to', 'assigned_to_name',
|
'assigned_to', 'assigned_to_name',
|
||||||
'vehicle', 'vehicle_registration', 'vehicle_make', 'vehicle_model',
|
'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',
|
'work_order', 'work_order_label',
|
||||||
'scheduled_date',
|
'scheduled_date',
|
||||||
'service_report_note',
|
'service_report_note',
|
||||||
@@ -212,3 +213,9 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||||||
if not obj.vehicle_id:
|
if not obj.vehicle_id:
|
||||||
return None
|
return None
|
||||||
return obj.vehicle.crane_serial_number or None
|
return obj.vehicle.crane_serial_number or None
|
||||||
|
|
||||||
|
def get_work_hours_table(self, obj):
|
||||||
|
table = getattr(obj, 'work_hours_table', None)
|
||||||
|
if not table:
|
||||||
|
return None
|
||||||
|
return TaskWorkHoursTableSerializer(table).data
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from django.test import TestCase
|
|||||||
from modules.task_management.serializers import TaskSerializer
|
from modules.task_management.serializers import TaskSerializer
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from modules.task_management.models import Task
|
from modules.task_management.models import Task, TaskWorkHoursTable
|
||||||
from modules.fleet.models import Vehicle, WorkOrder
|
from modules.fleet.models import Vehicle, WorkOrder
|
||||||
from modules.crm.models import Client
|
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_owner_name'] == 'Klijent zadatka'
|
||||||
assert serializer.data['vehicle_asset_type'] == 'crane'
|
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):
|
def test_scheduled_date_accepts_valid_date(self):
|
||||||
serializer = TaskSerializer(
|
serializer = TaskSerializer(
|
||||||
data={"title": "Task mit datum", "status": "aktivan",
|
data={"title": "Task mit datum", "status": "aktivan",
|
||||||
|
|||||||
24
backend/scripts/cleanup_tmp_files.sh
Normal file
24
backend/scripts/cleanup_tmp_files.sh
Normal 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))."
|
||||||
@@ -37,7 +37,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DEBUG: "False"
|
DEBUG: "False"
|
||||||
DJANGO_SETTINGS_MODULE: core.settings.production
|
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:
|
volumes:
|
||||||
- media_volume:/app/media
|
- media_volume:/app/media
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
const CACHE_NAME = 'erp-shell-v2';
|
const CACHE_NAME = 'erp-shell-v3';
|
||||||
const API_CACHE_NAME = 'erp-api-v1';
|
const API_CACHE_NAME = 'erp-api-v1';
|
||||||
const WRITE_QUEUE_DB_NAME = 'erp-write-queue-db';
|
const WRITE_QUEUE_DB_NAME = 'erp-write-queue-db';
|
||||||
const WRITE_QUEUE_STORE = 'requests';
|
const WRITE_QUEUE_STORE = 'requests';
|
||||||
const WRITE_QUEUE_SYNC_TAG = 'erp-write-queue-sync';
|
const WRITE_QUEUE_SYNC_TAG = 'erp-write-queue-sync';
|
||||||
const OFFLINE_URL = '/offline.html';
|
const OFFLINE_URL = '/offline.html';
|
||||||
const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL];
|
const PRECACHE_URLS = ['/', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL];
|
||||||
|
|
||||||
const API_CACHE_BLOCKLIST = [
|
const API_CACHE_BLOCKLIST = [
|
||||||
'/api/token/',
|
'/api/token/',
|
||||||
@@ -142,10 +142,26 @@ async function replayQueuedRequests() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
async function precacheShell() {
|
||||||
event.waitUntil(
|
const cache = await caches.open(CACHE_NAME);
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
|
await Promise.all(
|
||||||
|
PRECACHE_URLS.map(async (url) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { cache: 'reload' });
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
console.warn('Skipping precache for non-OK response:', url, response && response.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await cache.put(url, response);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Skipping precache for failed request:', url, error);
|
||||||
|
}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(precacheShell());
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import AuthWidget from './AuthWidget';
|
|||||||
import UserDisplay from './ui/UserDisplay';
|
import UserDisplay from './ui/UserDisplay';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { useSpring, animated } from '@react-spring/web';
|
import { useSpring, animated } from '@react-spring/web';
|
||||||
import { hydrateAuthFromStorage } from '../stores/authStore';
|
import { hydrateAuthFromStorage, loadCurrentUser, $user, $accessToken } from '../stores/authStore';
|
||||||
|
|
||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', href: '/' },
|
{ id: 'dashboard', label: 'Dashboard', href: '/' },
|
||||||
@@ -21,8 +21,12 @@ function normalizePath(p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActiveIndex(path) {
|
function getActiveIndex(path) {
|
||||||
const idx = ITEMS.findIndex((item) => item.href === path);
|
// Exact match (e.g. '/' → Dashboard)
|
||||||
return idx >= 0 ? idx : 0;
|
const exact = ITEMS.findIndex((item) => item.href === path);
|
||||||
|
if (exact >= 0) return exact;
|
||||||
|
// Prefix match for sub-paths (e.g. '/putni-nalozi/racuni' → Putni nalozi)
|
||||||
|
const prefix = ITEMS.findIndex((item) => item.href !== '/' && path.startsWith(item.href));
|
||||||
|
return prefix >= 0 ? prefix : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Navbar({ minimal = false }) {
|
export default function Navbar({ minimal = false }) {
|
||||||
@@ -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
|
// Mount: izmjeri početnu poziciju bez animacije, pa uključi animaciju za buduće navigacije
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hydrateAuthFromStorage();
|
hydrateAuthFromStorage();
|
||||||
|
// Učitaj korisnika ako token postoji ali $user još nije popunjen
|
||||||
|
if (!$user.get() && $accessToken.get()) {
|
||||||
|
loadCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
measureIndicator();
|
measureIndicator();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useStore } from '@nanostores/preact';
|
import { useStore } from '@nanostores/preact';
|
||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { animated, useSpring, useTransition } from '@react-spring/web';
|
import { animated, useSpring, useTransition } from '@react-spring/web';
|
||||||
import DashboardTopbar from './DashboardTopbar';
|
import DashboardTopbar from './DashboardTopbar';
|
||||||
@@ -1018,7 +1018,7 @@ export default function FleetDashboardShell({ pageMode = 'dashboard' }) {
|
|||||||
<details className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
|
<details className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
|
||||||
<summary className="flex cursor-pointer select-none items-center justify-between px-4 py-3 text-sm font-medium text-text-muted hover:bg-canvas-deep">
|
<summary className="flex cursor-pointer select-none items-center justify-between px-4 py-3 text-sm font-medium text-text-muted hover:bg-canvas-deep">
|
||||||
<span>Sync log</span>
|
<span>Sync log</span>
|
||||||
<span className="text-xs opacity-60">▼</span>
|
<span className="text-xs opacity-60">▾</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="border-t border-border-hairline">
|
<div className="border-t border-border-hairline">
|
||||||
<SyncLogPanel />
|
<SyncLogPanel />
|
||||||
@@ -1166,7 +1166,7 @@ export default function FleetDashboardShell({ pageMode = 'dashboard' }) {
|
|||||||
className="rounded px-2 py-1 text-xs font-medium text-text-muted hover:bg-canvas-deep hover:text-text-main"
|
className="rounded px-2 py-1 text-xs font-medium text-text-muted hover:bg-canvas-deep hover:text-text-main"
|
||||||
title="Upload fotografija putnog naloga"
|
title="Upload fotografija putnog naloga"
|
||||||
>
|
>
|
||||||
📷
|
Foto
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
@@ -1326,22 +1326,33 @@ export default function FleetDashboardShell({ pageMode = 'dashboard' }) {
|
|||||||
const isOpen = String(expandedServiceTaskId || '') === String(group.id);
|
const isOpen = String(expandedServiceTaskId || '') === String(group.id);
|
||||||
return (
|
return (
|
||||||
<animated.div key={group.id} style={style}>
|
<animated.div key={group.id} style={style}>
|
||||||
<button
|
<div className="flex items-center gap-3 px-4 py-3 hover:bg-canvas-deep">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setExpandedServiceTaskId(isOpen ? null : group.id)}
|
type="button"
|
||||||
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-canvas-deep"
|
onClick={() => setExpandedServiceTaskId(isOpen ? null : group.id)}
|
||||||
>
|
className="flex min-w-0 flex-1 items-center justify-between gap-3 text-left"
|
||||||
<div>
|
>
|
||||||
<span className="font-medium text-text-main">{group.title}</span>
|
<div className="min-w-0">
|
||||||
<span className="ml-2 text-xs text-text-muted">{group.status}</span>
|
<span className="font-medium text-text-main">{group.title}</span>
|
||||||
</div>
|
<span className="ml-2 text-xs text-text-muted">{group.status}</span>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<span className="rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700">
|
<div className="flex items-center gap-2">
|
||||||
{group.records.length} servisnih zapisa
|
<span className="rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700">
|
||||||
</span>
|
{group.records.length} servisnih zapisa
|
||||||
<span className="text-text-muted">{isOpen ? '▲' : '▼'}</span>
|
</span>
|
||||||
</div>
|
<span className="text-text-muted">{isOpen ? "^" : "v"}</span>
|
||||||
</button>
|
</div>
|
||||||
|
</button>
|
||||||
|
{group.task && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedTask(group.task)}
|
||||||
|
className="shrink-0 rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
||||||
|
>
|
||||||
|
Detalji taska
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="border-t border-border-hairline bg-canvas-base/50 px-4 py-3">
|
<div className="border-t border-border-hairline bg-canvas-base/50 px-4 py-3">
|
||||||
{group.records.length === 0 ? (
|
{group.records.length === 0 ? (
|
||||||
@@ -1389,7 +1400,7 @@ export default function FleetDashboardShell({ pageMode = 'dashboard' }) {
|
|||||||
onClick={() => setPhotoUpload({ open: true, serviceRecordId: record.id })}
|
onClick={() => setPhotoUpload({ open: true, serviceRecordId: record.id })}
|
||||||
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
||||||
>
|
>
|
||||||
📎 Dodaj datoteke
|
Dodaj datoteke
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
@@ -1408,7 +1419,7 @@ export default function FleetDashboardShell({ pageMode = 'dashboard' }) {
|
|||||||
|
|
||||||
{showClients && (
|
{showClients && (
|
||||||
<>
|
<>
|
||||||
{/* ── Tablica klijenata i njihovih dizalica ───────── */}
|
{/* Tablica klijenata i njihovih dizalica */}
|
||||||
<div id="section-clients">
|
<div id="section-clients">
|
||||||
<ClientsSection
|
<ClientsSection
|
||||||
onSetServiceContext={handleSetServiceContextForCraneId}
|
onSetServiceContext={handleSetServiceContextForCraneId}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ function toDateKey(value) {
|
|||||||
const text = String(value || '').trim();
|
const text = String(value || '').trim();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return text;
|
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);
|
const parsed = new Date(text);
|
||||||
if (Number.isNaN(parsed.getTime())) return null;
|
if (Number.isNaN(parsed.getTime())) return null;
|
||||||
return `${parsed.getFullYear()}-${String(parsed.getMonth() + 1).padStart(2, '0')}-${String(parsed.getDate()).padStart(2, '0')}`;
|
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(', ');
|
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 }) {
|
export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders = [], onTaskClick }) {
|
||||||
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
|
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
|
||||||
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
|
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
|
||||||
@@ -196,6 +277,17 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
};
|
};
|
||||||
}, [reportOpen, reportType, viewYear, viewMonth]);
|
}, [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(() => {
|
const servicerRows = useMemo(() => {
|
||||||
if (reportType !== 'servicer') return [];
|
if (reportType !== 'servicer') return [];
|
||||||
const rows = [];
|
const rows = [];
|
||||||
@@ -204,44 +296,32 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
|
|
||||||
for (let day = 1; day <= daysInMonth; day += 1) {
|
for (let day = 1; day <= daysInMonth; day += 1) {
|
||||||
const key = `${monthPrefix}-${String(day).padStart(2, '0')}`;
|
const key = `${monthPrefix}-${String(day).padStart(2, '0')}`;
|
||||||
const dayTasks = tasksByDate[key] ?? [];
|
const dayEntries = reportTaskEntriesByDate[key] ?? [];
|
||||||
const manualEntry = manualEntriesByDate[key];
|
const manualEntry = manualEntriesByDate[key];
|
||||||
|
|
||||||
if (dayTasks.length > 0) {
|
if (dayEntries.length > 0) {
|
||||||
const titles = [];
|
const titles = [];
|
||||||
const serials = [];
|
const serials = [];
|
||||||
const clients = [];
|
const clients = [];
|
||||||
const locations = [];
|
const locations = [];
|
||||||
const workOrderLabels = [];
|
const workOrderLabels = [];
|
||||||
const workStartTimes = [];
|
const startTimes = [];
|
||||||
const workEndTimes = [];
|
const endTimes = [];
|
||||||
const countedWorkOrders = new Set();
|
|
||||||
let totalHours = 0;
|
let totalHours = 0;
|
||||||
|
|
||||||
for (const task of dayTasks) {
|
for (const entry of dayEntries) {
|
||||||
if (task?.title) titles.push(task.title);
|
if (entry.title) titles.push(entry.title);
|
||||||
serials.push(getTaskCraneSerial(task));
|
if (entry.serial) serials.push(entry.serial);
|
||||||
clients.push(getTaskCraneOwner(task));
|
if (entry.client) clients.push(entry.client);
|
||||||
if (task?.work_order_label) workOrderLabels.push(task.work_order_label);
|
if (entry.location) locations.push(entry.location);
|
||||||
|
if (entry.workOrderLabel) workOrderLabels.push(entry.workOrderLabel);
|
||||||
const workOrder = workOrdersById.get(String(task.work_order || ''));
|
if (entry.startAt) startTimes.push(entry.startAt);
|
||||||
if (workOrder?.location) locations.push(workOrder.location);
|
if (entry.endAt) endTimes.push(entry.endAt);
|
||||||
if (
|
totalHours += Number(entry.totalHours || 0);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const regularHours = 8;
|
||||||
|
const overtimeHours = Math.max(0, totalHours - regularHours);
|
||||||
rows.push({
|
rows.push({
|
||||||
key,
|
key,
|
||||||
clickable: false,
|
clickable: false,
|
||||||
@@ -252,10 +332,10 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
joinUnique(serials) || '-',
|
joinUnique(serials) || '-',
|
||||||
joinUnique(clients) || '-',
|
joinUnique(clients) || '-',
|
||||||
joinUnique(locations) || '-',
|
joinUnique(locations) || '-',
|
||||||
workStartTimes.length ? formatTime(new Date(Math.min(...workStartTimes.map((item) => item.getTime()))).toISOString()) : '-',
|
startTimes.length ? formatTime(new Date(Math.min(...startTimes.map((item) => item.getTime())))) : '-',
|
||||||
workEndTimes.length ? formatTime(new Date(Math.max(...workEndTimes.map((item) => item.getTime()))).toISOString()) : '-',
|
endTimes.length ? formatTime(new Date(Math.max(...endTimes.map((item) => item.getTime())))) : '-',
|
||||||
totalHours > 0 ? formatHourValue(Math.min(totalHours, 8), '0') : '-',
|
formatHourValue(regularHours, '0'),
|
||||||
totalHours > 8 ? formatHourValue(totalHours - 8, '0') : '0',
|
formatHourValue(overtimeHours, '0'),
|
||||||
joinUnique(workOrderLabels) || '-',
|
joinUnique(workOrderLabels) || '-',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -292,7 +372,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
}, [manualEntriesByDate, reportType, tasksByDate, viewMonth, viewYear, workOrdersById]);
|
}, [manualEntriesByDate, reportTaskEntriesByDate, reportType, viewMonth, viewYear]);
|
||||||
|
|
||||||
const costsRows = useMemo(() => {
|
const costsRows = useMemo(() => {
|
||||||
if (reportType !== 'costs') return [];
|
if (reportType !== 'costs') return [];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { createTask, fetchTaskTemplates, getStatusLabel } from '../../stores/taskStore';
|
import { createTask, fetchTaskTemplates, getStatusLabel } from '../../stores/taskStore';
|
||||||
import { formatPurposeLabel, formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
import { formatPurposeLabel, formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||||
import ModalShell from '../ui/ModalShell';
|
import ModalShell from '../ui/ModalShell';
|
||||||
|
import { updateVehicle } from '../../stores/fleetDashboardStore';
|
||||||
|
|
||||||
const STATUS_OPTIONS = ['aktivan', 'servis', 'zavrsen', 'neaktivan'];
|
const STATUS_OPTIONS = ['aktivan', 'servis', 'zavrsen', 'neaktivan'];
|
||||||
|
|
||||||
@@ -32,6 +33,10 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
const [templateLoading, setTemplateLoading] = useState(false);
|
const [templateLoading, setTemplateLoading] = useState(false);
|
||||||
const [templateId, setTemplateId] = useState('');
|
const [templateId, setTemplateId] = useState('');
|
||||||
const [templates, setTemplates] = 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(
|
const selectedTemplate = useMemo(
|
||||||
() => templates.find((template) => String(template.id) === String(templateId)) || null,
|
() => templates.find((template) => String(template.id) === String(templateId)) || null,
|
||||||
@@ -51,6 +56,10 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
setTemplateId('');
|
setTemplateId('');
|
||||||
setTemplates([]);
|
setTemplates([]);
|
||||||
setTemplateLoading(false);
|
setTemplateLoading(false);
|
||||||
|
setCraneDataModalOpen(false);
|
||||||
|
setCraneDataForm({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
|
||||||
|
setSavingCraneData(false);
|
||||||
|
setCraneDataError('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Auto-pre-select today's open work order if exactly one exists for this crane
|
// 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]);
|
}, [selectedTemplate]);
|
||||||
|
|
||||||
|
function openCraneDataModal() {
|
||||||
|
if (!contextCrane?.id) {
|
||||||
|
setCraneDataError('Dizalica nije odabrana u servisnom kontekstu.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCraneDataForm({
|
||||||
|
superstructure_working_hours: String(contextCrane?.superstructure_working_hours ?? ''),
|
||||||
|
chassis_working_hours: String(contextCrane?.chassis_working_hours ?? ''),
|
||||||
|
current_mileage: String(contextCrane?.current_mileage ?? ''),
|
||||||
|
});
|
||||||
|
setCraneDataError('');
|
||||||
|
setCraneDataModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveCraneData() {
|
||||||
|
if (!contextCrane?.id) {
|
||||||
|
setCraneDataError('Dizalica nije odabrana u servisnom kontekstu.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const superstructure = craneDataForm.superstructure_working_hours === '' ? undefined : Number(craneDataForm.superstructure_working_hours);
|
||||||
|
const chassis = craneDataForm.chassis_working_hours === '' ? undefined : Number(craneDataForm.chassis_working_hours);
|
||||||
|
const mileage = craneDataForm.current_mileage === '' ? undefined : Number(craneDataForm.current_mileage);
|
||||||
|
if (superstructure !== undefined && (Number.isNaN(superstructure) || superstructure < 0)) {
|
||||||
|
setCraneDataError('Radni sati nadogradnje moraju biti pozitivan broj.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chassis !== undefined && (Number.isNaN(chassis) || chassis < 0)) {
|
||||||
|
setCraneDataError('Radni sati podvozja moraju biti pozitivan broj.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mileage !== undefined && (Number.isNaN(mileage) || mileage < 0)) {
|
||||||
|
setCraneDataError('Kilometraza mora biti pozitivan broj.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {};
|
||||||
|
if (superstructure !== undefined) payload.superstructure_working_hours = superstructure;
|
||||||
|
if (chassis !== undefined) payload.chassis_working_hours = chassis;
|
||||||
|
if (mileage !== undefined) payload.current_mileage = mileage;
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
setCraneDataError('Unesite barem jednu vrijednost za azuriranje.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingCraneData(true);
|
||||||
|
setCraneDataError('');
|
||||||
|
try {
|
||||||
|
await updateVehicle(contextCrane.id, payload);
|
||||||
|
setCraneDataModalOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
setCraneDataError(err?.message || 'Azuriranje podataka dizalice nije uspjelo.');
|
||||||
|
} finally {
|
||||||
|
setSavingCraneData(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
function setField(name, value) {
|
function setField(name, value) {
|
||||||
@@ -156,6 +219,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<ModalShell
|
<ModalShell
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
overlayClassName="z-50 overflow-y-auto p-4 pt-20"
|
overlayClassName="z-50 overflow-y-auto p-4 pt-20"
|
||||||
@@ -215,6 +279,39 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{contextCrane && (
|
||||||
|
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-text-main">Podaci odabrane dizalice</p>
|
||||||
|
<p className="text-xs text-text-muted">{contextCrane.registration_number || '-'} / {contextCrane.make || '-'} {contextCrane.model || ''}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCraneDataModal}
|
||||||
|
disabled={submitting}
|
||||||
|
className="rounded border border-border-hairline px-3 py-1 text-xs font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Uredi podatke dizalice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<dl className="mt-3 grid gap-2 text-sm sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-text-muted">Radni sati nadogradnje</dt>
|
||||||
|
<dd className="text-text-main">{contextCrane.superstructure_working_hours ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-text-muted">Radni sati podvozja</dt>
|
||||||
|
<dd className="text-text-main">{contextCrane.chassis_working_hours ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-text-muted">Kilometraza</dt>
|
||||||
|
<dd className="text-text-main">{contextCrane.current_mileage ?? '-'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-text-main">Naslov zadatka *</span>
|
<span className="font-medium text-text-main">Naslov zadatka *</span>
|
||||||
<input
|
<input
|
||||||
@@ -326,5 +423,84 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</ModalShell>
|
</ModalShell>
|
||||||
|
|
||||||
|
{craneDataModalOpen && (
|
||||||
|
<ModalShell
|
||||||
|
onClose={() => setCraneDataModalOpen(false)}
|
||||||
|
overlayClassName="z-[60] bg-black/40 p-4"
|
||||||
|
contentClassName="flex min-h-full items-center justify-center"
|
||||||
|
panelClassName="w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-base font-semibold text-text-main">Izmijeni podatke dizalice</h4>
|
||||||
|
<p className="mt-1 text-sm text-text-muted">
|
||||||
|
Azurirajte radne sate i kilometrazu za dizalicu: <strong>{contextCrane?.registration_number || "-"}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="text-xs text-text-muted">Radni sati nadogradnje</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={craneDataForm.superstructure_working_hours}
|
||||||
|
onInput={(event) => setCraneDataForm((prev) => ({ ...prev, superstructure_working_hours: event.currentTarget.value }))}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="text-xs text-text-muted">Radni sati podvozja</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={craneDataForm.chassis_working_hours}
|
||||||
|
onInput={(event) => setCraneDataForm((prev) => ({ ...prev, chassis_working_hours: event.currentTarget.value }))}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="text-xs text-text-muted">Kilometraza (km)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={craneDataForm.current_mileage}
|
||||||
|
onInput={(event) => setCraneDataForm((prev) => ({ ...prev, current_mileage: event.currentTarget.value }))}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{craneDataError && (
|
||||||
|
<div className="rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{craneDataError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCraneDataModalOpen(false)}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Odustani
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveCraneData}
|
||||||
|
disabled={savingCraneData}
|
||||||
|
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{savingCraneData ? "Spremanje..." : "Spremi podatke"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import ModalShell from '../ui/ModalShell';
|
import ModalShell from '../ui/ModalShell';
|
||||||
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||||
import { getStatusLabel } from '../../stores/taskStore';
|
import { getStatusLabel } from '../../stores/taskStore';
|
||||||
@@ -251,6 +251,15 @@ export default function TaskServiceRecordsModal({
|
|||||||
>
|
>
|
||||||
Postavi servisni kontekst
|
Postavi servisni kontekst
|
||||||
</button>
|
</button>
|
||||||
|
{task?.vehicle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCraneDataModal}
|
||||||
|
className="rounded-md border border-border-hairline px-3 py-1 text-xs font-semibold text-text-main hover:bg-canvas-base"
|
||||||
|
>
|
||||||
|
Uredi podatke dizalice
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -306,10 +315,10 @@ export default function TaskServiceRecordsModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openCraneDataModal}
|
onClick={openCraneDataModal}
|
||||||
className="rounded border border-border-hairline px-1.5 py-0.5 text-[10px] text-text-muted hover:bg-canvas-base"
|
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
||||||
title="Izmijeni radne sate i kilometražu dizalice"
|
title="Izmijeni radne sate i kilometrazu dizalice"
|
||||||
>
|
>
|
||||||
🔧
|
Uredi podatke
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
@@ -539,7 +548,12 @@ export default function TaskServiceRecordsModal({
|
|||||||
</ModalShell>
|
</ModalShell>
|
||||||
|
|
||||||
{craneDataModalOpen && (
|
{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">
|
<div className="p-4 space-y-4">
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
Ažurirajte radne sate i kilometražu za dizalicu: <strong>{getTaskCraneDisplay(task)}</strong>
|
Ažurirajte radne sate i kilometražu za dizalicu: <strong>{getTaskCraneDisplay(task)}</strong>
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import {
|
|||||||
fetchWorkOrderAdditionalCostsTable,
|
fetchWorkOrderAdditionalCostsTable,
|
||||||
fetchWorkOrderById,
|
fetchWorkOrderById,
|
||||||
fetchWorkOrderInvoices,
|
fetchWorkOrderInvoices,
|
||||||
|
fetchWorkOrderTravelExpensesTable,
|
||||||
fetchWorkOrderTaskServiceContext,
|
fetchWorkOrderTaskServiceContext,
|
||||||
updateTaskWorkHoursTable,
|
updateTaskWorkHoursTable,
|
||||||
updateTaskServiceReportNote,
|
updateTaskServiceReportNote,
|
||||||
updateWorkOrderAdditionalCostsTable,
|
updateWorkOrderAdditionalCostsTable,
|
||||||
|
updateWorkOrderTravelExpensesTable,
|
||||||
} from '../../stores/fleetDashboardStore';
|
} from '../../stores/fleetDashboardStore';
|
||||||
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
|
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
|
||||||
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
||||||
@@ -21,6 +23,7 @@ import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
|||||||
import { useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
import { useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
||||||
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
|
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
|
||||||
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
|
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
|
||||||
|
import WorkOrderTravelExpensesModal from './WorkOrderTravelExpensesModal';
|
||||||
import WorkOrderServiceNotesModal from './WorkOrderServiceNotesModal';
|
import WorkOrderServiceNotesModal from './WorkOrderServiceNotesModal';
|
||||||
|
|
||||||
function readWorkOrderIdFromQuery() {
|
function readWorkOrderIdFromQuery() {
|
||||||
@@ -147,6 +150,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
const [workOrder, setWorkOrder] = useState(null);
|
const [workOrder, setWorkOrder] = useState(null);
|
||||||
const [invoices, setInvoices] = useState([]);
|
const [invoices, setInvoices] = useState([]);
|
||||||
const [taskContext, setTaskContext] = useState({ tasks: [] });
|
const [taskContext, setTaskContext] = useState({ tasks: [] });
|
||||||
|
const [travelExpensesTable, setTravelExpensesTable] = useState({ broj_sati: '0.00', kolicina_dnevnica: '0.00', iznos_dnevnica: '30.00', daily_rate_country: 'HR', total_for_payout: '0.00' });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [editingTask, setEditingTask] = useState(null);
|
const [editingTask, setEditingTask] = useState(null);
|
||||||
@@ -154,6 +158,8 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' });
|
const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' });
|
||||||
const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false);
|
const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false);
|
||||||
const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false);
|
const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false);
|
||||||
|
const [editingTravelExpenses, setEditingTravelExpenses] = useState(false);
|
||||||
|
const [savingTravelExpenses, setSavingTravelExpenses] = useState(false);
|
||||||
const [editingTaskNote, setEditingTaskNote] = useState(null);
|
const [editingTaskNote, setEditingTaskNote] = useState(null);
|
||||||
const [savingTaskNote, setSavingTaskNote] = useState(false);
|
const [savingTaskNote, setSavingTaskNote] = useState(false);
|
||||||
const [taskNoteError, setTaskNoteError] = useState('');
|
const [taskNoteError, setTaskNoteError] = useState('');
|
||||||
@@ -192,11 +198,12 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [contextPayload, order, invoiceItems, additionalCostsPayload] = await Promise.all([
|
const [contextPayload, order, invoiceItems, additionalCostsPayload, travelExpensesPayload] = await Promise.all([
|
||||||
fetchWorkOrderTaskServiceContext(workOrderId),
|
fetchWorkOrderTaskServiceContext(workOrderId),
|
||||||
fetchWorkOrderById(workOrderId),
|
fetchWorkOrderById(workOrderId),
|
||||||
fetchWorkOrderInvoices(workOrderId),
|
fetchWorkOrderInvoices(workOrderId),
|
||||||
fetchWorkOrderAdditionalCostsTable(workOrderId),
|
fetchWorkOrderAdditionalCostsTable(workOrderId),
|
||||||
|
fetchWorkOrderTravelExpensesTable(workOrderId),
|
||||||
]);
|
]);
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
@@ -205,6 +212,13 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
setWorkOrder(order);
|
setWorkOrder(order);
|
||||||
setInvoices(invoiceItems);
|
setInvoices(invoiceItems);
|
||||||
setAdditionalCostsTable(additionalCostsPayload || contextPayload?.additional_costs_table || { data: { rows: [] }, total_for_payout: '0.00' });
|
setAdditionalCostsTable(additionalCostsPayload || contextPayload?.additional_costs_table || { data: { rows: [] }, total_for_payout: '0.00' });
|
||||||
|
setTravelExpensesTable(travelExpensesPayload || contextPayload?.travel_expenses_table || {
|
||||||
|
broj_sati: '0.00',
|
||||||
|
kolicina_dnevnica: '0.00',
|
||||||
|
iznos_dnevnica: '30.00',
|
||||||
|
daily_rate_country: 'HR',
|
||||||
|
total_for_payout: '0.00',
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.');
|
setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.');
|
||||||
@@ -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 (
|
return (
|
||||||
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
|
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
|
||||||
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
||||||
@@ -321,22 +353,32 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
{workOrder ? `Dizalica: ${workOrder.crane_label || workOrder.crane || '-'}` : 'Pregled računa prije kreiranja PDF-a.'}
|
{workOrder ? `Dizalica: ${workOrder.crane_label || workOrder.crane || '-'}` : 'Pregled računa prije kreiranja PDF-a.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => downloadWorkOrderPdf(workOrderId)}
|
type="button"
|
||||||
disabled={!workOrderId}
|
onClick={() => downloadWorkOrderPdf(workOrderId)}
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
disabled={!workOrderId}
|
||||||
>
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
Preuzmi PDF putnog naloga
|
>
|
||||||
</button>
|
Preuzmi PDF putnog naloga
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => downloadWorkOrderDocx(workOrderId)}
|
type="button"
|
||||||
disabled={!workOrderId}
|
onClick={() => downloadWorkOrderDocx(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"
|
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>
|
Preuzmi DOCX putnog naloga
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingTravelExpenses(true)}
|
||||||
|
disabled={!workOrderId}
|
||||||
|
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Uredi obračun putnih troškova
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -568,6 +610,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
onClose={() => setEditingAdditionalCosts(false)}
|
onClose={() => setEditingAdditionalCosts(false)}
|
||||||
onSave={handleSaveAdditionalCostsTable}
|
onSave={handleSaveAdditionalCostsTable}
|
||||||
/>
|
/>
|
||||||
|
<WorkOrderTravelExpensesModal
|
||||||
|
open={editingTravelExpenses}
|
||||||
|
workOrder={workOrder}
|
||||||
|
tableData={travelExpensesTable}
|
||||||
|
saving={savingTravelExpenses}
|
||||||
|
onClose={() => setEditingTravelExpenses(false)}
|
||||||
|
onSave={handleSaveTravelExpensesTable}
|
||||||
|
/>
|
||||||
<WorkOrderServiceNotesModal
|
<WorkOrderServiceNotesModal
|
||||||
open={!!editingTaskNote}
|
open={!!editingTaskNote}
|
||||||
task={editingTaskNote}
|
task={editingTaskNote}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -737,10 +737,12 @@ export async function fetchTasksByWorkOrder(workOrderId) {
|
|||||||
|
|
||||||
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
|
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
|
||||||
if (!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/`);
|
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) {
|
export async function fetchWorkOrderAdditionalCostsTable(workOrderId) {
|
||||||
@@ -765,6 +767,42 @@ export async function updateWorkOrderAdditionalCostsTable(workOrderId, data) {
|
|||||||
return payload;
|
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) {
|
export async function updateTaskWorkHoursTable(taskId, data) {
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
throw new Error('Task ID je obavezan.');
|
throw new Error('Task ID je obavezan.');
|
||||||
|
|||||||
Reference in New Issue
Block a user