This commit is contained in:
Marshal
2026-07-14 11:06:49 +00:00
parent 957a185a4d
commit 6d0cf50b4d
64 changed files with 4896 additions and 404 deletions

View File

@@ -4,7 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { Plus, Warehouse } from "lucide-react";
import { Inbox, Plus, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
@@ -15,6 +15,7 @@ import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -48,6 +49,7 @@ const FleetResourcePage = () => {
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
const [selectedDriver, setSelectedDriver] = useState<string>("");
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
const [transferRequestsOpen, setTransferRequestsOpen] = useState(false);
const { viewMode, setViewMode } = useFleetViewMode(slug);
const serverListFilters = useMemo((): FleetListFilters | undefined => {
@@ -373,15 +375,26 @@ const FleetResourcePage = () => {
</div>
<Group gap="sm">
{slug === "wagons" ? (
<Button
variant="light"
color="edr-green"
leftSection={<Warehouse size={16} />}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setWagonWorkspaceOpen(true)}
>
Yard Workspace
</Button>
<>
<Button
variant="light"
color="edr-green"
leftSection={<Warehouse size={16} />}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setWagonWorkspaceOpen(true)}
>
Yard Workspace
</Button>
<Button
variant="light"
color="grape"
leftSection={<Inbox size={16} />}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setTransferRequestsOpen(true)}
>
Transfer Requests
</Button>
</>
) : null}
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
setEditing(null);
@@ -605,6 +618,13 @@ const FleetResourcePage = () => {
/>
) : null}
{slug === "wagons" ? (
<WagonTransferRequestsModal
opened={transferRequestsOpen}
onClose={() => setTransferRequestsOpen(false)}
/>
) : null}
{slug === "wagons" ? (
<WagonMovementHistoryModal
opened={Boolean(historyTarget)}

View File

@@ -54,8 +54,8 @@ interface CargoNode extends RuleEngineRecord {
requiresDirectorApproval?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
unitOfMeasure?: string | null;
/** Wagon type FK used to carry this bulk cargo during scheduling; null if unset. */
wagonTypeId?: string | null;
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
wagonTypes?: { id: string; code?: string; name?: string }[];
isActive?: boolean;
displayOrder?: number;
}
@@ -82,16 +82,19 @@ const FORM_FIELDS: FormFieldDef[] = [
],
},
{
// Wagon type that carries this (bulk) commodity — drives train-scheduling
// wagon resolution. Optional: leave "None" for grouping categories and
// container/legacy cargo; set it on scheduled bulk commodities.
// Wagon types that can carry this (bulk) commodity — drive train-scheduling
// wagon resolution (the plan uses whichever type the train/yard has).
// Optional: leave empty for grouping categories and container/legacy cargo;
// set them on scheduled bulk commodities.
// Options injected at render from useWagonTypeOptions.
name: "wagonTypeId",
label: "Wagon type",
type: "select",
name: "wagonTypeIds",
label: "Wagon types",
type: "multiselect",
optional: true,
placeholder: "Select wagon type (bulk cargo)",
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }],
placeholder: "Select wagon types (bulk cargo)",
options: [],
getInitialValue: (record) =>
((record.wagonTypes as { id: string }[] | undefined) ?? []).map((wt) => wt.id),
},
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
@@ -119,19 +122,13 @@ const CargoTypesPage = () => {
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
// Wagon-type options for the "Wagon type" picker (bulk cargo → wagon FK).
// Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
const formFields = useMemo<FormFieldDef[]>(
() =>
FORM_FIELDS.map((field) =>
field.name === "wagonTypeId"
? {
...field,
options: [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
...(wagonTypeOptions ?? []),
],
}
field.name === "wagonTypeIds"
? { ...field, options: wagonTypeOptions ?? [] }
: field,
),
[wagonTypeOptions],

View File

@@ -153,7 +153,9 @@ const RuleEngineResourcePage = () => {
config?.formFields.some((f) => f.name === "rateId"),
);
const usesWagonTypeField = Boolean(
config?.formFields.some((f) => f.name === "wagonTypeId"),
config?.formFields.some(
(f) => f.name === "wagonTypeId" || f.name === "wagonTypeIds",
),
);
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
@@ -206,6 +208,13 @@ const RuleEngineResourcePage = () => {
options: wagonTypeOptions ?? [],
};
}
if (field.name === "wagonTypeIds") {
return {
...field,
type: "multiselect" as const,
options: wagonTypeOptions ?? [],
};
}
return field;
});
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);

