automation of loading and unloading

This commit is contained in:
Hagernesh
2026-06-17 22:33:06 +00:00
1637 changed files with 375027 additions and 20437 deletions

View File

@@ -0,0 +1,442 @@
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Group, Modal, Select, Stack, Text } from "@mantine/core";
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useCargoTypes } from "@/hooks/use-cargo-types";
import { useContainerTypes } from "@/hooks/use-container-types";
import { useWagonTypes } from "@/hooks/use-wagon-types";
import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
import { useContainers } from "@/hooks/useContainers";
import { useToast } from "@/hooks/use-toast";
import { useRouteYards } from "@/hooks/useRoutes";
import { useWagons } from "@/hooks/useWagons";
import type { FleetListFilters } from "@/services/fleet/fleet.service";
import {
FLEET_SELECT_NONE,
getFleetResource,
getFleetSlugFromPath,
type FleetFormFieldDef,
type FleetResourceSlug,
} from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
const FleetResourcePage = () => {
const location = useLocation();
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
const config = getFleetResource(slug);
const { toast } = useToast();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
const { viewMode, setViewMode } = useFleetViewMode(slug);
const serverListFilters = useMemo((): FleetListFilters | undefined => {
if (slug !== "wagons" && slug !== "locomotives") return undefined;
const filters: FleetListFilters = {};
const status = listFilterValues.status;
const currentYardId = listFilterValues.currentYardId;
if (status && status !== "ALL") {
(filters as { status?: string }).status = status;
}
if (currentYardId && currentYardId !== "ALL") {
filters.currentYardId = currentYardId;
}
if (slug === "wagons" && search.trim()) {
filters.search = search.trim();
}
return filters;
}, [slug, listFilterValues, search]);
const { data: allRows = [], isLoading, isError, error } = useFleetList(slug, serverListFilters);
const { create, update, remove } = useFleetMutations(slug);
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
const { data: containerTypes = [], isLoading: containerTypesLoading } = useContainerTypes();
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useCargoTypes();
const { data: wagons = [], isLoading: wagonsLoading } = useWagons();
const { data: containers = [], isLoading: containersLoading } = useContainers();
const { data: yards = [], isLoading: yardsLoading } = useRouteYards();
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
setSearch("");
setStatusFilter("ALL");
setListFilterValues({});
}, [slug, setPagination]);
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
}, [search, listFilterValues, setPagination]);
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
const usesServerListFilters = Boolean(config?.listFilters?.length);
const statusFilterOptions = useMemo(() => {
if (!hasStatusColumn || usesServerListFilters) return [];
const statuses = new Set(
allRows
.map((row) => String((row as unknown as Record<string, unknown>).status ?? ""))
.filter(Boolean),
);
return [
{ value: "ALL", label: "All statuses" },
...[...statuses].sort().map((status) => ({ value: status, label: status })),
];
}, [allRows, hasStatusColumn, usesServerListFilters]);
const dynamicOptions = useMemo(() => {
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
(t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }),
);
const containerTypeOpts = (
containerTypes as Array<{ id: string; label?: string; code?: string }>
).map((t) => ({ value: t.id, label: t.label ?? t.code ?? t.id }));
const cargoTypeOpts = (
cargoTypes as Array<{ id: string; cargoTypeName?: string; code?: string }>
).map((t) => ({ value: t.id, label: t.cargoTypeName ?? t.code ?? t.id }));
const wagonOpts = (wagons as Array<{ id: string; wagonNumber: string }>).map((w) => ({
value: w.id,
label: w.wagonNumber,
}));
const containerOpts = (containers as Array<{ id: string; containerNumber: string }>).map(
(c) => ({ value: c.id, label: c.containerNumber }),
);
const yardOpts = (yards as Array<{ id: string; label?: string; code?: string }>).map(
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
);
registerFleetOptionLabels("currentYardId", yardOpts);
return {
wagonTypes: wagonTypeOpts,
containerTypes: containerTypeOpts,
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
containers: containerOpts,
yards: yardOpts,
};
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]);
const listFilterSelects = useMemo(() => {
if (!config?.listFilters?.length) return null;
return config.listFilters.map((filter) => {
const dynamicOpts = filter.dynamicOptions
? (dynamicOptions[filter.dynamicOptions] ?? [])
: [];
const staticOpts =
filter.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? [];
const opts = filter.dynamicOptions ? dynamicOpts : staticOpts;
return {
...filter,
value: listFilterValues[filter.key] ?? "ALL",
data: [
{ value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
...opts,
],
};
});
}, [config?.listFilters, listFilterValues, dynamicOptions]);
useEffect(() => {
registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes);
registerFleetOptionLabels("containerTypeId", dynamicOptions.containerTypes);
registerFleetOptionLabels(
"cargoTypeId",
dynamicOptions.cargoTypes.filter((o) => o.value !== FLEET_SELECT_NONE),
);
registerFleetOptionLabels("wagonId", dynamicOptions.wagons);
registerFleetOptionLabels("containerId", dynamicOptions.containers);
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
}, [dynamicOptions]);
const formFields = useMemo((): FleetFormFieldDef[] => {
if (!config) return [];
return config.formFields.map((field) => {
if (!field.dynamicOptions) return field;
const options = dynamicOptions[field.dynamicOptions] ?? [];
return { ...field, type: "select" as const, options };
});
}, [config, dynamicOptions]);
const selectOptionsLoading =
wagonTypesLoading ||
containerTypesLoading ||
cargoTypesLoading ||
wagonsLoading ||
containersLoading ||
yardsLoading;
const filteredRows = useMemo(() => {
if (!config) return allRows;
if (usesServerListFilters) return allRows;
const term = search.trim().toLowerCase();
return allRows.filter((row) => {
const record = row as unknown as Record<string, unknown>;
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
return false;
}
if (!term) return true;
return config.searchKeys.some((key) =>
String(record[key] ?? "")
.toLowerCase()
.includes(term),
);
});
}, [allRows, search, statusFilter, config, usesServerListFilters]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
const pagedRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filteredRows.slice(start, start + pagination.pageSize);
}, [filteredRows, pagination.pageIndex, pagination.pageSize]);
const columns = useMemo((): ColumnDef<FleetRecord>[] => {
if (!config) return [];
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
const base: ColumnDef<FleetRecord>[] = config.columns.map((col) => ({
id: col.id,
header: col.header,
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
formatFleetCell(
(row.original as unknown as Record<string, unknown>)[col.accessorKey],
col.format,
col.accessorKey,
),
}));
base.push({
id: "actions",
header: "Actions",
size: 140,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
<FleetRecordActions
record={row.original}
config={config}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onRemove={setRemoveTarget}
/>
</div>
),
});
return base;
}, [config, dynamicOptions.yards]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
if (!config) {
return <Navigate to="/dashboard/locomotives" replace />;
}
const handleFormSubmit = async (values: Record<string, unknown>) => {
try {
if (editing && "id" in editing) {
await update.mutateAsync({ id: String(editing.id), data: values });
toast({ title: `${config.entityLabel} updated` });
} else {
await create.mutateAsync(values);
toast({ title: `${config.entityLabel} created` });
}
setFormOpen(false);
setEditing(null);
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Save failed";
toast({ title: "Save failed", description: String(message), variant: "destructive" });
}
};
const handleRemove = async () => {
if (!removeTarget || !("id" in removeTarget)) return;
try {
await remove.mutateAsync(String(removeTarget.id));
toast({
title: config.removeSuccessMessage ?? `${config.entityLabel} removed`,
});
setRemoveTarget(null);
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Remove failed";
toast({ title: "Remove failed", description: String(message), variant: "destructive" });
}
};
const itemLabel = config.label.toLowerCase();
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={config.searchPlaceholder}
showSearch={config.supportsSearch}
addLabel={config.addLabel}
onAdd={() => {
setEditing(null);
setFormOpen(true);
}}
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
listFilterSelects ? (
<Group gap="xs" wrap="nowrap">
{listFilterSelects.map((filter) => (
<Select
key={filter.key}
size="sm"
radius="lg"
label={filter.label}
value={filter.value}
onChange={(v) => {
if (!v) return;
setListFilterValues((prev) => ({ ...prev, [filter.key]: v }));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={filter.data}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
))}
</Group>
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
<Select
size="sm"
radius="lg"
value={statusFilter}
onChange={(v) => v && setStatusFilter(v)}
data={statusFilterOptions}
w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
) : undefined
}
/>
</Box>
{viewMode === "table" ? (
<DataTable
columns={columns}
data={pagedRows}
status={tableStatus}
error={
isError
? {
message: "Failed to load data",
description: error instanceof Error ? error.message : "Unknown error",
}
: undefined
}
emptyMessage={`No ${itemLabel} found`}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRows.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: itemLabel } }}
/>
)}
/>
) : (
<FleetCardGrid
config={config}
rows={pagedRows}
status={tableStatus}
emptyMessage={`No ${itemLabel} found`}
pagination={pagination}
pageCount={pageCount}
totalCount={filteredRows.length}
onPaginationChange={setPagination}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onRemove={setRemoveTarget}
/>
)}
</Stack>
</Card>
<FleetFormDialog
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open);
if (!open) setEditing(null);
}}
title={editing ? `Edit ${config.entityLabel}` : config.addLabel}
fields={formFields}
initialRecord={editing}
emptyValues={config.emptyValues}
isSubmitting={create.isPending || update.isPending}
selectOptionsLoading={selectOptionsLoading}
onSubmit={handleFormSubmit}
/>
<Modal
opened={Boolean(removeTarget)}
onClose={() => setRemoveTarget(null)}
title={<Text fw={600}>{config.removeActionLabel ?? "Delete"}</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm">
{config.removeConfirmMessage ??
`Are you sure you want to ${config.removeAction} this ${config.entityLabel.toLowerCase()}?`}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setRemoveTarget(null)}>
Cancel
</Button>
<Button color="red" loading={remove.isPending} onClick={handleRemove}>
{config.removeActionLabel ?? "Delete"}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};
export default FleetResourcePage;

