Compare commits
2 Commits
8485431180
...
cbcbf0b3af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbcbf0b3af | ||
|
|
ad7b293f54 |
@@ -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);
|
||||||
|
|||||||
@@ -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' : ''}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
|||||||
Reference in New Issue
Block a user