feat: dodaj display_code polje za putne naloge (MT070626 format)
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled

- Dodano polje 'display_code' na WorkOrder model (format: 2 inicijala + ddmmyy)
- Migracije za kreiranje polja i backfill postojećih naloga
- Ažuriran WorkOrderSerializer za validaciju i serijalizaciju display_code
- Backend tasks za auto-generiranje display_code pri kreiranju
- Frontend komponente ažurirane za prikaz display_code umjesto UUID:
  * FleetDashboardShell tablica putnih naloga
  * WorkOrderDetailModal
  * TaskCreateModal
  * Sve PDF stranice
- displayIds.js utility za formatiranjeDisplay ID-eva
- Admin interface prikazuje display_code
This commit is contained in:
mariomitte
2026-07-17 23:53:47 +02:00
parent ef8718a4cf
commit bf8e25efe7
19 changed files with 377 additions and 54 deletions

View File

@@ -66,7 +66,7 @@ class TeamMemberSerializer(serializers.ModelSerializer):
class Meta:
model = CustomUser
fields = ['id', 'email', 'full_name', 'is_team_member']
fields = ['id', 'email', 'full_name', 'is_team_member', 'is_serviser']
read_only_fields = fields
def get_full_name(self, obj):

View File

@@ -44,7 +44,7 @@ class ServiceAttachmentInline(admin.TabularInline):
class WorkOrderInline(admin.TabularInline):
model = WorkOrder
extra = 0
fields = ('creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
fields = ('display_code', 'creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
readonly_fields = ('date',)
@@ -176,9 +176,9 @@ class CraneAdmin(VehicleAdmin):
@admin.register(WorkOrder)
class WorkOrderAdmin(admin.ModelAdmin):
list_display = ('id', 'vehicle', 'creator', 'date', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status')
list_display = ('display_code', 'id', 'vehicle', 'creator', 'date', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status')
list_filter = ('status', 'date', 'vehicle')
search_fields = ('vehicle__registration_number', 'creator__username', 'purpose')
search_fields = ('display_code', 'vehicle__registration_number', 'creator__username', 'purpose')
readonly_fields = ('date',)
ordering = ('-date',)
inlines = (WorkOrderInvoiceInline, WorkOrderPhotoInline)

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.15 on 2026-07-17 21:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0024_alter_workorderphoto_created_at_and_more'),
]
operations = [
migrations.AddField(
model_name='workorder',
name='display_code',
field=models.CharField(
blank=True,
db_index=True,
help_text='Format: MT070626 (2 slova + 6 znamenki).',
max_length=8,
verbose_name='Prikaz putnog naloga',
),
),
]

View File

@@ -0,0 +1,30 @@
from django.db import migrations
def backfill_workorder_display_code(apps, schema_editor):
WorkOrder = apps.get_model('fleet', 'WorkOrder')
for work_order in WorkOrder.objects.select_related('creator').all():
current_code = str(getattr(work_order, 'display_code', '') or '').strip().upper()
if current_code:
continue
creator = getattr(work_order, 'creator', None)
first_name = str(getattr(creator, 'first_name', '') or '').strip()
last_name = str(getattr(creator, 'last_name', '') or '').strip()
if not first_name or not last_name:
continue
order_date = getattr(work_order, 'date', None)
if not order_date:
continue
display_code = f"{first_name[0].upper()}{last_name[0].upper()}{order_date.strftime('%d%m%y')}"
WorkOrder.objects.filter(pk=work_order.pk).update(display_code=display_code)
class Migration(migrations.Migration):
dependencies = [
('fleet', '0025_workorder_display_code'),
]
operations = [
migrations.RunPython(backfill_workorder_display_code, migrations.RunPython.noop),
]

View File

@@ -287,6 +287,13 @@ class WorkOrder(BaseModel):
location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija odredišta"))
travel_start_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Početak puta"))
travel_end_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Kraj puta"))
display_code = models.CharField(
max_length=8,
blank=True,
db_index=True,
verbose_name=_("Prikaz putnog naloga"),
help_text=_("Format: MT070626 (2 slova + 6 znamenki)."),
)
purpose = models.CharField(max_length=50, choices=PURPOSE_CHOICES, blank=True, verbose_name=_("Svrha"))
notes = models.TextField(blank=True, verbose_name=_("Bilješke"))
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open', verbose_name=_("Status"))
@@ -322,6 +329,12 @@ class WorkOrder(BaseModel):
def clean(self):
super().clean()
if self.display_code:
self.display_code = str(self.display_code).strip().upper()
if not re.fullmatch(r"[A-Z]{2}[0-9]{6}", self.display_code):
raise ValidationError({
"display_code": _("Prikaz putnog naloga mora biti u formatu MT070626 (2 slova + 6 znamenki).")
})
if self.travel_start_at and self.travel_end_at and self.travel_end_at < self.travel_start_at:
raise ValidationError({"travel_end_at": _("Kraj puta ne može biti prije početka puta.")})

View File

@@ -1,6 +1,7 @@
from rest_framework import serializers
import re
from django.urls import reverse
from django.utils import timezone
from .models import (
Crane,
Vehicle,
@@ -98,7 +99,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
class Meta:
model = WorkOrder
fields = [
'id', 'vehicle', 'crane', 'crane_label', 'creator', 'date',
'id', 'display_code', 'vehicle', 'crane', 'crane_label', 'creator', 'date',
'start_mileage', 'end_mileage',
'servicer_vehicle_registration', 'servicer_vehicle_make_model',
'servicer_vehicle_start_mileage', 'servicer_vehicle_end_mileage',
@@ -112,6 +113,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
'creator': {'required': False},
'start_mileage': {'required': False, 'allow_null': True},
'end_mileage': {'required': False, 'allow_null': True},
'display_code': {'required': False, 'allow_blank': False},
'servicer_vehicle_registration': {'required': False, 'allow_blank': True},
'servicer_vehicle_make_model': {'required': False, 'allow_blank': True},
'servicer_vehicle_start_mileage': {'required': False, 'allow_null': True},
@@ -146,6 +148,14 @@ class WorkOrderSerializer(serializers.ModelSerializer):
if user and 'creator' not in validated_data:
validated_data['creator'] = user
if not validated_data.get('display_code'):
creator = validated_data.get('creator')
generated_display_code = self._generate_display_code(creator, timezone.localdate())
if not generated_display_code:
raise serializers.ValidationError({
'display_code': 'Serviser mora imati ime i prezime za automatsko generiranje oznake. Unesite oznaku ručno u formatu MT070626.'
})
validated_data['display_code'] = generated_display_code
return services.VehicleService.create_work_order(validated_data)
@@ -182,6 +192,22 @@ class WorkOrderSerializer(serializers.ModelSerializer):
raise serializers.ValidationError({'servicer_vehicle_fuel_cost': 'Trošak goriva ne može biti negativan.'})
return attrs
def validate_display_code(self, value):
normalized = str(value or '').strip().upper()
if not re.fullmatch(r"[A-Z]{2}[0-9]{6}", normalized):
raise serializers.ValidationError('Prikaz putnog naloga mora biti u formatu MT070626 (2 slova + 6 znamenki).')
return normalized
@staticmethod
def _generate_display_code(user, target_date):
if user is None:
return None
first_name = str(getattr(user, 'first_name', '') or '').strip()
last_name = str(getattr(user, 'last_name', '') or '').strip()
if not first_name or not last_name:
return None
return f"{first_name[0].upper()}{last_name[0].upper()}{target_date.strftime('%d%m%y')}"
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField()

View File

@@ -141,7 +141,7 @@ class VehicleService:
Task.objects.filter(
is_active=True,
work_order=instance,
status='servis',
status__in=('aktivan', 'servis'),
).update(status='spreman_za_zavrsetak')
# Ako je nalog upravo zatvoren, obavijestimo kreatora/servisera

View File

@@ -18,6 +18,13 @@ logger = logging.getLogger(__name__)
register_unicode_fonts()
def _work_order_display_code(work_order):
normalized = str(getattr(work_order, 'display_code', '') or '').strip().upper()
if normalized:
return normalized
return 'NALOG'
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_notification_email(self, notification_id):
"""
@@ -95,6 +102,7 @@ def _build_work_order_invoices_pdf(work_order):
chassis_hours = str(getattr(work_order.vehicle, "chassis_working_hours", "-") or "-")
mileage = str(getattr(work_order.vehicle, "current_mileage", "-") or "-")
generated_date = timezone.localtime(timezone.now()).strftime("%d.%m.%Y")
display_code = _work_order_display_code(work_order)
def draw_header_footer(page_num):
draw_standard_header_footer(
@@ -107,7 +115,7 @@ def _build_work_order_invoices_pdf(work_order):
upgrade_hours=upgrade_hours,
chassis_hours=chassis_hours,
mileage=mileage,
work_order_number=str(work_order.pk),
work_order_number=display_code,
generated_date=generated_date,
report_title="Računi putnog naloga",
page_size=A4,
@@ -122,7 +130,7 @@ def _build_work_order_invoices_pdf(work_order):
y -= 8
pdf.setFont("Vera-Bold", 13)
pdf.drawString(margin, y, f"Računi putnog naloga WO-{work_order.pk}")
pdf.drawString(margin, y, f"Računi putnog naloga {display_code}")
y -= 20
pdf.setFont("Vera", 10)
pdf.drawString(margin, y, f"Datum naloga: {work_order.date}")
@@ -202,7 +210,7 @@ def build_work_order_invoices_pdf_task(work_order_id):
pdf_bytes = _build_work_order_invoices_pdf(work_order)
pdf_b64 = base64.b64encode(pdf_bytes).decode('ascii')
filename = f"{work_order.pk}.work-order-invoices.pdf"
filename = f"{_work_order_display_code(work_order)}.work-order-invoices.pdf"
return {
"filename": filename,
"pdf_base64": pdf_b64,
@@ -259,7 +267,7 @@ def build_work_order_pdf_cached_task(generated_pdf_id):
else:
pdf_bytes = _build_work_order_pdf(work_order)
filename = generated.filename or f"{work_order.pk}.{generated.pdf_type}.pdf"
filename = generated.filename or f"{_work_order_display_code(work_order)}.{generated.pdf_type}.pdf"
generated.file.save(filename, ContentFile(pdf_bytes), save=False)
generated.status = 'ready'
generated.error_message = ''
@@ -269,7 +277,7 @@ def build_work_order_pdf_cached_task(generated_pdf_id):
NotificationService.create_notification(
recipient=generated.requested_by,
title="PDF spreman",
message=f"PDF dokument je uspješno generiran za WO-{work_order.pk}.",
message=f"PDF dokument je uspješno generiran za putni nalog {_work_order_display_code(work_order)}.",
level="success",
send_email=False,
metadata={
@@ -294,7 +302,7 @@ def build_work_order_pdf_cached_task(generated_pdf_id):
NotificationService.create_notification(
recipient=generated.requested_by,
title="Greška kod PDF-a",
message=f"Generiranje PDF dokumenta nije uspjelo za WO-{work_order.pk}.",
message=f"Generiranje PDF dokumenta nije uspjelo za putni nalog {_work_order_display_code(work_order)}.",
level="warning",
send_email=False,
metadata={

View File

@@ -3,7 +3,9 @@ from django.test import TestCase
from django.contrib.auth import get_user_model
from modules.fleet.services import VehicleService
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
from modules.fleet.serializers import WorkOrderSerializer
from modules.task_management.models import Task
from rest_framework.test import APIRequestFactory
class FleetServiceTests(TestCase):
@@ -73,3 +75,57 @@ class FleetServiceTests(TestCase):
VehicleService.update_work_order(work_order, {"status": "closed"})
task.refresh_from_db()
self.assertEqual(task.status, "spreman_za_zavrsetak")
def test_closing_work_order_moves_active_task_to_ready_for_completion(self):
work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
purpose="kontrola",
status="open",
start_mileage=5000,
end_mileage=5200,
)
task = Task.objects.create(
title="Preventiva",
description="",
status="aktivan",
assigned_to=self.user,
vehicle=self.vehicle,
work_order=work_order,
)
VehicleService.update_work_order(work_order, {"status": "closed"})
task.refresh_from_db()
self.assertEqual(task.status, "spreman_za_zavrsetak")
def test_work_order_serializer_autogenerates_display_code(self):
self.user.first_name = "Mario"
self.user.last_name = "Tkalac"
self.user.save(update_fields=["first_name", "last_name"])
request = APIRequestFactory().post('/api/fleet/work-orders/', {})
request.user = self.user
serializer = WorkOrderSerializer(
data={
"vehicle": str(self.vehicle.id),
"purpose": "kontrola",
},
context={"request": request},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
work_order = serializer.save()
self.assertRegex(work_order.display_code, r'^MT[0-9]{6}$')
def test_work_order_serializer_rejects_invalid_display_code(self):
request = APIRequestFactory().post('/api/fleet/work-orders/', {})
request.user = self.user
serializer = WorkOrderSerializer(
data={
"vehicle": str(self.vehicle.id),
"purpose": "kontrola",
"display_code": "BAD-CODE",
},
context={"request": request},
)
self.assertFalse(serializer.is_valid())
self.assertIn("display_code", serializer.errors)

View File

@@ -142,17 +142,26 @@ def _parse_format(value):
GENERATED_PDF_TTL_HOURS = 24
def _work_order_display_code(work_order):
normalized = str(getattr(work_order, 'display_code', '') or '').strip().upper()
if normalized:
return normalized
return 'NALOG'
def _pdf_filename(work_order, pdf_type):
display_code = _work_order_display_code(work_order)
if pdf_type == 'invoices':
return f"{work_order.pk}.work-order-invoices.pdf"
return f"{display_code}.work-order-invoices.pdf"
if pdf_type == 'service_records':
return f"{work_order.pk}.work-order-service-records.pdf"
return f"{work_order.pk}.work-order.pdf"
return f"{display_code}.work-order-service-records.pdf"
return f"{display_code}.work-order.pdf"
def _get_cached_pdf(work_order, pdf_type):
now = timezone.now()
return (
expected_filename = _pdf_filename(work_order, pdf_type)
candidates = list(
GeneratedWorkOrderPdf.objects
.filter(
is_active=True,
@@ -164,8 +173,11 @@ def _get_cached_pdf(work_order, pdf_type):
.exclude(file='')
.exclude(file__isnull=True)
.order_by('-created_at')
.first()
)
for candidate in candidates:
if str(candidate.filename or '').strip() == expected_filename:
return candidate
return None
def _cleanup_expired_generated_pdfs():
@@ -203,17 +215,18 @@ def _notify_pdf_request(*, user, work_order, doc_type, stage, generated_pdf=None
else:
doc_label = "računa putnog naloga"
display_code = _work_order_display_code(work_order)
if stage == 'requested':
title = f"Zahtjev za PDF {doc_label}"
message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za WO-{work_order.pk}."
message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za putni nalog {display_code}."
level = "info"
elif stage == 'failed':
title = f"Greška kod PDF-a ({doc_label})"
message = f"Generiranje PDF dokumenta nije uspjelo za WO-{work_order.pk}."
message = f"Generiranje PDF dokumenta nije uspjelo za putni nalog {display_code}."
level = "warning"
else:
title = f"PDF spreman ({doc_label})"
message = f"PDF dokument je uspješno generiran za WO-{work_order.pk}."
message = f"PDF dokument je uspješno generiran za putni nalog {display_code}."
level = "success"
metadata = {
@@ -337,6 +350,7 @@ def _build_work_order_pdf(work_order):
invoices = list(work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at'))
now_local = timezone.localtime(timezone.now())
tz = timezone.get_current_timezone()
display_code = _work_order_display_code(work_order)
def _fmt_date(value):
if not value:
@@ -437,7 +451,7 @@ def _build_work_order_pdf(work_order):
_paragraph("N A L O G<br/>ZA SLUŽBENO PUTOVANJE", bold=True, size=11, align=1),
_paragraph(
f"Mjesto i datum:<br/><b>{place_label} &nbsp;&nbsp; {_fmt_date(now_local.date())}</b><br/>"
f"Broj naloga:<br/><b>{work_order.pk}</b>",
f"Broj naloga:<br/><b>{display_code}</b>",
size=8.8,
),
]]
@@ -686,7 +700,7 @@ def _build_work_order_service_records_pdf(work_order):
upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-')
chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-')
mileage_str = str(vehicle.current_mileage or '-')
nalog_str = str(work_order.pk)
nalog_str = _work_order_display_code(work_order)
generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y')
HEADER_H = 86
@@ -1064,7 +1078,7 @@ def _build_service_record_pdf(service_record):
upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-')
chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-')
mileage_str = str(vehicle.current_mileage or '-')
nalog_str = str(getattr(work_order, 'pk', '-') or '-')
nalog_str = _work_order_display_code(work_order) if work_order else '-'
generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y')
HEADER_H = 86
@@ -1487,7 +1501,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
pdf_bytes = _build_work_order_pdf(work_order)
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='completed')
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{work_order.pk}.work-order.pdf"'
response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "work_order")}"'
return response
@action(detail=True, methods=['post'], url_path='pdf-request')
@@ -1508,7 +1522,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
pdf_bytes = _build_work_order_service_records_pdf(work_order)
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed')
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{work_order.pk}.work-order-service-records.pdf"'
response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "service_records")}"'
return response
@action(detail=True, methods=['post'], url_path='service-records-pdf-request')
@@ -1545,7 +1559,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
except (ValueError, TypeError):
raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."})
filename = payload.get('filename') or f"{work_order.pk}.work-order-invoices.pdf"
filename = payload.get('filename') or _pdf_filename(work_order, 'invoices')
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='completed')
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
@@ -1600,7 +1614,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Računi putnog naloga WO-{work_order.pk}</title>
<title>Računi putnog naloga {_work_order_display_code(work_order)}</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 24px; color: #111827; background: #f8fafc; }}
.card {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; margin-bottom: 16px; }}
@@ -1614,7 +1628,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
</head>
<body>
<div class="card">
<h1>Putni nalog WO-{work_order.pk} — Računi</h1>
<h1>Putni nalog {_work_order_display_code(work_order)} — Računi</h1>
<p class="muted">Dizalica: {escape(work_order.vehicle.registration_number)} | Datum naloga: {escape(str(work_order.date))}</p>
<div class="actions">
<a href="{escape(pdf_url)}">Preuzmi PDF putnog naloga</a>
@@ -1693,13 +1707,13 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."})
pdf_bytes = _build_work_order_pdf(work_order)
subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog WO-{work_order.pk}"
subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog {_work_order_display_code(work_order)}"
body = _first_non_empty(request.data.get('message')) or "U prilogu je PDF putnog naloga."
_send_document_email(
recipient=recipient,
subject=subject,
body=body,
filename=f"{work_order.pk}.work-order.pdf",
filename=_pdf_filename(work_order, 'work_order'),
pdf_bytes=pdf_bytes,
)
return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK)

View File

@@ -155,7 +155,10 @@ class TaskSerializer(serializers.ModelSerializer):
def get_work_order_label(self, obj):
if not obj.work_order_id:
return None
return f"PN-{str(obj.work_order_id).split('-')[0].upper()}"
display_code = str(getattr(obj.work_order, 'display_code', '') or '').strip().upper()
if display_code:
return display_code
return None
def get_assigned_to_name(self, obj):
if not obj.assigned_to_id:

View File

@@ -25,6 +25,7 @@ import {
$serviceRecords,
createWorkOrder,
fetchFleetDashboardData,
fetchTeamMembers,
ensureVehiclesCatalog,
triggerOfflineSync,
updateWorkOrder,
@@ -32,7 +33,7 @@ import {
import { showToast } from '../../stores/toastStore';
import { $accessToken, $authReady, $user, hydrateAuthFromStorage, validateToken } from '../../stores/authStore';
import { cacheCraneSerial, cacheCraneSerialsFromVehicles } from '../../stores/craneSerialStore';
import { formatEntityCode } from '../../lib/displayIds';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { $tasks, fetchTasks, getStatusLabel, isTaskActive, updateTask } from '../../stores/taskStore';
import AnimatedDataTable from '../ui/AnimatedDataTable';
import AnimatedPage from '../ui/AnimatedPage';
@@ -75,6 +76,15 @@ function formatTaskCraneLabel(task, cranes) {
return model ? `${primary} / ${model}` : primary;
}
function formatServiceRecordCraneLabel(record, cranes) {
const crane = cranes.find((item) => String(item.id) === String(record?.vehicle || ''));
const serial = String(record?.crane_serial_number || crane?.crane_serial_number || '').trim();
const model = [crane?.make, crane?.model].filter(Boolean).join(' ').trim();
const registration = String(record?.vehicle_registration || crane?.registration_number || '').trim();
const primary = serial ? `SN ${serial}` : (registration || '-');
return model ? `${primary} / ${model}` : primary;
}
function formatCost(value) {
const numeric = Number(value);
if (Number.isNaN(numeric)) return '-';
@@ -121,6 +131,8 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const [currentPage, setCurrentPage] = useState(1);
const [taskFilter, setTaskFilter] = useState('active');
const [taskScope, setTaskScope] = useState('all');
const [supervisorServicers, setSupervisorServicers] = useState([]);
const [selectedServicerId, setSelectedServicerId] = useState('all');
const [workOrderScope, setWorkOrderScope] = useState('context');
const [selectedCraneRowId, setSelectedCraneRowId] = useState(null);
const [expandedServiceTaskId, setExpandedServiceTaskId] = useState(null);
@@ -360,6 +372,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const isTeamMember = Boolean(user?.is_team_member);
const isServiser = Boolean(user?.is_serviser);
const isCustomer = Boolean(user?.is_kupac);
const isSupervisorNotServiser = isTeamMember && !isServiser;
const canViewAllTasks = isTeamMember && (isServiser || !isCustomer);
const canToggleTaskScope = isTeamMember && isServiser;
const isTaskAssignedToCurrentUser = (task) => (
@@ -370,6 +383,42 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
setTaskScope(canViewAllTasks ? 'all' : 'mine');
}, [canViewAllTasks]);
useEffect(() => {
if (!authReady || !isSupervisorNotServiser) {
setSupervisorServicers([]);
setSelectedServicerId('all');
return;
}
let isActive = true;
(async () => {
try {
const members = await fetchTeamMembers();
if (!isActive) return;
const servicers = members.filter((member) => Boolean(member?.is_serviser));
setSupervisorServicers(servicers);
} catch {
if (isActive) {
setSupervisorServicers([]);
}
}
})();
return () => {
isActive = false;
};
}, [authReady, isSupervisorNotServiser]);
useEffect(() => {
if (selectedServicerId === 'all') {
return;
}
const servicerExists = supervisorServicers.some(
(member) => String(member?.id || '') === String(selectedServicerId)
);
if (!servicerExists) {
setSelectedServicerId('all');
}
}, [selectedServicerId, supervisorServicers]);
const dashboardTasks = useMemo(() => {
const sorted = [...tasks].sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
const scoped = (() => {
@@ -381,14 +430,22 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
}
return sorted;
})();
const filteredByServicer = (() => {
if (!isSupervisorNotServiser || selectedServicerId === 'all') {
return scoped;
}
return scoped.filter(
(task) => String(task?.assigned_to || '') === String(selectedServicerId)
);
})();
if (taskFilter === 'active') {
return scoped.filter((task) => isTaskActive(task));
return filteredByServicer.filter((task) => isTaskActive(task));
}
if (taskFilter === 'inactive') {
return scoped.filter((task) => !isTaskActive(task));
return filteredByServicer.filter((task) => !isTaskActive(task));
}
return scoped;
}, [tasks, taskFilter, canViewAllTasks, canToggleTaskScope, taskScope, user?.id]);
return filteredByServicer;
}, [tasks, taskFilter, canViewAllTasks, canToggleTaskScope, taskScope, user?.id, isSupervisorNotServiser, selectedServicerId]);
const selectedTaskServiceRecords = useMemo(() => {
if (!selectedTask?.id) return [];
return [...serviceRecords]
@@ -435,7 +492,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
showToast('Putni nalog je spremljen.', 'success');
await fetchFleetDashboardData({ silent: true });
} catch (error) {
showToast('Kreiranje naloga nije uspjelo.', 'error');
showToast(error?.message || 'Kreiranje naloga nije uspjelo.', 'error');
}
}
@@ -446,7 +503,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
await fetchFleetDashboardData({ silent: true });
await fetchTasks();
} catch (error) {
showToast('Ažuriranje naloga nije uspjelo.', 'error');
showToast(error?.message || 'Ažuriranje naloga nije uspjelo.', 'error');
}
}
@@ -595,6 +652,23 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
<h3 className="font-semibold text-text-main">Radni zadaci</h3>
<div className="flex items-center gap-2 text-xs">
{hasMounted && isSupervisorNotServiser && (
<label className="flex items-center gap-2 text-text-main">
<span>Serviser</span>
<select
value={selectedServicerId}
onChange={(event) => setSelectedServicerId(event.target.value)}
className="rounded-md border border-border-hairline bg-canvas-elevated px-2 py-1 text-xs text-text-main focus:border-indigo-400 focus:outline-none"
>
<option value="all">Svi serviseri</option>
{supervisorServicers.map((servicer) => (
<option key={servicer.id} value={servicer.id}>
{servicer.full_name || servicer.email}
</option>
))}
</select>
</label>
)}
{canToggleTaskScope && (
<>
<button
@@ -765,7 +839,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
className="cursor-pointer px-4 py-3 font-medium text-text-main"
onClick={() => openWorkOrderDetails(workOrder)}
>
{formatEntityCode('PN', workOrder.id)}
{formatWorkOrderDisplayCode(workOrder)}
</td>
<td
className="px-4 py-3"
@@ -912,7 +986,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
<AnimatedDataTable
columns={[
{ key: 'date', label: 'Datum', className: 'px-4 py-3' },
{ key: 'crane', label: 'SN', className: 'px-4 py-3' },
{ key: 'crane', label: 'DIZALICA', className: 'px-4 py-3' },
{ key: 'description', label: 'Opis', className: 'px-4 py-3' },
{ key: 'cost', label: 'Trošak', className: 'px-4 py-3' },
{ key: 'files', label: 'Datoteke', className: 'px-4 py-3' },
@@ -927,7 +1001,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
{JSON.stringify(group, null, 2)}
</pre> */}
<td className="px-4 py-3">{formatDate(record.service_date)}</td>
<td className="px-4 py-3">{record.crane_serial_number || '-'}</td>
<td className="px-4 py-3">{formatServiceRecordCraneLabel(record, cranes)}</td>
<td className="max-w-md truncate px-4 py-3" title={record.description || ''}>
{record.description || '-'}
</td>

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { createTask, fetchTaskTemplates, getStatusLabel } from '../../stores/taskStore';
import { formatEntityCode, formatPurposeLabel } from '../../lib/displayIds';
import { formatPurposeLabel, formatWorkOrderDisplayCode } from '../../lib/displayIds';
import ModalShell from '../ui/ModalShell';
const STATUS_OPTIONS = ['aktivan', 'servis', 'zavrsen', 'neaktivan'];
@@ -25,6 +25,10 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
() => templates.find((template) => String(template.id) === String(templateId)) || null,
[templates, templateId]
);
const availableWorkOrders = useMemo(
() => workOrders.filter((workOrder) => String(workOrder?.status || '').toLowerCase() !== 'closed'),
[workOrders]
);
useEffect(() => {
if (!open) {
@@ -235,9 +239,9 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
<option value=""> Bez povezanog naloga </option>
{workOrders.map((workOrder) => (
{availableWorkOrders.map((workOrder) => (
<option key={workOrder.id} value={String(workOrder.id)}>
{formatEntityCode('PN', workOrder.id)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {formatPurposeLabel(workOrder.purpose) || '-'}
{formatWorkOrderDisplayCode(workOrder)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {formatPurposeLabel(workOrder.purpose) || '-'}
</option>
))}
</select>
@@ -246,7 +250,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
Kod template unosa nalog nije obavezan i backend ga neće automatski povezati.
</span>
)}
{workOrders.length === 0 && (
{availableWorkOrders.length === 0 && (
<span className="text-[11px] text-amber-600">
Nema dostupnih naloga za odabranu dizalicu.
</span>

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
import { formatEntityCode } from '../../lib/displayIds';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { getStatusLabel } from '../../stores/taskStore';
const STATUS_OPTIONS = ['aktivan', 'servis', 'spreman_za_zavrsetak', 'zavrsen', 'neaktivan'];
@@ -301,7 +301,7 @@ export default function TaskServiceRecordsModal({
{currentWorkOrder && !showWorkOrderEditor ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded border border-border-hairline bg-canvas-elevated px-3 py-2">
<p className="text-sm text-text-main">
{formatEntityCode('PN', currentWorkOrder.id)} {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} {currentWorkOrder.status || '-'}
{formatWorkOrderDisplayCode(currentWorkOrder)} {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} {currentWorkOrder.status || '-'}
</p>
<button
type="button"
@@ -322,7 +322,7 @@ export default function TaskServiceRecordsModal({
<option value=""> Odaberi putni nalog </option>
{sortedWorkOrders.map((workOrder) => (
<option key={workOrder.id} value={String(workOrder.id)}>
{formatEntityCode('PN', workOrder.id)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {workOrder.status || '-'}
{formatWorkOrderDisplayCode(workOrder)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {workOrder.status || '-'}
</option>
))}
</select>

View File

@@ -11,7 +11,7 @@ import {
sendWorkOrderEmail,
} from '../../stores/fleetDashboardStore';
import { showToast } from '../../stores/toastStore';
import { formatEntityCode } from '../../lib/displayIds';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
const PURPOSE_OPTIONS = [
{ value: 'defektaza', label: 'Defektaža' },
@@ -23,6 +23,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
const [isEditing, setIsEditing] = useState(false);
const [form, setForm] = useState({
has_travel_order: false,
display_code: '',
status: 'open',
start_mileage: '',
end_mileage: '',
@@ -92,6 +93,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setIsEditing(false);
setForm({
has_travel_order: !!(workOrder.travel_start_at || workOrder.travel_end_at || workOrder.location || workOrder.start_mileage != null || workOrder.end_mileage != null),
display_code: workOrder.display_code || '',
status: workOrder.status || 'open',
start_mileage: String(workOrder.start_mileage ?? ''),
end_mileage: workOrder.end_mileage == null ? '' : String(workOrder.end_mileage),
@@ -308,10 +310,20 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setError('Svrha naloga je obavezna.');
return;
}
const displayCode = String(form.display_code || '').trim().toUpperCase();
if (!displayCode) {
setError('Prikaz putnog naloga je obavezan.');
return;
}
if (!/^[A-Z]{2}[0-9]{6}$/.test(displayCode)) {
setError('Prikaz putnog naloga mora biti u formatu MT070626.');
return;
}
setSubmitting(true);
try {
await onSubmit(workOrder, {
display_code: displayCode,
status: form.status,
start_mileage: startMileage,
end_mileage: endMileage,
@@ -430,7 +442,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Uredi putni nalog {formatEntityCode('PN', workOrder.id)}</h3>
<h3 className="text-lg font-semibold text-text-main">Uredi putni nalog {formatWorkOrderDisplayCode(workOrder)}</h3>
<p className="text-sm text-text-muted">Dizalica: {craneLabel}</p>
</div>
<button
@@ -491,6 +503,20 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
<div className="rounded-xl border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Status</h4>
<label className="mb-3 flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Prikaz putnog naloga *</span>
<input
type="text"
value={form.display_code}
onInput={(event) => setForm({ ...form, display_code: event.currentTarget.value.toUpperCase() })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
pattern="[A-Za-z]{2}[0-9]{6}"
maxLength={8}
placeholder="MT070626"
disabled={!isEditing}
required
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Status *</span>
<select

View File

@@ -11,7 +11,7 @@ import {
} from '../../stores/fleetDashboardStore';
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
import { formatEntityCode } from '../../lib/displayIds';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
@@ -122,7 +122,7 @@ export default function WorkOrderInvoicesPdfPage() {
if (!workOrder) {
return 'Računi putnog naloga';
}
return `${formatEntityCode('PN', workOrder.id)}`;
return formatWorkOrderDisplayCode(workOrder);
}, [workOrder]);
const mediaPaths = useMemo(() => {

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import ModalShell from '../ui/ModalShell';
import { $user } from '../../stores/authStore';
import { generateWorkOrderDisplayCode } from '../../lib/displayIds';
const PURPOSE_OPTIONS = [
{ value: 'defektaza', label: 'Defektaža' },
@@ -11,6 +12,7 @@ const PURPOSE_OPTIONS = [
const INITIAL_FORM = {
has_travel_order: false,
display_code: '',
start_mileage: '',
end_mileage: '',
servicer_vehicle_registration: '',
@@ -54,6 +56,7 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
setForm((prev) => ({
...prev,
display_code: generateWorkOrderDisplayCode(user?.first_name, user?.last_name, new Date()),
start_mileage: selectedCrane ? String(selectedCrane.current_mileage ?? '') : prev.start_mileage,
servicer_vehicle_registration: user?.assigned_vehicle?.registration_number || '',
servicer_vehicle_make_model: `${user?.assigned_vehicle?.make || ''} ${user?.assigned_vehicle?.model || ''}`.trim(),
@@ -116,6 +119,15 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
setErrorMessage('Svrha naloga je obavezna.');
return;
}
const displayCode = String(form.display_code || '').trim().toUpperCase();
if (!displayCode) {
setErrorMessage('Prikaz putnog naloga je obavezan. Provjerite da serviser ima ime i prezime ili unesite oznaku ručno.');
return;
}
if (!/^[A-Z]{2}[0-9]{6}$/.test(displayCode)) {
setErrorMessage('Prikaz putnog naloga mora biti u formatu MT070626.');
return;
}
let startMileage = null;
let endMileage = null;
@@ -191,6 +203,7 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
try {
await onSubmit({
vehicle: selectedCrane.id,
display_code: displayCode,
start_mileage: startMileage,
end_mileage: endMileage,
servicer_vehicle_registration: form.servicer_vehicle_registration.trim(),
@@ -233,6 +246,20 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
</div>
<form onSubmit={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Prikaz putnog naloga *</span>
<input
type="text"
value={form.display_code}
onInput={(event) => setForm({ ...form, display_code: event.currentTarget.value.toUpperCase() })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
placeholder="MT070626"
pattern="[A-Za-z]{2}[0-9]{6}"
maxLength={8}
required
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Status *</span>
<select

View File

@@ -52,7 +52,7 @@ const isDev = import.meta.env.DEV;
<div class="absolute inset-0 bg-[linear-gradient(to_right,var(--color-text-main)_0.03_1px,transparent_1px),linear-gradient(to_bottom,var(--color-text-main)_0.03_1px,transparent_1px)] bg-[size:64px_64px] opacity-[0.4] dark:opacity-[0.02] pointer-events-none z-0"></div>
{!isAuthPage && <NetworkGuard client:only="preact" />}
<Toast client:load />
<Toast client:only="preact" />
{!isAuthPage && isDev && <ToastTrigger client:load />}
{!isAuthPage && <Navbar client:load minimal={minimalNav} />}

View File

@@ -12,6 +12,24 @@ export function formatEntityCode(prefix, id, size = 8) {
return `${prefix}-${short}`;
}
export function generateWorkOrderDisplayCode(firstName, lastName, dateValue = new Date()) {
const firstInitial = String(firstName || '').trim().charAt(0).toUpperCase();
const lastInitial = String(lastName || '').trim().charAt(0).toUpperCase();
if (!firstInitial || !lastInitial) return '';
const parsedDate = dateValue instanceof Date ? dateValue : new Date(dateValue);
if (Number.isNaN(parsedDate.getTime())) return '';
const day = String(parsedDate.getDate()).padStart(2, '0');
const month = String(parsedDate.getMonth() + 1).padStart(2, '0');
const year = String(parsedDate.getFullYear()).slice(-2);
return `${firstInitial}${lastInitial}${day}${month}${year}`;
}
export function formatWorkOrderDisplayCode(workOrder) {
const displayCode = String(workOrder?.display_code || '').trim().toUpperCase();
if (displayCode) return displayCode;
return '-';
}
export function formatPurposeLabel(value) {
const normalized = String(value || '').trim().toLowerCase();
if (!normalized) return '';