Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx

490 lines
16 KiB
TypeScript

import { FormEvent, useMemo, useState } from "react";
import { Edit, Eye, Trash2 } from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Modal,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
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 {
useCreateRoute,
useDeactivateRoute,
useRouteYards,
useRoutes,
useUpdateRoute,
} from "@/hooks/useRoutes";
import { useToast } from "@/hooks/use-toast";
import type { RouteRecord, YardRef } from "@/services/routes.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
type RouteFormState = {
name: string;
milestones: string[];
};
const emptyForm = (): RouteFormState => ({ name: "", milestones: ["", ""] });
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : "—");
const routeStops = (route: RouteRecord) =>
(route.milestones ?? [])
.sort((left, right) => left.sequenceNo - right.sequenceNo)
.map((milestone) => milestone.yard?.label ?? milestone.yard?.code ?? milestone.yardId);
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";
};
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 = useRoutes();
const yardsQuery = useRouteYards();
const createMutation = useCreateRoute();
const updateMutation = useUpdateRoute();
const deactivateMutation = useDeactivateRoute();
const filteredRoutes = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return routesQuery.data ?? [];
return (routesQuery.data ?? []).filter((route) => {
const searchable = [
route.name,
route.originYard?.label,
route.originYard?.code,
route.destinationYard?.label,
route.destinationYard?.code,
...routeStops(route),
]
.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 yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((yard) => ({
value: yard.id,
label: `${yard.label} (${yard.code})`,
})),
[yardsQuery.data],
);
const resetForm = () => {
setFormOpen(false);
setEditing(null);
setForm(emptyForm());
};
const openCreate = () => {
setEditing(null);
setForm(emptyForm());
setFormOpen(true);
};
const openEdit = (route: RouteRecord) => {
setEditing(route);
setForm({
name: route.name,
milestones: (route.milestones ?? [])
.sort((left, right) => left.sequenceNo - right.sequenceNo)
.map((milestone) => milestone.yardId),
});
setFormOpen(true);
};
const setMilestone = (index: number, yardId: string) => {
setForm((current) => ({
...current,
milestones: current.milestones.map((value, currentIndex) =>
currentIndex === index ? yardId : value,
),
}));
};
const addMilestone = () => {
setForm((current) => ({ ...current, milestones: [...current.milestones, ""] }));
};
const removeMilestone = (index: number) => {
setForm((current) => ({
...current,
milestones: current.milestones.filter((_, currentIndex) => currentIndex !== index),
}));
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!form.name.trim()) {
toast({ title: "Save failed", description: "Route name is required", variant: "destructive" });
return;
}
if (form.milestones.length < 2 || form.milestones.some((yardId) => !yardId)) {
toast({
title: "Save failed",
description: "Select at least an origin and destination yard",
variant: "destructive",
});
return;
}
try {
const payload = {
name: form.name.trim(),
milestones: form.milestones.map((yardId) => ({ yardId })),
isActive: editing?.isActive ?? true,
};
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 deactivated" });
} catch {
toast({ title: "Deactivate failed", description: "Could not deactivate route", variant: "destructive" });
}
};
const isSaving = createMutation.isPending || updateMutation.isPending;
const availableOptionsForIndex = (index: number) => {
const selectedByOthers = new Set(
form.milestones.filter((value, currentIndex) => currentIndex !== index && value),
);
return yardOptions.filter(
(option) => option.value === form.milestones[index] || !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: "name", header: "Name", meta: { headerClassName, cellClassName }, cell: ({ row }) => row.original.name },
{
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: "milestones",
header: "Milestones",
meta: { headerClassName, cellClassName },
cell: ({ row }) => Math.max((row.original.milestones?.length ?? 0) - 2, 0),
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge color={row.original.isActive ? "green" : "gray"} variant="light" size="sm">
{row.original.isActive ? "Active" : "Inactive"}
</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="Deactivate">
<ActionIcon
variant="subtle"
color="red"
disabled={!row.original.isActive || deactivateMutation.isPending}
onClick={() => handleDeactivate(row.original)}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
),
},
];
}, [deactivateMutation.isPending]);
return (
<Stack gap="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search routes…"
addLabel="Add Route"
onAdd={openCreate}
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}>{route.name}</Text>
<Badge color={route.isActive ? "green" : "gray"} variant="light" size="sm">
{route.isActive ? "Active" : "Inactive"}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{yardLabel(route.originYard)} {yardLabel(route.destinationYard)}
</Text>
<Text size="xs" c="dimmed">
{Math.max((route.milestones?.length ?? 0) - 2, 0)} intermediate milestones
</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">
<TextInput
label="Name"
value={form.name}
onChange={(e) => setForm((current) => ({ ...current, name: e.currentTarget.value }))}
/>
<Group justify="space-between">
<Text size="sm" fw={500}>
Stops
</Text>
<Button type="button" variant="light" size="compact-sm" onClick={addMilestone}>
Add milestone
</Button>
</Group>
{form.milestones.map((yardId, index) => {
const role =
index === 0
? "Origin"
: index === form.milestones.length - 1
? "Destination"
: "Milestone";
return (
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap">
<Text w={100} size="sm" fw={500}>
{role}
</Text>
<Select
style={{ flex: 1 }}
data={availableOptionsForIndex(index)}
value={yardId || null}
onChange={(value) => value && setMilestone(index, value)}
placeholder="Select yard"
searchable
/>
<ActionIcon
variant="subtle"
color="red"
disabled={form.milestones.length <= 2}
onClick={() => removeMilestone(index)}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
);
})}
<Group justify="flex-end">
<Button variant="default" type="button" onClick={resetForm}>
Cancel
</Button>
<Button color="green" type="submit" loading={isSaving}>
Save
</Button>
</Group>
</Stack>
</form>
</Modal>
<Modal
opened={Boolean(viewing)}
onClose={() => setViewing(null)}
title={<Text fw={600}>Route details</Text>}
radius="lg"
centered
>
{viewing ? (
<Stack gap="sm">
<div>
<Text size="sm" fw={500}>
Name
</Text>
<Text size="sm" c="dimmed">
{viewing.name}
</Text>
</div>
<div>
<Text size="sm" fw={500}>
Status
</Text>
<Text size="sm" c="dimmed">
{viewing.isActive ? "Active" : "Inactive"}
</Text>
</div>
<div>
<Text size="sm" fw={500}>
Stops
</Text>
<Stack gap={6} mt={6}>
{routeStops(viewing).map((stop, index, stops) => (
<Text key={`${stop}-${index}`} size="sm" c="dimmed">
{index === 0
? "Origin"
: index === stops.length - 1
? "Destination"
: `Milestone ${index}`}
: {stop}
</Text>
))}
</Stack>
</div>
</Stack>
) : null}
</Modal>
</Stack>
);
}