2 Commits

Author SHA1 Message Date
mariomitte
cbcbf0b3af fix: sortiranje putnih naloga i upload modal u detaljima
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
Dodaje sortiranje stupca Putni nalog (↑/↓) u tablicama gdje je prikazan work order kod i uspoređuje kodove u formatu MTDDMMYY po godini, mjesecu i danu.

Popravlja tok uređivanja servisnog zapisa tako da upload fotografija ostaje u višem overlayju bez zatvaranja parent modala te nakon uspješnog uploada osvježava listu datoteka u detaljima.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 19:16:19 +02:00
mariomitte
ad7b293f54 fix: filtriraj putne naloge i prosiri servisni zapis modal
Ogranicava prikaz 'Dohvati sve putne naloge' na naloge trenutnog servisera, dok supervisor bez serviser uloge i dalje vidi sve.

U detaljima servisnog zapisa dodaje gumb za upload datoteka i povezuje ga s postojecim ServicePhotoUpload modalom.

U modalu Novi servisni zapis dodaje polje Naziv servisnog zapisa iznad opisa i salje ga kao service_title.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 19:02:05 +02:00
5 changed files with 179 additions and 17 deletions

View File

@@ -33,7 +33,7 @@ import {
import { showToast } from '../../stores/toastStore'; import { showToast } from '../../stores/toastStore';
import { $accessToken, $authReady, $user, hydrateAuthFromStorage, validateToken } from '../../stores/authStore'; import { $accessToken, $authReady, $user, hydrateAuthFromStorage, validateToken } from '../../stores/authStore';
import { cacheCraneSerial, cacheCraneSerialsFromVehicles } from '../../stores/craneSerialStore'; import { cacheCraneSerial, cacheCraneSerialsFromVehicles } from '../../stores/craneSerialStore';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds'; import { formatWorkOrderDisplayCode, parseWorkOrderDisplayCode } from '../../lib/displayIds';
import { $tasks, fetchTasks, getStatusLabel, isTaskActive, updateTask } from '../../stores/taskStore'; import { $tasks, fetchTasks, getStatusLabel, isTaskActive, updateTask } from '../../stores/taskStore';
import AnimatedDataTable from '../ui/AnimatedDataTable'; import AnimatedDataTable from '../ui/AnimatedDataTable';
import AnimatedPage from '../ui/AnimatedPage'; import AnimatedPage from '../ui/AnimatedPage';
@@ -91,6 +91,51 @@ function formatCost(value) {
return `${numeric.toFixed(2)} EUR`; return `${numeric.toFixed(2)} EUR`;
} }
function normalizeComparableId(value) {
if (value && typeof value === 'object') {
return value.id ?? value.pk ?? '';
}
return value ?? '';
}
function normalizeWorkOrderCodeForSort(value) {
return String(value || '').trim().toUpperCase().split(/\s+/)[0] || '';
}
function compareWorkOrderCodesByDate(leftCode, rightCode, direction = 'asc') {
const left = parseWorkOrderDisplayCode(normalizeWorkOrderCodeForSort(leftCode));
const right = parseWorkOrderDisplayCode(normalizeWorkOrderCodeForSort(rightCode));
if (left.valid && right.valid) {
if (left.sortKey !== right.sortKey) {
return direction === 'asc' ? left.sortKey - right.sortKey : right.sortKey - left.sortKey;
}
return left.normalized.localeCompare(right.normalized);
}
if (left.valid !== right.valid) {
return left.valid ? -1 : 1;
}
return String(leftCode || '').localeCompare(String(rightCode || ''));
}
function nextSortDirection(direction) {
return direction === 'asc' ? 'desc' : 'asc';
}
function SortableCodeLabel({ title, direction, onToggle }) {
return (
<button
type="button"
onClick={onToggle}
className="inline-flex items-center gap-1 rounded px-1 py-0.5 text-left hover:bg-canvas-base"
title="Sortiraj po datumu iz koda (godina, mjesec, dan)"
>
<span>{title}</span>
<span className={direction === 'asc' ? 'text-indigo-700' : 'text-text-muted/50'}></span>
<span className={direction === 'desc' ? 'text-indigo-700' : 'text-text-muted/50'}></span>
</button>
);
}
const PAGE_MODE_TO_SECTION = { const PAGE_MODE_TO_SECTION = {
dashboard: 'dashboard', dashboard: 'dashboard',
'work-orders': 'work-orders', 'work-orders': 'work-orders',
@@ -113,6 +158,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const [selectedServiceRecord, setSelectedServiceRecord] = useState(null); const [selectedServiceRecord, setSelectedServiceRecord] = useState(null);
// photoUpload: { open: bool, serviceRecordId: string|null } // photoUpload: { open: bool, serviceRecordId: string|null }
const [photoUpload, setPhotoUpload] = useState({ open: false, serviceRecordId: null }); const [photoUpload, setPhotoUpload] = useState({ open: false, serviceRecordId: null });
const [serviceRecordFilesRefreshToken, setServiceRecordFilesRefreshToken] = useState(0);
const [workOrderPhotoUpload, setWorkOrderPhotoUpload] = useState({ open: false, workOrderId: null }); const [workOrderPhotoUpload, setWorkOrderPhotoUpload] = useState({ open: false, workOrderId: null });
const loading = useStore($dashboardLoading); const loading = useStore($dashboardLoading);
const error = useStore($dashboardError); const error = useStore($dashboardError);
@@ -134,6 +180,8 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const [supervisorServicers, setSupervisorServicers] = useState([]); const [supervisorServicers, setSupervisorServicers] = useState([]);
const [selectedServicerId, setSelectedServicerId] = useState('all'); const [selectedServicerId, setSelectedServicerId] = useState('all');
const [workOrderScope, setWorkOrderScope] = useState('context'); const [workOrderScope, setWorkOrderScope] = useState('context');
const [workOrderCodeSortDirection, setWorkOrderCodeSortDirection] = useState('desc');
const [taskWorkOrderSortDirection, setTaskWorkOrderSortDirection] = useState('desc');
const [selectedCraneRowId, setSelectedCraneRowId] = useState(null); const [selectedCraneRowId, setSelectedCraneRowId] = useState(null);
const [expandedServiceTaskId, setExpandedServiceTaskId] = useState(null); const [expandedServiceTaskId, setExpandedServiceTaskId] = useState(null);
const [hasMounted, setHasMounted] = useState(false); const [hasMounted, setHasMounted] = useState(false);
@@ -344,14 +392,39 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
}); });
const scopedWorkOrders = useMemo(() => { const scopedWorkOrders = useMemo(() => {
const sorted = [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))); const sorted = [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
const isServiserUser = Boolean(user?.is_serviser);
const isSupervisorUser = Boolean(user?.is_team_member) && !Boolean(user?.is_serviser);
const currentUserId = normalizeComparableId(user?.id);
const roleScoped = sorted.filter((order) => {
if (!isServiserUser || isSupervisorUser) {
return true;
}
if (!currentUserId) {
return false;
}
const creatorCandidates = [order?.creator, order?.creator_id];
return creatorCandidates.some((candidate) => (
String(normalizeComparableId(candidate)) === String(currentUserId)
));
});
const codeSorted = [...roleScoped].sort((left, right) => (
compareWorkOrderCodesByDate(
formatWorkOrderDisplayCode(left),
formatWorkOrderDisplayCode(right),
workOrderCodeSortDirection
)
));
if (workOrderScope === 'all') { if (workOrderScope === 'all') {
return sorted; return codeSorted;
} }
if (!hydratedSelectedVehicleId) { if (!hydratedSelectedVehicleId) {
return []; return [];
} }
return sorted.filter((order) => String(order.vehicle) === String(hydratedSelectedVehicleId)); return codeSorted.filter((order) => String(order.vehicle) === String(hydratedSelectedVehicleId));
}, [workOrders, workOrderScope, hydratedSelectedVehicleId]); }, [workOrders, workOrderScope, hydratedSelectedVehicleId, user?.id, user?.is_serviser, user?.is_team_member, workOrderCodeSortDirection]);
const pageSize = 10; const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(scopedWorkOrders.length / pageSize)); const totalPages = Math.max(1, Math.ceil(scopedWorkOrders.length / pageSize));
const latestWorkOrders = useMemo( const latestWorkOrders = useMemo(
@@ -442,14 +515,24 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
(task) => String(task?.assigned_to || '') === String(selectedServicerId) (task) => String(task?.assigned_to || '') === String(selectedServicerId)
); );
})(); })();
if (taskFilter === 'active') { const filteredByStatus = (() => {
return filteredByServicer.filter((task) => isTaskActive(task)); if (taskFilter === 'active') {
} return filteredByServicer.filter((task) => isTaskActive(task));
if (taskFilter === 'inactive') { }
return filteredByServicer.filter((task) => !isTaskActive(task)); if (taskFilter === 'inactive') {
} return filteredByServicer.filter((task) => !isTaskActive(task));
return filteredByServicer; }
}, [tasks, taskFilter, canViewAllTasks, canToggleTaskScope, taskScope, user?.id, isSupervisorNotServiser, selectedServicerId]); return filteredByServicer;
})();
return [...filteredByStatus].sort((left, right) => (
compareWorkOrderCodesByDate(
left?.work_order_label || '',
right?.work_order_label || '',
taskWorkOrderSortDirection
)
));
}, [tasks, taskFilter, canViewAllTasks, canToggleTaskScope, taskScope, user?.id, isSupervisorNotServiser, selectedServicerId, taskWorkOrderSortDirection]);
const selectedTaskServiceRecords = useMemo(() => { const selectedTaskServiceRecords = useMemo(() => {
if (!selectedTask?.id) return []; if (!selectedTask?.id) return [];
return [...serviceRecords] return [...serviceRecords]
@@ -563,7 +646,17 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
} }
const workOrderColumns = [ const workOrderColumns = [
{ key: 'code', label: 'Putni nalog', className: 'px-4 py-3' }, {
key: 'code',
label: (
<SortableCodeLabel
title="Putni nalog"
direction={workOrderCodeSortDirection}
onToggle={() => setWorkOrderCodeSortDirection((prev) => nextSortDirection(prev))}
/>
),
className: 'px-4 py-3',
},
{ key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' }, { key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' },
{ key: 'serialNumber', label: 'SN', className: 'px-4 py-3' }, { key: 'serialNumber', label: 'SN', className: 'px-4 py-3' },
{ key: 'createdAt', label: 'Kreirano', className: 'px-4 py-3' }, { key: 'createdAt', label: 'Kreirano', className: 'px-4 py-3' },
@@ -585,7 +678,17 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
{ key: 'scheduledDate', label: 'Planirano', className: 'px-4 py-3' }, { key: 'scheduledDate', label: 'Planirano', className: 'px-4 py-3' },
{ key: 'createdAt', label: 'Vrijeme izrade', className: 'px-4 py-3' }, { key: 'createdAt', label: 'Vrijeme izrade', className: 'px-4 py-3' },
{ key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' }, { key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' },
{ key: 'workOrder', label: 'Putni nalog', className: 'px-4 py-3' }, {
key: 'workOrder',
label: (
<SortableCodeLabel
title="Putni nalog"
direction={taskWorkOrderSortDirection}
onToggle={() => setTaskWorkOrderSortDirection((prev) => nextSortDirection(prev))}
/>
),
className: 'px-4 py-3',
},
{ key: 'assigned', label: 'Dodijeljeno', className: 'px-4 py-3' }, { key: 'assigned', label: 'Dodijeljeno', className: 'px-4 py-3' },
]; ];
const isDashboardPage = pageMode === 'dashboard'; const isDashboardPage = pageMode === 'dashboard';
@@ -1193,7 +1296,10 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
open={photoUpload.open} open={photoUpload.open}
serviceRecordId={photoUpload.serviceRecordId} serviceRecordId={photoUpload.serviceRecordId}
onClose={() => setPhotoUpload({ open: false, serviceRecordId: null })} onClose={() => setPhotoUpload({ open: false, serviceRecordId: null })}
onSuccess={() => fetchFleetDashboardData({ silent: true })} onSuccess={() => {
setServiceRecordFilesRefreshToken((prev) => prev + 1);
fetchFleetDashboardData({ silent: true });
}}
/> />
<WorkOrderPhotoUpload <WorkOrderPhotoUpload
@@ -1214,6 +1320,12 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
setSelectedServiceRecord(updatedRecord); setSelectedServiceRecord(updatedRecord);
fetchFleetDashboardData({ silent: true }); fetchFleetDashboardData({ silent: true });
}} }}
onOpenPhotoUpload={(record) => {
const targetId = record?.id ?? selectedServiceRecord?.id;
if (!targetId) return;
setPhotoUpload({ open: true, serviceRecordId: targetId });
}}
filesRefreshToken={serviceRecordFilesRefreshToken}
onBack={serviceRecordBackTask onBack={serviceRecordBackTask
? () => { ? () => {
setSelectedServiceRecord(null); setSelectedServiceRecord(null);

View File

@@ -134,7 +134,7 @@ export default function ServicePhotoUpload({ open, serviceRecordId, onClose, onS
return ( return (
<ModalShell <ModalShell
onClose={handleCancel} onClose={handleCancel}
overlayClassName="z-50 p-4" overlayClassName="z-[70] p-4"
contentClassName="flex items-center justify-center min-h-full" contentClassName="flex items-center justify-center min-h-full"
panelClassName={`w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl ${isOffline ? 'pointer-events-none opacity-50' : ''}`} panelClassName={`w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl ${isOffline ? 'pointer-events-none opacity-50' : ''}`}
> >

View File

@@ -34,6 +34,8 @@ export default function ServiceRecordDetailModal({
serviceRecord, serviceRecord,
onClose, onClose,
onUpdated, onUpdated,
onOpenPhotoUpload = null,
filesRefreshToken = 0,
onBack = null, onBack = null,
backLabel = 'Natrag', backLabel = 'Natrag',
}) { }) {
@@ -116,7 +118,7 @@ export default function ServiceRecordDetailModal({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, serviceRecord?.id]); }, [open, serviceRecord?.id, filesRefreshToken]);
useEffect(() => { useEffect(() => {
if (!emailModalOpen) return; if (!emailModalOpen) return;
@@ -411,6 +413,14 @@ export default function ServiceRecordDetailModal({
)} )}
{editMode && ( {editMode && (
<> <>
<button
type="button"
onClick={() => onOpenPhotoUpload?.(serviceRecord)}
disabled={editSaving}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
📎 Dodaj datoteke
</button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {

View File

@@ -11,6 +11,7 @@ import { showToast } from '../../stores/toastStore.js';
const INITIAL = { const INITIAL = {
task: '', task: '',
service_title: '',
description: '', description: '',
parts: '', parts: '',
cost: '', cost: '',
@@ -144,6 +145,7 @@ export default function ServiceRecordForm({
try { try {
const payload = { const payload = {
task: form.task, task: form.task,
service_title: form.service_title.trim(),
description: form.description.trim(), description: form.description.trim(),
parts: form.parts.trim(), parts: form.parts.trim(),
cost, cost,
@@ -268,6 +270,18 @@ export default function ServiceRecordForm({
</label> </label>
</div> </div>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Naziv servisnog zapisa</span>
<input
type="text"
value={form.service_title}
onInput={field('service_title')}
disabled={disabled}
placeholder="Npr. Redovni servis hidraulike"
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main placeholder-text-muted/40 focus:outline-none disabled:opacity-50"
/>
</label>
<label className="flex flex-col gap-1 text-sm"> <label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Opis posla *</span> <span className="font-medium text-text-main">Opis posla *</span>
<textarea <textarea

View File

@@ -30,6 +30,32 @@ export function formatWorkOrderDisplayCode(workOrder) {
return '-'; return '-';
} }
const WORK_ORDER_CODE_PATTERN = /^([A-Z]{2})(\d{2})(\d{2})(\d{2})$/;
export function parseWorkOrderDisplayCode(value) {
const normalized = String(value || '').trim().toUpperCase();
const match = normalized.match(WORK_ORDER_CODE_PATTERN);
if (!match) {
return { valid: false, normalized };
}
const day = Number(match[2]);
const month = Number(match[3]);
const year = 2000 + Number(match[4]);
if (day < 1 || day > 31 || month < 1 || month > 12) {
return { valid: false, normalized };
}
const sortKey = year * 10000 + month * 100 + day;
return {
valid: true,
normalized,
prefix: match[1],
day,
month,
year,
sortKey,
};
}
export function formatPurposeLabel(value) { export function formatPurposeLabel(value) {
const normalized = String(value || '').trim().toLowerCase(); const normalized = String(value || '').trim().toLowerCase();
if (!normalized) return ''; if (!normalized) return '';