feat: calendar ZIP UX grouping, archive notification polling, service records pagination
- 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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user