feat: calendar ZIP UX grouping, archive notification polling, service records pagination
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled

- Group 'Preuzmi mjesečni izvještaj' and 'Preuzmi ZIP' buttons together in the calendar toolbar
- Close the ZIP modal immediately on action click so the user is no longer blocked in the sidebar
- Backend: include generated_archive_id in the 'requested' notification so the frontend can track the specific archive lifecycle
- Frontend: refresh notifications immediately after archive request and start targeted polling (every 10s) until a completed/failed notification arrives for that archive ID
- Add previous/next pagination to the service records table (page mode) when no service context is selected, showing all records 10 per page (matching the work orders table pattern)
- Tests: assert generated_archive_id is present in both requested and completed notifications

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-08-07 06:08:53 +02:00
parent cb165df30c
commit bf381dcf13
5 changed files with 182 additions and 59 deletions

View File

@@ -499,6 +499,10 @@ class WorkOrderImagesEndpointTests(TestCase):
notifications = VehicleNotification.objects.filter(recipient=self.user).order_by('created_at') 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='requested').exists())
self.assertTrue(notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='completed').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): def test_monthly_service_tasks_archive_request_ignores_stale_cached_archive(self):
stale = GeneratedFleetArchive.objects.create( stale = GeneratedFleetArchive.objects.create(
@@ -604,3 +608,10 @@ class WorkOrderImagesEndpointTests(TestCase):
metadata__stage='completed', metadata__stage='completed',
).exists() ).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))

View File

@@ -3080,6 +3080,7 @@ def _request_monthly_archive_generation(*, request, archive_type):
stage='requested', stage='requested',
year=year, year=year,
month=month, month=month,
generated_archive=generated_archive,
) )
try: try:

View File

