Export interchange document — a generation error after Djibouti unloading failed the whole request and left the document missing until a manual rerun. Generation is now best-effort (unload never fails on paperwork) and reruns backfill the document.

This commit is contained in:
Hagernesh
2026-07-23 11:31:00 +00:00
parent 4df17f36ce
commit 73f36fe46e
12 changed files with 694 additions and 113 deletions

View File

@@ -208,6 +208,8 @@ export const QUERY_KEYS = {
["maintenance", "history", vehicleId ?? "all"] as const,
stats: (vehicleId?: string) =>
["maintenance", "stats", vehicleId ?? "all"] as const,
intervals: (vehicleId?: string) =>
["maintenance", "intervals", vehicleId ?? "all"] as const,
},
FINANCIAL_REPORTS: {

View File

@@ -14,8 +14,10 @@ import {
Text,
Title,
Container,
ActionIcon,
Tooltip,
} from '@mantine/core';
import { Plus } from 'lucide-react';
import { CheckCircle2, Plus, Trash2 } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { useToast } from '@/hooks/use-toast';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
@@ -26,6 +28,7 @@ interface MaintenanceSchedule {
id: string;
vehicleId: string;
maintenanceType: string;
serviceItem?: string | null;
description: string;
scheduledDate: string;
completedDate?: string;
@@ -40,6 +43,7 @@ interface DueBoardRow {
vehicleId: string;
plateNumber: string;
maintenanceType: string;
serviceItem: string | null;
description: string;
scheduledDate: string;
nextDueDate: string | null;
@@ -50,8 +54,20 @@ interface DueBoardRow {
overdue: boolean;
}
interface MaintenanceInterval {
id: string;
vehicleId: string;
maintenanceType: string;
serviceItem: string | null;
intervalKm: number | null;
intervalDays: number | null;
description: string | null;
isActive: boolean;
}
const emptyForm = {
maintenanceType: 'PREVENTIVE',
serviceItem: '',
description: '',
scheduledDate: new Date().toISOString().split('T')[0],
estimatedCost: 0,
@@ -59,12 +75,24 @@ const emptyForm = {
notes: '',
};
const emptyIntervalForm = {
maintenanceType: 'PREVENTIVE',
serviceItem: '',
intervalKm: '' as number | '',
intervalDays: '' as number | '',
description: '',
};
export function MaintenancePage() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
const [openScheduleModal, setOpenScheduleModal] = useState(false);
const [formData, setFormData] = useState(emptyForm);
const [intervalForm, setIntervalForm] = useState(emptyIntervalForm);
const [completeTarget, setCompleteTarget] = useState<MaintenanceSchedule | null>(null);
const [completeOdometer, setCompleteOdometer] = useState<number | ''>('');
const [completeCost, setCompleteCost] = useState<number | ''>('');
// 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.
@@ -94,7 +122,32 @@ export function MaintenancePage() {
enabled: !!selectedVehicle,
});
const { data: intervals } = useQuery({
queryKey: QUERY_KEYS.MAINTENANCE.intervals(selectedVehicle || ''),
queryFn: async () => {
if (!selectedVehicle) return [];
const res = await api.get(`/maintenance/intervals/${selectedVehicle}`);
return (res.data || []) as MaintenanceInterval[];
},
enabled: !!selectedVehicle,
});
const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : [];
const intervalList: MaintenanceInterval[] = Array.isArray(intervals) ? intervals : [];
const invalidateVehicle = () => {
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.ROOT });
};
const onError = (err: unknown) => {
toast({
title: 'Error',
description:
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
'Failed',
variant: 'destructive',
});
};
const scheduleMutation = useMutation({
mutationFn: async () => {
@@ -102,24 +155,79 @@ export function MaintenancePage() {
const res = await api.post('/maintenance/schedules', {
vehicleId: selectedVehicle,
...formData,
serviceItem: formData.serviceItem.trim() || undefined,
});
return res.data;
},
onSuccess: () => {
toast({ title: 'Maintenance scheduled' });
queryClient.invalidateQueries({
queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''),
});
invalidateVehicle();
setOpenScheduleModal(false);
setFormData(emptyForm);
},
onError: (err: any) => {
toast({
title: 'Error',
description: err?.response?.data?.message ?? 'Failed',
variant: 'destructive',
onError,
});
// Interval upsert: "oil change every 10,000 km" — drives the auto-scheduling
// of the next service when a maintenance completes with an odometer reading.
const intervalMutation = useMutation({
mutationFn: async () => {
if (!selectedVehicle) return;
const res = await api.post('/maintenance/intervals', {
vehicleId: selectedVehicle,
maintenanceType: intervalForm.maintenanceType,
serviceItem: intervalForm.serviceItem.trim() || undefined,
intervalKm: intervalForm.intervalKm === '' ? undefined : Number(intervalForm.intervalKm),
intervalDays:
intervalForm.intervalDays === '' ? undefined : Number(intervalForm.intervalDays),
description: intervalForm.description.trim() || undefined,
});
return res.data;
},
onSuccess: () => {
toast({ title: 'Interval saved' });
invalidateVehicle();
setIntervalForm(emptyIntervalForm);
},
onError,
});
const deactivateIntervalMutation = useMutation({
mutationFn: async (id: string) => api.delete(`/maintenance/intervals/${id}`),
onSuccess: () => {
toast({ title: 'Interval deactivated' });
invalidateVehicle();
},
onError,
});
// Completion with odometer: the reading is what advances KM-based
// scheduling — the API auto-creates the next SCHEDULED item from it.
const completeMutation = useMutation({
mutationFn: async () => {
if (!completeTarget) return;
const res = await api.patch(`/maintenance/schedules/${completeTarget.id}`, {
status: 'COMPLETED',
completedDate: new Date().toISOString(),
odometerReading: completeOdometer === '' ? undefined : Number(completeOdometer),
actualCost: completeCost === '' ? undefined : Number(completeCost),
});
return res.data;
},
onSuccess: () => {
toast({
title: 'Maintenance completed',
description:
completeOdometer === ''
? 'No odometer recorded — next service was NOT auto-scheduled.'
: 'Next service auto-scheduled from the recorded odometer.',
});
invalidateVehicle();
setCompleteTarget(null);
setCompleteOdometer('');
setCompleteCost('');
},
onError,
});
const vehicleOptions =
@@ -170,6 +278,7 @@ export function MaintenancePage() {
<Table.Tr>
<Table.Th>Vehicle</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Service Item</Table.Th>
<Table.Th>Next Due Date</Table.Th>
<Table.Th>Next Due Km</Table.Th>
<Table.Th>Current Km</Table.Th>
@@ -186,6 +295,7 @@ export function MaintenancePage() {
>
<Table.Td>{row.plateNumber}</Table.Td>
<Table.Td>{row.maintenanceType}</Table.Td>
<Table.Td>{row.serviceItem ?? '—'}</Table.Td>
<Table.Td>
{row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'}
</Table.Td>
@@ -231,50 +341,179 @@ export function MaintenancePage() {
</Text>
</Card>
) : (
<Card withBorder>
<Card.Section p="md" withBorder>
<Text fw={500}>Upcoming Maintenance</Text>
</Card.Section>
<Card.Section p="md">
{isLoading ? (
<Text>Loading...</Text>
) : upcomingList.length > 0 ? (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Scheduled</Table.Th>
<Table.Th>Est. Cost</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{upcomingList.map((m) => (
<Table.Tr key={m.id}>
<Table.Td>{m.maintenanceType}</Table.Td>
<Table.Td>{m.description}</Table.Td>
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
<Table.Td>
{m.estimatedCost != null
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`
: '—'}
</Table.Td>
<Table.Td>
<Badge color={statusColor(m.status)}>{m.status}</Badge>
</Table.Td>
<>
<Card withBorder>
<Card.Section p="md" withBorder>
<Text fw={500}>Service Intervals drives auto-scheduling</Text>
<Text size="xs" c="dimmed">
e.g. oil change every 10,000 km. On completion with an odometer reading, the
next service is scheduled automatically at reading + interval.
</Text>
</Card.Section>
<Card.Section p="md">
<Stack gap="sm">
{intervalList.length > 0 && (
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Service Item</Table.Th>
<Table.Th>Every (km)</Table.Th>
<Table.Th>Every (days)</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{intervalList.map((i) => (
<Table.Tr key={i.id}>
<Table.Td>{i.maintenanceType}</Table.Td>
<Table.Td>{i.serviceItem ?? '—'}</Table.Td>
<Table.Td>{i.intervalKm ?? '—'}</Table.Td>
<Table.Td>{i.intervalDays ?? '—'}</Table.Td>
<Table.Td>{i.description ?? '—'}</Table.Td>
<Table.Td>
<Tooltip label="Deactivate — stops auto-scheduling">
<ActionIcon
variant="subtle"
color="red"
onClick={() => deactivateIntervalMutation.mutate(i.id)}
>
<Trash2 size={15} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Group align="flex-end" gap="sm" wrap="wrap">
<Select
label="Type"
w={150}
data={['PREVENTIVE', 'CORRECTIVE', 'INSPECTION', 'REPAIR']}
value={intervalForm.maintenanceType}
onChange={(v) =>
setIntervalForm({ ...intervalForm, maintenanceType: v || 'PREVENTIVE' })
}
/>
<TextInput
label="Service item"
placeholder="e.g. oil change"
w={170}
value={intervalForm.serviceItem}
onChange={(e) =>
setIntervalForm({ ...intervalForm, serviceItem: e.currentTarget.value })
}
/>
<NumberInput
label="Every (km)"
min={0}
w={130}
value={intervalForm.intervalKm}
onChange={(v) =>
setIntervalForm({ ...intervalForm, intervalKm: v === '' ? '' : Number(v) })
}
/>
<NumberInput
label="Every (days)"
min={0}
w={130}
value={intervalForm.intervalDays}
onChange={(v) =>
setIntervalForm({
...intervalForm,
intervalDays: v === '' ? '' : Number(v),
})
}
/>
<TextInput
label="Description"
placeholder="Oil and filter change"
style={{ flex: 1, minWidth: 160 }}
value={intervalForm.description}
onChange={(e) =>
setIntervalForm({ ...intervalForm, description: e.currentTarget.value })
}
/>
<Button
color="edr-green"
leftSection={<Plus size={14} />}
loading={intervalMutation.isPending}
disabled={
intervalForm.intervalKm === '' && intervalForm.intervalDays === ''
}
onClick={() => intervalMutation.mutate()}
>
Save interval
</Button>
</Group>
</Stack>
</Card.Section>
</Card>
<Card withBorder>
<Card.Section p="md" withBorder>
<Text fw={500}>Upcoming Maintenance</Text>
</Card.Section>
<Card.Section p="md">
{isLoading ? (
<Text>Loading...</Text>
) : upcomingList.length > 0 ? (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Service Item</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Scheduled</Table.Th>
<Table.Th>Est. Cost</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text c="dimmed">No upcoming maintenance</Text>
)}
</Card.Section>
</Card>
</Table.Thead>
<Table.Tbody>
{upcomingList.map((m) => (
<Table.Tr key={m.id}>
<Table.Td>{m.maintenanceType}</Table.Td>
<Table.Td>{m.serviceItem ?? '—'}</Table.Td>
<Table.Td>{m.description}</Table.Td>
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
<Table.Td>
{m.estimatedCost != null
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`
: '—'}
</Table.Td>
<Table.Td>
<Badge color={statusColor(m.status)}>{m.status}</Badge>
</Table.Td>
<Table.Td>
{(m.status === 'SCHEDULED' || m.status === 'IN_PROGRESS') && (
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<CheckCircle2 size={13} />}
onClick={() => setCompleteTarget(m)}
>
Complete
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text c="dimmed">No upcoming maintenance</Text>
)}
</Card.Section>
</Card>
</>
)}
</Stack>
@@ -291,6 +530,12 @@ export function MaintenancePage() {
value={formData.maintenanceType}
onChange={(v) => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
/>
<TextInput
label="Service item"
placeholder="e.g. oil change — links this schedule to its interval"
value={formData.serviceItem}
onChange={(e) => setFormData({ ...formData, serviceItem: e.currentTarget.value })}
/>
<TextInput
label="Description"
placeholder="What needs to be done?"
@@ -335,6 +580,49 @@ export function MaintenancePage() {
</Group>
</Stack>
</Modal>
<Modal
opened={completeTarget != null}
onClose={() => setCompleteTarget(null)}
title={`Complete maintenance${completeTarget?.serviceItem ? `${completeTarget.serviceItem}` : ''}`}
size="md"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Record the odometer at completion the next service is auto-scheduled at reading +
interval (e.g. completed at 50,000 km with a 10,000 km interval next due at 60,000
km).
</Text>
<NumberInput
label="Odometer reading (km)"
placeholder="e.g. 50000"
min={0}
required
value={completeOdometer}
onChange={(v) => setCompleteOdometer(v === '' ? '' : Number(v))}
/>
<NumberInput
label="Actual cost (ETB)"
min={0}
value={completeCost}
onChange={(v) => setCompleteCost(v === '' ? '' : Number(v))}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setCompleteTarget(null)}>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<CheckCircle2 size={15} />}
loading={completeMutation.isPending}
disabled={completeOdometer === ''}
onClick={() => completeMutation.mutate()}
>
Complete & schedule next
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}