View File

@@ -1,30 +1,45 @@
import { FormEvent, useMemo, useState } from 'react';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FormEvent, useMemo, useState } from "react";
import { Edit, Eye, Trash2 } from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useCreateRoute, useDeactivateRoute, useRouteYards, useRoutes, useUpdateRoute } from '@/hooks/useRoutes';
import { useToast } from '@/hooks/use-toast';
import type { RouteRecord, YardRef } from '@/services/routes.service';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
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 emptyForm = (): RouteFormState => ({ name: "", milestones: ["", ""] });
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : '-');
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : "—");
const routeStops = (route: RouteRecord) =>
(route.milestones ?? [])
@@ -33,22 +48,26 @@ const routeStops = (route: RouteRecord) =>
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 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.join(", ")
: rawMessage
? String(rawMessage)
: 'Save failed';
: "Save failed";
};
export default function RoutesPage() {
const [search, setSearch] = useState('');
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();
@@ -60,7 +79,6 @@ export default function RoutesPage() {
const filteredRoutes = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return routesQuery.data ?? [];
return (routesQuery.data ?? []).filter((route) => {
const searchable = [
route.name,
@@ -71,13 +89,18 @@ export default function RoutesPage() {
...routeStops(route),
]
.filter(Boolean)
.join(' ')
.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) => ({
@@ -120,7 +143,7 @@ export default function RoutesPage() {
};
const addMilestone = () => {
setForm((current) => ({ ...current, milestones: [...current.milestones, ''] }));
setForm((current) => ({ ...current, milestones: [...current.milestones, ""] }));
};
const removeMilestone = (index: number) => {
@@ -132,17 +155,15 @@ export default function RoutesPage() {
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!form.name.trim()) {
toast({ title: 'Save failed', description: 'Route name is required', variant: 'destructive' });
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',
title: "Save failed",
description: "Select at least an origin and destination yard",
variant: "destructive",
});
return;
}
@@ -153,29 +174,25 @@ export default function RoutesPage() {
milestones: form.milestones.map((yardId) => ({ yardId })),
isActive: editing?.isActive ?? true,
};
if (editing) {
await updateMutation.mutateAsync({ id: editing.id, data: payload });
toast({ title: 'Route updated' });
toast({ title: "Route updated" });
} else {
await createMutation.mutateAsync(payload);
toast({ title: 'Route created' });
toast({ title: "Route created" });
}
resetForm();
} catch (error) {
toast({ title: 'Save failed', description: normalizeRouteError(error), variant: 'destructive' });
toast({ title: "Save failed", description: normalizeRouteError(error), variant: "destructive" });
}
};
const handleDeactivate = async (route: RouteRecord) => {
if (!window.confirm('Deactivate this route?')) return;
try {
await deactivateMutation.mutateAsync(route.id);
toast({ title: 'Route deactivated' });
toast({ title: "Route deactivated" });
} catch {
toast({ title: 'Deactivate failed', description: 'Could not deactivate route', variant: 'destructive' });
toast({ title: "Deactivate failed", description: "Could not deactivate route", variant: "destructive" });
}
};
@@ -185,195 +202,288 @@ export default function RoutesPage() {
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 (
<div className="space-y-5 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Routes</h1>
<p className="mt-1 text-sm text-muted-foreground">
Build train routes from an ordered yard list where the first stop is the origin and the last stop is the destination.
</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
Add Route
</Button>
</div>
<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>
<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 routes"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Origin</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Milestones</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[150px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRoutes.map((route) => (
<TableRow key={route.id}>
<TableCell>{route.name}</TableCell>
<TableCell>{yardLabel(route.originYard)}</TableCell>
<TableCell>{yardLabel(route.destinationYard)}</TableCell>
<TableCell>{Math.max((route.milestones?.length ?? 0) - 2, 0)}</TableCell>
<TableCell>{route.isActive ? 'Active' : 'Inactive'}</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => setViewing(route)} title="View">
<Eye className="size-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => openEdit(route)} title="Edit">
<Edit className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeactivate(route)}
title="Deactivate"
disabled={!route.isActive || deactivateMutation.isPending}
>
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{!routesQuery.isLoading && filteredRoutes.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-28 text-center text-muted-foreground">
No routes found.
</TableCell>
</TableRow>
) : null}
{routesQuery.isLoading ? (
<TableRow>
<TableCell colSpan={6} className="h-28 text-center text-muted-foreground">
Loading...
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<Dialog open={formOpen} onOpenChange={(open) => (!open ? resetForm() : setFormOpen(true))}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? 'Edit Route' : 'Add Route'}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="route-name">Name</Label>
<Input
id="route-name"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
{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}
/>
</div>
</Stack>
)}
</Stack>
</Card>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Stops</Label>
<Button type="button" variant="outline" size="sm" onClick={addMilestone}>
<Plus className="size-4" />
Add next milestone
</Button>
</div>
{form.milestones.map((yardId, index) => {
const role = index === 0 ? 'Origin' : index === form.milestones.length - 1 ? 'Destination' : 'Milestone';
const availableOptions = availableOptionsForIndex(index);
return (
<div key={`${role}-${index}`} className="grid gap-2 rounded-lg border p-3 sm:grid-cols-[120px,1fr,auto] sm:items-center">
<p className="text-sm font-medium">{role}</p>
<Select value={yardId} onValueChange={(value) => setMilestone(index, value)}>
<SelectTrigger>
<SelectValue placeholder="Select yard" />
</SelectTrigger>
<SelectContent>
{availableOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeMilestone(index)}
disabled={form.milestones.length <= 2}
title="Remove stop"
>
<Trash2 className="size-4" />
</Button>
</div>
);
})}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={resetForm}>
<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 type="submit" disabled={isSaving}>
<Button color="green" type="submit" loading={isSaving}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</Group>
</Stack>
</form>
</Modal>
<Dialog open={Boolean(viewing)} onOpenChange={(open) => (!open ? setViewing(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Route details</DialogTitle>
</DialogHeader>
{viewing ? (
<div className="space-y-3 text-sm">
<div>
<p className="font-medium">Name</p>
<p className="text-muted-foreground">{viewing.name}</p>
</div>
<div>
<p className="font-medium">Status</p>
<p className="text-muted-foreground">{viewing.isActive ? 'Active' : 'Inactive'}</p>
</div>
<div>
<p className="font-medium">Stops</p>
<div className="mt-2 space-y-2">
{routeStops(viewing).map((stop, index, stops) => (
<div key={`${stop}-${index}`} className="rounded-md border px-3 py-2 text-muted-foreground">
{index === 0 ? 'Origin' : index === stops.length - 1 ? 'Destination' : `Milestone ${index}`}:
{' '}
{stop}
</div>
))}
</div>
</div>
<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>
) : null}
</DialogContent>
</Dialog>
</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>
);
}

View File

@@ -0,0 +1,359 @@
import { Freight } from "@edr/types";
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources";
export type FleetResourceSlug =
| "locomotives"
| "trains"
| "wagons"
| "containers"
| "cargoes";
export const FLEET_SELECT_NONE = "__none__";
import type { WagonListFilters } from "@/services/wagon.service";
export type FleetListFilters = WagonListFilters;
export type FleetDynamicOptions =
| "wagonTypes"
| "containerTypes"
| "cargoTypes"
| "wagons"
| "containers"
| "yards";
export interface FleetResourceColumn {
id: string;
header: string;
accessorKey: string;
format?: ColumnFormat | "statusBadge";
}
export interface FleetFormFieldDef extends FormFieldDef {
dynamicOptions?: FleetDynamicOptions;
noneOption?: boolean;
}
export interface FleetListFilterDef {
key: "status" | "currentYardId" | "wagonTypeId" | "trainId";
label: string;
options?: Array<{ value: string; label: string }>;
allLabel?: string;
dynamicOptions?: FleetDynamicOptions;
}
export interface FleetResourceConfig {
slug: FleetResourceSlug;
label: string;
subtitle: string;
basePath: string;
addLabel: string;
entityLabel: string;
searchPlaceholder: string;
supportsSearch: boolean;
/** Server-side list filters (e.g. wagon status / readiness). */
listFilters?: FleetListFilterDef[];
columns: FleetResourceColumn[];
formFields: FleetFormFieldDef[];
emptyValues: Record<string, unknown>;
removeAction: "delete" | "decommission";
removeActionLabel?: string;
removeConfirmMessage?: string;
removeSuccessMessage?: string;
detailPath?: string;
cardTitleKey?: string;
cardCodeKey?: string;
cardSubtitleKey?: string;
searchKeys: string[];
}
export const FLEET_BASE_PATH_BY_SLUG: Record<FleetResourceSlug, string> = {
locomotives: "/dashboard/locomotives",
trains: "/dashboard/trains",
wagons: "/dashboard/wagons",
containers: "/dashboard/containers",
cargoes: "/dashboard/cargoes",
};
const LOCOMOTIVE_TYPE_OPTIONS = [
{ label: "Diesel", value: "DIESEL" },
{ label: "Electric", value: "ELECTRIC" },
];
const LOCOMOTIVE_STATUS_OPTIONS = [
{ label: "Available", value: "AVAILABLE" },
{ label: "Maintenance", value: "MAINTENANCE" },
{ label: "Assigned", value: "ASSIGNED" },
{ label: "Out of service", value: "OUT_OF_SERVICE" },
];
const WAGON_STATUS_OPTIONS = [
{ label: "Available", value: Freight.WagonStatus.Available },
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
{ label: "Maintenance", value: Freight.WagonStatus.Maintenance },
{ label: "Retired", value: Freight.WagonStatus.Retired },
];
export const FLEET_RESOURCES: FleetResourceConfig[] = [
{
slug: "locomotives",
label: "Locomotives",
subtitle: "Manage locomotive master data used by train scheduling and fleet operations",
basePath: "/dashboard/locomotives",
addLabel: "Add Locomotive",
entityLabel: "Locomotive",
searchPlaceholder: "Search locomotives…",
supportsSearch: true,
removeAction: "decommission",
removeActionLabel: "Decommission",
removeConfirmMessage: "Decommission this locomotive?",
removeSuccessMessage: "Locomotive decommissioned",
cardTitleKey: "name",
cardCodeKey: "code",
listFilters: [
{
key: "status",
label: "Status",
allLabel: "All statuses",
options: LOCOMOTIVE_STATUS_OPTIONS,
},
{
key: "currentYardId",
label: "Current Yard",
allLabel: "All yards",
dynamicOptions: "yards",
},
],
cardSubtitleKey: "currentYard",
searchKeys: ["code", "name", "locomotiveType", "status", "currentYardId"],
columns: [
{ id: "code", header: "Code", accessorKey: "code", format: "code" },
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "locomotiveType", header: "Type", accessorKey: "locomotiveType" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "name", label: "Name", type: "text" },
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
{ name: "powerKw", label: "Power (kW)", type: "number" },
{ name: "tractionForceKn", label: "Traction force (kN)", type: "number" },
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
],
emptyValues: {
code: "",
name: "",
locomotiveType: "DIESEL",
status: "AVAILABLE",
currentYardId: "",
maxPullWeightTons: 0,
maxTrainLengthMeters: 760,
powerKw: "",
tractionForceKn: "",
maxSpeedKmh: "",
},
},
{
slug: "trains",
label: "Trains",
subtitle: "Manage train master data independently from train scheduling",
basePath: "/dashboard/trains",
addLabel: "Add Train",
entityLabel: "Train",
searchPlaceholder: "Search trains…",
supportsSearch: true,
removeAction: "delete",
detailPath: "/dashboard/trains/:id",
cardTitleKey: "trainName",
cardCodeKey: "code",
cardSubtitleKey: "trainNumber",
searchKeys: ["code", "trainNumber", "trainName", "status"],
columns: [
{ id: "code", header: "Code", accessorKey: "code", format: "code" },
{ id: "trainNumber", header: "Number", accessorKey: "trainNumber" },
{ id: "trainName", header: "Name", accessorKey: "trainName" },
{ id: "capacityTons", header: "Capacity (tons)", accessorKey: "capacityTons", format: "number" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
{ name: "trainNumber", label: "Train number", type: "text" },
{ name: "trainName", label: "Train name", type: "text" },
{ name: "locomotiveNumber", label: "Locomotive number", type: "text" },
{ name: "status", label: "Status", type: "text" },
{ name: "notes", label: "Notes", type: "textarea" },
{ name: "remarks", label: "Remarks", type: "textarea" },
],
emptyValues: {
code: "",
capacityTons: 0,
trainNumber: "",
trainName: "",
locomotiveNumber: "",
status: "AVAILABLE",
notes: "",
remarks: "",
},
},
{
slug: "wagons",
label: "Wagons",
subtitle: "Manage wagon master data. Operational scheduling uses train schedules separately",
basePath: "/dashboard/wagons",
addLabel: "Add Wagon",
entityLabel: "Wagon",
searchPlaceholder: "Search wagons…",
supportsSearch: true,
removeAction: "delete",
listFilters: [
{
key: "status",
label: "Status",
allLabel: "All statuses",
options: WAGON_STATUS_OPTIONS,
},
{
key: "currentYardId",
label: "Current Yard",
allLabel: "All yards",
dynamicOptions: "yards",
},
],
cardTitleKey: "wagonNumber",
cardSubtitleKey: "currentYard",
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"],
columns: [
{ id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" },
{ id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" },
{ id: "maxPayloadWeight", header: "Max payload", accessorKey: "maxPayloadWeight", format: "number" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
{ name: "tareWeight", label: "Tare weight", type: "number", required: true },
{ name: "maxPayloadWeight", label: "Max payload weight", type: "number", required: true },
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
{ name: "notes", label: "Notes", type: "textarea" },
],
emptyValues: {
wagonNumber: "",
wagonTypeId: "",
tareWeight: 0,
maxPayloadWeight: 0,
currentYardId: "",
status: Freight.WagonStatus.Available,
notes: "",
},
},
{
slug: "containers",
label: "Containers",
subtitle: "Manage container master data and wagon assignments",
basePath: "/dashboard/containers",
addLabel: "Add Container",
entityLabel: "Container",
searchPlaceholder: "Search containers…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "containerNumber",
cardSubtitleKey: "status",
searchKeys: ["containerNumber", "containerTypeId", "wagonId", "status"],
columns: [
{ id: "containerNumber", header: "Number", accessorKey: "containerNumber", format: "code" },
{ id: "containerTypeId", header: "Type", accessorKey: "containerTypeId", format: "entityLabel" },
{ id: "wagonId", header: "Wagon", accessorKey: "wagonId", format: "entityLabel" },
{ id: "maxGrossWeight", header: "Max gross", accessorKey: "maxGrossWeight", format: "number" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "containerNumber", label: "Container number", type: "text", required: true },
{ name: "containerTypeId", label: "Container type", type: "select", required: true, dynamicOptions: "containerTypes" },
{ name: "wagonId", label: "Wagon", type: "select", dynamicOptions: "wagons", noneOption: true },
{ name: "position", label: "Position", type: "number" },
{ name: "tareWeight", label: "Tare weight", type: "number", required: true },
{ name: "maxGrossWeight", label: "Max gross weight", type: "number", required: true },
{ name: "sealNumber", label: "Seal number", type: "text" },
{ name: "status", label: "Status", type: "text" },
],
emptyValues: {
containerNumber: "",
containerTypeId: "",
wagonId: "",
position: "",
tareWeight: 0,
maxGrossWeight: 0,
sealNumber: "",
status: "AVAILABLE",
},
},
{
slug: "cargoes",
label: "Cargoes",
subtitle: "Manage cargo records linked to containers",
basePath: "/dashboard/cargoes",
addLabel: "Add Cargo",
entityLabel: "Cargo",
searchPlaceholder: "Search cargoes…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "cargoReference",
cardSubtitleKey: "status",
searchKeys: ["cargoReference", "description", "containerId", "status"],
columns: [
{ id: "cargoReference", header: "Reference", accessorKey: "cargoReference", format: "code" },
{ id: "cargoTypeId", header: "Cargo type", accessorKey: "cargoTypeId", format: "entityLabel" },
{ id: "containerId", header: "Container", accessorKey: "containerId", format: "entityLabel" },
{ id: "quantity", header: "Quantity", accessorKey: "quantity", format: "number" },
{ id: "weight", header: "Weight", accessorKey: "weight", format: "number" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "cargoReference", label: "Cargo reference", type: "text", required: true },
{ name: "shipmentId", label: "Shipment ID", type: "text", required: true },
{ name: "containerId", label: "Container", type: "select", required: true, dynamicOptions: "containers" },
{ name: "cargoTypeId", label: "Cargo type", type: "select", dynamicOptions: "cargoTypes", noneOption: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "quantity", label: "Quantity", type: "number", required: true },
{ name: "weight", label: "Weight", type: "number", required: true },
{ name: "volume", label: "Volume", type: "number" },
{ name: "status", label: "Status", type: "text" },
],
emptyValues: {
cargoReference: "",
shipmentId: "",
containerId: "",
cargoTypeId: "",
description: "",
quantity: 0,
weight: 0,
volume: "",
status: "PENDING",
},
},
];
export const getFleetResource = (slug: string): FleetResourceConfig | undefined =>
FLEET_RESOURCES.find((resource) => resource.slug === slug);
export const getFleetSlugFromPath = (pathname: string): FleetResourceSlug | undefined => {
const normalized = pathname.toLowerCase();
return FLEET_RESOURCES.find((resource) => normalized === resource.basePath.toLowerCase())?.slug;
};
export const getFleetRouteMeta = () =>
FLEET_RESOURCES.map((resource) => ({
prefix: resource.basePath,
meta: { title: resource.label, subtitle: resource.subtitle },
}));