@@ -165,6 +165,7 @@ export default function FleetDashboardShell({ pageMode = 'dashboard' }) {
const selectedClientId = useStore($selectedClientId); const selectedClientId = useStore($selectedClientId);
const selectedVehicleId = useStore($selectedVehicleId); const selectedVehicleId = useStore($selectedVehicleId);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [serviceRecordsPage, setServiceRecordsPage] = useState(1);
const [taskFilter, setTaskFilter] = useState('active'); const [taskFilter, setTaskFilter] = useState('active');
const [taskScope, setTaskScope] = useState('all'); const [taskScope, setTaskScope] = useState('all');
const [supervisorServicers, setSupervisorServicers] = useState([]); const [supervisorServicers, setSupervisorServicers] = useState([]);
@@ -361,8 +362,16 @@ export default function FleetDashboardShell({ pageMode = 'dashboard' }) {
const recentServiceRecords = useMemo(() => ( const recentServiceRecords = useMemo(() => (
[...serviceRecords] [...serviceRecords]
.sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || ''))) .sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || '')))
.slice(0, 10)
), [serviceRecords]); ), [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(() => { const serviceRecordsForContext = useMemo(() => {
if (!selectedVehicleId) return []; if (!selectedVehicleId) return [];
return [...serviceRecords] return [...serviceRecords]
@@ -606,10 +615,20 @@ export default function FleetDashboardShell({ pageMode = 'dashboard' }) {
} }
}, [currentPage, totalPages]); }, [currentPage, totalPages]);
useEffect(() => {
if (serviceRecordsPage > serviceRecordsTotalPages) {
setServiceRecordsPage(serviceRecordsTotalPages);
}
}, [serviceRecordsPage, serviceRecordsTotalPages]);
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
}, [workOrderScope, hydratedSelectedVehicleId]); }, [workOrderScope, hydratedSelectedVehicleId]);
useEffect(() => {
setServiceRecordsPage(1);
}, [hydratedSelectedVehicleId]);
useEffect(() => { useEffect(() => {
if (!expandedServiceTaskId) return; if (!expandedServiceTaskId) return;
const exists = serviceTaskGroups.some((group) => String(group.id) === String(expandedServiceTaskId)); const exists = serviceTaskGroups.some((group) => String(group.id) === String(expandedServiceTaskId));
@@ -1219,45 +1238,75 @@ export default function FleetDashboardShell({ pageMode = 'dashboard' }) {
<div> <div>
<p className="border-b border-border-hairline px-4 py-3 text-xs text-text-muted"> <p className="border-b border-border-hairline px-4 py-3 text-xs text-text-muted">
Odaberite dizalicu u servisnom kontekstu za prikaz zapisa po radnim zadacima. Odaberite dizalicu u servisnom kontekstu za prikaz zapisa po radnim zadacima.
Ispod su prikazani zadnji servisni zapisi. Ispod su prikazani svi servisni zapisi.
</p> </p>
{recentServiceRecords.length === 0 ? ( {recentServiceRecords.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-text-muted">Nema servisnih zapisa.</p> <p className="px-4 py-6 text-center text-sm text-text-muted">Nema servisnih zapisa.</p>
) : ( ) : (
<AnimatedDataTable <>
columns={[ <AnimatedDataTable
{ key: 'date', label: 'Datum', className: 'px-4 py-3' }, columns={[
{ key: 'crane', label: 'Dizalica', className: 'px-4 py-3' }, { key: 'date', label: 'Datum', className: 'px-4 py-3' },
{ key: 'description', label: 'Opis', className: 'px-4 py-3' }, { key: 'crane', label: 'Dizalica', className: 'px-4 py-3' },
{ key: 'cost', label: 'Trošak', className: 'px-4 py-3' }, { key: 'description', label: 'Opis', className: 'px-4 py-3' },
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' }, { key: 'cost', label: 'Trošak', className: 'px-4 py-3' },
]} { key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
rows={recentServiceRecords} ]}
rowKey={(r) => r.id} rows={paginatedRecentServiceRecords}
rowClassName="hover:bg-canvas-deep" rowKey={(r) => r.id}
renderRow={(record) => ( rowClassName="hover:bg-canvas-deep"
<> renderRow={(record) => (
<td className="px-4 py-3">{formatDate(record.service_date)}</td> <>
<td className="px-4 py-3">{formatServiceRecordCraneLabel(record, cranes)}</td> <td className="px-4 py-3">{formatDate(record.service_date)}</td>
<td className="max-w-xs truncate px-4 py-3" title={record.description || ''}> <td className="px-4 py-3">{formatServiceRecordCraneLabel(record, cranes)}</td>
{record.description || '-'} <td className="max-w-xs truncate px-4 py-3" title={record.description || ''}>
</td> {record.description || '-'}
<td className="px-4 py-3">{formatCost(record.cost)}</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">{formatCost(record.cost)}</td>
<button <td className="px-4 py-3">
type="button" <button
onClick={() => { type="button"
setServiceRecordBackTask(null); onClick={() => {
setSelectedServiceRecord(record); setServiceRecordBackTask(null);
}} setSelectedServiceRecord(record);
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base" }}
> className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
Detalji >
</button> Detalji
</td> </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 {recentServiceRecords.length ? (serviceRecordsPage - 1) * serviceRecordsPageSize + 1 : 0}
-
{recentServiceRecords.length ? Math.min(serviceRecordsPage * serviceRecordsPageSize, recentServiceRecords.length) : 0} od {recentServiceRecords.length}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setServiceRecordsPage((page) => Math.max(1, page - 1))}
disabled={serviceRecordsPage <= 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">
{serviceRecordsPage}/{serviceRecordsTotalPages}
</span>
<button
type="button"
onClick={() => setServiceRecordsPage((page) => Math.min(serviceRecordsTotalPages, page + 1))}
disabled={serviceRecordsPage >= serviceRecordsTotalPages}
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>
</>
)} )}
</div> </div>
)} )}

View File

@@ -367,10 +367,10 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
async function handleDownloadAllTasksArchive() { async function handleDownloadAllTasksArchive() {
if (downloadingAllTasks) return; if (downloadingAllTasks) return;
setBulkDownloadOpen(false);
setDownloadingAllTasks(true); setDownloadingAllTasks(true);
try { try {
await downloadMonthlyServiceTasksArchive(viewYear, viewMonth + 1); await downloadMonthlyServiceTasksArchive(viewYear, viewMonth + 1);
setBulkDownloadOpen(false);
} finally { } finally {
setDownloadingAllTasks(false); setDownloadingAllTasks(false);
} }
@@ -378,10 +378,10 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
async function handleDownloadAllWorkOrdersArchive() { async function handleDownloadAllWorkOrdersArchive() {
if (downloadingAllWorkOrders) return; if (downloadingAllWorkOrders) return;
setBulkDownloadOpen(false);
setDownloadingAllWorkOrders(true); setDownloadingAllWorkOrders(true);
try { try {
await downloadMonthlyWorkOrdersArchive(viewYear, viewMonth + 1); await downloadMonthlyWorkOrdersArchive(viewYear, viewMonth + 1);
setBulkDownloadOpen(false);
} finally { } finally {
setDownloadingAllWorkOrders(false); setDownloadingAllWorkOrders(false);
} }
@@ -655,23 +655,25 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
{MONTH_NAMES[viewMonth]} {viewYear} {MONTH_NAMES[viewMonth]} {viewYear}
</span> </span>
</div> </div>
<button <div className="flex items-center gap-2">
type="button"
onClick={handleDownload}
disabled={downloading}
className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-60"
>
{downloading ? 'Preuzimanje...' : reportType === 'servicer' ? 'Preuzmi mjesečni izvještaj' : 'Preuzmi DOCX'}
</button>
{reportType === 'servicer' && (
<button <button
type="button" type="button"
onClick={() => setBulkDownloadOpen(true)} onClick={handleDownload}
className="ml-2 rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep" disabled={downloading}
className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-60"
> >
Preuzmi ZIP {downloading ? 'Preuzimanje...' : reportType === 'servicer' ? 'Preuzmi mjesečni izvještaj' : 'Preuzmi DOCX'}
</button> </button>
)} {reportType === 'servicer' && (
<button
type="button"
onClick={() => setBulkDownloadOpen(true)}
className="rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Preuzmi ZIP
</button>
)}
</div>
</div> </div>
<p className="border-b border-border-hairline bg-canvas-deep px-4 py-1.5 text-[11px] text-text-muted"> <p className="border-b border-border-hairline bg-canvas-deep px-4 py-1.5 text-[11px] text-text-muted">

View File

@@ -28,6 +28,9 @@ const DASHBOARD_FETCH_TTL_MS = 30_000; // 30 sekundi
let dbPromise = null; let dbPromise = null;
let syncListenerStarted = false; let syncListenerStarted = false;
let isSyncInProgress = 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() { function isBrowser() {
return typeof window !== 'undefined'; 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; if (!isBrowser()) return;
[5000, 20000, 60000].forEach((delay) => { if (!generatedArchiveId) {
setTimeout(() => _refreshNotificationsAsync(), delay); [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) { export async function downloadWorkOrderPdf(workOrderId) {
@@ -1030,13 +1090,13 @@ export async function downloadMonthlyCostsReport(year, month) {
export async function downloadMonthlyServiceTasksArchive(year, month) { export async function downloadMonthlyServiceTasksArchive(year, month) {
try { try {
const payload = await api.post('fleet/reports/monthly-service-tasks-archive-request/', { year, month }); const payload = await api.post('fleet/reports/monthly-service-tasks-archive-request/', { year, month });
await _refreshNotificationsAsync();
if (payload?.status === 'ready' && payload?.download_url) { if (payload?.status === 'ready' && payload?.download_url) {
showToast('ZIP arhiva servisnih taskova je spremna. Preuzmite je kroz notifikaciju.', 'success'); showToast('ZIP arhiva servisnih taskova je spremna. Preuzmite je kroz notifikaciju.', 'success');
_refreshNotificationsAsync();
return payload; return payload;
} }
showToast('ZIP arhiva servisnih taskova se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info'); showToast('ZIP arhiva servisnih taskova se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
_scheduleArchiveNotificationPolling(); _scheduleArchiveNotificationPolling(payload?.generated_archive_id || null);
return payload; return payload;
} catch (err) { } catch (err) {
showToast(err?.message || 'Pokretanje generiranja ZIP arhive servisnih taskova nije uspjelo.', 'error'); 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) { export async function downloadMonthlyWorkOrdersArchive(year, month) {
try { try {
const payload = await api.post('fleet/reports/monthly-work-orders-archive-request/', { year, month }); const payload = await api.post('fleet/reports/monthly-work-orders-archive-request/', { year, month });
await _refreshNotificationsAsync();
if (payload?.status === 'ready' && payload?.download_url) { if (payload?.status === 'ready' && payload?.download_url) {
showToast('ZIP arhiva putnih naloga je spremna. Preuzmite je kroz notifikaciju.', 'success'); showToast('ZIP arhiva putnih naloga je spremna. Preuzmite je kroz notifikaciju.', 'success');
_refreshNotificationsAsync();
return payload; return payload;
} }
showToast('ZIP arhiva putnih naloga se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info'); showToast('ZIP arhiva putnih naloga se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
_scheduleArchiveNotificationPolling(); _scheduleArchiveNotificationPolling(payload?.generated_archive_id || null);
return payload; return payload;
} catch (err) { } catch (err) {
showToast(err?.message || 'Pokretanje generiranja ZIP arhive putnih naloga nije uspjelo.', 'error'); showToast(err?.message || 'Pokretanje generiranja ZIP arhive putnih naloga nije uspjelo.', 'error');