Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
Marshal
2026-08-15 10:12:59 +00:00
87 changed files with 4750 additions and 1175 deletions

View File

@@ -18,11 +18,16 @@ import {
} from "@mantine/core";
import { Plus, AlertTriangle } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import {
applyClientFilters,
FilterBar,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
} from "@/components/filters";
import { useToast } from "@/hooks/use-toast";
import {
complianceService,
@@ -52,6 +57,8 @@ const statusColor = (status: ComplianceRecord["status"]) => {
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,
@@ -91,10 +98,18 @@ export default function CompliancePage() {
},
});
const controls = useListControls(records as ComplianceRecord[], {
searchKeys: ["type", "status", "documentNumber"],
dateKey: "expiryDate",
});
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) => {
@@ -220,17 +235,11 @@ export default function CompliancePage() {
Compliance Records
</Title>
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
<FilterBar
defs={COMPLIANCE_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search type, status, document no…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Expiry"
hasFilters={controls.hasFilters}
onReset={controls.reset}
viewId="fleet-compliance"
/>
<Table striped highlightOnHover>
<Table.Thead>
@@ -261,7 +270,7 @@ export default function CompliancePage() {
</Table.Td>
</Table.Tr>
) : null}
{controls.pagedRows.map((record) => (
{pagedRecords.map((record) => (
<Table.Tr key={record.id}>
<Table.Td>{vehicleLabel(record)}</Table.Td>
<Table.Td>
@@ -282,11 +291,8 @@ export default function CompliancePage() {
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="records"
onPaginationChange={controls.setPagination}
{...toRuleEngineFooterProps(controls, filteredRecords.length)}
/>
</Card>

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { Edit, Eye, Plus, Trash2 } from 'lucide-react';
import { FormEvent, ReactNode, useMemo, useState } from 'react';
import { api } from '@/services/api';
@@ -44,6 +44,13 @@ import type { Train } from '@/services/trains.service';
import type { WagonType } from '@/services/wagon-types.service';
import type { Wagon } from '@/services/wagon.service';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import {
applyClientFilters,
FilterBar,
useFilters,
type FilterDef,
type FilterOption,
} from '@/components/filters';
type FormValue = string | number | boolean | string[];
@@ -86,6 +93,8 @@ type FleetCrudPageProps<T extends { id: string }> = {
hideViewAction?: boolean;
/** Optional custom actions rendered before the view/edit/delete buttons in each row. */
rowActions?: (item: T) => React.ReactNode;
/** Enables the Status filter pill; the item's `status` field is matched against these. */
statusOptions?: FilterOption[];
};
const normalizePayload = (values: Record<string, FormValue>) =>
@@ -172,9 +181,16 @@ function FleetCrudPage<T extends { id: string }>({
removeSuccessMessage,
hideViewAction = false,
rowActions,
statusOptions,
}: FleetCrudPageProps<T>) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const filterDefs: FilterDef[] = useMemo(
() =>
statusOptions
? [{ key: 'status', label: 'Status', type: 'enum', multiple: false, options: statusOptions }]
: [],
[statusOptions],
);
const controls = useFilters(filterDefs, { pageSize: 10 });
const [sortKey, setSortKey] = useState<string>('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [formOpen, setFormOpen] = useState(false);
@@ -184,11 +200,13 @@ function FleetCrudPage<T extends { id: string }>({
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const { toast } = useToast();
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return data ?? [];
return (data ?? []).filter((item) => searchText(item).toLowerCase().includes(query));
}, [data, search, searchText]);
const filtered = useMemo(
() =>
applyClientFilters(data ?? [], filterDefs, controls.values, controls.searchText, {
searchValue: searchText,
}),
[data, filterDefs, controls.values, controls.searchText, searchText],
);
const sorted = useMemo(() => {
if (!sortKey) return filtered;
return [...filtered].sort((a, b) => {
@@ -198,12 +216,13 @@ function FleetCrudPage<T extends { id: string }>({
return sortDirection === 'asc' ? result : -result;
});
}, [filtered, sortDirection, sortKey]);
const pageSize = 10;
const pageSize = controls.pageSize;
const page = controls.page;
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
const toggleSort = (key: string) => {
setPage(1);
controls.setPage(1);
if (sortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
return;
@@ -298,18 +317,11 @@ function FleetCrudPage<T extends { id: string }>({
</Button>
</div>
<div className="flex max-w-md items-center gap-2 rounded-md border bg-background px-3">
<Search className="size-4 text-muted-foreground" />
<Input
className="border-0 px-0 shadow-none focus-visible:ring-0"
placeholder={`Search ${title.toLowerCase()}`}
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
/>
</div>
<FilterBar
defs={filterDefs}
controls={controls}
searchPlaceholder={`Search ${title.toLowerCase()}`}
/>
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
@@ -379,10 +391,10 @@ function FleetCrudPage<T extends { id: string }>({
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of {sorted.length}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page === 1} onClick={() => setPage((current) => current - 1)}>
<Button variant="outline" size="sm" disabled={page === 1} onClick={() => controls.setPage(page - 1)}>
Previous
</Button>
<Button variant="outline" size="sm" disabled={page === pageCount} onClick={() => setPage((current) => current + 1)}>
<Button variant="outline" size="sm" disabled={page === pageCount} onClick={() => controls.setPage(page + 1)}>
Next
</Button>
</div>
@@ -487,6 +499,47 @@ const statusBadge = (status?: string) => <Badge variant="outline">{status ?? '-'
const optionLabel = (options: { value: string; label: string }[], value?: string | null) =>
options.find((option) => option.value === value)?.label ?? value ?? '-';
const TRAIN_STATUS_OPTIONS: FilterOption[] = [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'SCHEDULED', label: 'Scheduled' },
{ value: 'IN_SERVICE', label: 'In service' },
{ value: 'UNDER_MAINTENANCE', label: 'Under maintenance' },
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
{ value: 'DEACTIVATED', label: 'Deactivated' },
];
const WAGON_STATUS_OPTIONS: FilterOption[] = [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'IMPORT_READY', label: 'Import ready' },
{ value: 'EXPORT_READY', label: 'Export ready' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'DETAINED', label: 'Detained' },
];
const CONTAINER_STATUS_OPTIONS: FilterOption[] = [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'LOADED', label: 'Loaded' },
{ value: 'IN_TRANSIT', label: 'In transit' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'DAMAGED', label: 'Damaged' },
];
const CARGO_STATUS_OPTIONS: FilterOption[] = [
{ value: 'PENDING', label: 'Pending' },
{ value: 'LOADED', label: 'Loaded' },
{ value: 'IN_TRANSIT', label: 'In transit' },
{ value: 'DELIVERED', label: 'Delivered' },
{ value: 'UNLOADED', label: 'Unloaded' },
];
const LOCOMOTIVE_STATUS_OPTIONS: FilterOption[] = [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
];
export function TrainMasterDataPage() {
const query = useQuery(api.trains.list.queryOptions());
return (
@@ -499,6 +552,7 @@ export function TrainMasterDataPage() {
create={useMutation(api.trains.create.mutationOptions())}
update={useMutation(api.trains.update.mutationOptions())}
remove={useMutation(api.trains.remove.mutationOptions())}
statusOptions={TRAIN_STATUS_OPTIONS}
searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')}
columns={[
{ key: 'code', label: 'Code' },
@@ -522,14 +576,17 @@ export function TrainMasterDataPage() {
);
}
const WAGON_TYPE_FILTER_DEFS: FilterDef[] = [
{ key: 'isActive', label: 'Status', type: 'boolean', trueLabel: 'Active', falseLabel: 'Inactive' },
];
export function WagonTypesCrudPage() {
const query = useQuery(api.wagonTypes.list.queryOptions());
const create = useMutation(api.wagonTypes.create.mutationOptions());
const update = useMutation(api.wagonTypes.update.mutationOptions());
const remove = useMutation(api.wagonTypes.remove.mutationOptions());
const { toast } = useToast();
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const controls = useFilters(WAGON_TYPE_FILTER_DEFS, { pageSize: 10 });
const [sortKey, setSortKey] = useState<keyof WagonType>('code');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [formOpen, setFormOpen] = useState(false);
@@ -546,18 +603,15 @@ export function WagonTypesCrudPage() {
});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const pageSize = 10;
const filtered = useMemo(() => {
const queryText = search.trim().toLowerCase();
const rows = query.data ?? [];
if (!queryText) return rows;
return rows.filter((type) =>
[type.code, type.name, type.supportedLoadTypes?.join(' '), type.isActive ? 'active' : 'inactive']
.join(' ')
.toLowerCase()
.includes(queryText),
);
}, [query.data, search]);
const pageSize = controls.pageSize;
const page = controls.page;
const filtered = useMemo(
() =>
applyClientFilters(query.data ?? [], WAGON_TYPE_FILTER_DEFS, controls.values, controls.searchText, {
searchValue: (type) => [type.code, type.name, type.supportedLoadTypes?.join(' ')].join(' '),
}),
[query.data, controls.values, controls.searchText],
);
const sorted = useMemo(() => {
return [...filtered].sort((left, right) => {
@@ -573,7 +627,7 @@ export function WagonTypesCrudPage() {
const isSaving = create.isPending || update.isPending;
const toggleSort = (key: keyof WagonType) => {
setPage(1);
controls.setPage(1);
if (sortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
return;
@@ -689,16 +743,7 @@ export function WagonTypesCrudPage() {
</MantineButton>
</Group>
<TextInput
maw={420}
leftSection={<Search size={16} />}
placeholder="Search wagon types"
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
/>
<FilterBar defs={WAGON_TYPE_FILTER_DEFS} controls={controls} searchPlaceholder="Search wagon types" />
<Paper withBorder radius="md">
<ScrollArea>
@@ -789,7 +834,7 @@ export function WagonTypesCrudPage() {
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of{' '}
{sorted.length}
</Text>
<Pagination total={pageCount} value={page} onChange={setPage} size="sm" />
<Pagination total={pageCount} value={page} onChange={controls.setPage} size="sm" />
</Group>
</Stack>
@@ -906,6 +951,7 @@ export function WagonsCrudPage() {
create={useMutation(api.wagons.create.mutationOptions())}
update={useMutation(api.wagons.update.mutationOptions())}
remove={useMutation(api.wagons.remove.mutationOptions())}
statusOptions={WAGON_STATUS_OPTIONS}
searchText={(wagon) => [
wagon.wagonNumber,
wagon.wagonTypeId,
@@ -959,14 +1005,7 @@ export function WagonsCrudPage() {
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'IMPORT_READY', label: 'Import ready' },
{ value: 'EXPORT_READY', label: 'Export ready' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'DETAINED', label: 'Detained' },
],
options: WAGON_STATUS_OPTIONS,
},
{ key: 'notes', label: 'Notes' },
]}
@@ -999,6 +1038,7 @@ export function ContainersCrudPage() {
create={useMutation(api.containers.create.mutationOptions())}
update={useMutation(api.containers.update.mutationOptions())}
remove={useMutation(api.containers.remove.mutationOptions())}
statusOptions={CONTAINER_STATUS_OPTIONS}
searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')}
columns={[
{ key: 'containerNumber', label: 'Number' },
@@ -1058,6 +1098,7 @@ export function CargoesCrudPage() {
create={useMutation(api.cargoes.create.mutationOptions())}
update={useMutation(api.cargoes.update.mutationOptions())}
remove={useMutation(api.cargoes.remove.mutationOptions())}
statusOptions={CARGO_STATUS_OPTIONS}
searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')}
columns={[
{ key: 'cargoReference', label: 'Reference' },
@@ -1122,6 +1163,7 @@ export function LocomotivesCrudPage() {
removeActionLabel="Decommission"
removeConfirmMessage="Decommission this locomotive?"
removeSuccessMessage="Locomotive decommissioned"
statusOptions={LOCOMOTIVE_STATUS_OPTIONS}
searchText={(locomotive) =>
[
locomotive.code,
@@ -1166,12 +1208,7 @@ export function LocomotivesCrudPage() {
label: 'Status',
type: 'select',
required: true,
options: [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
],
options: LOCOMOTIVE_STATUS_OPTIONS,
},
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },

View File

@@ -19,11 +19,16 @@ import {
} from "@mantine/core";
import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import {
applyClientFilters,
FilterBar,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
} from "@/components/filters";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -45,6 +50,8 @@ interface FuelPurchase {
}
const FUEL_FILTER_DEFS: FilterDef[] = [{ key: "purchaseDate", label: "Purchased", type: "date" }];
export default function FuelPurchasePage() {
const { toast } = useToast();
const qc = useQueryClient();
@@ -123,10 +130,18 @@ export default function FuelPurchasePage() {
const totalCost = formData.liters * formData.costPerLiter;
// Aggregate stats (guarded against divide-by-zero when there are no purchases)
const controls = useListControls(purchasesData as FuelPurchase[], {
searchKeys: ["fuelStation", "paymentMethod"],
dateKey: "purchaseDate",
});
const controls = useFilters(FUEL_FILTER_DEFS, { pageSize: 10 });
const filteredPurchases = applyClientFilters(
purchasesData as FuelPurchase[],
FUEL_FILTER_DEFS,
controls.values,
controls.searchText,
{ searchKeys: ["fuelStation", "paymentMethod"] },
);
const pagedPurchases = filteredPurchases.slice(
(controls.page - 1) * controls.pageSize,
controls.page * controls.pageSize,
);
const totalLiters = (purchasesData as FuelPurchase[]).reduce(
(sum, p) => sum + Number(p.liters),
@@ -195,17 +210,11 @@ export default function FuelPurchasePage() {
{/* Purchases Table */}
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
<FilterBar
defs={FUEL_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search station or payment method…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Purchased"
hasFilters={controls.hasFilters}
onReset={controls.reset}
viewId="fleet-fuel-purchases"
/>
<Table striped highlightOnHover>
<Table.Thead>
@@ -237,7 +246,7 @@ export default function FuelPurchasePage() {
</Table.Td>
</Table.Tr>
) : null}
{controls.pagedRows.map((purchase) => (
{pagedPurchases.map((purchase) => (
<Table.Tr key={purchase.id}>
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
@@ -253,11 +262,8 @@ export default function FuelPurchasePage() {
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="purchases"
onPaginationChange={controls.setPagination}
{...toRuleEngineFooterProps(controls, filteredPurchases.length)}
/>
</Card>

View File

@@ -20,11 +20,16 @@ import {
} from "@mantine/core";
import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import {
applyClientFilters,
FilterBar,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
} from "@/components/filters";
import { useToast } from "@/hooks/use-toast";
import {
incidentsService,
@@ -89,6 +94,8 @@ const initialForm = {
reportedBy: "",
};
const INCIDENT_FILTER_DEFS: FilterDef[] = [{ key: "occurredAt", label: "Occurred", type: "date" }];
export default function IncidentsPage() {
const { toast } = useToast();
const qc = useQueryClient();
@@ -166,10 +173,18 @@ export default function IncidentsPage() {
})) || [];
const incidents = incidentsData as Incident[];
const controls = useListControls(incidents, {
searchKeys: ["type", "severity", "status"],
dateKey: "occurredAt",
});
const controls = useFilters(INCIDENT_FILTER_DEFS, { pageSize: 10 });
const filteredIncidents = applyClientFilters(
incidents,
INCIDENT_FILTER_DEFS,
controls.values,
controls.searchText,
{ searchKeys: ["type", "severity", "status"] },
);
const pagedIncidents = filteredIncidents.slice(
(controls.page - 1) * controls.pageSize,
controls.page * controls.pageSize,
);
const totalCount = incidents.length;
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
@@ -246,17 +261,11 @@ export default function IncidentsPage() {
{/* Incidents Table */}
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
<FilterBar
defs={INCIDENT_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search type, severity, status…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Occurred"
hasFilters={controls.hasFilters}
onReset={controls.reset}
viewId="fleet-incidents"
/>
<Table striped highlightOnHover>
<Table.Thead>
@@ -288,7 +297,7 @@ export default function IncidentsPage() {
</Table.Td>
</Table.Tr>
) : null}
{controls.pagedRows.map((incident) => (
{pagedIncidents.map((incident) => (
<Table.Tr key={incident.id}>
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
<Table.Td>
@@ -316,11 +325,8 @@ export default function IncidentsPage() {
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="incidents"
onPaginationChange={controls.setPagination}
{...toRuleEngineFooterProps(controls, filteredIncidents.length)}
/>
</Card>