fix: add editable work order travel expenses
Introduce a dedicated travel-expenses table for work orders so Broj sati, Količina dnevnica, and Iznos dnevnice can be reviewed and edited before PDF generation. The PDF now uses the stored travel-expenses values, with default HR rate and cache invalidation on updates.
This commit is contained in:
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -417,6 +417,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
|
||||||
|
|||||||
@@ -274,6 +274,30 @@ 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_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,
|
||||||
@@ -491,6 +515,19 @@ class WorkOrderImagesEndpointTests(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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/')
|
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.status_code, 200, response.content)
|
||||||
self.assertEqual(response['Content-Type'], 'application/pdf')
|
self.assertEqual(response['Content-Type'], 'application/pdf')
|
||||||
@@ -501,6 +538,39 @@ class WorkOrderImagesEndpointTests(TestCase):
|
|||||||
self.assertIn('25.12.2033.', text)
|
self.assertIn('25.12.2033.', text)
|
||||||
self.assertIn('9,5', text)
|
self.assertIn('9,5', text)
|
||||||
self.assertIn('0,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):
|
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(0), 0.0)
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -276,6 +278,84 @@ def _task_work_hours_entries(task):
|
|||||||
}]
|
}]
|
||||||
|
|
||||||
return []
|
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 _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 = [entry['start_dt'] for entry in trip_entries if entry.get('start_dt')]
|
||||||
|
end_candidates = [entry['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)
|
||||||
@@ -715,35 +795,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
|
||||||
related_tasks = list(_work_order_related_tasks_queryset(work_order))
|
travel_context = _work_order_travel_expenses_context(work_order)
|
||||||
trip_entries = []
|
related_tasks = travel_context['related_tasks']
|
||||||
for task in related_tasks:
|
trip_entries = travel_context['trip_entries']
|
||||||
trip_entries.extend(_task_work_hours_entries(task))
|
travel_start = travel_context['travel_start']
|
||||||
|
travel_end = travel_context['travel_end']
|
||||||
travel_start = work_order.travel_start_at
|
trip_start_date = travel_context['trip_start_date']
|
||||||
travel_end = work_order.travel_end_at
|
trip_end_date = travel_context['trip_end_date']
|
||||||
trip_start_date = travel_start.date() if travel_start else getattr(work_order, 'date', None)
|
travel_hours = travel_context['broj_sati']
|
||||||
trip_end_date = travel_end.date() if travel_end else getattr(work_order, 'date', None)
|
daily_qty = travel_context['kolicina_dnevnica']
|
||||||
travel_hours = _hours_between(travel_start, travel_end)
|
daily_rate = travel_context['iznos_dnevnica']
|
||||||
|
daily_total = travel_context['total_for_payout']
|
||||||
if trip_entries:
|
transport_total = _parse_decimal(work_order.servicer_vehicle_fuel_cost)
|
||||||
entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')]
|
|
||||||
start_candidates = [entry['start_dt'] for entry in trip_entries if entry.get('start_dt')]
|
|
||||||
end_candidates = [entry['end_dt'] for entry in trip_entries if entry.get('end_dt')]
|
|
||||||
total_hours = sum((entry['total_hours'] for entry in trip_entries), Decimal('0.00'))
|
|
||||||
travel_hours = float(total_hours.quantize(Decimal('0.01')))
|
|
||||||
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)
|
|
||||||
|
|
||||||
daily_qty = _calculate_daily_quantity_from_hours(travel_hours)
|
|
||||||
daily_rate = 0.0
|
|
||||||
daily_total = daily_qty * daily_rate
|
|
||||||
transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0)
|
|
||||||
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)
|
||||||
@@ -752,7 +815,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()
|
||||||
@@ -926,8 +989,8 @@ def _build_work_order_pdf(work_order):
|
|||||||
_fmt_time(travel_start),
|
_fmt_time(travel_start),
|
||||||
_fmt_date(trip_end_date or 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),
|
||||||
],
|
],
|
||||||
@@ -3720,11 +3783,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')
|
||||||
@@ -3757,6 +3835,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()
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -338,6 +370,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
>
|
>
|
||||||
Preuzmi DOCX putnog naloga
|
Preuzmi DOCX putnog naloga
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingTravelExpenses(true)}
|
||||||
|
disabled={!workOrderId}
|
||||||
|
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Uredi obračun putnih troškova
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -570,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