Files
004.erp.generic/frontend/src/components/dashboard/FleetDashboardShell.jsx
mariomitte 0c9e1b7d50 feat: dodaj scheduled_date na Task i kalendar widget
- 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>
2026-07-19 14:52:10 +02:00

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>
);
}