mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 01:55:41 +00:00
677 lines
21 KiB
TypeScript
677 lines
21 KiB
TypeScript
import { FormEvent, useMemo, useState } from "react";
|
|
import {
|
|
ArrowRight,
|
|
Ban,
|
|
CircleCheck,
|
|
Edit,
|
|
Eye,
|
|
Plus,
|
|
Route as RouteIcon,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import type { ColumnDef } from "@edr/ui-common";
|
|
import {
|
|
ActionIcon,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Divider,
|
|
Group,
|
|
Modal,
|
|
NumberInput,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
ThemeIcon,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
|
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
|
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
|
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
|
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
|
import { api } from "@/services/api";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import {
|
|
formatRouteLabel,
|
|
ROUTE_STATUS_OPTIONS,
|
|
totalRouteDistanceKm,
|
|
type RouteRecord,
|
|
type RouteStatus,
|
|
type YardRef,
|
|
} from "@/services/routes.service";
|
|
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
|
|
|
type MilestoneFormRow = { yardId: string; distanceKm: string };
|
|
|
|
type RouteFormState = {
|
|
status: RouteStatus;
|
|
milestones: MilestoneFormRow[];
|
|
};
|
|
|
|
const emptyForm = (): RouteFormState => ({
|
|
status: "AVAILABLE",
|
|
milestones: [
|
|
{ yardId: "", distanceKm: "0" },
|
|
{ yardId: "", distanceKm: "" },
|
|
],
|
|
});
|
|
|
|
const yardLabel = (yard?: YardRef | null) =>
|
|
yard ? `${yard.label} (${yard.code})` : "—";
|
|
|
|
const statusColor = (status: RouteStatus) => {
|
|
switch (status) {
|
|
case "AVAILABLE":
|
|
return "edr-green";
|
|
case "MAINTENANCE":
|
|
return "yellow";
|
|
case "DAMAGED":
|
|
return "red";
|
|
case "STOP_WORKING":
|
|
return "gray";
|
|
default:
|
|
return "gray";
|
|
}
|
|
};
|
|
|
|
const statusLabel = (status: RouteStatus) =>
|
|
ROUTE_STATUS_OPTIONS.find((o) => o.value === status)?.label ?? status;
|
|
|
|
const normalizeRouteError = (error: unknown) => {
|
|
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
|
const data =
|
|
responseData && typeof responseData === "object"
|
|
? (responseData as Record<string, unknown>)
|
|
: undefined;
|
|
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
|
return Array.isArray(rawMessage)
|
|
? rawMessage.join(", ")
|
|
: rawMessage
|
|
? String(rawMessage)
|
|
: "Save failed";
|
|
};
|
|
|
|
function RouteTimeline({ route }: { route: RouteRecord }) {
|
|
const stops = [...(route.milestones ?? [])].sort(
|
|
(a, b) => a.sequenceNo - b.sequenceNo,
|
|
);
|
|
const total = totalRouteDistanceKm(route);
|
|
|
|
return (
|
|
<Stack gap="sm">
|
|
{stops.map((milestone, index) => {
|
|
const label =
|
|
milestone.yard?.label ?? milestone.yard?.code ?? milestone.yardId;
|
|
const role =
|
|
index === 0
|
|
? "Origin"
|
|
: index === stops.length - 1
|
|
? "Destination"
|
|
: `Milestone ${index}`;
|
|
const km = Number(milestone.distanceKm ?? 0);
|
|
return (
|
|
<Box key={milestone.id ?? `${milestone.yardId}-${index}`}>
|
|
{index > 0 && (
|
|
<Group gap={8} pl={18} py={6}>
|
|
<ThemeIcon size={22} radius="xl" variant="light" color="gray">
|
|
<ArrowRight size={12} />
|
|
</ThemeIcon>
|
|
<Text size="xs" c="dimmed" fw={600}>
|
|
{km} km
|
|
</Text>
|
|
</Group>
|
|
)}
|
|
<Group gap="sm" wrap="nowrap">
|
|
<Badge size="sm" variant="light" color={index === 0 ? "teal" : "gray"}>
|
|
{role}
|
|
</Badge>
|
|
<Text size="sm" fw={500}>
|
|
{label}
|
|
</Text>
|
|
</Group>
|
|
</Box>
|
|
);
|
|
})}
|
|
<Divider />
|
|
<Group justify="space-between">
|
|
<Text size="sm" fw={600}>
|
|
Total distance
|
|
</Text>
|
|
<Text size="sm" fw={700}>
|
|
{total} km
|
|
</Text>
|
|
</Group>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
export default function RoutesPage() {
|
|
const [search, setSearch] = useState("");
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [viewing, setViewing] = useState<RouteRecord | null>(null);
|
|
const [editing, setEditing] = useState<RouteRecord | null>(null);
|
|
const [form, setForm] = useState<RouteFormState>(emptyForm());
|
|
const { viewMode, setViewMode } = useFleetViewMode("routes");
|
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
const { toast } = useToast();
|
|
|
|
const routesQuery = useQuery(api.routes.list.queryOptions());
|
|
const yardsQuery = useQuery(api.routes.yards.queryOptions());
|
|
const createMutation = useMutation(api.routes.create.mutationOptions());
|
|
const updateMutation = useMutation(api.routes.update.mutationOptions());
|
|
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
|
|
|
|
const filteredRoutes = useMemo(() => {
|
|
const query = search.trim().toLowerCase();
|
|
if (!query) return routesQuery.data ?? [];
|
|
return (routesQuery.data ?? []).filter((route) => {
|
|
const searchable = [
|
|
formatRouteLabel(route),
|
|
route.originYard?.label,
|
|
route.originYard?.code,
|
|
route.destinationYard?.label,
|
|
route.destinationYard?.code,
|
|
...(route.milestones ?? []).map(
|
|
(m) => m.yard?.label ?? m.yard?.code ?? m.yardId,
|
|
),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
return searchable.includes(query);
|
|
});
|
|
}, [routesQuery.data, search]);
|
|
|
|
const pageCount = Math.max(1, Math.ceil(filteredRoutes.length / pagination.pageSize));
|
|
const pagedRoutes = useMemo(() => {
|
|
const start = pagination.pageIndex * pagination.pageSize;
|
|
return filteredRoutes.slice(start, start + pagination.pageSize);
|
|
}, [filteredRoutes, pagination.pageIndex, pagination.pageSize]);
|
|
|
|
const allRoutes = routesQuery.data ?? [];
|
|
const availableCount = allRoutes.filter((r) => r.status === "AVAILABLE").length;
|
|
|
|
const yardOptions = useMemo(
|
|
() =>
|
|
(yardsQuery.data ?? []).map((yard) => ({
|
|
value: yard.id,
|
|
label: `${yard.label} (${yard.code})`,
|
|
})),
|
|
[yardsQuery.data],
|
|
);
|
|
|
|
const formTotalKm = useMemo(
|
|
() =>
|
|
form.milestones.reduce(
|
|
(sum, row, index) =>
|
|
index === 0 ? sum : sum + Number(row.distanceKm || 0),
|
|
0,
|
|
),
|
|
[form.milestones],
|
|
);
|
|
|
|
const resetForm = () => {
|
|
setFormOpen(false);
|
|
setEditing(null);
|
|
setForm(emptyForm());
|
|
};
|
|
|
|
const openCreate = () => {
|
|
setEditing(null);
|
|
setForm(emptyForm());
|
|
setFormOpen(true);
|
|
};
|
|
|
|
const openEdit = (route: RouteRecord) => {
|
|
setEditing(route);
|
|
setForm({
|
|
status: route.status,
|
|
milestones: [...(route.milestones ?? [])]
|
|
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
|
.map((m, index) => ({
|
|
yardId: m.yardId,
|
|
distanceKm: String(index === 0 ? 0 : (m.distanceKm ?? "")),
|
|
})),
|
|
});
|
|
setFormOpen(true);
|
|
};
|
|
|
|
const setMilestone = (index: number, patch: Partial<MilestoneFormRow>) => {
|
|
setForm((current) => ({
|
|
...current,
|
|
milestones: current.milestones.map((row, i) =>
|
|
i === index ? { ...row, ...patch } : row,
|
|
),
|
|
}));
|
|
};
|
|
|
|
const addMilestone = () => {
|
|
setForm((current) => ({
|
|
...current,
|
|
milestones: [...current.milestones, { yardId: "", distanceKm: "" }],
|
|
}));
|
|
};
|
|
|
|
const removeMilestone = (index: number) => {
|
|
setForm((current) => ({
|
|
...current,
|
|
milestones: current.milestones.filter((_, i) => i !== index),
|
|
}));
|
|
};
|
|
|
|
const buildPayload = () => ({
|
|
status: form.status,
|
|
milestones: form.milestones.map((row, index) => ({
|
|
yardId: row.yardId,
|
|
distanceKm:
|
|
index === 0 ? 0 : row.distanceKm ? Number(row.distanceKm) : undefined,
|
|
})),
|
|
});
|
|
|
|
const handleSubmit = async (event: FormEvent) => {
|
|
event.preventDefault();
|
|
if (form.milestones.length < 2 || form.milestones.some((row) => !row.yardId)) {
|
|
toast({
|
|
title: "Save failed",
|
|
description: "Select at least an origin and destination yard",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
for (let i = 1; i < form.milestones.length; i++) {
|
|
const km = Number(form.milestones[i].distanceKm);
|
|
if (!form.milestones[i].distanceKm || Number.isNaN(km) || km < 0) {
|
|
toast({
|
|
title: "Save failed",
|
|
description: `Enter segment KM for stop ${i + 1}`,
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const payload = buildPayload();
|
|
if (editing) {
|
|
await updateMutation.mutateAsync({ id: editing.id, data: payload });
|
|
toast({ title: "Route updated" });
|
|
} else {
|
|
await createMutation.mutateAsync(payload);
|
|
toast({ title: "Route created" });
|
|
}
|
|
resetForm();
|
|
} catch (error) {
|
|
toast({ title: "Save failed", description: normalizeRouteError(error), variant: "destructive" });
|
|
}
|
|
};
|
|
|
|
const handleDeactivate = async (route: RouteRecord) => {
|
|
try {
|
|
await deactivateMutation.mutateAsync(route.id);
|
|
toast({ title: "Route marked stop working" });
|
|
} catch {
|
|
toast({ title: "Update failed", description: "Could not update route status", variant: "destructive" });
|
|
}
|
|
};
|
|
|
|
const handleStatusChange = async (route: RouteRecord, status: RouteStatus) => {
|
|
try {
|
|
await updateMutation.mutateAsync({ id: route.id, data: { status } });
|
|
setViewing((current) => (current?.id === route.id ? { ...current, status } : current));
|
|
toast({ title: "Status updated" });
|
|
} catch (error) {
|
|
toast({ title: "Update failed", description: normalizeRouteError(error), variant: "destructive" });
|
|
}
|
|
};
|
|
|
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
|
|
|
const availableOptionsForIndex = (index: number) => {
|
|
const selectedByOthers = new Set(
|
|
form.milestones
|
|
.filter((row, i) => i !== index && row.yardId)
|
|
.map((row) => row.yardId),
|
|
);
|
|
return yardOptions.filter(
|
|
(option) =>
|
|
option.value === form.milestones[index]?.yardId ||
|
|
!selectedByOthers.has(option.value),
|
|
);
|
|
};
|
|
|
|
const tableStatus = routesQuery.isLoading
|
|
? "loading"
|
|
: routesQuery.isError
|
|
? "error"
|
|
: "success";
|
|
|
|
const columns = useMemo((): ColumnDef<RouteRecord>[] => {
|
|
const headerClassName = ruleEngineTable.headerCell;
|
|
const cellClassName = ruleEngineTable.bodyCell;
|
|
return [
|
|
{
|
|
id: "corridor",
|
|
header: "Corridor",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => (
|
|
<Text fw={600} size="sm">
|
|
{formatRouteLabel(row.original)}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "origin",
|
|
header: "Origin",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => yardLabel(row.original.originYard),
|
|
},
|
|
{
|
|
id: "destination",
|
|
header: "Destination",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => yardLabel(row.original.destinationYard),
|
|
},
|
|
{
|
|
id: "distance",
|
|
header: "Total KM",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => `${totalRouteDistanceKm(row.original)} km`,
|
|
},
|
|
{
|
|
id: "milestones",
|
|
header: "Stops",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => row.original.milestones?.length ?? 0,
|
|
},
|
|
{
|
|
id: "status",
|
|
header: "Status",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => (
|
|
<Badge color={statusColor(row.original.status)} variant="light" size="sm">
|
|
{statusLabel(row.original.status)}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "Actions",
|
|
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
|
|
cell: ({ row }) => (
|
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
|
<Tooltip label="View">
|
|
<ActionIcon variant="subtle" color="gray" onClick={() => setViewing(row.original)}>
|
|
<Eye size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
<Tooltip label="Edit">
|
|
<ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}>
|
|
<Edit size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
<Tooltip label="Mark stop working">
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
|
|
onClick={() => handleDeactivate(row.original)}
|
|
>
|
|
<Trash2 size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Group>
|
|
),
|
|
},
|
|
];
|
|
}, [deactivateMutation.isPending]);
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title="Routes"
|
|
subtitle="Define rail corridors, segment distances, and operational status for train scheduling."
|
|
action={
|
|
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
|
Add route
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<KpiStrip
|
|
loading={routesQuery.isLoading}
|
|
items={[
|
|
{ label: "Total routes", value: allRoutes.length, icon: RouteIcon },
|
|
{ label: "Available", value: availableCount, icon: CircleCheck, color: "edr-green" },
|
|
{
|
|
label: "Unavailable",
|
|
value: allRoutes.length - availableCount,
|
|
icon: Ban,
|
|
color: "gray",
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<Card radius="lg" padding={0} withBorder>
|
|
<Stack gap={0}>
|
|
<Box px="md" pt="md" pb="sm" w="100%">
|
|
<FleetToolbar
|
|
search={search}
|
|
onSearchChange={setSearch}
|
|
searchPlaceholder="Search corridors…"
|
|
viewMode={viewMode}
|
|
onViewModeChange={setViewMode}
|
|
/>
|
|
</Box>
|
|
|
|
{viewMode === "table" ? (
|
|
<DataTable
|
|
columns={columns}
|
|
data={pagedRoutes}
|
|
status={tableStatus}
|
|
emptyMessage="No routes found"
|
|
pagination={{
|
|
pageIndex: pagination.pageIndex,
|
|
pageSize: pagination.pageSize,
|
|
pageCount,
|
|
totalCount: filteredRoutes.length,
|
|
}}
|
|
tableOptions={{
|
|
manualPagination: true,
|
|
pageCount,
|
|
state: { pagination },
|
|
onPaginationChange: setPagination,
|
|
}}
|
|
containerClassName="border-0 shadow-none bg-transparent"
|
|
footer={({ table, pagination: footerPagination }) => (
|
|
<DataTableFooter
|
|
table={table}
|
|
pagination={footerPagination}
|
|
options={{ labels: { items: "routes" } }}
|
|
/>
|
|
)}
|
|
/>
|
|
) : (
|
|
<Stack gap={0}>
|
|
{tableStatus === "loading" ? (
|
|
<Text py="xl" ta="center" c="dimmed" size="sm">
|
|
Loading…
|
|
</Text>
|
|
) : !pagedRoutes.length ? (
|
|
<Text py="xl" ta="center" c="dimmed" size="sm">
|
|
No routes found
|
|
</Text>
|
|
) : (
|
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
|
|
{pagedRoutes.map((route) => (
|
|
<Card key={route.id} radius="lg" padding="lg" withBorder>
|
|
<Stack gap="sm">
|
|
<Group justify="space-between">
|
|
<Text fw={600}>{formatRouteLabel(route)}</Text>
|
|
<Badge color={statusColor(route.status)} variant="light" size="sm">
|
|
{statusLabel(route.status)}
|
|
</Badge>
|
|
</Group>
|
|
<Text size="sm" c="dimmed">
|
|
{totalRouteDistanceKm(route)} km · {route.milestones?.length ?? 0} stops
|
|
</Text>
|
|
<Group gap={6} justify="flex-end">
|
|
<Button variant="light" size="compact-sm" onClick={() => setViewing(route)}>
|
|
View
|
|
</Button>
|
|
<Button variant="light" size="compact-sm" onClick={() => openEdit(route)}>
|
|
Edit
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Card>
|
|
))}
|
|
</SimpleGrid>
|
|
)}
|
|
<RuleEngineListFooter
|
|
pagination={pagination}
|
|
pageCount={pageCount}
|
|
totalCount={filteredRoutes.length}
|
|
itemLabel="routes"
|
|
onPaginationChange={setPagination}
|
|
/>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
</Card>
|
|
|
|
<Modal
|
|
opened={formOpen}
|
|
onClose={resetForm}
|
|
title={<Text fw={600}>{editing ? "Edit Route" : "Add Route"}</Text>}
|
|
size="lg"
|
|
radius="lg"
|
|
centered
|
|
>
|
|
<form onSubmit={handleSubmit}>
|
|
<Stack gap="md">
|
|
{editing && (
|
|
<Select
|
|
label="Status"
|
|
data={ROUTE_STATUS_OPTIONS}
|
|
value={form.status}
|
|
onChange={(value) =>
|
|
value && setForm((current) => ({ ...current, status: value as RouteStatus }))
|
|
}
|
|
/>
|
|
)}
|
|
<Group justify="space-between">
|
|
<Text size="sm" fw={500}>
|
|
Stops & segment distances
|
|
</Text>
|
|
<Button type="button" variant="light" size="compact-sm" onClick={addMilestone}>
|
|
Add milestone
|
|
</Button>
|
|
</Group>
|
|
{form.milestones.map((row, index) => {
|
|
const role =
|
|
index === 0
|
|
? "Origin"
|
|
: index === form.milestones.length - 1
|
|
? "Destination"
|
|
: "Milestone";
|
|
return (
|
|
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap" gap="sm">
|
|
<Text w={90} size="sm" fw={500}>
|
|
{role}
|
|
</Text>
|
|
<Select
|
|
style={{ flex: 1 }}
|
|
data={availableOptionsForIndex(index)}
|
|
value={row.yardId || null}
|
|
onChange={(value) => value && setMilestone(index, { yardId: value })}
|
|
placeholder="Select yard"
|
|
searchable
|
|
/>
|
|
{index > 0 ? (
|
|
<NumberInput
|
|
w={120}
|
|
label="KM"
|
|
min={0}
|
|
decimalScale={2}
|
|
value={row.distanceKm ? Number(row.distanceKm) : ""}
|
|
onChange={(value) =>
|
|
setMilestone(index, { distanceKm: String(value ?? "") })
|
|
}
|
|
/>
|
|
) : (
|
|
<Box w={120} />
|
|
)}
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
disabled={form.milestones.length <= 2}
|
|
onClick={() => removeMilestone(index)}
|
|
>
|
|
<Trash2 size={16} />
|
|
</ActionIcon>
|
|
</Group>
|
|
);
|
|
})}
|
|
<Text size="sm" c="dimmed">
|
|
Total route distance: <strong>{formTotalKm} km</strong>
|
|
</Text>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" type="button" onClick={resetForm}>
|
|
Cancel
|
|
</Button>
|
|
<Button color="edr-green" type="submit" loading={isSaving}>
|
|
Save
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
opened={Boolean(viewing)}
|
|
onClose={() => setViewing(null)}
|
|
title={<Text fw={600}>{viewing ? formatRouteLabel(viewing) : "Route details"}</Text>}
|
|
radius="lg"
|
|
centered
|
|
size="md"
|
|
>
|
|
{viewing ? (
|
|
<Stack gap="md">
|
|
<Group justify="space-between" align="flex-end">
|
|
<div>
|
|
<Text size="sm" fw={500}>
|
|
Status
|
|
</Text>
|
|
<Badge mt={4} color={statusColor(viewing.status)} variant="light">
|
|
{statusLabel(viewing.status)}
|
|
</Badge>
|
|
</div>
|
|
<Select
|
|
w={200}
|
|
label="Update status"
|
|
data={ROUTE_STATUS_OPTIONS}
|
|
value={viewing.status}
|
|
onChange={(value) =>
|
|
value && handleStatusChange(viewing, value as RouteStatus)
|
|
}
|
|
/>
|
|
</Group>
|
|
<div>
|
|
<Text size="sm" fw={500} mb={8}>
|
|
Road timeline
|
|
</Text>
|
|
<RouteTimeline route={viewing} />
|
|
</div>
|
|
</Stack>
|
|
) : null}
|
|
</Modal>
|
|
</PageContainer>
|
|
);
|
|
}
|