From 15540f78e21126ccf57ab4648c60f76eae82d322 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 22 Jul 2026 13:34:53 +0000 Subject: [PATCH] fix(portal): Table has no size prop in BulkTruckUploadModal --- .../maintenance/maintenance-due-alert.spec.ts | 76 +++++++++++++++++ .../backoffice/src/constants/QUERY_KEYS.ts | 1 + .../src/pages/fleet/MaintenancePage.tsx | 81 +++++++++++++++++++ .../components/BulkTruckUploadModal.tsx | 2 +- 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts new file mode 100644 index 000000000..7aac5e863 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts @@ -0,0 +1,76 @@ +import { NotificationAudience } from '@edr/types'; + +import { MaintenanceService } from './maintenance.service'; + +/** + * The daily due-alert: a SCHEDULED item that crossed its km or date threshold + * gets one BACKOFFICE notification, then is stamped so it isn't repeated. + */ +function makeService(due: Array>) { + const update = jest.fn(); + const notify = jest.fn(); + const service = Object.create(MaintenanceService.prototype) as Record; + service.maintenanceRepository = { getUnnotifiedDue: jest.fn().mockResolvedValue(due) }; + service.scheduleRepository = { update }; + service.inbox = { notify }; + service.logger = { error: jest.fn() }; + return { service: service as unknown as MaintenanceService, update, notify }; +} + +describe('MaintenanceService.sendDueAlerts', () => { + it('reports the km reason when the km threshold was crossed', async () => { + const { service, notify, update } = makeService([ + { + id: 'sched-1', + vehicleId: 'v-1', + plateNumber: 'ET-9875', + maintenanceType: 'PREVENTIVE', + description: 'Oil change', + nextDueKm: 50000, + nextDueDate: null, + currentKm: 50200, + }, + ]); + + await service.sendDueAlerts(); + + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + audience: NotificationAudience.BACKOFFICE, + title: 'Maintenance due — ET-9875', + body: expect.stringContaining('driven 50200 km (due at 50000 km)'), + }), + ); + expect(update).toHaveBeenCalledWith('sched-1', { dueNotifiedAt: expect.any(Date) }); + }); + + it('reports the date reason when only the due date has passed', async () => { + const { service, notify } = makeService([ + { + id: 'sched-2', + vehicleId: 'v-2', + plateNumber: 'AA-8642', + maintenanceType: 'INSPECTION', + description: 'Annual inspection', + nextDueKm: null, + nextDueDate: new Date('2026-01-01'), + currentKm: 1000, + }, + ]); + + await service.sendDueAlerts(); + + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.stringContaining('due 1/1/2026') }), + ); + }); + + it('does nothing when nothing is due', async () => { + const { service, notify, update } = makeService([]); + + await service.sendDueAlerts(); + + expect(notify).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 2f65e110e..80645d256 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -199,6 +199,7 @@ export const QUERY_KEYS = { MAINTENANCE: { ROOT: ["maintenance"] as const, + dueBoard: () => ["maintenance", "due-board"] as const, schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const, upcoming: (vehicleId?: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index 3aa2506b5..0fcb35a27 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -35,6 +35,21 @@ interface MaintenanceSchedule { serviceProvider?: string; } +interface DueBoardRow { + scheduleId: string; + vehicleId: string; + plateNumber: string; + maintenanceType: string; + description: string; + scheduledDate: string; + nextDueDate: string | null; + nextDueKm: number | null; + currentKm: number | null; + kmRemaining: number | null; + daysRemaining: number | null; + overdue: boolean; +} + const emptyForm = { maintenanceType: 'PREVENTIVE', description: '', @@ -51,6 +66,16 @@ export function MaintenancePage() { const [openScheduleModal, setOpenScheduleModal] = useState(false); const [formData, setFormData] = useState(emptyForm); + // Maintenance is driven by time AND km, not a picked-then-scheduled action — + // this is the fleet-wide board of what's actually due, by date or mileage. + const { data: dueBoard, isLoading: dueLoading } = useQuery({ + queryKey: QUERY_KEYS.MAINTENANCE.dueBoard(), + queryFn: async () => { + const res = await api.get('/maintenance/due-board'); + return (res.data || []) as DueBoardRow[]; + }, + }); + const { data: vehiclesData } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), queryFn: async () => { @@ -132,6 +157,62 @@ export function MaintenancePage() { + + + Due Board — by date and driven km + + + {dueLoading ? ( + Loading… + ) : dueBoard && dueBoard.length > 0 ? ( + + + + Vehicle + Type + Next Due Date + Next Due Km + Current Km + Remaining + Status + + + + {dueBoard.map((row) => ( + setSelectedVehicle(row.vehicleId)} + style={{ cursor: 'pointer' }} + > + {row.plateNumber} + {row.maintenanceType} + + {row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'} + + {row.nextDueKm ?? '—'} + {row.currentKm ?? '—'} + + {row.kmRemaining != null + ? `${row.kmRemaining} km` + : row.daysRemaining != null + ? `${row.daysRemaining} d` + : '—'} + + + + {row.overdue ? 'OVERDUE' : 'SCHEDULED'} + + + + ))} + +
+ ) : ( + Nothing scheduled fleet-wide + )} +
+
+