mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
- 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).
379 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|