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

@@ -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' }) {
<div>
<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.
Ispod su prikazani zadnji servisni zapisi.
Ispod su prikazani svi servisni zapisi.
</p>
{recentServiceRecords.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-text-muted">Nema servisnih zapisa.</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: 'actions', label: 'Akcije', className: 'px-4 py-3' },
]}
rows={recentServiceRecords}
rowKey={(r) => r.id}
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="max-w-xs 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">
<button
type="button"
onClick={() => {
setServiceRecordBackTask(null);
setSelectedServiceRecord(record);
}}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Detalji
</button>
</td>
</>
)}
/>
<>
<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: 'actions', label: 'Akcije', className: 'px-4 py-3' },
]}
rows={paginatedRecentServiceRecords}
rowKey={(r) => r.id}
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="max-w-xs 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">
<button
type="button"
onClick={() => {
setServiceRecordBackTask(null);
setSelectedServiceRecord(record);
}}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Detalji
</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>
)}

View File

@@ -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}
</span>
</div>
<button
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' && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setBulkDownloadOpen(true)}
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"
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"
>
Preuzmi ZIP
{downloading ? 'Preuzimanje...' : reportType === 'servicer' ? 'Preuzmi mjesečni izvještaj' : 'Preuzmi DOCX'}
</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>
<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 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');