diff --git a/backend/modules/fleet/tests/test_work_order_images_endpoint.py b/backend/modules/fleet/tests/test_work_order_images_endpoint.py index 9a750cb..4f7b652 100644 --- a/backend/modules/fleet/tests/test_work_order_images_endpoint.py +++ b/backend/modules/fleet/tests/test_work_order_images_endpoint.py @@ -499,6 +499,10 @@ class WorkOrderImagesEndpointTests(TestCase): notifications = VehicleNotification.objects.filter(recipient=self.user).order_by('created_at') self.assertTrue(notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='requested').exists()) self.assertTrue(notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='completed').exists()) + requested_notification = notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='requested').last() + completed_notification = notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='completed').last() + self.assertEqual(str(requested_notification.metadata.get('generated_archive_id')), str(generated.pk)) + self.assertEqual(str(completed_notification.metadata.get('generated_archive_id')), str(generated.pk)) def test_monthly_service_tasks_archive_request_ignores_stale_cached_archive(self): stale = GeneratedFleetArchive.objects.create( @@ -604,3 +608,10 @@ class WorkOrderImagesEndpointTests(TestCase): metadata__stage='completed', ).exists() ) + requested_notification = VehicleNotification.objects.filter( + recipient=self.user, + metadata__entity_type='fleet_archive', + metadata__archive_type='work_orders', + metadata__stage='requested', + ).order_by('created_at').last() + self.assertEqual(str(requested_notification.metadata.get('generated_archive_id')), str(generated.pk)) diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index 6333f65..2ceb88e 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -3080,6 +3080,7 @@ def _request_monthly_archive_generation(*, request, archive_type): stage='requested', year=year, month=month, + generated_archive=generated_archive, ) try: diff --git a/frontend/src/components/dashboard/FleetDashboardShell.jsx b/frontend/src/components/dashboard/FleetDashboardShell.jsx index 1ad76a1..a45bc9b 100644 --- a/frontend/src/components/dashboard/FleetDashboardShell.jsx +++ b/frontend/src/components/dashboard/FleetDashboardShell.jsx @@ -165,6 +165,7 @@ export default function FleetDashboardShell({ pageMode = 'dashboard' }) { const selectedClientId = useStore($selectedClientId); const selectedVehicleId = useStore($selectedVehicleId); const [currentPage, setCurrentPage] = useState(1); + const [serviceRecordsPage, setServiceRecordsPage] = useState(1); const [taskFilter, setTaskFilter] = useState('active'); const [taskScope, setTaskScope] = useState('all'); const [supervisorServicers, setSupervisorServicers] = useState([]); @@ -361,8 +362,16 @@ export default function FleetDashboardShell({ pageMode = 'dashboard' }) { const recentServiceRecords = useMemo(() => ( [...serviceRecords] .sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || ''))) - .slice(0, 10) ), [serviceRecords]); + const serviceRecordsPageSize = 10; + const serviceRecordsTotalPages = Math.max(1, Math.ceil(recentServiceRecords.length / serviceRecordsPageSize)); + const paginatedRecentServiceRecords = useMemo( + () => recentServiceRecords.slice( + (serviceRecordsPage - 1) * serviceRecordsPageSize, + serviceRecordsPage * serviceRecordsPageSize + ), + [recentServiceRecords, serviceRecordsPage] + ); const serviceRecordsForContext = useMemo(() => { if (!selectedVehicleId) return []; return [...serviceRecords] @@ -606,10 +615,20 @@ export default function FleetDashboardShell({ pageMode = 'dashboard' }) { } }, [currentPage, totalPages]); + useEffect(() => { + if (serviceRecordsPage > serviceRecordsTotalPages) { + setServiceRecordsPage(serviceRecordsTotalPages); + } + }, [serviceRecordsPage, serviceRecordsTotalPages]); + useEffect(() => { setCurrentPage(1); }, [workOrderScope, hydratedSelectedVehicleId]); + useEffect(() => { + setServiceRecordsPage(1); + }, [hydratedSelectedVehicleId]); + useEffect(() => { if (!expandedServiceTaskId) return; const exists = serviceTaskGroups.some((group) => String(group.id) === String(expandedServiceTaskId)); @@ -1219,45 +1238,75 @@ export default function FleetDashboardShell({ pageMode = 'dashboard' }) {

Odaberite dizalicu u servisnom kontekstu za prikaz zapisa po radnim zadacima. - Ispod su prikazani zadnji servisni zapisi. + Ispod su prikazani svi servisni zapisi.

{recentServiceRecords.length === 0 ? (

Nema servisnih zapisa.

) : ( - r.id} - rowClassName="hover:bg-canvas-deep" - renderRow={(record) => ( - <> - {formatDate(record.service_date)} - {formatServiceRecordCraneLabel(record, cranes)} - - {record.description || '-'} - - {formatCost(record.cost)} - - - - - )} - /> + <> + r.id} + rowClassName="hover:bg-canvas-deep" + renderRow={(record) => ( + <> + {formatDate(record.service_date)} + {formatServiceRecordCraneLabel(record, cranes)} + + {record.description || '-'} + + {formatCost(record.cost)} + + + + + )} + /> +
+ + Prikaz {recentServiceRecords.length ? (serviceRecordsPage - 1) * serviceRecordsPageSize + 1 : 0} + - + {recentServiceRecords.length ? Math.min(serviceRecordsPage * serviceRecordsPageSize, recentServiceRecords.length) : 0} od {recentServiceRecords.length} + +
+ + + {serviceRecordsPage}/{serviceRecordsTotalPages} + + +
+
+ )}
)} diff --git a/frontend/src/components/dashboard/TaskCalendarWidget.jsx b/frontend/src/components/dashboard/TaskCalendarWidget.jsx index 294eb4a..8cfcb36 100644 --- a/frontend/src/components/dashboard/TaskCalendarWidget.jsx +++ b/frontend/src/components/dashboard/TaskCalendarWidget.jsx @@ -367,10 +367,10 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders async function handleDownloadAllTasksArchive() { if (downloadingAllTasks) return; + setBulkDownloadOpen(false); setDownloadingAllTasks(true); try { await downloadMonthlyServiceTasksArchive(viewYear, viewMonth + 1); - setBulkDownloadOpen(false); } finally { setDownloadingAllTasks(false); } @@ -378,10 +378,10 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders async function handleDownloadAllWorkOrdersArchive() { if (downloadingAllWorkOrders) return; + setBulkDownloadOpen(false); setDownloadingAllWorkOrders(true); try { await downloadMonthlyWorkOrdersArchive(viewYear, viewMonth + 1); - setBulkDownloadOpen(false); } finally { setDownloadingAllWorkOrders(false); } @@ -655,23 +655,25 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders {MONTH_NAMES[viewMonth]} {viewYear} - - {reportType === 'servicer' && ( +
- )} + {reportType === 'servicer' && ( + + )} +

diff --git a/frontend/src/stores/fleetDashboardStore.js b/frontend/src/stores/fleetDashboardStore.js index dcdfde0..668c3cc 100644 --- a/frontend/src/stores/fleetDashboardStore.js +++ b/frontend/src/stores/fleetDashboardStore.js @@ -28,6 +28,9 @@ const DASHBOARD_FETCH_TTL_MS = 30_000; // 30 sekundi let dbPromise = null; let syncListenerStarted = false; let isSyncInProgress = false; +const archiveNotificationPollers = new Map(); +const ARCHIVE_NOTIFICATION_POLL_INTERVAL_MS = 10_000; +const ARCHIVE_NOTIFICATION_POLL_TIMEOUT_MS = 15 * 60 * 1000; function isBrowser() { return typeof window !== 'undefined'; @@ -831,11 +834,68 @@ function _schedulePdfNotificationPolling() { }); } -function _scheduleArchiveNotificationPolling() { +function _findCompletedArchiveNotification(notifications, generatedArchiveId) { + if (!generatedArchiveId || !Array.isArray(notifications)) { + return null; + } + return notifications.find((notification) => { + const metadata = notification?.metadata || {}; + return metadata.entity_type === 'fleet_archive' + && String(metadata.generated_archive_id || '') === String(generatedArchiveId) + && ['completed', 'failed'].includes(String(metadata.stage || '')); + }) || null; +} + +function _clearArchiveNotificationPoller(generatedArchiveId) { + const key = String(generatedArchiveId || ''); + const handles = archiveNotificationPollers.get(key); + if (!handles) { + return; + } + window.clearInterval(handles.intervalId); + window.clearTimeout(handles.timeoutId); + archiveNotificationPollers.delete(key); +} + +function _scheduleArchiveNotificationPolling(generatedArchiveId = null) { if (!isBrowser()) return; - [5000, 20000, 60000].forEach((delay) => { - setTimeout(() => _refreshNotificationsAsync(), delay); - }); + if (!generatedArchiveId) { + [5000, 20000, 60000].forEach((delay) => { + setTimeout(() => _refreshNotificationsAsync(), delay); + }); + return; + } + + const key = String(generatedArchiveId); + if (archiveNotificationPollers.has(key)) { + return; + } + + const pollOnce = async () => { + try { + const notificationModule = await import('./notificationStore.js'); + await notificationModule.fetchNotifications(); + const resolvedNotification = _findCompletedArchiveNotification( + notificationModule.$notifications.get(), + key + ); + if (resolvedNotification) { + _clearArchiveNotificationPoller(key); + } + } catch (_) { + // silent — korisnik će i dalje vidjeti toast ili ručno osvježiti notifikacije + } + }; + + const intervalId = window.setInterval(() => { + void pollOnce(); + }, ARCHIVE_NOTIFICATION_POLL_INTERVAL_MS); + const timeoutId = window.setTimeout(() => { + _clearArchiveNotificationPoller(key); + }, ARCHIVE_NOTIFICATION_POLL_TIMEOUT_MS); + + archiveNotificationPollers.set(key, { intervalId, timeoutId }); + void pollOnce(); } export async function downloadWorkOrderPdf(workOrderId) { @@ -1030,13 +1090,13 @@ export async function downloadMonthlyCostsReport(year, month) { export async function downloadMonthlyServiceTasksArchive(year, month) { try { const payload = await api.post('fleet/reports/monthly-service-tasks-archive-request/', { year, month }); + await _refreshNotificationsAsync(); if (payload?.status === 'ready' && payload?.download_url) { showToast('ZIP arhiva servisnih taskova je spremna. Preuzmite je kroz notifikaciju.', 'success'); - _refreshNotificationsAsync(); return payload; } showToast('ZIP arhiva servisnih taskova se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info'); - _scheduleArchiveNotificationPolling(); + _scheduleArchiveNotificationPolling(payload?.generated_archive_id || null); return payload; } catch (err) { showToast(err?.message || 'Pokretanje generiranja ZIP arhive servisnih taskova nije uspjelo.', 'error'); @@ -1047,13 +1107,13 @@ export async function downloadMonthlyServiceTasksArchive(year, month) { export async function downloadMonthlyWorkOrdersArchive(year, month) { try { const payload = await api.post('fleet/reports/monthly-work-orders-archive-request/', { year, month }); + await _refreshNotificationsAsync(); if (payload?.status === 'ready' && payload?.download_url) { showToast('ZIP arhiva putnih naloga je spremna. Preuzmite je kroz notifikaciju.', 'success'); - _refreshNotificationsAsync(); return payload; } showToast('ZIP arhiva putnih naloga se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info'); - _scheduleArchiveNotificationPolling(); + _scheduleArchiveNotificationPolling(payload?.generated_archive_id || null); return payload; } catch (err) { showToast(err?.message || 'Pokretanje generiranja ZIP arhive putnih naloga nije uspjelo.', 'error');