- Novo polje Task.scheduled_date (DateField, nullable, db_index) za planiranje - Migracija 0009_task_scheduled_date - Celery task notify_upcoming_tasks: dnevno u 08:00 šalje Pusher + email za sutrašnje zadatke - TaskCalendarWidget: react-spring slide panel s lijeve strane, grid prikaz mjeseca, dot indikatori, klik otvara TaskServiceRecordsModal - FleetDashboardShell: import + render kalendara, Planirano stupac u tablici zadataka - TaskCreateModal + TaskServiceRecordsModal: scheduled_date input i prikaz - Admin + serializer + views: scheduled_date filter i prikaz - 47 backend testova prolaze (uključuje 8 novih za notify_upcoming_tasks) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1262 lines
66 KiB
JavaScript
1262 lines
66 KiB
JavaScript
import { useStore } from '@nanostores/preact';
|
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
|
import { animated, useSpring, useTransition } from '@react-spring/web';
|
|
import DashboardTopbar from './DashboardTopbar';
|
|
import WorkOrderModal from './WorkOrderModal';
|
|
import TaskCreateModal from './TaskCreateModal';
|
|
import TaskServiceRecordsModal from './TaskServiceRecordsModal';
|
|
import WorkOrderDetailModal from './WorkOrderDetailModal';
|
|
import TaskCalendarWidget from './TaskCalendarWidget';
|
|
import SyncLogPanel from './SyncLogPanel';
|
|
import ServiceRecordForm from '../fleet/ServiceRecordForm';
|
|
import ServicePhotoUpload from '../fleet/ServicePhotoUpload';
|
|
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
|
|
import ServiceRecordDetailModal from '../fleet/ServiceRecordDetailModal';
|
|
import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
|
|
import ClientsSection from '../crm/ClientsSection';
|
|
import { $clients } from '../../stores/clientStore';
|
|
import { $selectedClientId, $selectedVehicleId, hydrateServiceContext, setServiceContext } from '../../stores/serviceContextStore';
|
|
import {
|
|
$dashboardError,
|
|
$dashboardLoading,
|
|
$offlineQueueCount,
|
|
$dashboardStats,
|
|
$cranes,
|
|
$workOrdersWithVehicle,
|
|
$serviceRecords,
|
|
createWorkOrder,
|
|
fetchFleetDashboardData,
|
|
fetchTeamMembers,
|
|
ensureVehiclesCatalog,
|
|
triggerOfflineSync,
|
|
updateWorkOrder,
|
|
} from '../../stores/fleetDashboardStore';
|
|
import { showToast } from '../../stores/toastStore';
|
|
import { $accessToken, $authReady, $user, hydrateAuthFromStorage, validateToken } from '../../stores/authStore';
|
|
import { cacheCraneSerial, cacheCraneSerialsFromVehicles } from '../../stores/craneSerialStore';
|
|
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
|
import { $tasks, fetchTasks, getStatusLabel, isTaskActive, updateTask } from '../../stores/taskStore';
|
|
import AnimatedDataTable from '../ui/AnimatedDataTable';
|
|
import AnimatedPage from '../ui/AnimatedPage';
|
|
|
|
function AnimatedStatusBadge({ status }) {
|
|
const normalizedStatus = status === 'closed' ? 'closed' : 'open';
|
|
const springStyle = useSpring({
|
|
backgroundColor: normalizedStatus === 'closed' ? 'rgba(16, 185, 129, 0.16)' : 'rgba(99, 102, 241, 0.16)',
|
|
color: normalizedStatus === 'closed' ? 'rgb(4, 120, 87)' : 'rgb(67, 56, 202)',
|
|
config: { tension: 240, friction: 24 },
|
|
});
|
|
|
|
return (
|
|
<animated.span style={springStyle} className="rounded-full px-2 py-1 text-xs font-medium">
|
|
{normalizedStatus}
|
|
</animated.span>
|
|
);
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
if (!value) return '-';
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) return '-';
|
|
return parsed.toLocaleString('hr-HR');
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '-';
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) return value;
|
|
return parsed.toLocaleDateString('hr-HR');
|
|
}
|
|
|
|
function formatTaskCraneLabel(task, cranes) {
|
|
const crane = cranes.find((item) => String(item.id) === String(task?.vehicle || ''));
|
|
const serial = String(crane?.crane_serial_number || '').trim();
|
|
const model = [crane?.make, crane?.model].filter(Boolean).join(' ').trim();
|
|
const registration = String(task?.vehicle_registration || crane?.registration_number || '').trim();
|
|
const primary = serial ? `SN ${serial}` : (registration || '-');
|
|
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 '-';
|
|
return `${numeric.toFixed(2)} EUR`;
|
|
}
|
|
|
|
const PAGE_MODE_TO_SECTION = {
|
|
dashboard: 'dashboard',
|
|
'work-orders': 'work-orders',
|
|
'service-records': 'service-records',
|
|
vehicles: 'cranes',
|
|
clients: 'clients',
|
|
};
|
|
|
|
export default function FleetDashboardShell({ initialSection = 'dashboard', pageMode = 'dashboard' }) {
|
|
const resolvedInitialSection = initialSection || PAGE_MODE_TO_SECTION[pageMode] || 'dashboard';
|
|
const [activeSection, setActiveSection] = useState(resolvedInitialSection);
|
|
const [isWorkOrderModalOpen, setIsWorkOrderModalOpen] = useState(false);
|
|
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
|
const [selectedWorkOrder, setSelectedWorkOrder] = useState(null);
|
|
const [selectedTask, setSelectedTask] = useState(null);
|
|
const [serviceRecordBackTask, setServiceRecordBackTask] = useState(null);
|
|
const [serviceRecordFormBackTask, setServiceRecordFormBackTask] = useState(null);
|
|
const [serviceRecordOpen, setServiceRecordOpen] = useState(false);
|
|
const [serviceRecordCrane, setServiceRecordCrane] = useState(null);
|
|
const [selectedServiceRecord, setSelectedServiceRecord] = useState(null);
|
|
// photoUpload: { open: bool, serviceRecordId: string|null }
|
|
const [photoUpload, setPhotoUpload] = useState({ open: false, serviceRecordId: null });
|
|
const [workOrderPhotoUpload, setWorkOrderPhotoUpload] = useState({ open: false, workOrderId: null });
|
|
const loading = useStore($dashboardLoading);
|
|
const error = useStore($dashboardError);
|
|
const stats = useStore($dashboardStats);
|
|
const workOrders = useStore($workOrdersWithVehicle);
|
|
const serviceRecords = useStore($serviceRecords);
|
|
const tasks = useStore($tasks);
|
|
const cranes = useStore($cranes);
|
|
const clients = useStore($clients);
|
|
const offlineQueueCount = useStore($offlineQueueCount);
|
|
const user = useStore($user);
|
|
const token = useStore($accessToken);
|
|
const authReady = useStore($authReady);
|
|
const selectedClientId = useStore($selectedClientId);
|
|
const selectedVehicleId = useStore($selectedVehicleId);
|
|
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);
|
|
const [hasMounted, setHasMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setHasMounted(true);
|
|
hydrateAuthFromStorage();
|
|
hydrateServiceContext();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const nextSection = String(resolvedInitialSection || 'dashboard');
|
|
setActiveSection(nextSection);
|
|
}, [resolvedInitialSection]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
const syncContextFromStorage = () => {
|
|
const persistedClientId = localStorage.getItem('topbar_selected_client');
|
|
const persistedVehicleId = localStorage.getItem('topbar_selected_vehicle');
|
|
if (!persistedVehicleId) {
|
|
return;
|
|
}
|
|
if (String(selectedVehicleId || '') === String(persistedVehicleId)) {
|
|
return;
|
|
}
|
|
setServiceContext(persistedClientId, persistedVehicleId);
|
|
};
|
|
|
|
syncContextFromStorage();
|
|
window.addEventListener('storage', syncContextFromStorage);
|
|
window.addEventListener('focus', syncContextFromStorage);
|
|
window.addEventListener('service-context:changed', syncContextFromStorage);
|
|
window.addEventListener('astro:page-load', syncContextFromStorage);
|
|
return () => {
|
|
window.removeEventListener('storage', syncContextFromStorage);
|
|
window.removeEventListener('focus', syncContextFromStorage);
|
|
window.removeEventListener('service-context:changed', syncContextFromStorage);
|
|
window.removeEventListener('astro:page-load', syncContextFromStorage);
|
|
};
|
|
}, [selectedVehicleId]);
|
|
|
|
useEffect(() => {
|
|
if (authReady && !token) {
|
|
window.location.href = '/login';
|
|
}
|
|
}, [authReady, token]);
|
|
|
|
function openWorkOrderDetails(workOrder) {
|
|
if (!workOrder) {
|
|
return;
|
|
}
|
|
const targetCraneId = workOrder.crane || workOrder.vehicle;
|
|
const craneFromCatalog = cranes.find((item) => String(item.id) === String(targetCraneId)) || null;
|
|
const serialFromContext = craneFromCatalog?.crane_serial_number || workOrder.craneInfo?.crane_serial_number;
|
|
cacheCraneSerial(targetCraneId, serialFromContext);
|
|
setSelectedWorkOrder({
|
|
...workOrder,
|
|
craneInfo: craneFromCatalog || workOrder.craneInfo || null,
|
|
});
|
|
}
|
|
|
|
useEffect(() => {
|
|
cacheCraneSerialsFromVehicles(cranes);
|
|
}, [cranes]);
|
|
|
|
useEffect(() => {
|
|
if (!authReady) return;
|
|
let active = true;
|
|
|
|
(async () => {
|
|
await validateToken();
|
|
if (!active) return;
|
|
await Promise.all([
|
|
fetchFleetDashboardData(),
|
|
fetchTasks(),
|
|
]);
|
|
})();
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [authReady]);
|
|
|
|
useEffect(() => {
|
|
function handleNavbarNavigate(event) {
|
|
const section = event?.detail;
|
|
if (typeof section === 'string' && section.length > 0) {
|
|
setActiveSection(section);
|
|
}
|
|
}
|
|
|
|
window.addEventListener('navbar:navigate', handleNavbarNavigate);
|
|
return () => window.removeEventListener('navbar:navigate', handleNavbarNavigate);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
function handleNotificationOpenEntity(event) {
|
|
const detail = event?.detail || {};
|
|
const section = detail.section;
|
|
if (typeof section === 'string' && section.length > 0) {
|
|
setActiveSection(section);
|
|
}
|
|
|
|
if (detail.entityType === 'work_order' && detail.entityId) {
|
|
if (detail.vehicleId) {
|
|
const targetCrane = cranes.find((item) => String(item.id) === String(detail.craneId || detail.vehicleId));
|
|
if (targetCrane) {
|
|
setServiceContext(targetCrane.client || null, targetCrane.id);
|
|
}
|
|
}
|
|
const targetWorkOrder = workOrders.find((item) => String(item.id) === String(detail.entityId));
|
|
if (targetWorkOrder) {
|
|
openWorkOrderDetails(targetWorkOrder);
|
|
} else {
|
|
showToast('Traženi putni nalog nije pronađen u trenutnom prikazu.', 'warning');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (detail.entityType === 'service_record' && detail.entityId) {
|
|
if (detail.vehicleId) {
|
|
const targetCrane = cranes.find((item) => String(item.id) === String(detail.craneId || detail.vehicleId));
|
|
if (targetCrane) {
|
|
setServiceContext(targetCrane.client || null, targetCrane.id);
|
|
}
|
|
}
|
|
const targetServiceRecord = serviceRecords.find((item) => String(item.id) === String(detail.entityId));
|
|
if (targetServiceRecord) {
|
|
setServiceRecordBackTask(null);
|
|
setSelectedServiceRecord(targetServiceRecord);
|
|
} else {
|
|
showToast('Traženi servisni zapis nije pronađen u trenutnom prikazu.', 'warning');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (detail.entityType === 'service_context' && detail.vehicleId) {
|
|
const targetCrane = cranes.find((item) => String(item.id) === String(detail.vehicleId));
|
|
if (targetCrane) {
|
|
setServiceContext(targetCrane.client || null, targetCrane.id);
|
|
setSelectedCraneRowId(targetCrane.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
window.addEventListener('notification:open-entity', handleNotificationOpenEntity);
|
|
return () => window.removeEventListener('notification:open-entity', handleNotificationOpenEntity);
|
|
}, [serviceRecords, cranes, workOrders]);
|
|
|
|
const contextCrane = useMemo(() => (
|
|
cranes.find((item) => String(item.id) === String(selectedVehicleId)) || null
|
|
), [cranes, selectedVehicleId]);
|
|
const hydratedSelectedVehicleId = hasMounted ? selectedVehicleId : null;
|
|
const hydratedContextCrane = hasMounted ? contextCrane : null;
|
|
const selectedContextClientName = useMemo(() => (
|
|
clients.find((client) => String(client.id) === String(selectedClientId))?.name
|
|
|| contextCrane?.client_name
|
|
|| null
|
|
), [clients, selectedClientId, contextCrane]);
|
|
const contextCraneLabel = useMemo(() => {
|
|
if (!hydratedContextCrane) return null;
|
|
const serial = String(hydratedContextCrane.crane_serial_number || '').trim();
|
|
if (serial) return `SN ${serial}`;
|
|
return hydratedContextCrane.registration_number || '-';
|
|
}, [hydratedContextCrane]);
|
|
const serviceRecordsForContext = useMemo(() => {
|
|
if (!selectedVehicleId) return [];
|
|
return [...serviceRecords]
|
|
.filter((record) => String(record.vehicle) === String(selectedVehicleId))
|
|
.sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || '')));
|
|
}, [serviceRecords, selectedVehicleId]);
|
|
const serviceTaskGroups = useMemo(() => {
|
|
if (!selectedVehicleId) return [];
|
|
const contextTasks = [...tasks]
|
|
.filter((task) => String(task.vehicle || '') === String(selectedVehicleId))
|
|
.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
|
|
const groups = contextTasks.map((task) => ({
|
|
id: String(task.id),
|
|
title: task.title || 'Radni zadatak',
|
|
status: task.status || '-',
|
|
task,
|
|
records: serviceRecordsForContext.filter((record) => String(record.task || '') === String(task.id)),
|
|
}));
|
|
const unassignedRecords = serviceRecordsForContext.filter((record) => !record.task);
|
|
if (unassignedRecords.length > 0) {
|
|
groups.push({
|
|
id: 'unassigned-task',
|
|
title: 'Bez radnog zadatka',
|
|
status: '-',
|
|
task: null,
|
|
records: unassignedRecords,
|
|
});
|
|
}
|
|
return groups;
|
|
}, [selectedVehicleId, tasks, serviceRecordsForContext]);
|
|
const serviceTaskTransitions = useTransition(serviceTaskGroups, {
|
|
keys: (group) => group.id,
|
|
from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
|
|
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
|
|
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
|
|
trail: 30,
|
|
config: { tension: 230, friction: 26 },
|
|
});
|
|
const scopedWorkOrders = useMemo(() => {
|
|
const sorted = [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
|
if (workOrderScope === 'all') {
|
|
return sorted;
|
|
}
|
|
if (!hydratedSelectedVehicleId) {
|
|
return [];
|
|
}
|
|
return sorted.filter((order) => String(order.vehicle) === String(hydratedSelectedVehicleId));
|
|
}, [workOrders, workOrderScope, hydratedSelectedVehicleId]);
|
|
const pageSize = 10;
|
|
const totalPages = Math.max(1, Math.ceil(scopedWorkOrders.length / pageSize));
|
|
const latestWorkOrders = useMemo(
|
|
() => scopedWorkOrders.slice((currentPage - 1) * pageSize, currentPage * pageSize),
|
|
[scopedWorkOrders, currentPage]
|
|
);
|
|
const serviceRecordWorkOrders = useMemo(() => {
|
|
const craneId = serviceRecordCrane?.id || contextCrane?.id;
|
|
if (!craneId) return [];
|
|
return workOrders
|
|
.filter((order) => String(order.vehicle) === String(craneId))
|
|
.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
|
}, [workOrders, serviceRecordCrane, contextCrane]);
|
|
const serviceRecordTasks = useMemo(() => {
|
|
const craneId = serviceRecordCrane?.id || contextCrane?.id;
|
|
if (!craneId) return [];
|
|
return tasks
|
|
.filter((task) => isTaskActive(task))
|
|
.filter((task) => task.vehicle && String(task.vehicle) === String(craneId))
|
|
.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
|
|
}, [tasks, serviceRecordCrane, contextCrane]);
|
|
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) => (
|
|
Boolean(user?.id) && String(task?.assigned_to || '') === String(user.id)
|
|
);
|
|
|
|
useEffect(() => {
|
|
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 = (() => {
|
|
if (!canViewAllTasks) {
|
|
return sorted.filter((task) => isTaskAssignedToCurrentUser(task));
|
|
}
|
|
if (canToggleTaskScope && taskScope === 'mine') {
|
|
return sorted.filter((task) => isTaskAssignedToCurrentUser(task));
|
|
}
|
|
return sorted;
|
|
})();
|
|
const filteredByServicer = (() => {
|
|
if (!isSupervisorNotServiser || selectedServicerId === 'all') {
|
|
return scoped;
|
|
}
|
|
return scoped.filter(
|
|
(task) => String(task?.assigned_to || '') === String(selectedServicerId)
|
|
);
|
|
})();
|
|
if (taskFilter === 'active') {
|
|
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]);
|
|
const selectedTaskServiceRecords = useMemo(() => {
|
|
if (!selectedTask?.id) return [];
|
|
return [...serviceRecords]
|
|
.filter((record) => String(record.task || '') === String(selectedTask.id))
|
|
.sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || '')));
|
|
}, [serviceRecords, selectedTask?.id]);
|
|
const selectedTaskWorkOrders = useMemo(() => {
|
|
if (!selectedTask) return [];
|
|
const byVehicle = selectedTask.vehicle
|
|
? workOrders.filter((order) => String(order.vehicle) === String(selectedTask.vehicle))
|
|
: workOrders;
|
|
return [...byVehicle].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
|
}, [selectedTask, workOrders]);
|
|
|
|
useEffect(() => {
|
|
if (currentPage > totalPages) {
|
|
setCurrentPage(totalPages);
|
|
}
|
|
}, [currentPage, totalPages]);
|
|
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [workOrderScope, hydratedSelectedVehicleId]);
|
|
|
|
useEffect(() => {
|
|
if (!expandedServiceTaskId) return;
|
|
const exists = serviceTaskGroups.some((group) => String(group.id) === String(expandedServiceTaskId));
|
|
if (!exists) {
|
|
setExpandedServiceTaskId(null);
|
|
}
|
|
}, [expandedServiceTaskId, serviceTaskGroups]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedCraneRowId) return;
|
|
const exists = cranes.some((item) => String(item.id) === String(selectedCraneRowId));
|
|
if (!exists) {
|
|
setSelectedCraneRowId(null);
|
|
}
|
|
}, [cranes, selectedCraneRowId]);
|
|
|
|
async function handleCreateWorkOrder(formData) {
|
|
try {
|
|
await createWorkOrder(formData);
|
|
showToast('Putni nalog je spremljen.', 'success');
|
|
await fetchFleetDashboardData({ silent: true });
|
|
} catch (error) {
|
|
showToast(error?.message || 'Kreiranje naloga nije uspjelo.', 'error');
|
|
}
|
|
}
|
|
|
|
async function handleUpdateWorkOrder(workOrder, changes) {
|
|
try {
|
|
await updateWorkOrder(workOrder.id, changes);
|
|
showToast('Nalog je uspješno ažuriran.', 'success');
|
|
await fetchFleetDashboardData({ silent: true });
|
|
await fetchTasks();
|
|
} catch (error) {
|
|
showToast(error?.message || 'Ažuriranje naloga nije uspjelo.', 'error');
|
|
}
|
|
}
|
|
|
|
function openServiceRecordForCrane(crane) {
|
|
setServiceRecordFormBackTask(null);
|
|
setServiceRecordCrane(crane ?? null);
|
|
setServiceRecordOpen(true);
|
|
}
|
|
|
|
async function handleSetServiceContext() {
|
|
if (!selectedCraneRowId) {
|
|
showToast('Prvo označite redak dizalice.', 'warning');
|
|
return;
|
|
}
|
|
const selectedCrane = cranes.find((item) => String(item.id) === String(selectedCraneRowId));
|
|
handleSetServiceContextForCrane(selectedCrane);
|
|
}
|
|
|
|
function handleSetServiceContextForCraneId(craneId) {
|
|
const selectedCrane = cranes.find((item) => String(item.id) === String(craneId));
|
|
handleSetServiceContextForCrane(selectedCrane);
|
|
}
|
|
|
|
function handleSetServiceContextForCrane(selectedCrane) {
|
|
if (!selectedCrane) {
|
|
showToast('Odabrana dizalica više nije dostupna.', 'warning');
|
|
return;
|
|
}
|
|
setSelectedCraneRowId(selectedCrane.id);
|
|
setServiceContext(selectedCrane.client || null, selectedCrane.id);
|
|
showToast('Servisni kontekst je postavljen.', 'success');
|
|
}
|
|
|
|
async function openTaskModalFromContext() {
|
|
if (!selectedClientId || !selectedVehicleId) {
|
|
showToast('Odaberite kupca i dizalicu u Servisnom kontekstu prije kreiranja radnog zadatka.', 'warning');
|
|
return;
|
|
}
|
|
try {
|
|
const availableCranes = await ensureVehiclesCatalog();
|
|
const selectedFromContext = availableCranes.find(
|
|
(item) => String(item.id) === String(selectedVehicleId)
|
|
);
|
|
if (!selectedFromContext) {
|
|
showToast('Odabrana dizalica nije dostupna u trenutnom kontekstu.', 'warning');
|
|
return;
|
|
}
|
|
} catch {
|
|
showToast('Ne mogu dohvatiti dizalice. Provjerite mrežu ili prijavu.', 'error');
|
|
return;
|
|
}
|
|
setIsTaskModalOpen(true);
|
|
}
|
|
|
|
const workOrderColumns = [
|
|
{ key: 'code', label: 'Putni nalog', className: 'px-4 py-3' },
|
|
{ key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' },
|
|
{ key: 'serialNumber', label: 'SN', className: 'px-4 py-3' },
|
|
{ key: 'createdAt', label: 'Kreirano', className: 'px-4 py-3' },
|
|
{ key: 'status', label: 'Status', className: 'px-4 py-3' },
|
|
{ key: 'creator', label: 'Creator', className: 'px-4 py-3' },
|
|
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
|
|
];
|
|
const vehicleColumns = [
|
|
{ key: 'registration', label: 'Registracija', className: 'px-4 py-3' },
|
|
{ key: 'serialNumber', label: 'SN', className: 'px-4 py-3' },
|
|
{ key: 'model', label: 'Marka / Model', className: 'px-4 py-3' },
|
|
{ key: 'client', label: 'Kupac', className: 'px-4 py-3' },
|
|
{ key: 'mileage', label: 'Kilometraža', className: 'px-4 py-3' },
|
|
{ key: 'status', label: 'Status', className: 'px-4 py-3' },
|
|
];
|
|
const taskColumns = [
|
|
{ key: 'title', label: 'Radni zadatak', className: 'px-4 py-3' },
|
|
{ key: 'status', label: 'Status', 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: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' },
|
|
{ key: 'workOrder', label: 'Putni nalog', className: 'px-4 py-3' },
|
|
{ key: 'assigned', label: 'Dodijeljeno', className: 'px-4 py-3' },
|
|
];
|
|
const isDashboardPage = pageMode === 'dashboard';
|
|
const showWorkOrders = pageMode === 'work-orders';
|
|
const showServiceRecords = pageMode === 'service-records';
|
|
const showClients = pageMode === 'clients';
|
|
const showVehicles = pageMode === 'vehicles';
|
|
|
|
return (
|
|
<AnimatedPage className="min-h-screen">
|
|
{hasMounted && (
|
|
<TaskCalendarWidget
|
|
tasks={tasks}
|
|
onTaskClick={(task) => setSelectedTask(task)}
|
|
/>
|
|
)}
|
|
<main className="min-w-0">
|
|
<DashboardTopbar
|
|
activeSection={activeSection}
|
|
onSectionChange={setActiveSection}
|
|
showSectionNav={false}
|
|
onOpenWorkOrderModal={async () => {
|
|
try {
|
|
const availableCranes = await ensureVehiclesCatalog();
|
|
const selectedFromContext = availableCranes.find(
|
|
(item) => String(item.id) === String(selectedVehicleId)
|
|
);
|
|
if (!selectedFromContext) {
|
|
showToast('Odaberite dizalicu u Servisnom kontekstu prije kreiranja naloga.', 'warning');
|
|
return;
|
|
}
|
|
} catch {
|
|
showToast('Ne mogu dohvatiti dizalice. Provjerite mrežu ili prijavu.', 'error');
|
|
return;
|
|
}
|
|
setIsWorkOrderModalOpen(true);
|
|
}}
|
|
onOpenTaskModal={openTaskModalFromContext}
|
|
onNewServiceRecord={({ craneId }) => {
|
|
const crane = cranes.find((item) => String(item.id) === String(craneId));
|
|
openServiceRecordForCrane(crane ?? null);
|
|
}}
|
|
pendingSyncCount={offlineQueueCount}
|
|
/>
|
|
|
|
<section className="space-y-6 p-4 sm:p-6">
|
|
{error && (
|
|
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{isDashboardPage && (
|
|
<div id="section-dashboard" className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
|
<p className="text-sm text-text-muted">Otvoreni nalozi</p>
|
|
<p className="mt-2 text-2xl font-semibold text-text-main">{stats.openWorkOrders}</p>
|
|
</article>
|
|
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
|
<p className="text-sm text-text-muted">Servisi danas</p>
|
|
<p className="mt-2 text-2xl font-semibold text-text-main">{stats.servicesToday}</p>
|
|
</article>
|
|
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
|
<p className="text-sm text-text-muted">Upozorenja</p>
|
|
<p className="mt-2 text-2xl font-semibold text-amber-600">{stats.warnings}</p>
|
|
</article>
|
|
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
|
<p className="text-sm text-text-muted">Dovršeni nalozi</p>
|
|
<p className="mt-2 text-2xl font-semibold text-emerald-600">{stats.closedWorkOrders}</p>
|
|
</article>
|
|
</div>
|
|
)}
|
|
{isDashboardPage && (
|
|
<article className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
|
|
<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
|
|
type="button"
|
|
onClick={() => setTaskScope('all')}
|
|
className={taskScope === 'all'
|
|
? 'rounded-md bg-indigo-50 px-2.5 py-1 font-semibold text-indigo-700'
|
|
: 'rounded-md px-2.5 py-1 text-text-main hover:bg-canvas-deep'}
|
|
>
|
|
Svi radni zadaci
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTaskScope('mine')}
|
|
className={taskScope === 'mine'
|
|
? 'rounded-md bg-indigo-50 px-2.5 py-1 font-semibold text-indigo-700'
|
|
: 'rounded-md px-2.5 py-1 text-text-main hover:bg-canvas-deep'}
|
|
>
|
|
Moji radni zadaci
|
|
</button>
|
|
</>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => setTaskFilter('active')}
|
|
className={taskFilter === 'active'
|
|
? 'rounded-md bg-indigo-50 px-2.5 py-1 font-semibold text-indigo-700'
|
|
: 'rounded-md px-2.5 py-1 text-text-main hover:bg-canvas-deep'}
|
|
>
|
|
Aktivni
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTaskFilter('inactive')}
|
|
className={taskFilter === 'inactive'
|
|
? 'rounded-md bg-indigo-50 px-2.5 py-1 font-semibold text-indigo-700'
|
|
: 'rounded-md px-2.5 py-1 text-text-main hover:bg-canvas-deep'}
|
|
>
|
|
Neaktivni
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTaskFilter('all')}
|
|
className={taskFilter === 'all'
|
|
? 'rounded-md bg-indigo-50 px-2.5 py-1 font-semibold text-indigo-700'
|
|
: 'rounded-md px-2.5 py-1 text-text-main hover:bg-canvas-deep'}
|
|
>
|
|
Svi
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<AnimatedDataTable
|
|
columns={taskColumns}
|
|
rows={dashboardTasks}
|
|
rowKey={(task) => task.id}
|
|
loading={loading}
|
|
loadingMessage="Učitavanje radnih zadataka..."
|
|
emptyMessage="Nema radnih zadataka za odabrani filter."
|
|
rowClassName={(task) => {
|
|
const status = String(task?.status || '').toLowerCase();
|
|
if (status === 'hitno') {
|
|
return 'bg-amber-50 hover:bg-amber-100';
|
|
}
|
|
return 'hover:bg-canvas-deep';
|
|
}}
|
|
renderRow={(task) => (
|
|
<>
|
|
<td className="px-4 py-3 font-medium text-text-main">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedTask(task)}
|
|
className="cursor-pointer text-left text-text-main hover:underline"
|
|
>
|
|
{task.title || '-'}
|
|
</button>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className={isTaskActive(task) ? 'text-emerald-700' : 'text-red-700'}>
|
|
{getStatusLabel(task.status || '-')}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{task.scheduled_date ? (
|
|
<span className="font-medium text-indigo-700">
|
|
{formatDate(task.scheduled_date)}
|
|
</span>
|
|
) : (
|
|
<span className="text-text-muted">-</span>
|
|
)}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3"
|
|
title={formatDateTime(task.created_at) !== '-' ? formatDateTime(task.created_at) : undefined}
|
|
>
|
|
<span className="cursor-help">{formatDate(task.created_at)}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{formatTaskCraneLabel(task, cranes)}
|
|
</td>
|
|
<td className="px-4 py-3">{task.work_order_label || '-'}</td>
|
|
<td className="px-4 py-3">{task.assigned_to_name || '-'}</td>
|
|
</>
|
|
)}
|
|
/>
|
|
</article>
|
|
)}
|
|
{isDashboardPage && (
|
|
<div>
|
|
<SyncLogPanel />
|
|
</div>
|
|
)}
|
|
|
|
{showWorkOrders && (
|
|
<article id="section-work-orders" className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
|
|
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
|
|
<div>
|
|
<h3 className="font-semibold text-text-main">Putni nalozi dizalica</h3>
|
|
<p className="text-xs text-text-muted">
|
|
{workOrderScope === 'context'
|
|
? (selectedContextClientName && contextCraneLabel
|
|
? `Kontekst: ${selectedContextClientName} / ${contextCraneLabel}`
|
|
: 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.')
|
|
: 'Prikaz svih putnih naloga.'}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setWorkOrderScope('all')}
|
|
className={workOrderScope === 'all'
|
|
? 'rounded-md bg-indigo-50 px-2.5 py-1 text-xs font-semibold text-indigo-700'
|
|
: 'rounded-md px-2.5 py-1 text-xs text-text-main hover:bg-canvas-deep'}
|
|
>
|
|
Dohvati sve putne naloge
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setWorkOrderScope('context')}
|
|
className={workOrderScope === 'context'
|
|
? 'rounded-md bg-indigo-50 px-2.5 py-1 text-xs font-semibold text-indigo-700'
|
|
: 'rounded-md px-2.5 py-1 text-xs text-text-main hover:bg-canvas-deep'}
|
|
>
|
|
Dohvati putne naloge prema servisnom kontekstu
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => triggerOfflineSync()}
|
|
className="text-sm font-medium text-amber-700 hover:text-amber-800"
|
|
>
|
|
Sinkroniziraj offline
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => fetchFleetDashboardData()}
|
|
className="text-sm font-medium text-indigo-600 hover:text-indigo-800"
|
|
>
|
|
Osvježi
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<AnimatedDataTable
|
|
columns={workOrderColumns}
|
|
rows={latestWorkOrders}
|
|
rowKey={(workOrder) => workOrder.id}
|
|
loading={loading}
|
|
loadingMessage="Učitavanje..."
|
|
emptyMessage={
|
|
workOrderScope === 'context'
|
|
? (!hydratedSelectedVehicleId
|
|
? 'Odaberite dizalicu za prikaz putnih naloga iz servisnog konteksta.'
|
|
: 'Nema dostupnih putnih naloga za odabrani servisni kontekst.')
|
|
: 'Nema dostupnih putnih naloga.'
|
|
}
|
|
renderRow={(workOrder) => (
|
|
<>
|
|
<td
|
|
className="cursor-pointer px-4 py-3 font-medium text-text-main"
|
|
onClick={() => openWorkOrderDetails(workOrder)}
|
|
>
|
|
{formatWorkOrderDisplayCode(workOrder)}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3"
|
|
>
|
|
{[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ') || workOrder.craneLabel || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`}
|
|
</td>
|
|
<td className="px-4 py-3 font-mono text-xs text-text-muted">
|
|
{workOrder.craneInfo?.crane_serial_number || '-'}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{formatDate(workOrder.date)}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3"
|
|
>
|
|
<AnimatedStatusBadge status={workOrder.status} />
|
|
</td>
|
|
<td
|
|
className="px-4 py-3"
|
|
>
|
|
{workOrder.creator || '-'}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setWorkOrderPhotoUpload({ open: true, workOrderId: workOrder.id });
|
|
}}
|
|
className="rounded px-2 py-1 text-xs font-medium text-text-muted hover:bg-canvas-deep hover:text-text-main"
|
|
title="Upload fotografija putnog naloga"
|
|
>
|
|
📷
|
|
</button>
|
|
</td>
|
|
</>
|
|
)}
|
|
/>
|
|
<div className="flex items-center justify-between border-t border-border-hairline px-4 py-3 text-sm">
|
|
<span className="text-text-muted">
|
|
Prikaz {scopedWorkOrders.length ? (currentPage - 1) * pageSize + 1 : 0}
|
|
-
|
|
{scopedWorkOrders.length ? Math.min(currentPage * pageSize, scopedWorkOrders.length) : 0} od {scopedWorkOrders.length}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
|
|
disabled={currentPage <= 1}
|
|
className="rounded border border-border-hairline px-2 py-1 text-text-main hover:bg-canvas-deep disabled:opacity-50"
|
|
>
|
|
Prethodna
|
|
</button>
|
|
<span className="text-text-muted">
|
|
{currentPage}/{totalPages}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
|
|
disabled={currentPage >= totalPages}
|
|
className="rounded border border-border-hairline px-2 py-1 text-text-main hover:bg-canvas-deep disabled:opacity-50"
|
|
>
|
|
Sljedeća
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
)}
|
|
|
|
{showServiceRecords && (
|
|
<article id="section-service-records" className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
|
|
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
|
|
<div>
|
|
<h3 className="font-semibold text-text-main">Servisni zapisi</h3>
|
|
<p className="text-xs text-text-muted">
|
|
{selectedContextClientName && contextCraneLabel
|
|
? `Kontekst: ${selectedContextClientName} / ${contextCraneLabel}`
|
|
: 'Odaberite kupca i dizalicu u "Servisni kontekst" traci.'}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={openTaskModalFromContext}
|
|
className="rounded-md bg-indigo-600 px-3 py-1 text-xs font-semibold text-white hover:bg-indigo-700"
|
|
>
|
|
+ Novi radni zadatak
|
|
</button>
|
|
{hydratedSelectedVehicleId && hydratedContextCrane && (
|
|
<ServiceRecordCreateButton onClick={() => openServiceRecordForCrane(hydratedContextCrane)} />
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => fetchFleetDashboardData()}
|
|
className="text-sm font-medium text-indigo-600 hover:text-indigo-800"
|
|
>
|
|
Osvježi
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{!hydratedSelectedVehicleId && (
|
|
<p className="px-4 py-6 text-center text-sm text-text-muted">
|
|
Odaberite dizalicu za prikaz servisnih zapisa.
|
|
</p>
|
|
)}
|
|
{hydratedSelectedVehicleId && loading && (
|
|
<p className="px-4 py-6 text-center text-sm text-text-muted">
|
|
Učitavanje...
|
|
</p>
|
|
)}
|
|
{hydratedSelectedVehicleId && !loading && serviceTaskGroups.length === 0 && (
|
|
<p className="px-4 py-6 text-center text-sm text-text-muted">
|
|
Nema grupiranih radnih zadataka za odabranu dizalicu.
|
|
</p>
|
|
)}
|
|
{hydratedSelectedVehicleId && !loading && serviceTaskGroups.length > 0 && (
|
|
<div className="divide-y divide-border-hairline">
|
|
{serviceTaskTransitions((style, group) => {
|
|
const isOpen = String(expandedServiceTaskId || '') === String(group.id);
|
|
return (
|
|
<animated.div key={group.id} style={style}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpandedServiceTaskId(isOpen ? null : group.id)}
|
|
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-canvas-deep"
|
|
>
|
|
<div>
|
|
<span className="font-medium text-text-main">{group.title}</span>
|
|
<span className="ml-2 text-xs text-text-muted">{group.status}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700">
|
|
{group.records.length} servisnih zapisa
|
|
</span>
|
|
<span className="text-text-muted">{isOpen ? '▲' : '▼'}</span>
|
|
</div>
|
|
</button>
|
|
{isOpen && (
|
|
<div className="border-t border-border-hairline bg-canvas-base/50 px-4 py-3">
|
|
{group.records.length === 0 ? (
|
|
<p className="text-sm text-text-muted">
|
|
Nema servisnih zapisa za odabrani radni zadatak.
|
|
</p>
|
|
) : (
|
|
<AnimatedDataTable
|
|
columns={[
|
|
{ key: 'date', label: 'Datum', 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' },
|
|
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
|
|
]}
|
|
rows={group.records}
|
|
rowKey={(record) => record.id}
|
|
rowClassName="hover:bg-canvas-deep"
|
|
renderRow={(record) => (
|
|
<>
|
|
{/* <pre className="text-xs bg-gray-100 p-2">
|
|
{JSON.stringify(group, null, 2)}
|
|
</pre> */}
|
|
<td className="px-4 py-3">{formatDate(record.service_date)}</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>
|
|
<td className="px-4 py-3">{formatCost(record.cost)}</td>
|
|
<td className="px-4 py-3">{record.files_count ?? (record.photos_count ?? 0)}</td>
|
|
<td className="space-x-2 px-4 py-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setServiceRecordBackTask(group.task);
|
|
setSelectedServiceRecord(record);
|
|
}}
|
|
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
|
>
|
|
Detalji
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPhotoUpload({ open: true, serviceRecordId: record.id })}
|
|
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
|
|
>
|
|
📎 Dodaj datoteke
|
|
</button>
|
|
</td>
|
|
</>
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</animated.div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</article>
|
|
)}
|
|
|
|
{showClients && (
|
|
<>
|
|
{/* ── Tablica klijenata i njihovih dizalica ───────── */}
|
|
<div id="section-clients">
|
|
<ClientsSection
|
|
onSetServiceContext={handleSetServiceContextForCraneId}
|
|
currentSelectedVehicleId={selectedVehicleId}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{showVehicles && (
|
|
<article id="section-cranes" className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
|
|
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
|
|
<h3 className="font-semibold text-text-main">Vozila i dizalice</h3>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={handleSetServiceContext}
|
|
className="btn-erp rounded-md bg-emerald-600 px-3 py-1 text-xs font-semibold normal-case tracking-normal text-white hover:bg-emerald-700"
|
|
>
|
|
Postavi servisni kontekst
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => fetchFleetDashboardData()}
|
|
className="text-sm font-medium text-indigo-600 hover:text-indigo-800"
|
|
>
|
|
Osvježi
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<AnimatedDataTable
|
|
columns={vehicleColumns}
|
|
rows={cranes}
|
|
rowKey={(vehicle) => vehicle.id}
|
|
rowClassName="hover:bg-canvas-deep"
|
|
isRowSelected={(vehicle) => String(vehicle.id) === String(selectedCraneRowId)}
|
|
loading={loading}
|
|
loadingMessage="Učitavanje vozila..."
|
|
emptyMessage="Nema dostupnih vozila."
|
|
renderRow={(vehicle) => (
|
|
<>
|
|
<td
|
|
className="px-4 py-3 font-medium text-text-main"
|
|
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
|
>
|
|
{vehicle.registration_number || '-'}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3"
|
|
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
|
>
|
|
{vehicle.crane_serial_number || '-'}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3"
|
|
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
|
>
|
|
{vehicle.make || '-'} {vehicle.model || ''}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3"
|
|
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
|
>
|
|
{vehicle.client_name || '-'}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3"
|
|
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
|
>
|
|
{vehicle.current_mileage ?? '-'}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3"
|
|
onClick={() => setSelectedCraneRowId(vehicle.id)}
|
|
>
|
|
<span className={vehicle.is_active ? 'text-emerald-700' : 'text-red-700'}>
|
|
{vehicle.is_active ? 'Aktivno' : 'Neaktivno'}
|
|
</span>
|
|
</td>
|
|
</>
|
|
)}
|
|
/>
|
|
</article>
|
|
)}
|
|
</section>
|
|
</main>
|
|
|
|
<WorkOrderModal
|
|
open={isWorkOrderModalOpen}
|
|
selectedCrane={contextCrane}
|
|
onClose={() => setIsWorkOrderModalOpen(false)}
|
|
onSubmit={handleCreateWorkOrder}
|
|
/>
|
|
|
|
<TaskCreateModal
|
|
open={isTaskModalOpen}
|
|
workOrders={serviceRecordWorkOrders}
|
|
contextCrane={contextCrane}
|
|
onClose={() => setIsTaskModalOpen(false)}
|
|
onSuccess={() => fetchFleetDashboardData({ silent: true })}
|
|
/>
|
|
|
|
<WorkOrderDetailModal
|
|
open={!!selectedWorkOrder}
|
|
workOrder={selectedWorkOrder}
|
|
onClose={() => setSelectedWorkOrder(null)}
|
|
onSubmit={handleUpdateWorkOrder}
|
|
/>
|
|
|
|
{/* Modali su na root razini div-a — van <main> i <section> —
|
|
kako bi fixed pozicioniranje i z-index radili ispravno */}
|
|
<ServiceRecordForm
|
|
open={serviceRecordOpen}
|
|
assignedCrane={serviceRecordCrane ?? contextCrane}
|
|
availableTasks={serviceRecordTasks}
|
|
onClose={() => {
|
|
setServiceRecordOpen(false);
|
|
setServiceRecordCrane(null);
|
|
setServiceRecordFormBackTask(null);
|
|
}}
|
|
onSuccess={() => fetchFleetDashboardData({ silent: true })}
|
|
onBack={serviceRecordFormBackTask
|
|
? () => {
|
|
setServiceRecordOpen(false);
|
|
setServiceRecordCrane(null);
|
|
setSelectedTask(serviceRecordFormBackTask);
|
|
setServiceRecordFormBackTask(null);
|
|
}
|
|
: null}
|
|
backLabel="Natrag na radni zadatak"
|
|
/>
|
|
|
|
<ServicePhotoUpload
|
|
open={photoUpload.open}
|
|
serviceRecordId={photoUpload.serviceRecordId}
|
|
onClose={() => setPhotoUpload({ open: false, serviceRecordId: null })}
|
|
onSuccess={() => fetchFleetDashboardData({ silent: true })}
|
|
/>
|
|
|
|
<WorkOrderPhotoUpload
|
|
open={workOrderPhotoUpload.open}
|
|
workOrderId={workOrderPhotoUpload.workOrderId}
|
|
onClose={() => setWorkOrderPhotoUpload({ open: false, workOrderId: null })}
|
|
onSuccess={() => fetchFleetDashboardData({ silent: true })}
|
|
/>
|
|
|
|
<ServiceRecordDetailModal
|
|
open={!!selectedServiceRecord}
|
|
serviceRecord={selectedServiceRecord}
|
|
onClose={() => {
|
|
setSelectedServiceRecord(null);
|
|
setServiceRecordBackTask(null);
|
|
}}
|
|
onUpdated={(updatedRecord) => {
|
|
setSelectedServiceRecord(updatedRecord);
|
|
fetchFleetDashboardData({ silent: true });
|
|
}}
|
|
onBack={serviceRecordBackTask
|
|
? () => {
|
|
setSelectedServiceRecord(null);
|
|
setSelectedTask(serviceRecordBackTask);
|
|
}
|
|
: null}
|
|
backLabel="Natrag na popis servisnih zapisa"
|
|
/>
|
|
|
|
<TaskServiceRecordsModal
|
|
open={!!selectedTask}
|
|
task={selectedTask}
|
|
records={selectedTaskServiceRecords}
|
|
workOrders={selectedTaskWorkOrders}
|
|
onClose={() => setSelectedTask(null)}
|
|
onOpenServiceRecord={(record) => {
|
|
setServiceRecordBackTask(selectedTask);
|
|
setSelectedTask(null);
|
|
setSelectedServiceRecord(record);
|
|
}}
|
|
onAssignWorkOrder={async (task, workOrderId) => {
|
|
const updatedTask = await updateTask(task.id, { work_order: workOrderId });
|
|
setSelectedTask(updatedTask);
|
|
return updatedTask;
|
|
}}
|
|
onUpdateTask={async (task, changes) => {
|
|
const updatedTask = await updateTask(task.id, changes);
|
|
setSelectedTask(updatedTask);
|
|
await fetchFleetDashboardData({ silent: true });
|
|
return updatedTask;
|
|
}}
|
|
onCreateServiceRecord={(task) => {
|
|
const taskCrane = cranes.find((item) => String(item.id) === String(task?.vehicle));
|
|
setSelectedTask(null);
|
|
setServiceRecordFormBackTask(task);
|
|
setServiceRecordCrane(taskCrane ?? null);
|
|
setServiceRecordOpen(true);
|
|
}}
|
|
/>
|
|
</AnimatedPage>
|
|
);
|
|
}
|