View File

@@ -55,6 +55,12 @@ export interface FormFieldDef {
* from the fleet config's string-based `dynamicOptions` to avoid a clash.)
*/
optionsFromValues?: (values: Record<string, unknown>) => { label: string; value: string }[];
/**
* Derive the field's initial form value from the record being edited when it
* doesn't live under `record[name]` — e.g. a multiselect of ids backed by a
* relation list (`wagonTypeIds` read from `record.wagonTypes`).
*/
getInitialValue?: (record: Record<string, unknown>) => unknown;
}
export interface RuleEngineOrderConfig {
@@ -258,11 +264,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
// Options injected at render from useWagonTypeOptions (RuleEngineResourcePage).
{
name: "wagonTypeId",
label: "Wagon type",
type: "select",
name: "wagonTypeIds",
label: "Wagon types",
type: "multiselect",
required: true,
description: "Wagon type used to carry this container during train scheduling.",
description:
"Wagon types that can carry this container during train scheduling (one container size per wagon at a time).",
getInitialValue: (record) =>
((record.wagonTypes as { id: string }[] | undefined) ?? []).map((wt) => wt.id),
},
{ name: "isOpenTop", label: "Open top", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },

View File

@@ -0,0 +1,355 @@
import {
Alert,
Badge,
Button,
Card,
Grid,
Group,
Menu,
Modal,
Progress,
Stack,
Text,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import {
AlertTriangle,
CalendarClock,
MoreHorizontal,
Replace,
Ruler,
Trash2,
Train as TrainIcon,
TrainFront,
Weight,
} from "lucide-react";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import TrainConsistStrip from "@/components/trainBuilder/TrainConsistStrip";
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
};
/** Utilization bar color: green while safe, amber when close, red when over. */
const utilizationColor = (pct: number | null) => {
if (pct == null) return "gray";
if (pct > 100) return "red";
if (pct > 85) return "yellow";
return "edr-green";
};
/**
* Train Builder workspace for one train: the visual consist, the wagon yard
* panel, and the locomotive set — everything needed to (re)compose the train.
*/
export default function TrainBuilderDetailPage() {
const { id = "" } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const [locoModalOpen, setLocoModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false);
const compositionQuery = useQuery(
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
);
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
const composition = compositionQuery.data;
const busy =
assignWagons.isPending || removeWagon.isPending || reorderWagons.isPending;
const withToast = async (action: () => Promise<unknown>, failTitle: string) => {
try {
await action();
} catch (err) {
toast({
title: failTitle,
description: parseError(err, "Something went wrong"),
variant: "destructive",
});
}
};
if (compositionQuery.isLoading) {
return (
<PageContainer>
<Text py="xl" ta="center" c="dimmed">
Loading train
</Text>
</PageContainer>
);
}
if (compositionQuery.isError || !composition) {
return (
<PageContainer>
<Alert color="red" icon={<AlertTriangle size={16} />}>
Failed to load this train.{" "}
<Button variant="subtle" size="compact-sm" onClick={() => compositionQuery.refetch()}>
Retry
</Button>
</Alert>
</PageContainer>
);
}
const { totals } = composition;
const yard = composition.currentYard;
return (
<PageContainer>
<PageHeader
title={`Train ${composition.code}`}
subtitle={
composition.trainName
? `${composition.trainName} · built in ${yard?.label ?? "unknown yard"}`
: `Built in ${yard?.label ?? "unknown yard"}`
}
backTo="/dashboard/train-builder"
meta={
<Badge color={trainStatusColor(composition.status)} variant="light">
{trainStatusLabel(composition.status)}
</Badge>
}
action={
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
<Menu.Target>
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
Actions
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Replace size={15} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Change locomotives
</Menu.Item>
<Menu.Item
color="red"
leftSection={<Trash2 size={15} />}
disabled={composition.activeSchedules.length > 0}
onClick={() => setDisbandOpen(true)}
>
Disband train
</Menu.Item>
</Menu.Dropdown>
</Menu>
}
/>
<KpiStrip
items={[
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
{
label: "Max gross / haul limit",
value: `${totals.maxGrossTons}T / ${totals.maxPullWeightTons}T`,
icon: Weight,
},
{
label: "Length / limit",
value: `${totals.totalLengthMeters}m / ${totals.maxTrainLengthMeters}m`,
icon: Ruler,
},
]}
/>
{!composition.editable ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
This train is out on a dispatched run its composition is frozen until arrival.
</Alert>
) : null}
<Card>
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600}>Consist</Text>
<Text size="xs" c="dimmed">
{composition.locomotives.length} locomotive
{composition.locomotives.length === 1 ? "" : "s"} · {totals.wagonCount} wagon
{totals.wagonCount === 1 ? "" : "s"}
</Text>
</Group>
<TrainConsistStrip
locomotives={composition.locomotives}
wagons={composition.wagons}
/>
<Grid gap="lg">
<Grid.Col span={{ base: 12, sm: 6 }}>
<UtilizationBar
label="Weight utilization (fully loaded)"
pct={totals.weightUtilizationPct}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<UtilizationBar label="Length utilization" pct={totals.lengthUtilizationPct} />
</Grid.Col>
</Grid>
</Stack>
</Card>
<Grid gap="lg" align="stretch">
{composition.editable ? (
<Grid.Col span={{ base: 12, md: 5 }}>
<Card h="100%">
<Stack gap="sm">
<Text fw={600}>Available wagons {yard?.label ?? "yard"}</Text>
<Text size="xs" c="dimmed">
Only AVAILABLE wagons standing in the train's own yard can be coupled.
</Text>
<AvailableWagonsPanel
yardId={yard?.id ?? ""}
yardLabel={yard?.label}
assigning={assignWagons.isPending}
onAssign={(wagonIds) =>
void withToast(
() => assignWagons.mutateAsync({ id: composition.id, wagonIds }),
"Could not add wagons",
)
}
/>
</Stack>
</Card>
</Grid.Col>
) : null}
<Grid.Col span={{ base: 12, md: composition.editable ? 7 : 12 }}>
<Card h="100%">
<Stack gap="sm">
<Text fw={600}>Wagon order</Text>
<Text size="xs" c="dimmed">
Drag to reorder position 1 couples right behind the locomotives.
</Text>
<ConsistWagonList
wagons={composition.wagons}
editable={composition.editable}
busy={busy}
onReorder={(wagonIds) =>
void withToast(
() => reorderWagons.mutateAsync({ id: composition.id, wagonIds }),
"Could not reorder wagons",
)
}
onRemove={(wagonId) =>
void withToast(
() => removeWagon.mutateAsync({ id: composition.id, wagonId }),
"Could not detach wagon",
)
}
/>
</Stack>
</Card>
</Grid.Col>
</Grid>
{composition.activeSchedules.length ? (
<Card>
<Stack gap="sm">
<Text fw={600}>Upcoming runs</Text>
{composition.activeSchedules.map((schedule) => (
<Group key={schedule.id} justify="space-between">
<Group gap="sm">
<CalendarClock size={15} color="var(--mantine-color-gray-6)" />
<Text size="sm" ff="monospace" fw={600}>
{schedule.reference ?? schedule.id.slice(0, 8)}
</Text>
<Badge size="sm" variant="light">
{schedule.status}
</Badge>
</Group>
<Button
variant="subtle"
size="compact-sm"
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
>
Open schedule
</Button>
</Group>
))}
</Stack>
</Card>
) : null}
<ChangeLocomotivesModal
composition={composition}
opened={locoModalOpen}
onClose={() => setLocoModalOpen(false)}
/>
<Modal
opened={disbandOpen}
onClose={() => setDisbandOpen(false)}
title={<Text fw={600}>Disband train {composition.code}?</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
All wagons and locomotives are released back to{" "}
{yard?.label ?? "their yard"} and the train is deleted. This cannot be undone.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDisbandOpen(false)}>
Keep train
</Button>
<Button
color="red"
loading={disband.isPending}
onClick={() =>
void withToast(async () => {
await disband.mutateAsync(composition.id);
toast({ title: `Train ${composition.code} disbanded` });
navigate("/dashboard/train-builder");
}, "Could not disband train")
}
>
Disband
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}
function UtilizationBar({ label, pct }: { label: string; pct: number | null }) {
return (
<Stack gap={4}>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" fw={600} c={pct != null && pct > 100 ? "red" : undefined}>
{pct != null ? `${pct}%` : "—"}
</Text>
</Group>
<Progress
value={Math.min(pct ?? 0, 100)}
color={utilizationColor(pct)}
size="sm"
radius="xl"
/>
</Stack>
);
}

