feat: add async monthly ZIP generation with notifications
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:
@@ -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()}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user