mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
Operators know the yards by name, not by the internal code: the yard coded KALITY is universally called GMP / Gelan Multipurpose Port (Indode), so rendering the code alongside the name read as two different places. Show the name alone in route creation, the yard desks modal and the booking route card, falling back to the code only when a yard has no name. The code is unchanged in the data and remains searchable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
863 lines
29 KiB
TypeScript
863 lines
29 KiB
TypeScript
import { FormEvent, useEffect, useMemo, useState } from "react";
|
|
import {
|
|
ArrowRight,
|
|
Ban,
|
|
CircleCheck,
|
|
Edit,
|
|
Eye,
|
|
Plus,
|
|
Route as RouteIcon,
|
|
Trash2,
|
|
ShieldAlert,
|
|
} from "lucide-react";
|
|
import type { ColumnDef } from "@edr/ui-common";
|
|
import {
|
|
ActionIcon,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Divider,
|
|
Group,
|
|
Modal,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
ThemeIcon,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
|
|
import { useDebouncedValue } from "@mantine/hooks";
|
|
import { keepPreviousData, 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 { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
|
|
import { useAuth } from "@/auth/useAuth";
|
|
import { canFleetAction, canFleetHardDelete } from "@/lib/permissions";
|
|
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 };
|
|
|
|
type RouteFormState = {
|
|
status: RouteStatus;
|
|
milestones: MilestoneFormRow[];
|
|
};
|
|
|
|
const emptyForm = (): RouteFormState => ({
|
|
status: "AVAILABLE",
|
|
milestones: [{ yardId: "" }, { yardId: "" }],
|
|
});
|
|
|
|
/** Order-insensitive pair key — yard distances are symmetric. */
|
|
const pairKey = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`);
|
|
|
|
// Yards are identified to operators by name, not by their internal code: the
|
|
// yard coded KALITY is universally called GMP / Gelan Multipurpose Port, and
|
|
// showing both read as two different places. The code stays in the data.
|
|
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 [debouncedSearch] = useDebouncedValue(search, 300);
|
|
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 { user } = useAuth();
|
|
const canCreate = canFleetAction(user, "routes", "create");
|
|
const canUpdate = canFleetAction(user, "routes", "update");
|
|
const canDelete = canFleetAction(user, "routes", "delete");
|
|
// Irreversible purge needs its own grant — the coarse fleet:manage key that
|
|
// canFleetAction accepts deliberately does not unlock it.
|
|
const canPurge = canFleetHardDelete(user, "routes");
|
|
// Both destructive actions confirm first: deactivate is recoverable but still
|
|
// changes what operations can book, and a purge cannot be undone at all.
|
|
const [deactivateTarget, setDeactivateTarget] = useState<RouteRecord | null>(null);
|
|
const [purgeTarget, setPurgeTarget] = useState<RouteRecord | null>(null);
|
|
const [purgeConfirmText, setPurgeConfirmText] = useState("");
|
|
|
|
const routesQuery = useQuery({
|
|
...api.routes.listPaged.queryOptions({
|
|
input: {
|
|
page: pagination.pageIndex + 1,
|
|
pageSize: pagination.pageSize,
|
|
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
|
},
|
|
}),
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
// KPI counts stay whole-fleet (they must not move with the search box), so
|
|
// they come from two count-only pages rather than the visible one.
|
|
const totalCountQuery = useQuery(
|
|
api.routes.listPaged.queryOptions({ input: { page: 1, pageSize: 1 } }),
|
|
);
|
|
const availableCountQuery = useQuery(
|
|
api.routes.listPaged.queryOptions({
|
|
input: { page: 1, pageSize: 1, status: "AVAILABLE" },
|
|
}),
|
|
);
|
|
const yardsQuery = useQuery(api.routes.yards.queryOptions());
|
|
// Segment km are configured in Configuration → Yard Distances and resolved
|
|
// by the API on save; this fetch is only to preview them in the form.
|
|
const yardDistancesQuery = useQuery({
|
|
queryKey: ["yard-distances", "all"],
|
|
queryFn: () =>
|
|
ruleEngineService.listAll<{ id: string; fromYardId: string; toYardId: string; distanceKm: string }>(
|
|
"yard-distances",
|
|
),
|
|
});
|
|
const createMutation = useMutation(api.routes.create.mutationOptions());
|
|
const updateMutation = useMutation(api.routes.update.mutationOptions());
|
|
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
|
|
const purgeMutation = useMutation(api.routes.purge.mutationOptions());
|
|
|
|
// Narrowing the result set can strand the user on a page that no longer
|
|
// exists (search down to 3 rows while on page 5 → empty table).
|
|
useEffect(() => {
|
|
setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }));
|
|
}, [debouncedSearch, setPagination]);
|
|
|
|
// Filtering, sorting and the page window all happen server-side.
|
|
const pagedRoutes = routesQuery.data?.items ?? [];
|
|
const matchCount = routesQuery.data?.meta.total ?? 0;
|
|
const pageCount = Math.max(1, routesQuery.data?.meta.totalPages ?? 1);
|
|
|
|
const totalRoutes = totalCountQuery.data?.meta.total ?? 0;
|
|
const availableCount = availableCountQuery.data?.meta.total ?? 0;
|
|
|
|
const yardOptions = useMemo(
|
|
() =>
|
|
(yardsQuery.data ?? []).map((yard) => ({
|
|
value: yard.id,
|
|
label: yard.label ?? yard.code,
|
|
})),
|
|
[yardsQuery.data],
|
|
);
|
|
|
|
const distanceByPair = useMemo(() => {
|
|
const map = new Map<string, number>();
|
|
for (const row of yardDistancesQuery.data ?? []) {
|
|
map.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm));
|
|
}
|
|
return map;
|
|
}, [yardDistancesQuery.data]);
|
|
|
|
/** Configured km for the segment ending at `index` (undefined = pair not configured yet). */
|
|
const segmentKm = (index: number): number | undefined => {
|
|
if (index === 0) return 0;
|
|
const from = form.milestones[index - 1]?.yardId;
|
|
const to = form.milestones[index]?.yardId;
|
|
if (!from || !to) return undefined;
|
|
return distanceByPair.get(pairKey(from, to));
|
|
};
|
|
|
|
const formTotalKm = useMemo(() => {
|
|
let total = 0;
|
|
for (let i = 1; i < form.milestones.length; i++) {
|
|
const from = form.milestones[i - 1]?.yardId;
|
|
const to = form.milestones[i]?.yardId;
|
|
if (!from || !to) continue;
|
|
total += distanceByPair.get(pairKey(from, to)) ?? 0;
|
|
}
|
|
return total;
|
|
}, [form.milestones, distanceByPair]);
|
|
|
|
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) => ({ yardId: m.yardId })),
|
|
});
|
|
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: "" }],
|
|
}));
|
|
};
|
|
|
|
const removeMilestone = (index: number) => {
|
|
setForm((current) => ({
|
|
...current,
|
|
milestones: current.milestones.filter((_, i) => i !== index),
|
|
}));
|
|
};
|
|
|
|
const buildPayload = () => ({
|
|
status: form.status,
|
|
milestones: form.milestones.map((row) => ({ yardId: row.yardId })),
|
|
});
|
|
|
|
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;
|
|
}
|
|
// Pre-empt the API's missing-pair rejection with a readable message; if the
|
|
// distance list failed to load, skip and let the API validate.
|
|
if (yardDistancesQuery.data) {
|
|
const missing: string[] = [];
|
|
for (let i = 1; i < form.milestones.length; i++) {
|
|
const from = form.milestones[i - 1].yardId;
|
|
const to = form.milestones[i].yardId;
|
|
if (!distanceByPair.has(pairKey(from, to))) {
|
|
const label = (id: string) =>
|
|
yardOptions.find((o) => o.value === id)?.label ?? id;
|
|
missing.push(`${label(from)} ↔ ${label(to)}`);
|
|
}
|
|
}
|
|
if (missing.length > 0) {
|
|
toast({
|
|
title: "Save failed",
|
|
description: `No distance configured for: ${missing.join(", ")}. Add it under Configuration → Yard Distances first.`,
|
|
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 () => {
|
|
if (!deactivateTarget) return;
|
|
try {
|
|
await deactivateMutation.mutateAsync(deactivateTarget.id);
|
|
toast({ title: "Route marked stop working" });
|
|
setDeactivateTarget(null);
|
|
} catch {
|
|
toast({ title: "Update failed", description: "Could not update route status", variant: "destructive" });
|
|
}
|
|
};
|
|
|
|
const closePurge = () => {
|
|
setPurgeTarget(null);
|
|
setPurgeConfirmText("");
|
|
};
|
|
|
|
/** The label the operator must retype to confirm an irreversible purge. */
|
|
const purgeLabel = purgeTarget ? formatRouteLabel(purgeTarget) : "";
|
|
|
|
const handlePurge = async () => {
|
|
if (!purgeTarget) return;
|
|
try {
|
|
await purgeMutation.mutateAsync(purgeTarget.id);
|
|
toast({ title: "Route permanently deleted" });
|
|
closePurge();
|
|
} catch (error) {
|
|
toast({
|
|
title: "Permanent delete failed",
|
|
description: normalizeRouteError(error),
|
|
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>
|
|
{canUpdate ? (
|
|
<Tooltip label="Edit">
|
|
<ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}>
|
|
<Edit size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
) : null}
|
|
{canDelete ? (
|
|
<Tooltip label="Mark stop working">
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
|
|
onClick={() => setDeactivateTarget(row.original)}
|
|
>
|
|
<Trash2 size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
) : null}
|
|
{canPurge ? (
|
|
<Tooltip label="Delete permanently">
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
disabled={purgeMutation.isPending}
|
|
onClick={() => setPurgeTarget(row.original)}
|
|
>
|
|
<ShieldAlert size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
) : null}
|
|
</Group>
|
|
),
|
|
},
|
|
];
|
|
}, [deactivateMutation.isPending, purgeMutation.isPending, canUpdate, canDelete, canPurge]);
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title="Routes"
|
|
subtitle="Define rail corridors, segment distances, and operational status for train scheduling."
|
|
action={
|
|
canCreate ? (
|
|
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
|
Add route
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
|
|
<KpiStrip
|
|
loading={routesQuery.isLoading}
|
|
items={[
|
|
{ label: "Total routes", value: totalRoutes, icon: RouteIcon },
|
|
{ label: "Available", value: availableCount, icon: CircleCheck, color: "edr-green" },
|
|
{
|
|
label: "Unavailable",
|
|
value: totalRoutes - 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: matchCount,
|
|
}}
|
|
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>
|
|
{canUpdate ? (
|
|
<Button variant="light" size="compact-sm" onClick={() => openEdit(route)}>
|
|
Edit
|
|
</Button>
|
|
) : null}
|
|
</Group>
|
|
</Stack>
|
|
</Card>
|
|
))}
|
|
</SimpleGrid>
|
|
)}
|
|
<RuleEngineListFooter
|
|
pagination={pagination}
|
|
pageCount={pageCount}
|
|
totalCount={matchCount}
|
|
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";
|
|
const km = segmentKm(index);
|
|
const bothSelected =
|
|
index > 0 && Boolean(row.yardId && form.milestones[index - 1]?.yardId);
|
|
return (
|
|
<Group key={`${role}-${index}`} align="center" 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 ? (
|
|
<Box w={120}>
|
|
{bothSelected ? (
|
|
km != null ? (
|
|
<Text size="sm" fw={600} ta="right">
|
|
{km} km
|
|
</Text>
|
|
) : (
|
|
<Tooltip label="No distance configured for this yard pair — add it under Configuration → Yard Distances">
|
|
<Text size="xs" c="red.7" fw={600} ta="right">
|
|
Not configured
|
|
</Text>
|
|
</Tooltip>
|
|
)
|
|
) : (
|
|
<Text size="xs" c="dimmed" ta="right">
|
|
— km
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
) : (
|
|
<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> — segment
|
|
distances come from Configuration → Yard Distances
|
|
</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(deactivateTarget)}
|
|
onClose={() => setDeactivateTarget(null)}
|
|
title={<Text fw={600}>Mark stop working</Text>}
|
|
radius="lg"
|
|
centered
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm">
|
|
Stop new operations on{" "}
|
|
<Text span fw={700}>
|
|
{deactivateTarget ? formatRouteLabel(deactivateTarget) : ""}
|
|
</Text>
|
|
? The route keeps its history and can no longer be booked.
|
|
</Text>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={() => setDeactivateTarget(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="red"
|
|
loading={deactivateMutation.isPending}
|
|
onClick={handleDeactivate}
|
|
>
|
|
Mark stop working
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
<Modal
|
|
opened={Boolean(purgeTarget)}
|
|
onClose={closePurge}
|
|
title={<Text fw={600}>Delete permanently</Text>}
|
|
radius="lg"
|
|
centered
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm">
|
|
This permanently removes{" "}
|
|
<Text span fw={700}>
|
|
{purgeLabel}
|
|
</Text>{" "}
|
|
and its stops from the database. It cannot be undone.
|
|
</Text>
|
|
<Text size="sm" c="dimmed">
|
|
Only unused routes can be purged — if any train schedule still
|
|
references it, the request is refused and you should mark it stop
|
|
working instead.
|
|
</Text>
|
|
<TextInput
|
|
label="Type the route to confirm"
|
|
placeholder={purgeLabel}
|
|
value={purgeConfirmText}
|
|
onChange={(e) => setPurgeConfirmText(e.currentTarget.value)}
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={closePurge}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="red"
|
|
loading={purgeMutation.isPending}
|
|
disabled={purgeConfirmText.trim() !== purgeLabel || !purgeLabel}
|
|
onClick={handlePurge}
|
|
>
|
|
Delete permanently
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</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>
|
|
);
|
|
}
|