feat: add async monthly ZIP generation with notifications
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

Implement background generation for monthly SN/PN ZIP archives and expose completion through in-app notifications.

- add GeneratedFleetArchive persistence model with expiry metadata
- add archive request/download endpoints and Celery background tasks
- emit notification stages for requested/completed/failed archive jobs
- update calendar bulk download actions to trigger async requests
- add notification modal actions to download generated ZIP files
- extend backend tests for async archive request and download flows

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-08-06 10:59:19 +02:00
parent 594881f6cd
commit 456c9e4c3e
8 changed files with 693 additions and 100 deletions

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
import {
downloadGeneratedArchiveByUrl,
downloadGeneratedPdfByUrl,
downloadWorkOrderInvoicesPdf,
downloadWorkOrderPdf,
@@ -21,6 +22,7 @@ function formatTimestamp(value) {
}
function detectNotificationKind(notification) {
if (notification?.metadata?.entity_type === 'fleet_archive') return 'fleet_archive';
const title = String(notification?.title || '').toLowerCase();
if (title.includes('servisni kontekst')) return 'service_context';
if (title.includes('pdf')) return 'work_order_pdf';
@@ -79,6 +81,28 @@ function getEntityAction(notification, meta) {
},
};
}
if (metadata.entity_type === 'fleet_archive') {
const archiveType = metadata.archive_type === 'work_orders' ? 'work_order' : 'service_tasks';
if (metadata.stage === 'completed' && metadata.download_url) {
return {
label: archiveType === 'work_order'
? 'Preuzmi ZIP putnih naloga + računa'
: 'Preuzmi ZIP servisnih taskova',
run() {
return downloadGeneratedArchiveByUrl(
metadata.download_url,
metadata.filename || `MT-${String(metadata.month || '').padStart(2, '0')}-${metadata.year || ''}-${archiveType === 'work_order' ? 'PN' : 'SN'}.zip`,
);
},
};
}
return {
label: 'Otvori servisne zapise',
run() {
window.dispatchEvent(new CustomEvent('navbar:navigate', { detail: 'service-records' }));
},
};
}
if (metadata.entity_type === 'work_order' && metadata.work_order_id) {
return {
label: 'Otvori detalje naloga',
@@ -125,6 +149,13 @@ function getNotificationTypeMeta(kind) {
recommendation: 'Možete odmah preuzeti generirani PDF dokument.',
};
}
if (kind === 'fleet_archive') {
return {
category: 'ZIP arhive',
section: 'service-records',
recommendation: 'Pričekajte završetak generiranja, zatim preuzmite ZIP iz notifikacije.',
};
}
if (kind.startsWith('work_order')) {
return {
category: 'Putni nalozi',
@@ -168,6 +199,11 @@ export default function NotificationDetailModal({ open, notification, onClose })
notification?.metadata?.stage === 'completed' &&
notification?.metadata?.work_order_id
);
const showArchiveLink = Boolean(
notification?.metadata?.entity_type === 'fleet_archive' &&
notification?.metadata?.stage === 'completed' &&
notification?.metadata?.download_url
);
const failedEmailCachedPdfs = Array.isArray(notification?.metadata?.cached_pdfs)
? notification.metadata.cached_pdfs
: [];
@@ -212,7 +248,7 @@ export default function NotificationDetailModal({ open, notification, onClose })
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<p className="text-sm font-semibold text-text-main">{notification.title || 'Obavijest'}</p>
<p className="mt-2 text-sm text-text-muted">{notification.message || '-'}</p>
{showPdfLink && (
{(showPdfLink || showArchiveLink) && (
<button
type="button"
onClick={() => action.run()}

View File

@@ -831,6 +831,13 @@ function _schedulePdfNotificationPolling() {
});
}
function _scheduleArchiveNotificationPolling() {
if (!isBrowser()) return;
[5000, 20000, 60000].forEach((delay) => {
setTimeout(() => _refreshNotificationsAsync(), delay);
});
}
export async function downloadWorkOrderPdf(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
@@ -1022,28 +1029,34 @@ export async function downloadMonthlyCostsReport(year, month) {
export async function downloadMonthlyServiceTasksArchive(year, month) {
try {
const blob = await api.get(
`fleet/reports/monthly-service-tasks-archive/?year=${year}&month=${month}`,
{ responseType: 'blob' },
);
saveBlobToFile(blob, `MT-${String(month).padStart(2, '0')}-${year}-SN.zip`);
showToast('ZIP pojedinačnih servisnih taskova je preuzet.', 'success');
const payload = await api.post('fleet/reports/monthly-service-tasks-archive-request/', { year, month });
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();
return payload;
} catch (err) {
showToast(err?.message || 'Preuzimanje ZIP-a servisnih taskova nije uspjelo.', 'error');
showToast(err?.message || 'Pokretanje generiranja ZIP arhive servisnih taskova nije uspjelo.', 'error');
throw err;
}
}
export async function downloadMonthlyWorkOrdersArchive(year, month) {
try {
const blob = await api.get(
`fleet/reports/monthly-work-orders-archive/?year=${year}&month=${month}`,
{ responseType: 'blob' },
);
saveBlobToFile(blob, `MT-${String(month).padStart(2, '0')}-${year}-PN.zip`);
showToast('ZIP putnih naloga i računa je preuzet.', 'success');
const payload = await api.post('fleet/reports/monthly-work-orders-archive-request/', { year, month });
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();
return payload;
} catch (err) {
showToast(err?.message || 'Preuzimanje ZIP-a putnih naloga nije uspjelo.', 'error');
showToast(err?.message || 'Pokretanje generiranja ZIP arhive putnih naloga nije uspjelo.', 'error');
throw err;
}
}
@@ -1064,6 +1077,22 @@ export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'documen
}
}
export async function downloadGeneratedArchiveByUrl(downloadUrl, filename = 'archive.zip') {
if (!downloadUrl) {
showToast('Nedostaje URL za preuzimanje ZIP arhive.', 'error');
throw new Error('Nedostaje URL za preuzimanje ZIP arhive.');
}
try {
const blob = await api.get(downloadUrl, { responseType: 'blob' });
saveBlobToFile(blob, filename);
return true;
} catch (err) {
const msg = err?.message || 'Preuzimanje ZIP arhive nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
export function openWorkOrderInvoicesPdfPage(workOrderId) {
if (!isBrowser() || !workOrderId) {
return;