feat: dodaj dodatne troškove s OCR sinkronizacijom
Dodana je tablica DODATNI TROŠKOVI za putni nalog (backend model + API + frontend modal) s izračunom ukupnog iznosa za isplatu. Upload računa sada odmah upisuje redak u dodatne troškove, a asinkroni OCR (Celery) naknadno pokušava popuniti broj računa i iznos iz slike/PDF-a. Uvedena je invalidacija PDF cache-a nakon promjena računa/dodatnih troškova kako bi generirani PDF uvijek prikazivao najnovije podatke. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -7,6 +7,7 @@ from .models import (
|
||||
WorkOrder,
|
||||
WorkOrderPhoto,
|
||||
WorkOrderInvoice,
|
||||
WorkOrderAdditionalCostsTable,
|
||||
GeneratedWorkOrderPdf,
|
||||
VehicleServiceRecord,
|
||||
VehicleServicePhoto,
|
||||
@@ -196,6 +197,14 @@ class WorkOrderInvoiceAdmin(admin.ModelAdmin):
|
||||
ordering = ('-datum', '-created_at')
|
||||
|
||||
|
||||
@admin.register(WorkOrderAdditionalCostsTable)
|
||||
class WorkOrderAdditionalCostsTableAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'work_order', 'total_for_payout', 'updated_at')
|
||||
search_fields = ('work_order__display_code', 'work_order__vehicle__registration_number')
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
ordering = ('-updated_at',)
|
||||
|
||||
|
||||
@admin.register(GeneratedWorkOrderPdf)
|
||||
class GeneratedWorkOrderPdfAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'work_order', 'pdf_type', 'status', 'requested_by', 'expires_at', 'is_active', 'created_at')
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-19 17:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0028_alter_workorderinvoice_image'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='workorder',
|
||||
name='purpose',
|
||||
field=models.CharField(blank=True, choices=[('defektaza', 'Defektaža'), ('kontrola', 'Kontrola'), ('redovni_pregled', 'Redovni pregled'), ('popravak', 'Popravak')], max_length=50, verbose_name='Svrha'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0028_alter_workorderinvoice_image'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WorkOrderAdditionalCostsTable',
|
||||
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)),
|
||||
('data', models.JSONField(blank=True, default=None, null=True, verbose_name='Podaci tablice dodatnih troškova')),
|
||||
('total_for_payout', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12, verbose_name='Ukupno za isplatu')),
|
||||
('work_order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='additional_costs_table', to='fleet.workorder', verbose_name='Putni nalog')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Tablica dodatnih troškova putnog naloga',
|
||||
'verbose_name_plural': 'Tablice dodatnih troškova putnog naloga',
|
||||
'ordering': ['-updated_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
11
backend/modules/fleet/migrations/0030_merge_20260719_2057.py
Normal file
11
backend/modules/fleet/migrations/0030_merge_20260719_2057.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0029_alter_workorder_purpose'),
|
||||
('fleet', '0029_workorderadditionalcoststable'),
|
||||
]
|
||||
|
||||
operations = []
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-19 19:01
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0030_merge_20260719_2057'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='workorderadditionalcoststable',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workorderadditionalcoststable',
|
||||
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='workorderadditionalcoststable',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True, verbose_name='Aktivan zapis'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='workorderadditionalcoststable',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
|
||||
),
|
||||
]
|
||||
@@ -377,6 +377,35 @@ class WorkOrderInvoice(BaseModel):
|
||||
return f"{self.naziv_racuna} ({self.work_order_id})"
|
||||
|
||||
|
||||
class WorkOrderAdditionalCostsTable(BaseModel):
|
||||
work_order = models.OneToOneField(
|
||||
WorkOrder,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='additional_costs_table',
|
||||
verbose_name=_("Putni nalog"),
|
||||
)
|
||||
data = models.JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
verbose_name=_("Podaci tablice dodatnih troškova"),
|
||||
)
|
||||
total_for_payout = models.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
default=Decimal('0.00'),
|
||||
verbose_name=_("Ukupno za isplatu"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-updated_at']
|
||||
verbose_name = _("Tablica dodatnih troškova putnog naloga")
|
||||
verbose_name_plural = _("Tablice dodatnih troškova putnog naloga")
|
||||
|
||||
def __str__(self):
|
||||
return f"Additional costs table for work order {self.work_order_id}"
|
||||
|
||||
|
||||
class WorkOrderPhoto(BaseModel):
|
||||
work_order = models.ForeignKey(
|
||||
WorkOrder,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
import re
|
||||
from pathlib import Path
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from .models import (
|
||||
@@ -8,6 +9,7 @@ from .models import (
|
||||
Vehicle,
|
||||
VehicleNotification,
|
||||
WorkOrder,
|
||||
WorkOrderAdditionalCostsTable,
|
||||
WorkOrderPhoto,
|
||||
WorkOrderInvoice,
|
||||
VehicleServiceRecord,
|
||||
@@ -268,6 +270,61 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
||||
return value
|
||||
|
||||
|
||||
class WorkOrderAdditionalCostsTableSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = WorkOrderAdditionalCostsTable
|
||||
fields = ['id', 'work_order', 'data', '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 Decimal('0.00')
|
||||
try:
|
||||
normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.')
|
||||
return Decimal(normalized)
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return Decimal('0.00')
|
||||
|
||||
def _normalize_row(self, row):
|
||||
return {
|
||||
'naziv': str(row.get('naziv', '') or '').strip(),
|
||||
'broj_racuna': str(row.get('broj_racuna', '') or '').strip(),
|
||||
'ukupan_iznos': str(row.get('ukupan_iznos', '') or '').strip(),
|
||||
'prilog': str(row.get('prilog', '') or '').strip(),
|
||||
'source_invoice_id': str(row.get('source_invoice_id', '') or '').strip(),
|
||||
}
|
||||
|
||||
def validate_data(self, value):
|
||||
if value is None:
|
||||
return {'rows': []}
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("Podaci tablice moraju biti JSON objekt.")
|
||||
rows = value.get('rows', [])
|
||||
if not isinstance(rows, list):
|
||||
raise serializers.ValidationError({"rows": "Polje 'rows' mora biti lista redaka."})
|
||||
normalized_rows = []
|
||||
for row in rows[:100]:
|
||||
if not isinstance(row, dict):
|
||||
raise serializers.ValidationError({"rows": "Svaki redak mora biti JSON objekt."})
|
||||
normalized_rows.append(self._normalize_row(row))
|
||||
return {'rows': normalized_rows}
|
||||
|
||||
def _calculate_total_for_payout(self, data):
|
||||
rows = data.get('rows', []) if isinstance(data, dict) else []
|
||||
total = sum((self._parse_decimal(row.get('ukupan_iznos')) for row in rows), Decimal('0.00'))
|
||||
return total.quantize(Decimal('0.01'))
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['total_for_payout'] = self._calculate_total_for_payout(validated_data.get('data'))
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
data = validated_data.get('data', instance.data or {'rows': []})
|
||||
validated_data['total_for_payout'] = self._calculate_total_for_payout(data)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class WorkOrderPhotoSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = WorkOrderPhoto
|
||||
|
||||
@@ -8,7 +8,14 @@ import logging
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import base64
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from django.db import transaction
|
||||
from pypdf import PdfReader
|
||||
from pypdf.errors import PdfReadError
|
||||
import pytesseract
|
||||
from pytesseract import TesseractNotFoundError
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfgen import canvas
|
||||
@@ -223,6 +230,148 @@ def build_work_order_invoices_pdf_task(work_order_id):
|
||||
}
|
||||
|
||||
|
||||
def _parse_decimal(value):
|
||||
if value in (None, ''):
|
||||
return Decimal('0.00')
|
||||
try:
|
||||
normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.')
|
||||
return Decimal(normalized)
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return Decimal('0.00')
|
||||
|
||||
|
||||
def _extract_invoice_text(invoice):
|
||||
if not invoice.image:
|
||||
return ''
|
||||
suffix = Path(invoice.image.name or '').suffix.lower()
|
||||
if suffix == '.pdf':
|
||||
invoice.image.open('rb')
|
||||
try:
|
||||
reader = PdfReader(invoice.image)
|
||||
return '\n'.join((page.extract_text() or '') for page in reader.pages)
|
||||
finally:
|
||||
invoice.image.close()
|
||||
|
||||
invoice.image.open('rb')
|
||||
try:
|
||||
with Image.open(invoice.image) as source:
|
||||
image = source.convert('RGB')
|
||||
return pytesseract.image_to_string(image, lang='hrv+eng')
|
||||
finally:
|
||||
invoice.image.close()
|
||||
|
||||
|
||||
def _extract_invoice_number(text, fallback):
|
||||
if not text:
|
||||
return fallback
|
||||
patterns = [
|
||||
r'(?i)(?:broj\s*računa|račun\s*broj|invoice\s*no\.?)\s*[:#]?\s*([A-Z0-9\/\-\._]+)',
|
||||
r'(?i)(?:broj)\s*[:#]?\s*([A-Z0-9\/\-\._]{4,})',
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
candidate = (match.group(1) or '').strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
return fallback
|
||||
|
||||
|
||||
def _extract_total_amount(text):
|
||||
if not text:
|
||||
return Decimal('0.00')
|
||||
|
||||
def _line_candidates(line):
|
||||
number_pattern = r'(?<!\d)(\d{1,3}(?:[.\s]\d{3})*(?:,\d{2})|\d+(?:[.,]\d{2}))(?!\d)'
|
||||
return [_parse_decimal(match) for match in re.findall(number_pattern, line)]
|
||||
|
||||
preferred_lines = []
|
||||
for line in text.splitlines():
|
||||
lower = line.lower()
|
||||
if any(token in lower for token in ('ukupno', 'za platiti', 'iznos', 'total')):
|
||||
preferred_lines.append(line)
|
||||
|
||||
for line in preferred_lines:
|
||||
candidates = [value for value in _line_candidates(line) if value > Decimal('0.00')]
|
||||
if candidates:
|
||||
return max(candidates)
|
||||
|
||||
all_candidates = []
|
||||
for line in text.splitlines():
|
||||
all_candidates.extend([value for value in _line_candidates(line) if value > Decimal('0.00')])
|
||||
if all_candidates:
|
||||
return max(all_candidates)
|
||||
return Decimal('0.00')
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_work_order_invoice_ocr(invoice_id):
|
||||
from .models import WorkOrderInvoice, WorkOrderAdditionalCostsTable, GeneratedWorkOrderPdf
|
||||
|
||||
invoice = (
|
||||
WorkOrderInvoice.objects
|
||||
.select_related('work_order')
|
||||
.filter(pk=invoice_id, is_active=True)
|
||||
.first()
|
||||
)
|
||||
if invoice is None or invoice.work_order_id is None:
|
||||
return {"status": "skipped", "reason": "invoice-not-found"}
|
||||
|
||||
try:
|
||||
text = _extract_invoice_text(invoice)
|
||||
except (UnidentifiedImageError, OSError, TesseractNotFoundError, ValueError, RuntimeError, PdfReadError):
|
||||
text = ''
|
||||
|
||||
attachment_name = Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else ''
|
||||
row = {
|
||||
'naziv': (invoice.naziv_racuna or '').strip() or attachment_name or f'Račun {invoice.pk}',
|
||||
'broj_racuna': _extract_invoice_number(text, fallback=str(invoice.pk)),
|
||||
'ukupan_iznos': f"{_extract_total_amount(text):.2f}",
|
||||
'prilog': attachment_name,
|
||||
'source_invoice_id': str(invoice.pk),
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
table, _ = WorkOrderAdditionalCostsTable.objects.select_for_update().get_or_create(
|
||||
work_order=invoice.work_order,
|
||||
defaults={'data': {'rows': []}},
|
||||
)
|
||||
existing_rows = table.data.get('rows', []) if isinstance(table.data, dict) else []
|
||||
existing_row = next(
|
||||
(
|
||||
item for item in existing_rows
|
||||
if isinstance(item, dict) and str(item.get('source_invoice_id', '')) == str(invoice.pk)
|
||||
),
|
||||
None,
|
||||
)
|
||||
normalized_rows = [
|
||||
item for item in existing_rows
|
||||
if isinstance(item, dict) and str(item.get('source_invoice_id', '')) != str(invoice.pk)
|
||||
]
|
||||
if existing_row and row['ukupan_iznos'] == '0.00':
|
||||
existing_amount = str(existing_row.get('ukupan_iznos', '') or '').strip()
|
||||
if existing_amount:
|
||||
row['ukupan_iznos'] = existing_amount
|
||||
if existing_row and not row['broj_racuna']:
|
||||
row['broj_racuna'] = str(existing_row.get('broj_racuna', '') or '').strip()
|
||||
normalized_rows.append(row)
|
||||
total = sum((_parse_decimal(item.get('ukupan_iznos')) for item in normalized_rows if isinstance(item, dict)), Decimal('0.00'))
|
||||
table.data = {'rows': normalized_rows}
|
||||
table.total_for_payout = total.quantize(Decimal('0.01'))
|
||||
table.save(update_fields=['data', 'total_for_payout', 'updated_at'])
|
||||
GeneratedWorkOrderPdf.objects.filter(
|
||||
is_active=True,
|
||||
work_order=invoice.work_order,
|
||||
pdf_type__in=['work_order', 'invoices'],
|
||||
).update(
|
||||
is_active=False,
|
||||
status='failed',
|
||||
error_message='PDF cache invalidiran zbog OCR ažuriranja dodatnih troškova.',
|
||||
)
|
||||
|
||||
return {"status": "ok", "invoice_id": str(invoice.pk)}
|
||||
|
||||
|
||||
@shared_task
|
||||
def cleanup_expired_generated_pdfs_task():
|
||||
now = timezone.now()
|
||||
|
||||
@@ -2,12 +2,14 @@ from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from datetime import timedelta
|
||||
from PIL import Image
|
||||
|
||||
from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto
|
||||
from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, GeneratedWorkOrderPdf
|
||||
|
||||
|
||||
def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
|
||||
@@ -167,3 +169,74 @@ class WorkOrderImagesEndpointTests(TestCase):
|
||||
file_response = self.client.get(image_url)
|
||||
self.assertEqual(file_response.status_code, 200)
|
||||
self.assertEqual(file_response['Content-Type'], 'application/pdf')
|
||||
|
||||
def test_additional_costs_table_can_be_saved_and_total_is_computed(self):
|
||||
url = f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/"
|
||||
response = self.client.put(
|
||||
url,
|
||||
data={
|
||||
"rows": [
|
||||
{"naziv": "Cestarina", "broj_racuna": "R-100", "ukupan_iznos": "12,50", "prilog": "racun-1.pdf"},
|
||||
{"naziv": "Gorivo", "broj_racuna": "R-101", "ukupan_iznos": "7.50", "prilog": "racun-2.jpg"},
|
||||
]
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload.get('total_for_payout'), '20.00')
|
||||
rows = payload.get('data', {}).get('rows', [])
|
||||
self.assertEqual(len(rows), 2)
|
||||
|
||||
def test_task_service_context_contains_additional_costs_table(self):
|
||||
table_url = f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/"
|
||||
save_response = self.client.put(
|
||||
table_url,
|
||||
data={"rows": [{"naziv": "Parking", "broj_racuna": "P-1", "ukupan_iznos": "5.00", "prilog": "parking.pdf"}]},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(save_response.status_code, 200, save_response.content)
|
||||
|
||||
context_response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/task-service-context/")
|
||||
self.assertEqual(context_response.status_code, 200, context_response.content)
|
||||
payload = context_response.json()
|
||||
self.assertIn('additional_costs_table', payload)
|
||||
self.assertEqual(payload['additional_costs_table']['total_for_payout'], '5.00')
|
||||
|
||||
def test_invoice_upload_immediately_appears_in_additional_costs_table(self):
|
||||
create_response = self.client.post(
|
||||
reverse('work-order-invoice-list'),
|
||||
data={
|
||||
'work_order': str(self.work_order.pk),
|
||||
'naziv_racuna': 'Test2',
|
||||
'datum': '2026-07-19',
|
||||
'image': create_test_image('test2.jpg'),
|
||||
},
|
||||
format='multipart',
|
||||
)
|
||||
self.assertEqual(create_response.status_code, 201, create_response.content)
|
||||
|
||||
table_response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/")
|
||||
self.assertEqual(table_response.status_code, 200, table_response.content)
|
||||
rows = table_response.json().get('data', {}).get('rows', [])
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0].get('naziv'), 'Test2')
|
||||
|
||||
def test_additional_costs_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='MT010101.work-order.pdf',
|
||||
expires_at=timezone.now() + timedelta(hours=1),
|
||||
)
|
||||
response = self.client.put(
|
||||
f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/",
|
||||
data={"rows": [{"naziv": "Test", "broj_racuna": "R-1", "ukupan_iznos": "10.00", "prilog": "r1.pdf"}]},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
cached.refresh_from_db()
|
||||
self.assertFalse(cached.is_active)
|
||||
self.assertEqual(cached.status, 'failed')
|
||||
|
||||
@@ -4,6 +4,7 @@ from io import BytesIO
|
||||
import base64
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from django.conf import settings
|
||||
@@ -35,6 +36,7 @@ from .models import (
|
||||
WorkOrderPhoto,
|
||||
GeneratedWorkOrderPdf,
|
||||
WorkOrderInvoice,
|
||||
WorkOrderAdditionalCostsTable,
|
||||
VehicleServiceRecord,
|
||||
VehicleServicePhoto,
|
||||
VehicleServiceAttachment,
|
||||
@@ -44,6 +46,7 @@ from .serializers import (
|
||||
VehicleSerializer,
|
||||
WorkOrderSerializer,
|
||||
WorkOrderInvoiceSerializer,
|
||||
WorkOrderAdditionalCostsTableSerializer,
|
||||
WorkOrderPhotoSerializer,
|
||||
VehicleServiceRecordSerializer,
|
||||
VehicleNotificationSerializer,
|
||||
@@ -60,6 +63,7 @@ from .tasks import (
|
||||
build_work_order_invoices_pdf_task,
|
||||
build_work_order_pdf_cached_task,
|
||||
cleanup_expired_generated_pdfs_task,
|
||||
process_work_order_invoice_ocr,
|
||||
)
|
||||
|
||||
register_unicode_fonts()
|
||||
@@ -197,6 +201,60 @@ def _cleanup_expired_generated_pdfs():
|
||||
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
||||
|
||||
|
||||
def _parse_amount_decimal(value):
|
||||
if value in (None, ''):
|
||||
return Decimal('0.00')
|
||||
try:
|
||||
normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.')
|
||||
return Decimal(normalized)
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return Decimal('0.00')
|
||||
|
||||
|
||||
def _invalidate_work_order_pdf_cache(work_order, *, pdf_types=None):
|
||||
cache_qs = GeneratedWorkOrderPdf.objects.filter(
|
||||
is_active=True,
|
||||
work_order=work_order,
|
||||
)
|
||||
if pdf_types:
|
||||
cache_qs = cache_qs.filter(pdf_type__in=pdf_types)
|
||||
for cached in cache_qs:
|
||||
if cached.file:
|
||||
cached.file.delete(save=False)
|
||||
cached.is_active = False
|
||||
cached.status = 'failed'
|
||||
cached.error_message = 'PDF cache invalidiran zbog promjene podataka.'
|
||||
cached.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
||||
|
||||
|
||||
def _upsert_additional_cost_row_from_invoice(invoice):
|
||||
if invoice is None or invoice.work_order_id is None:
|
||||
return
|
||||
table, _ = WorkOrderAdditionalCostsTable.objects.get_or_create(
|
||||
work_order=invoice.work_order,
|
||||
defaults={'data': {'rows': []}, 'total_for_payout': Decimal('0.00')},
|
||||
)
|
||||
rows = table.data.get('rows', []) if isinstance(table.data, dict) else []
|
||||
normalized_rows = [
|
||||
row for row in rows
|
||||
if isinstance(row, dict) and str(row.get('source_invoice_id', '')) != str(invoice.pk)
|
||||
]
|
||||
normalized_rows.append({
|
||||
'naziv': str(invoice.naziv_racuna or '').strip() or f'Račun {invoice.pk}',
|
||||
'broj_racuna': str(invoice.pk),
|
||||
'ukupan_iznos': '0.00',
|
||||
'prilog': Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else '',
|
||||
'source_invoice_id': str(invoice.pk),
|
||||
})
|
||||
total = sum(
|
||||
(_parse_amount_decimal(row.get('ukupan_iznos')) for row in normalized_rows if isinstance(row, dict)),
|
||||
Decimal('0.00'),
|
||||
).quantize(Decimal('0.01'))
|
||||
table.data = {'rows': normalized_rows}
|
||||
table.total_for_payout = total
|
||||
table.save(update_fields=['data', 'total_for_payout', 'updated_at'])
|
||||
|
||||
|
||||
def _cached_pdf_file_response(generated_pdf, *, default_filename):
|
||||
generated_pdf.file.open('rb')
|
||||
filename = generated_pdf.filename or default_filename
|
||||
@@ -371,6 +429,15 @@ def _build_work_order_pdf(work_order):
|
||||
except (TypeError, ValueError):
|
||||
return "0,00 €"
|
||||
|
||||
def _parse_decimal(value):
|
||||
if value in (None, ''):
|
||||
return Decimal('0.00')
|
||||
try:
|
||||
normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.')
|
||||
return Decimal(normalized)
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return Decimal('0.00')
|
||||
|
||||
def _paragraph(text, *, bold=False, size=9, align=0):
|
||||
styles = getSampleStyleSheet()
|
||||
base = styles['BodyText'].clone('wo-p')
|
||||
@@ -400,11 +467,24 @@ def _build_work_order_pdf(work_order):
|
||||
daily_rate = 0.0
|
||||
daily_total = daily_qty * daily_rate
|
||||
transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0)
|
||||
grand_total = daily_total + transport_total
|
||||
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'
|
||||
invoice_names = [inv.naziv_racuna for inv in invoices[:5] if inv.naziv_racuna]
|
||||
attachments_text = ', '.join(invoice_names) if invoice_names else '-'
|
||||
additional_table = getattr(work_order, 'additional_costs_table', None)
|
||||
additional_rows_data = []
|
||||
additional_total_decimal = Decimal('0.00')
|
||||
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)
|
||||
|
||||
attachment_names = [
|
||||
str(row.get('prilog', '')).strip()
|
||||
for row in additional_rows_data
|
||||
if isinstance(row, dict) and str(row.get('prilog', '')).strip()
|
||||
]
|
||||
if not attachment_names:
|
||||
attachment_names = [Path(inv.image.name).name for inv in invoices if inv.image and getattr(inv.image, 'name', '')]
|
||||
attachments_text = ', '.join(attachment_names[:8]) if attachment_names else '-'
|
||||
assigned_servicer_vehicle = (
|
||||
creator.assigned_vehicles
|
||||
.filter(asset_type='vehicle', is_active=True)
|
||||
@@ -620,8 +700,20 @@ def _build_work_order_pdf(work_order):
|
||||
|
||||
# 5) Dodatni troškovi
|
||||
additional_rows = [["DODATNI TROŠKOVI", "", ""], ["NAZIV", "Broj računa", "Ukupan iznos"]]
|
||||
for inv in invoices[:5]:
|
||||
if additional_rows_data:
|
||||
for row in additional_rows_data[:4]:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
additional_rows.append([
|
||||
str(row.get('naziv', '') or '-'),
|
||||
str(row.get('broj_racuna', '') or '-'),
|
||||
_fmt_eur(_parse_decimal(row.get('ukupan_iznos'))),
|
||||
])
|
||||
else:
|
||||
for inv in invoices[:4]:
|
||||
additional_rows.append([inv.naziv_racuna or '-', str(inv.pk), "-"])
|
||||
additional_total_row_idx = len(additional_rows)
|
||||
additional_rows.append(["UKUPNO", "", _fmt_eur(additional_total_decimal)])
|
||||
while len(additional_rows) < 7:
|
||||
additional_rows.append(["", "", ""])
|
||||
y = draw_table(
|
||||
@@ -633,6 +725,7 @@ def _build_work_order_pdf(work_order):
|
||||
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
||||
('FONTNAME', (0, 0), (2, 1), 'Vera-Bold'),
|
||||
('FONTNAME', (0, 2), (-1, -1), 'Vera'),
|
||||
('FONTNAME', (0, additional_total_row_idx), (2, additional_total_row_idx), 'Vera-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8.4),
|
||||
('ALIGN', (0, 0), (2, 0), 'CENTER'),
|
||||
('ALIGN', (2, 1), (2, -1), 'CENTER'),
|
||||
@@ -1692,11 +1785,49 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
||||
'service_records': records_payload,
|
||||
})
|
||||
|
||||
additional_costs_table = getattr(work_order, 'additional_costs_table', None)
|
||||
additional_costs_payload = (
|
||||
WorkOrderAdditionalCostsTableSerializer(additional_costs_table).data
|
||||
if additional_costs_table
|
||||
else {'work_order': str(work_order.pk), 'data': {'rows': []}, 'total_for_payout': '0.00'}
|
||||
)
|
||||
|
||||
return Response({
|
||||
'work_order_id': work_order.pk,
|
||||
'tasks': payload,
|
||||
'additional_costs_table': additional_costs_payload,
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=['get', 'put', 'patch'], url_path='additional-costs-table')
|
||||
def additional_costs_table(self, request, pk=None):
|
||||
work_order = self.get_object()
|
||||
table = getattr(work_order, 'additional_costs_table', None)
|
||||
|
||||
if request.method.lower() == 'get':
|
||||
if table:
|
||||
return Response(WorkOrderAdditionalCostsTableSerializer(table).data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
{'work_order': str(work_order.pk), 'data': {'rows': []}, 'total_for_payout': '0.00'},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if isinstance(request.data, dict) and 'data' in request.data:
|
||||
payload_data = request.data.get('data')
|
||||
else:
|
||||
payload_data = request.data
|
||||
if payload_data == {}:
|
||||
payload_data = {'rows': []}
|
||||
|
||||
serializer = WorkOrderAdditionalCostsTableSerializer(
|
||||
table,
|
||||
data={'work_order': str(work_order.pk), 'data': payload_data},
|
||||
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', 'invoices'])
|
||||
return Response(WorkOrderAdditionalCostsTableSerializer(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()
|
||||
@@ -1791,7 +1922,7 @@ class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
|
||||
def image(self, request, pk=None):
|
||||
invoice = self.get_object()
|
||||
if not invoice.image:
|
||||
raise DRFValidationError({"detail": "Slika računa nije dostupna."})
|
||||
raise DRFValidationError({"detail": "Datoteka računa nije dostupna."})
|
||||
|
||||
suffix = Path(invoice.image.name or '').suffix.lower()
|
||||
if suffix == '.pdf':
|
||||
@@ -1851,7 +1982,13 @@ class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
|
||||
allowed = _work_orders_queryset_for_user(self.request.user).filter(pk=work_order.pk).exists()
|
||||
if not allowed:
|
||||
raise DRFValidationError({"work_order": "Nemate dozvolu za odabrani putni nalog."})
|
||||
serializer.save(created_by=self.request.user)
|
||||
invoice = serializer.save(created_by=self.request.user)
|
||||
_upsert_additional_cost_row_from_invoice(invoice)
|
||||
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'invoices'])
|
||||
try:
|
||||
process_work_order_invoice_ocr.delay(str(invoice.pk))
|
||||
except KombuOperationalError:
|
||||
process_work_order_invoice_ocr.apply(args=[str(invoice.pk)])
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
instance.is_active = False
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-19 17:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('task_management', '0009_task_scheduled_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='tasktemplate',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('aktivan', 'Aktivan'), ('servis', 'U tijeku'), ('spreman_za_zavrsetak', 'Spreman za završetak'), ('zavrsen', 'Završen'), ('neaktivan', 'Otkazano')], default='aktivan', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
|
||||
const EMPTY_ROW = {
|
||||
naziv: '',
|
||||
broj_racuna: '',
|
||||
ukupan_iznos: '',
|
||||
prilog: '',
|
||||
source_invoice_id: '',
|
||||
};
|
||||
|
||||
function parseAmount(value) {
|
||||
const normalized = String(value || '').trim().replace('€', '').replace(/\s+/g, '').replace(',', '.');
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export default function WorkOrderAdditionalCostsModal({
|
||||
open,
|
||||
workOrder,
|
||||
tableData,
|
||||
saving = false,
|
||||
onClose,
|
||||
onSave,
|
||||
}) {
|
||||
const [rows, setRows] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
const incoming = Array.isArray(tableData?.data?.rows) ? tableData.data.rows : [];
|
||||
setRows(incoming.length > 0 ? incoming.map((row) => ({ ...EMPTY_ROW, ...(row || {}) })) : [{ ...EMPTY_ROW }]);
|
||||
}, [open, tableData]);
|
||||
|
||||
const total = useMemo(() => {
|
||||
const value = rows.reduce((sum, row) => sum + parseAmount(row.ukupan_iznos), 0);
|
||||
return value.toFixed(2);
|
||||
}, [rows]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const updateRowField = (index, field, value) => {
|
||||
setRows((prev) => prev.map((row, i) => (i === index ? { ...row, [field]: value } : row)));
|
||||
};
|
||||
|
||||
const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]);
|
||||
const removeRow = (index) => setRows((prev) => prev.filter((_, i) => i !== index));
|
||||
|
||||
const handleSave = async () => {
|
||||
const payloadRows = rows.map((row) => ({
|
||||
naziv: String(row.naziv || '').trim(),
|
||||
broj_racuna: String(row.broj_racuna || '').trim(),
|
||||
ukupan_iznos: String(row.ukupan_iznos || '').trim(),
|
||||
prilog: String(row.prilog || '').trim(),
|
||||
source_invoice_id: String(row.source_invoice_id || '').trim(),
|
||||
}));
|
||||
await onSave?.({ rows: payloadRows });
|
||||
};
|
||||
|
||||
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-5xl 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">DODATNI TROŠKOVI</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="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
+ Dodaj redak
|
||||
</button>
|
||||
</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">NAZIV</th>
|
||||
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Broj računa</th>
|
||||
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Ukupan iznos</th>
|
||||
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Prilog</th>
|
||||
<th className="px-2 py-2.5 text-gray-400">Akcija</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={index} className="border-t border-gray-100 dark:border-gray-700">
|
||||
<td className="px-2 py-1.5 text-center text-[11px] text-gray-400">{index + 1}</td>
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
type="text"
|
||||
value={row.naziv}
|
||||
onInput={(event) => updateRowField(index, 'naziv', 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.broj_racuna}
|
||||
onInput={(event) => updateRowField(index, 'broj_racuna', 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.ukupan_iznos}
|
||||
onInput={(event) => updateRowField(index, 'ukupan_iznos', 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.prilog}
|
||||
onInput={(event) => updateRowField(index, 'prilog', 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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(index)}
|
||||
className="rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Obriši
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-100 text-xs font-bold dark:border-gray-600 dark:bg-gray-800">
|
||||
<td colSpan={3} className="px-3 py-2 text-gray-700 dark:text-gray-200">UKUPNO ZA ISPLATU</td>
|
||||
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100">{total} €</td>
|
||||
<td colSpan={2} />
|
||||
</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 dodatne troškove'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
@@ -4,16 +4,19 @@ import {
|
||||
downloadWorkOrderPdf,
|
||||
downloadWorkOrderInvoicesPdf,
|
||||
downloadWorkOrderServiceRecordsPdf,
|
||||
fetchWorkOrderAdditionalCostsTable,
|
||||
fetchWorkOrderById,
|
||||
fetchWorkOrderInvoices,
|
||||
fetchWorkOrderTaskServiceContext,
|
||||
updateTaskWorkHoursTable,
|
||||
updateWorkOrderAdditionalCostsTable,
|
||||
} from '../../stores/fleetDashboardStore';
|
||||
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
|
||||
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
||||
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||
import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
||||
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
|
||||
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
|
||||
|
||||
function readWorkOrderIdFromQuery() {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -71,6 +74,9 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [editingTask, setEditingTask] = useState(null);
|
||||
const [savingWorkHours, setSavingWorkHours] = useState(false);
|
||||
const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' });
|
||||
const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false);
|
||||
const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
hydrateAuthFromStorage();
|
||||
@@ -105,10 +111,11 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [contextPayload, order, invoiceItems] = await Promise.all([
|
||||
const [contextPayload, order, invoiceItems, additionalCostsPayload] = await Promise.all([
|
||||
fetchWorkOrderTaskServiceContext(workOrderId),
|
||||
fetchWorkOrderById(workOrderId),
|
||||
fetchWorkOrderInvoices(workOrderId),
|
||||
fetchWorkOrderAdditionalCostsTable(workOrderId),
|
||||
]);
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -116,6 +123,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
setTaskContext(contextPayload);
|
||||
setWorkOrder(order);
|
||||
setInvoices(invoiceItems);
|
||||
setAdditionalCostsTable(additionalCostsPayload || contextPayload?.additional_costs_table || { data: { rows: [] }, total_for_payout: '0.00' });
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.');
|
||||
@@ -187,6 +195,18 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAdditionalCostsTable = async (tableData) => {
|
||||
if (!workOrderId) return;
|
||||
setSavingAdditionalCosts(true);
|
||||
try {
|
||||
const response = await updateWorkOrderAdditionalCostsTable(workOrderId, tableData);
|
||||
setAdditionalCostsTable(response || { data: { rows: [] }, total_for_payout: '0.00' });
|
||||
setEditingAdditionalCosts(false);
|
||||
} finally {
|
||||
setSavingAdditionalCosts(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
|
||||
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
||||
@@ -289,6 +309,15 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-main">Računi</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingAdditionalCosts(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 dodatne troškove
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadWorkOrderInvoicesPdf(workOrderId)}
|
||||
@@ -298,6 +327,12 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
Preuzmi račune putnog naloga
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!loading && !error && (
|
||||
<p className="mb-3 text-sm text-text-muted">
|
||||
Ukupno dodatni troškovi: <span className="font-semibold text-text-main">{additionalCostsTable?.total_for_payout || '0.00'} €</span>
|
||||
</p>
|
||||
)}
|
||||
{loading && <p className="text-sm text-text-muted">Učitavanje računa...</p>}
|
||||
{!loading && error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
@@ -337,6 +372,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
onClose={() => setEditingTask(null)}
|
||||
onSave={handleSaveWorkHoursTable}
|
||||
/>
|
||||
<WorkOrderAdditionalCostsModal
|
||||
open={editingAdditionalCosts}
|
||||
workOrder={workOrder}
|
||||
tableData={additionalCostsTable}
|
||||
saving={savingAdditionalCosts}
|
||||
onClose={() => setEditingAdditionalCosts(false)}
|
||||
onSave={handleSaveAdditionalCostsTable}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -712,10 +712,32 @@ export async function fetchWorkOrderById(workOrderId) {
|
||||
|
||||
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
return { tasks: [] };
|
||||
return { tasks: [], additional_costs_table: null };
|
||||
}
|
||||
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/task-service-context/`);
|
||||
return payload && typeof payload === 'object' ? payload : { tasks: [] };
|
||||
return payload && typeof payload === 'object' ? payload : { tasks: [], additional_costs_table: null };
|
||||
}
|
||||
|
||||
export async function fetchWorkOrderAdditionalCostsTable(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
return { data: { rows: [] }, total_for_payout: '0.00' };
|
||||
}
|
||||
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/additional-costs-table/`);
|
||||
return payload && typeof payload === 'object'
|
||||
? payload
|
||||
: { data: { rows: [] }, total_for_payout: '0.00' };
|
||||
}
|
||||
|
||||
export async function updateWorkOrderAdditionalCostsTable(workOrderId, data) {
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
const payload = await api.put(
|
||||
`fleet/work-orders/${encodeURIComponent(workOrderId)}/additional-costs-table/`,
|
||||
data ?? { rows: [] },
|
||||
);
|
||||
showToast('Dodatni troškovi su uspješno spremljeni.', 'success');
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function updateTaskWorkHoursTable(taskId, data) {
|
||||
|
||||
Reference in New Issue
Block a user