Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx
natib21 9d3efe7f15 fix
2026-07-04 03:47:36 +00:00

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>
);
}