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

@@ -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 '';