diff --git a/backend/modules/fleet/migrations/0036_workordertravelexpenses_table.py b/backend/modules/fleet/migrations/0036_workordertravelexpenses_table.py new file mode 100644 index 0000000..e6375e1 --- /dev/null +++ b/backend/modules/fleet/migrations/0036_workordertravelexpenses_table.py @@ -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'], + }, + ), + ] diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py index 4829f51..d617578 100644 --- a/backend/modules/fleet/models.py +++ b/backend/modules/fleet/models.py @@ -417,6 +417,63 @@ class WorkOrderAdditionalCostsTable(BaseModel): return f"Additional costs table for work order {self.work_order_id}" +class WorkOrderTravelExpensesTable(BaseModel): + RATE_CHOICES = [ + ('HR', _("Hrvatska")), + ('BIH', _("BiH")), + ('SI', _("Slovenija")), + ('CG', _("Crna Gora")), + ] + + DEFAULT_RATE_COUNTRY = 'HR' + DEFAULT_RATE_AMOUNT = Decimal('30.00') + + work_order = models.OneToOneField( + WorkOrder, + on_delete=models.CASCADE, + related_name='travel_expenses_table', + verbose_name=_("Putni nalog"), + ) + broj_sati = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + verbose_name=_("Broj sati"), + ) + kolicina_dnevnica = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + verbose_name=_("Količina dnevnica"), + ) + iznos_dnevnica = models.DecimalField( + max_digits=10, + decimal_places=2, + default=DEFAULT_RATE_AMOUNT, + verbose_name=_("Iznos dnevnice"), + ) + daily_rate_country = models.CharField( + max_length=8, + choices=RATE_CHOICES, + default=DEFAULT_RATE_COUNTRY, + verbose_name=_("Država dnevnice"), + ) + total_for_payout = models.DecimalField( + max_digits=12, + decimal_places=2, + default=Decimal('0.00'), + verbose_name=_("Ukupan iznos"), + ) + + class Meta: + ordering = ['-updated_at'] + verbose_name = _("Tablica obračuna putnih troškova") + verbose_name_plural = _("Tablice obračuna putnih troškova") + + def __str__(self): + return f"Travel expenses table for work order {self.work_order_id}" + + class WorkOrderPhoto(BaseModel): work_order = models.ForeignKey( WorkOrder, diff --git a/backend/modules/fleet/serializers.py b/backend/modules/fleet/serializers.py index 76ab3c0..7be24b8 100644 --- a/backend/modules/fleet/serializers.py +++ b/backend/modules/fleet/serializers.py @@ -14,6 +14,7 @@ from .models import ( VehicleNotification, WorkOrder, WorkOrderAdditionalCostsTable, + WorkOrderTravelExpensesTable, WorkOrderPhoto, WorkOrderInvoice, VehicleServiceRecord, @@ -501,6 +502,105 @@ class WorkOrderAdditionalCostsTableSerializer(serializers.ModelSerializer): return super().update(instance, validated_data) +class WorkOrderTravelExpensesTableSerializer(serializers.ModelSerializer): + RATE_DEFAULTS = { + 'HR': Decimal('30.00'), + 'BIH': Decimal('50.00'), + 'SI': Decimal('80.00'), + 'CG': Decimal('50.00'), + } + + class Meta: + model = WorkOrderTravelExpensesTable + fields = [ + 'id', + 'work_order', + 'broj_sati', + 'kolicina_dnevnica', + 'iznos_dnevnica', + 'daily_rate_country', + 'total_for_payout', + 'created_at', + 'updated_at', + ] + read_only_fields = ['id', 'total_for_payout', 'created_at', 'updated_at'] + + @staticmethod + def _parse_decimal(value): + if value in (None, ''): + return None + try: + normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.') + return Decimal(normalized) + except (InvalidOperation, ValueError, TypeError): + raise serializers.ValidationError('Neispravna decimalna vrijednost.') + + def validate_daily_rate_country(self, value): + normalized = str(value or '').strip().upper() + if not normalized: + return WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY + valid_choices = {choice for choice, _label in WorkOrderTravelExpensesTable.RATE_CHOICES} + if normalized not in valid_choices: + raise serializers.ValidationError('Neispravna država dnevnice.') + return normalized + + def validate(self, attrs): + instance = getattr(self, 'instance', None) + country = attrs.get( + 'daily_rate_country', + getattr(instance, 'daily_rate_country', WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY), + ) + + if 'iznos_dnevnica' not in attrs or attrs.get('iznos_dnevnica') in (None, ''): + attrs['iznos_dnevnica'] = self.RATE_DEFAULTS.get( + country, + WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT, + ) + + if 'broj_sati' in attrs: + attrs['broj_sati'] = self._parse_decimal(attrs['broj_sati']) + if 'kolicina_dnevnica' in attrs: + attrs['kolicina_dnevnica'] = self._parse_decimal(attrs['kolicina_dnevnica']) + if 'iznos_dnevnica' in attrs: + attrs['iznos_dnevnica'] = self._parse_decimal(attrs['iznos_dnevnica']) + + attrs['broj_sati'] = attrs.get( + 'broj_sati', + getattr(instance, 'broj_sati', Decimal('0.00')), + ) or Decimal('0.00') + attrs['kolicina_dnevnica'] = attrs.get( + 'kolicina_dnevnica', + getattr(instance, 'kolicina_dnevnica', Decimal('0.00')), + ) or Decimal('0.00') + attrs['iznos_dnevnica'] = attrs.get( + 'iznos_dnevnica', + getattr(instance, 'iznos_dnevnica', self.RATE_DEFAULTS.get(country, WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT)), + ) or self.RATE_DEFAULTS.get(country, WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT) + attrs['daily_rate_country'] = country + return attrs + + def _calculate_total_for_payout(self, validated_data): + quantity = validated_data.get('kolicina_dnevnica', Decimal('0.00')) + rate = validated_data.get('iznos_dnevnica', Decimal('0.00')) + if not isinstance(quantity, Decimal): + quantity = self._parse_decimal(quantity) or Decimal('0.00') + if not isinstance(rate, Decimal): + rate = self._parse_decimal(rate) or Decimal('0.00') + return (quantity * rate).quantize(Decimal('0.01')) + + def create(self, validated_data): + validated_data['total_for_payout'] = self._calculate_total_for_payout(validated_data) + return super().create(validated_data) + + def update(self, instance, validated_data): + merged = { + 'kolicina_dnevnica': validated_data.get('kolicina_dnevnica', instance.kolicina_dnevnica), + 'iznos_dnevnica': validated_data.get('iznos_dnevnica', instance.iznos_dnevnica), + } + validated_data['total_for_payout'] = self._calculate_total_for_payout(merged) + return super().update(instance, validated_data) + + class WorkOrderPhotoSerializer(serializers.ModelSerializer): class Meta: model = WorkOrderPhoto diff --git a/backend/modules/fleet/tests/test_work_order_images_endpoint.py b/backend/modules/fleet/tests/test_work_order_images_endpoint.py index 1085c19..e57b33b 100644 --- a/backend/modules/fleet/tests/test_work_order_images_endpoint.py +++ b/backend/modules/fleet/tests/test_work_order_images_endpoint.py @@ -274,6 +274,30 @@ class WorkOrderImagesEndpointTests(TestCase): self.assertFalse(cached.is_active) self.assertEqual(cached.status, 'failed') + def test_travel_expenses_update_invalidates_work_order_pdf_cache(self): + cached = GeneratedWorkOrderPdf.objects.create( + work_order=self.work_order, + requested_by=self.user, + pdf_type='work_order', + status='ready', + filename='MT150726.work-order.pdf', + expires_at=timezone.now() + timedelta(hours=1), + ) + response = self.client.put( + f"/api/fleet/work-orders/{self.work_order.pk}/travel-expenses-table/", + data={ + 'broj_sati': '9.5', + 'kolicina_dnevnica': '0.5', + 'iznos_dnevnica': '30', + 'daily_rate_country': 'HR', + }, + format='json', + ) + self.assertEqual(response.status_code, 200, response.content) + cached.refresh_from_db() + self.assertFalse(cached.is_active) + self.assertEqual(cached.status, 'failed') + def test_service_records_docx_contains_embedded_service_photos(self): VehicleServicePhoto.objects.create( 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/') self.assertEqual(response.status_code, 200, response.content) self.assertEqual(response['Content-Type'], 'application/pdf') @@ -501,6 +538,39 @@ class WorkOrderImagesEndpointTests(TestCase): 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) diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index b7871ea..c91e7f2 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -53,6 +53,7 @@ from .models import ( GeneratedFleetArchive, WorkOrderInvoice, WorkOrderAdditionalCostsTable, + WorkOrderTravelExpensesTable, VehicleServiceRecord, VehicleServicePhoto, VehicleServiceAttachment, @@ -63,6 +64,7 @@ from .serializers import ( WorkOrderSerializer, WorkOrderInvoiceSerializer, WorkOrderAdditionalCostsTableSerializer, + WorkOrderTravelExpensesTableSerializer, WorkOrderPhotoSerializer, VehicleServiceRecordSerializer, VehicleNotificationSerializer, @@ -276,6 +278,84 @@ def _task_work_hours_entries(task): }] 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): 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_work_position = (getattr(creator, 'work_position', None) or '').strip() or creator_occupation - 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 = work_order.travel_start_at - travel_end = work_order.travel_end_at - 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) - travel_hours = _hours_between(travel_start, travel_end) - - 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')] - 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) + travel_context = _work_order_travel_expenses_context(work_order) + related_tasks = travel_context['related_tasks'] + trip_entries = travel_context['trip_entries'] + travel_start = travel_context['travel_start'] + travel_end = travel_context['travel_end'] + trip_start_date = travel_context['trip_start_date'] + trip_end_date = travel_context['trip_end_date'] + travel_hours = travel_context['broj_sati'] + daily_qty = travel_context['kolicina_dnevnica'] + daily_rate = travel_context['iznos_dnevnica'] + daily_total = travel_context['total_for_payout'] + transport_total = _parse_decimal(work_order.servicer_vehicle_fuel_cost) place_label = (work_order.location or 'Zagreb').split(',')[0].strip() or 'Zagreb' origin_label = (work_order.origin_location or 'Zagreb').split(',')[0].strip() or 'Zagreb' additional_table = getattr(work_order, 'additional_costs_table', None) @@ -752,7 +815,7 @@ def _build_work_order_pdf(work_order): if additional_table and isinstance(additional_table.data, dict): additional_rows_data = additional_table.data.get('rows', []) if isinstance(additional_table.data.get('rows', []), list) else [] additional_total_decimal = _parse_decimal(additional_table.total_for_payout) - grand_total = daily_total + transport_total + float(additional_total_decimal) + grand_total = daily_total + transport_total + additional_total_decimal attachment_names = [ str(row.get('prilog', '')).strip() @@ -926,8 +989,8 @@ def _build_work_order_pdf(work_order): _fmt_time(travel_start), _fmt_date(trip_end_date or work_order.date), _fmt_time(travel_end), - str(travel_hours).replace('.', ','), - str(daily_qty).replace('.', ','), + _format_decimal_display(travel_hours), + _format_decimal_display(daily_qty), _fmt_eur(daily_rate), _fmt_eur(daily_total), ], @@ -3720,11 +3783,26 @@ class WorkOrderViewSet(viewsets.ModelViewSet): if additional_costs_table else {'work_order': str(work_order.pk), 'data': {'rows': []}, 'total_for_payout': '0.00'} ) + travel_expenses_context = _work_order_travel_expenses_context(work_order) + travel_expenses_table = getattr(work_order, 'travel_expenses_table', None) + travel_expenses_payload = ( + WorkOrderTravelExpensesTableSerializer(travel_expenses_table).data + if travel_expenses_table + else { + 'work_order': str(work_order.pk), + 'broj_sati': _format_decimal_fixed(travel_expenses_context['broj_sati']), + 'kolicina_dnevnica': _format_decimal_fixed(travel_expenses_context['kolicina_dnevnica']), + 'iznos_dnevnica': _format_decimal_fixed(travel_expenses_context['iznos_dnevnica']), + 'daily_rate_country': WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY, + 'total_for_payout': _format_decimal_fixed(travel_expenses_context['total_for_payout']), + } + ) return Response({ 'work_order_id': work_order.pk, 'tasks': payload, 'additional_costs_table': additional_costs_payload, + 'travel_expenses_table': travel_expenses_payload, }, status=status.HTTP_200_OK) @action(detail=True, methods=['get', 'put', 'patch'], url_path='additional-costs-table') @@ -3757,6 +3835,40 @@ class WorkOrderViewSet(viewsets.ModelViewSet): _invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'invoices']) return Response(WorkOrderAdditionalCostsTableSerializer(instance).data, status=status.HTTP_200_OK) + @action(detail=True, methods=['get', 'put', 'patch'], url_path='travel-expenses-table') + def travel_expenses_table(self, request, pk=None): + work_order = self.get_object() + table = getattr(work_order, 'travel_expenses_table', None) + fallback = _work_order_travel_expenses_context(work_order) + + if request.method.lower() == 'get': + if table: + return Response(WorkOrderTravelExpensesTableSerializer(table).data, status=status.HTTP_200_OK) + return Response({ + 'work_order': str(work_order.pk), + 'broj_sati': _format_decimal_fixed(fallback['broj_sati']), + 'kolicina_dnevnica': _format_decimal_fixed(fallback['kolicina_dnevnica']), + 'iznos_dnevnica': _format_decimal_fixed(fallback['iznos_dnevnica']), + 'daily_rate_country': fallback['daily_rate_country'], + 'total_for_payout': _format_decimal_fixed(fallback['total_for_payout']), + }, status=status.HTTP_200_OK) + + serializer = WorkOrderTravelExpensesTableSerializer( + table, + data={ + 'work_order': str(work_order.pk), + 'broj_sati': request.data.get('broj_sati', fallback['broj_sati']), + 'kolicina_dnevnica': request.data.get('kolicina_dnevnica', fallback['kolicina_dnevnica']), + 'iznos_dnevnica': request.data.get('iznos_dnevnica', fallback['iznos_dnevnica']), + 'daily_rate_country': request.data.get('daily_rate_country', fallback['daily_rate_country']), + }, + partial=bool(table), + ) + serializer.is_valid(raise_exception=True) + instance = serializer.save(work_order=work_order) + _invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order']) + return Response(WorkOrderTravelExpensesTableSerializer(instance).data, status=status.HTTP_200_OK) + @action(detail=True, methods=['post'], url_path='send-email') def send_email(self, request, pk=None): work_order = self.get_object() diff --git a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx index fe54e7c..157428e 100644 --- a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx +++ b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx @@ -10,10 +10,12 @@ import { fetchWorkOrderAdditionalCostsTable, fetchWorkOrderById, fetchWorkOrderInvoices, + fetchWorkOrderTravelExpensesTable, fetchWorkOrderTaskServiceContext, updateTaskWorkHoursTable, updateTaskServiceReportNote, updateWorkOrderAdditionalCostsTable, + updateWorkOrderTravelExpensesTable, } from '../../stores/fleetDashboardStore'; import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore'; import { fetchNotifications, connectNotifications } from '../../stores/notificationStore'; @@ -21,6 +23,7 @@ import { formatWorkOrderDisplayCode } from '../../lib/displayIds'; import { useAuthenticatedMediaSources } from './WorkOrderImageCarousel'; import TaskWorkHoursTableModal from './TaskWorkHoursTableModal'; import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal'; +import WorkOrderTravelExpensesModal from './WorkOrderTravelExpensesModal'; import WorkOrderServiceNotesModal from './WorkOrderServiceNotesModal'; function readWorkOrderIdFromQuery() { @@ -147,6 +150,7 @@ export default function WorkOrderInvoicesPdfPage() { const [workOrder, setWorkOrder] = useState(null); const [invoices, setInvoices] = useState([]); const [taskContext, setTaskContext] = useState({ tasks: [] }); + const [travelExpensesTable, setTravelExpensesTable] = useState({ broj_sati: '0.00', kolicina_dnevnica: '0.00', iznos_dnevnica: '30.00', daily_rate_country: 'HR', total_for_payout: '0.00' }); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [editingTask, setEditingTask] = useState(null); @@ -154,6 +158,8 @@ export default function WorkOrderInvoicesPdfPage() { const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' }); const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false); const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false); + const [editingTravelExpenses, setEditingTravelExpenses] = useState(false); + const [savingTravelExpenses, setSavingTravelExpenses] = useState(false); const [editingTaskNote, setEditingTaskNote] = useState(null); const [savingTaskNote, setSavingTaskNote] = useState(false); const [taskNoteError, setTaskNoteError] = useState(''); @@ -192,11 +198,12 @@ export default function WorkOrderInvoicesPdfPage() { (async () => { try { - const [contextPayload, order, invoiceItems, additionalCostsPayload] = await Promise.all([ + const [contextPayload, order, invoiceItems, additionalCostsPayload, travelExpensesPayload] = await Promise.all([ fetchWorkOrderTaskServiceContext(workOrderId), fetchWorkOrderById(workOrderId), fetchWorkOrderInvoices(workOrderId), fetchWorkOrderAdditionalCostsTable(workOrderId), + fetchWorkOrderTravelExpensesTable(workOrderId), ]); if (cancelled) { return; @@ -205,6 +212,13 @@ export default function WorkOrderInvoicesPdfPage() { setWorkOrder(order); setInvoices(invoiceItems); setAdditionalCostsTable(additionalCostsPayload || contextPayload?.additional_costs_table || { data: { rows: [] }, total_for_payout: '0.00' }); + setTravelExpensesTable(travelExpensesPayload || contextPayload?.travel_expenses_table || { + broj_sati: '0.00', + kolicina_dnevnica: '0.00', + iznos_dnevnica: '30.00', + daily_rate_country: 'HR', + total_for_payout: '0.00', + }); } catch (err) { if (!cancelled) { setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.'); @@ -311,6 +325,24 @@ export default function WorkOrderInvoicesPdfPage() { } }; + const handleSaveTravelExpensesTable = async (tableData) => { + if (!workOrderId) return; + setSavingTravelExpenses(true); + try { + const response = await updateWorkOrderTravelExpensesTable(workOrderId, tableData); + setTravelExpensesTable(response || { + broj_sati: '0.00', + kolicina_dnevnica: '0.00', + iznos_dnevnica: '30.00', + daily_rate_country: 'HR', + total_for_payout: '0.00', + }); + setEditingTravelExpenses(false); + } finally { + setSavingTravelExpenses(false); + } + }; + return (
@@ -338,6 +370,14 @@ export default function WorkOrderInvoicesPdfPage() { > Preuzmi DOCX putnog naloga +
@@ -570,6 +610,14 @@ export default function WorkOrderInvoicesPdfPage() { onClose={() => setEditingAdditionalCosts(false)} onSave={handleSaveAdditionalCostsTable} /> + setEditingTravelExpenses(false)} + onSave={handleSaveTravelExpensesTable} + /> 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 ( + +
+
+
+

OBRAČUN PUTNIH TROŠKOVA

+

{workOrder?.display_code || workOrder?.id || '-'}

+
+ +
+ +
+
+

Brzi odabir dnevnice

+

Odabir države automatski postavlja iznos dnevnice. Po potrebi iznos možete ručno izmijeniti.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
#Broj satiKoličina dnevnicaDržavaIznos dnevniceUkupan iznos
1 + 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" + /> + + 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" + /> + + + + 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" + /> + +
+ {total} € +
+
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/stores/fleetDashboardStore.js b/frontend/src/stores/fleetDashboardStore.js index 668c3cc..9e261a5 100644 --- a/frontend/src/stores/fleetDashboardStore.js +++ b/frontend/src/stores/fleetDashboardStore.js @@ -737,10 +737,12 @@ export async function fetchTasksByWorkOrder(workOrderId) { export async function fetchWorkOrderTaskServiceContext(workOrderId) { if (!workOrderId) { - return { tasks: [], additional_costs_table: null }; + return { tasks: [], additional_costs_table: null, travel_expenses_table: null }; } const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/task-service-context/`); - return payload && typeof payload === 'object' ? payload : { tasks: [], additional_costs_table: null }; + return payload && typeof payload === 'object' + ? payload + : { tasks: [], additional_costs_table: null, travel_expenses_table: null }; } export async function fetchWorkOrderAdditionalCostsTable(workOrderId) { @@ -765,6 +767,42 @@ export async function updateWorkOrderAdditionalCostsTable(workOrderId, data) { return payload; } +export async function fetchWorkOrderTravelExpensesTable(workOrderId) { + if (!workOrderId) { + return { + work_order: null, + broj_sati: '0.00', + kolicina_dnevnica: '0.00', + iznos_dnevnica: '30.00', + daily_rate_country: 'HR', + total_for_payout: '0.00', + }; + } + const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/travel-expenses-table/`); + return payload && typeof payload === 'object' + ? payload + : { + work_order: null, + broj_sati: '0.00', + kolicina_dnevnica: '0.00', + iznos_dnevnica: '30.00', + daily_rate_country: 'HR', + total_for_payout: '0.00', + }; +} + +export async function updateWorkOrderTravelExpensesTable(workOrderId, data) { + if (!workOrderId) { + throw new Error('Work order ID je obavezan.'); + } + const payload = await api.put( + `fleet/work-orders/${encodeURIComponent(workOrderId)}/travel-expenses-table/`, + data ?? {}, + ); + showToast('Obračun putnih troškova je uspješno spremljen.', 'success'); + return payload; +} + export async function updateTaskWorkHoursTable(taskId, data) { if (!taskId) { throw new Error('Task ID je obavezan.');