mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 07:45:45 +00:00
automation of loading and unloading
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}));
|
||||
Reference in New Issue
Block a user