View File

@@ -0,0 +1,336 @@
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import {
Badge,
Box,
Button,
Card,
Group,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
Hammer,
Ruler,
Search,
Train as TrainIcon,
TrainFront,
Weight,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
import { api } from "@/services/api";
import type {
BuiltTrainListFilters,
BuiltTrainStatus,
BuiltTrainSummary,
} from "@/services/trainBuilder.service";
/**
* Train Builder board: every built train (code, yard, locomotive set, consist
* totals, lifecycle status) plus the entry point for building a new one.
*/
export default function TrainBuilderListPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [statusFilter, setStatusFilter] = useState<"ALL" | BuiltTrainStatus>("ALL");
const [yardFilter, setYardFilter] = useState("ALL");
const [buildOpen, setBuildOpen] = useState(false);
const resetPage = useCallback(() => {
setPagination((prev) =>
prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 },
);
}, [setPagination]);
useEffect(() => {
resetPage();
}, [debouncedSearch, resetPage]);
const filters = useMemo<BuiltTrainListFilters>(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
...(yardFilter !== "ALL" ? { currentYardId: yardFilter } : {}),
sortBy: "createdAt",
sortOrder: "DESC",
}),
[
pagination.pageIndex,
pagination.pageSize,
debouncedSearch,
statusFilter,
yardFilter,
],
);
const trainsQuery = useQuery(
api.trainBuilder.list.queryOptions({
input: { filters },
placeholderData: keepPreviousData,
staleTime: 30_000,
}),
);
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
const trains = trainsQuery.data?.items ?? [];
const totalTrains = trainsQuery.data?.meta.total ?? 0;
const pageCount = Math.max(1, trainsQuery.data?.meta.totalPages ?? 1);
const stats = useMemo(() => {
const base = { available: 0, scheduled: 0, inService: 0, wagons: 0 };
for (const train of trains) {
if (train.status === "AVAILABLE") base.available += 1;
if (train.status === "SCHEDULED") base.scheduled += 1;
if (train.status === "IN_SERVICE") base.inService += 1;
base.wagons += train.wagonCount;
}
return base;
}, [trains]);
const yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
})),
[yardsQuery.data],
);
const columns = useMemo((): ColumnDef<BuiltTrainSummary>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "code",
header: "Train",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap="sm" wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 9,
background: "var(--mantine-color-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
flexShrink: 0,
}}
>
<TrainIcon size={16} />
</Box>
<Stack gap={0}>
<Text size="sm" fw={600} ff="monospace" lh={1.2}>
{row.original.code}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{row.original.trainName ?? "—"}
</Text>
</Stack>
</Group>
),
},
{
id: "yard",
header: "Yard",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm">{row.original.currentYard?.label ?? "—"}</Text>
),
},
{
id: "locomotives",
header: "Locomotives",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const locos = row.original.locomotives;
if (!locos.length) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<Group gap={6} wrap="nowrap">
<TrainFront size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
{locos.map((l) => l.code).join(" + ")}
</Text>
</Group>
);
},
},
{
id: "consist",
header: "Consist",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm">
{row.original.wagonCount} wagons · {row.original.maxGrossTons}T ·{" "}
{row.original.totalLengthMeters}m
</Text>
),
},
{
id: "capacity",
header: "Haul limit",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm" fw={500}>
{row.original.maxPullWeightTons}T
</Text>
),
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge color={trainStatusColor(row.original.status)} variant="light">
{trainStatusLabel(row.original.status)}
</Badge>
),
},
];
}, []);
const tableStatus = trainsQuery.isLoading
? "loading"
: trainsQuery.isError
? "error"
: "success";
return (
<PageContainer>
<PageHeader
title="Train Builder"
subtitle="Assemble coded trains from locomotives and wagons in a yard, ready to schedule as a unit."
action={
<Button leftSection={<Hammer size={18} />} onClick={() => setBuildOpen(true)}>
Build train
</Button>
}
/>
<KpiStrip
items={[
{ label: "Trains", value: totalTrains, icon: TrainIcon },
{ label: "Available", value: stats.available, icon: Hammer },
{ label: "Scheduled / in service", value: stats.scheduled + stats.inService, icon: Weight },
{ label: "Wagons coupled", value: stats.wagons, icon: Ruler },
]}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group gap="sm" wrap="wrap">
<TextInput
size="sm"
radius="lg"
placeholder="Search by code or name…"
leftSection={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1, minWidth: 220 }}
/>
<Select
size="sm"
radius="lg"
value={statusFilter}
onChange={(v) => {
if (!v) return;
setStatusFilter(v as "ALL" | BuiltTrainStatus);
resetPage();
}}
data={[
{ value: "ALL", label: "All statuses" },
{ value: "AVAILABLE", label: "Available" },
{ value: "SCHEDULED", label: "Scheduled" },
{ value: "IN_SERVICE", label: "In service" },
{ value: "UNDER_MAINTENANCE", label: "Under maintenance" },
{ value: "OUT_OF_SERVICE", label: "Out of service" },
]}
w={180}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
placeholder="Yard"
searchable
value={yardFilter}
onChange={(v) => {
setYardFilter(v ?? "ALL");
resetPage();
}}
data={[{ value: "ALL", label: "All yards" }, ...yardOptions]}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
</Group>
</Box>
<DataTable
columns={columns}
data={trains}
status={tableStatus}
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
error={
trainsQuery.isError
? {
message: "Failed to load trains.",
onRetry: () => void trainsQuery.refetch(),
}
: undefined
}
emptyMessage="No trains built yet — build the first one"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: totalTrains,
}}
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: "trains" } }}
/>
)}
/>
</Stack>
</Card>
<BuildTrainModal
opened={buildOpen}
onClose={() => setBuildOpen(false)}
onBuilt={(composition) => navigate(`/dashboard/train-builder/${composition.id}`)}
/>
</PageContainer>
);
}

