Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx
Nathnael e5e75b4166 feat(filter-bar): migrate 3 more fleet pages, extract footer adapter
- ruleEngineFooterProps.ts: toRuleEngineFooterProps() — the
  useFilters-to-RuleEngineListFooter pagination adapter had already been
  hand-written twice (TrucksOnSitePage, CompliancePage) with the same
  pageSize-drop risk useFilters itself just had fixed; extracted before a
  third copy could drift.
- CompliancePage, FuelPurchasePage, IncidentsPage: ListControls ->
  FilterBar + applyClientFilters, same mechanical pattern as the
  warehouse pages (search + one date range, endpoints take no params at
  all per the inventory sweep — confirmed correct bucket, not assumed).
2026-08-14 14:13:11 +00:00

379 lines
11 KiB
TypeScript

import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Button,
Card,
Container,
Grid,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import { Plus, AlertTriangle } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import {
applyClientFilters,
FilterBar,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
} from "@/components/filters";
import { useToast } from "@/hooks/use-toast";
import {
complianceService,
type ComplianceAlert,
type ComplianceRecord,
type ComplianceType,
} from "@/services/compliance.service";
import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service";
const COMPLIANCE_TYPES: ComplianceType[] = [
"INSPECTION",
"INSURANCE",
"ROADWORTHINESS",
"PERMIT",
"TAX",
];
const severityColor = (severity: ComplianceAlert["severity"]) =>
severity === "OVERDUE" ? "red" : "yellow";
const statusColor = (status: ComplianceRecord["status"]) => {
if (status === "EXPIRED") return "red";
if (status === "EXPIRING") return "yellow";
return "green";
};
const formatDate = (value?: string | null) =>
value ? new Date(value).toLocaleDateString() : "—";
const COMPLIANCE_FILTER_DEFS: FilterDef[] = [{ key: "expiryDate", label: "Expiry", type: "date" }];
const emptyForm = {
vehicleId: "",
type: "INSPECTION" as ComplianceType,
documentNumber: "",
issuedDate: "",
expiryDate: new Date().toISOString().split("T")[0],
notes: "",
};
export default function CompliancePage() {
const { toast } = useToast();
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [formData, setFormData] = useState(emptyForm);
const { data: vehiclesData } = useQuery({
queryKey: ["vehicles", "compliance-select"],
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
},
});
const { data: alerts = [], isLoading: isLoadingAlerts } = useQuery({
queryKey: ["compliance", "alerts"],
queryFn: async () => {
const res = await complianceService.getAlerts();
return res.data || [];
},
});
const { data: records = [], isLoading: isLoadingRecords } = useQuery({
queryKey: ["compliance"],
queryFn: async () => {
const res = await complianceService.list();
return res.data || [];
},
});
const controls = useFilters(COMPLIANCE_FILTER_DEFS, { pageSize: 10 });
const filteredRecords = applyClientFilters(
records as ComplianceRecord[],
COMPLIANCE_FILTER_DEFS,
controls.values,
controls.searchText,
{ searchKeys: ["type", "status", "documentNumber"] },
);
const pagedRecords = filteredRecords.slice(
(controls.page - 1) * controls.pageSize,
controls.page * controls.pageSize,
);
const createMutation = useMutation({
mutationFn: async (data: typeof formData) => {
const res = await complianceService.create({
vehicleId: data.vehicleId,
type: data.type,
expiryDate: data.expiryDate,
documentNumber: data.documentNumber || undefined,
issuedDate: data.issuedDate || undefined,
notes: data.notes || undefined,
});
return res.data;
},
onSuccess: () => {
toast({ title: "Compliance record created" });
setModalOpen(false);
setFormData(emptyForm);
qc.invalidateQueries({ queryKey: ["compliance"] });
qc.invalidateQueries({ queryKey: ["compliance", "alerts"] });
},
onError: (error: any) => {
toast({
title: "Error creating record",
description:
error?.response?.data?.message || "Failed to create compliance record",
variant: "destructive",
});
},
});
const vehicleOptions =
vehiclesData?.map((v: VehicleType) => ({
value: v.id,
label: `${v.plateNumber ?? v.code ?? v.id} - ${v.manufacturer ?? ""} ${v.model ?? ""}`.trim(),
})) || [];
const vehicleLabel = (record: ComplianceRecord) =>
record.vehicle?.plateNumber ||
vehiclesData?.find((v) => v.id === record.vehicleId)?.plateNumber ||
record.vehicleId;
const overdueCount = (alerts as ComplianceAlert[]).filter(
(a) => a.severity === "OVERDUE",
).length;
const dueSoonCount = (alerts as ComplianceAlert[]).filter(
(a) => a.severity === "DUE_SOON",
).length;
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Compliance" }]} />
<Group justify="space-between" mb="lg">
<Title order={1}>Compliance & Alerts</Title>
<Button
leftSection={<Plus size={16} />}
onClick={() => setModalOpen(true)}
color="edr-green"
>
New Record
</Button>
</Group>
{/* Alerts */}
<Group mb="sm" gap="xs">
<AlertTriangle size={18} />
<Title order={3}>Expiry Alerts</Title>
{overdueCount > 0 && (
<Badge color="red" variant="light">
{overdueCount} overdue
</Badge>
)}
{dueSoonCount > 0 && (
<Badge color="yellow" variant="light">
{dueSoonCount} due soon
</Badge>
)}
</Group>
{isLoadingAlerts ? (
<Group justify="center" py="md" mb="lg">
<Loader size="sm" />
</Group>
) : (alerts as ComplianceAlert[]).length === 0 ? (
<Card withBorder padding="lg" mb="lg">
<Text c="dimmed" ta="center">
No compliance items are overdue or due soon. All clear.
</Text>
</Card>
) : (
<Grid mb="lg">
{(alerts as ComplianceAlert[]).map((alert, index) => (
<Grid.Col
key={`${alert.vehicleId}-${alert.kind}-${index}`}
span={{ base: 12, sm: 6, md: 4 }}
>
<Card withBorder padding="md" h="100%">
<Group justify="space-between" mb="xs">
<Badge color={severityColor(alert.severity)}>
{alert.severity === "OVERDUE" ? "Overdue" : "Due Soon"}
</Badge>
<Text size="sm" c="dimmed">
{alert.daysUntil < 0
? `${Math.abs(alert.daysUntil)}d ago`
: `in ${alert.daysUntil}d`}
</Text>
</Group>
<Text fw={600}>{alert.label}</Text>
<Text size="sm" c="dimmed">
{alert.vehiclePlate || alert.vehicleId}
</Text>
<Text size="sm" mt="xs">
Expires {formatDate(alert.expiryDate)}
</Text>
</Card>
</Grid.Col>
))}
</Grid>
)}
{/* Records */}
<Title order={3} mb="sm">
Compliance Records
</Title>
<Card withBorder>
<FilterBar
defs={COMPLIANCE_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search type, status, document no…"
viewId="fleet-compliance"
/>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Vehicle</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Document #</Table.Th>
<Table.Th>Issued</Table.Th>
<Table.Th>Expiry</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoadingRecords ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : (records as ComplianceRecord[]).length === 0 ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Text c="dimmed" ta="center" py="md">
No compliance records yet.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{pagedRecords.map((record) => (
<Table.Tr key={record.id}>
<Table.Td>{vehicleLabel(record)}</Table.Td>
<Table.Td>
<Badge variant="light" size="sm">
{record.type}
</Badge>
</Table.Td>
<Table.Td>{record.documentNumber || "—"}</Table.Td>
<Table.Td>{formatDate(record.issuedDate)}</Table.Td>
<Table.Td>{formatDate(record.expiryDate)}</Table.Td>
<Table.Td>
<Badge color={statusColor(record.status)} size="sm">
{record.status}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<RuleEngineListFooter
itemLabel="records"
{...toRuleEngineFooterProps(controls, filteredRecords.length)}
/>
</Card>
{/* Modal */}
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title="New Compliance Record"
size="lg"
>
<Stack gap="md">
<Select
label="Vehicle"
placeholder="Select vehicle"
data={vehicleOptions}
value={formData.vehicleId}
onChange={(val) => setFormData({ ...formData, vehicleId: val || "" })}
searchable
required
/>
<Select
label="Type"
data={COMPLIANCE_TYPES.map((t) => ({ value: t, label: t }))}
value={formData.type}
onChange={(val) =>
setFormData({ ...formData, type: (val as ComplianceType) || "INSPECTION" })
}
required
/>
<TextInput
label="Document Number"
placeholder="Optional"
value={formData.documentNumber}
onChange={(e) =>
setFormData({ ...formData, documentNumber: e.currentTarget.value })
}
/>
<TextInput
label="Issued Date"
type="date"
value={formData.issuedDate}
onChange={(e) =>
setFormData({ ...formData, issuedDate: e.currentTarget.value })
}
/>
<TextInput
label="Expiry Date"
type="date"
value={formData.expiryDate}
onChange={(e) =>
setFormData({ ...formData, expiryDate: e.currentTarget.value })
}
required
/>
<TextInput
label="Notes"
placeholder="Optional notes"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.currentTarget.value })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button
onClick={() => createMutation.mutate(formData)}
loading={createMutation.isPending}
disabled={!formData.vehicleId || !formData.expiryDate}
>
Create Record
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}