View File

@@ -7,7 +7,6 @@ import {
Group,
Menu,
Modal,
MultiSelect,
Select,
SimpleGrid,
Stack,
@@ -41,10 +40,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
import {
locomotiveOption,
showScheduleWarnings,
} from "@/components/trainScheduling/locomotiveOptions";
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
import {
RouteCorridor,
StatusPill,
@@ -119,7 +115,7 @@ export default function TrainScheduleV2ListPage() {
useState<TrainScheduleListItem | null>(null);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const [trainId, setTrainId] = useState("");
// Recomputed each time the create modal opens so a long-lived tab can't keep
// offering a stale "now" as the earliest selectable departure.
const minScheduleDate = useMemo(
@@ -186,9 +182,10 @@ export default function TrainScheduleV2ListPage() {
const routesQuery = useQuery(
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
);
const locomotivesQuery = useQuery(
api.trainScheduling.availableLocomotives.queryOptions({
input: { routeId: routeId || undefined },
const trainsQuery = useQuery(
api.trainScheduling.availableTrains.queryOptions({
input: { routeId },
enabled: Boolean(routeId),
}),
);
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
@@ -203,17 +200,17 @@ export default function TrainScheduleV2ListPage() {
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
const locomotiveYardHint = useMemo(() => {
const trainYardHint = useMemo(() => {
if (!selectedRoute) return "Select a route first";
const originLabel =
selectedRoute.originYard?.label ??
selectedRoute.originYard?.code ??
"the route origin yard";
return `All in-service locomotives are shown — those not yet at ${originLabel} or already on future schedules are flagged`;
return `All schedulable built trains are shown — those not yet at ${originLabel} or already on future schedules are flagged`;
}, [selectedRoute]);
useEffect(() => {
setLocomotiveIds([]);
setTrainId("");
}, [routeId]);
// Filtering, sorting, and paging all happen server-side — `schedules` IS the
@@ -326,10 +323,29 @@ export default function TrainScheduleV2ListPage() {
cell: ({ row }) => <FreightTypeBadge freightType={row.original.freightType} />,
},
{
id: "loco",
header: "Locomotives",
id: "train",
header: "Train",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
// Schedules created from the Train Builder carry the train code;
// legacy rows fall back to their locomotive set.
if (row.original.train) {
return (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Stack gap={0}>
<Text size="sm" fw={600} ff="monospace" lh={1.2}>
{row.original.train.code}
</Text>
{row.original.train.trainName ? (
<Text size="xs" c="dimmed" lh={1.2}>
{row.original.train.trainName}
</Text>
) : null}
</Stack>
</Group>
);
}
const locos =
row.original.locomotives && row.original.locomotives.length > 0
? row.original.locomotives
@@ -456,9 +472,9 @@ export default function TrainScheduleV2ListPage() {
}, [navigate, cancel.isPending, cancel, toast]);
const handleCreate = async () => {
if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
if (!routeId || !scheduleDate || !trainId) {
toast({
title: "Select route, date, and at least two locomotives",
title: "Select route, date, and the train to run",
variant: "destructive",
});
return;
@@ -475,7 +491,7 @@ export default function TrainScheduleV2ListPage() {
payload: {
routeId,
scheduleDate: new Date(scheduleDate).toISOString(),
locomotiveIds,
trainId,
},
});
toast({ title: "Train schedule created" });
@@ -728,7 +744,7 @@ export default function TrainScheduleV2ListPage() {
/>
{routeId ? (
<Text size="xs" c="dimmed">
{locomotiveYardHint}
{trainYardHint}
</Text>
) : null}
<TextInput
@@ -738,24 +754,26 @@ export default function TrainScheduleV2ListPage() {
value={scheduleDate}
onChange={(e) => setScheduleDate(e.currentTarget.value)}
/>
<MultiSelect
label="Locomotives"
description="A train must be pulled by at least two locomotives (front and back)"
placeholder={
routeId ? "Select at least two locomotives" : "Select a route first"
}
data={(locomotivesQuery.data ?? []).map((l) => locomotiveOption(l))}
value={locomotiveIds}
onChange={setLocomotiveIds}
<Select
label="Train"
description="A built train (Train Builder) runs this departure with its locomotives and wagons"
placeholder={routeId ? "Select a train" : "Select a route first"}
data={(trainsQuery.data ?? []).map((train) => ({
value: train.id,
label: `${train.code}${train.trainName ? `${train.trainName}` : ""} · ${
train.locomotives.length
} locos · ${train.wagonCount} wagons${train.atOriginYard ? "" : " · not at origin yard"}${
train.futureScheduleCount ? ` · ${train.futureScheduleCount} future run(s)` : ""
}`,
}))}
value={trainId || null}
onChange={(v) => setTrainId(v ?? "")}
searchable
disabled={!routeId}
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
}
nothingFoundMessage={
routeId ? "No available locomotives for this corridor" : "Select a route first"
routeId
? "No built trains yet — assemble one in the Train Builder first"
: "Select a route first"
}
/>
<Group justify="flex-end">