mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
refactor(train-scheduling): move the schedule list onto the shared FilterBar
The list was the last major board still on ad-hoc filtering: usePagination plus FleetToolbar's hand-rolled Selects, a debounced search and a manual sort Select, assembled into a filters object by hand. It now uses useFilters/FilterBar like every other list, so pagination, search, sort and filters all travel as URL params and a link reproduces the view. Origin and destination ride the shared Route filter, which no longer requires both ends — filtering by origin alone stays possible, and each end now takes several stations. The card/table view toggle moves into FilterBar's children slot, and the row tinting for shipping-line and direction is untouched.
This commit is contained in:
@@ -201,16 +201,20 @@ export function StatTile({
|
||||
/**
|
||||
* Origin → destination corridor visual: two anchored stops joined by a rail
|
||||
* line. `variant="compact"` is for dense table rows; `default` for cards.
|
||||
* `orientation="vertical"` stacks the stops as waypoints, which keeps a long
|
||||
* yard name off one wide line in a table cell.
|
||||
*/
|
||||
export function RouteCorridor({
|
||||
origin,
|
||||
destination,
|
||||
variant = "default",
|
||||
orientation = "horizontal",
|
||||
onDark = false,
|
||||
}: {
|
||||
origin?: string | null;
|
||||
destination?: string | null;
|
||||
variant?: "default" | "compact";
|
||||
orientation?: "horizontal" | "vertical";
|
||||
onDark?: boolean;
|
||||
}) {
|
||||
const compact = variant === "compact";
|
||||
@@ -218,6 +222,48 @@ export function RouteCorridor({
|
||||
const strong = onDark ? "white" : "var(--mantine-color-gray-8)";
|
||||
const lineColor = onDark ? "rgba(255,255,255,0.4)" : "var(--mantine-color-gray-3)";
|
||||
const accent = onDark ? "white" : freightBrand.primary;
|
||||
const dot = compact ? 7 : 9;
|
||||
|
||||
if (orientation === "vertical") {
|
||||
return (
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={dot}
|
||||
h={dot}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
border: `2px solid ${accent}`,
|
||||
background: onDark ? "transparent" : "white",
|
||||
}}
|
||||
/>
|
||||
<Text size="sm" fw={600} c={strong} lh={1.2} truncate>
|
||||
{origin ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
{/* Rail between the stops, aligned to the dot centres. */}
|
||||
<Box
|
||||
ml={dot / 2 - 1}
|
||||
style={{
|
||||
width: 0,
|
||||
height: compact ? 10 : 14,
|
||||
borderLeft: `2px dashed ${lineColor}`,
|
||||
}}
|
||||
/>
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={dot}
|
||||
h={dot}
|
||||
style={{ borderRadius: 999, flexShrink: 0, background: accent }}
|
||||
/>
|
||||
<Text size="sm" fw={600} c={strong} lh={1.2} truncate>
|
||||
{destination ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={compact ? 6 : 8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -19,7 +21,6 @@ import {
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -27,27 +28,33 @@ import {
|
||||
CalendarClock,
|
||||
Clock,
|
||||
Eye,
|
||||
LayoutGrid,
|
||||
MoreHorizontal,
|
||||
Navigation,
|
||||
Pencil,
|
||||
Play,
|
||||
Send,
|
||||
Table2,
|
||||
Train,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import {
|
||||
FilterBar,
|
||||
routeParams,
|
||||
toRuleEngineFooterProps,
|
||||
useFilters,
|
||||
type FilterDef,
|
||||
type SortOption,
|
||||
} from "@/components/filters";
|
||||
import { useFleetViewMode, type FleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import {
|
||||
directionColor,
|
||||
directionRowStyle,
|
||||
} from "@/components/trainBuilder/trainStatus";
|
||||
import { directionColor, directionRowStyle } from "@/components/trainBuilder/trainStatus";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
import CreateScheduleWindowFields, {
|
||||
buildWindowRulePayload,
|
||||
@@ -56,10 +63,7 @@ import CreateScheduleWindowFields, {
|
||||
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
|
||||
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
|
||||
import { ExportButton } from "@/components/export/ExportButton";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatusPill,
|
||||
} from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { formatRouteLabel } from "@/services/routes.service";
|
||||
@@ -68,12 +72,34 @@ import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, canCreateSchedule, hasPermission } from "@/lib/permissions";
|
||||
import type {
|
||||
CreateScheduleWindowRulePayload,
|
||||
FreightType,
|
||||
TrainScheduleListFilters,
|
||||
TrainScheduleListItem,
|
||||
TrainScheduleStatus,
|
||||
} from "@/types/trainScheduling";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
import { DataTable, DataTableFooter } from "@edr/ui-common";
|
||||
|
||||
const SCHEDULE_STATUS_OPTIONS = [
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "SCHEDULED", label: "Scheduled" },
|
||||
{ value: "DISPATCHED", label: "Dispatched" },
|
||||
{ value: "ARRIVED", label: "Arrived" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
];
|
||||
|
||||
const FREIGHT_TYPE_OPTIONS = [
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
{ value: "MIXED", label: "Mixed" },
|
||||
];
|
||||
|
||||
/** Server sort fields (TRAIN_SCHEDULE_SORT_FIELDS) in the shared "field:DIR" form. */
|
||||
const SORT_OPTIONS: SortOption[] = [
|
||||
{ value: "createdAt:DESC", label: "Newest created" },
|
||||
{ value: "createdAt:ASC", label: "Oldest created" },
|
||||
{ value: "scheduledDepartureDate:DESC", label: "Departure ↓" },
|
||||
{ value: "scheduledDepartureDate:ASC", label: "Departure ↑" },
|
||||
{ value: "reference:ASC", label: "Reference ↑" },
|
||||
{ value: "reference:DESC", label: "Reference ↓" },
|
||||
];
|
||||
|
||||
/** `min` for a `datetime-local` input: now, in the browser's local zone. */
|
||||
const nowLocalDateTime = () => {
|
||||
@@ -115,34 +141,16 @@ export default function TrainScheduleV2ListPage() {
|
||||
const canCreate = canCreateSchedule(user);
|
||||
const canDispatch = hasPermission(user, FREIGHT_PERMS.trainScheduling.dispatch);
|
||||
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | TrainScheduleStatus>("ALL");
|
||||
const [freightFilter, setFreightFilter] = useState<"ALL" | FreightType>("ALL");
|
||||
// Origin/destination hold yard IDs ("ALL" = no filter); the server matches
|
||||
// the schedule's origin_station_id / destination_station_id exactly.
|
||||
const [originFilter, setOriginFilter] = useState("ALL");
|
||||
const [destinationFilter, setDestinationFilter] = useState("ALL");
|
||||
// Default: newest-created first, matching the API's default order. Values
|
||||
// are the server sort fields (see TRAIN_SCHEDULE_SORT_FIELDS).
|
||||
const [sortBy, setSortBy] = useState<
|
||||
"createdAt" | "scheduledDepartureDate" | "reference"
|
||||
>("createdAt");
|
||||
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
|
||||
// Dispatch is irreversible from this screen, so it goes through an explicit
|
||||
// confirmation.
|
||||
const [dispatchTarget, setDispatchTarget] =
|
||||
useState<TrainScheduleListItem | null>(null);
|
||||
const [dispatchTarget, setDispatchTarget] = useState<TrainScheduleListItem | null>(null);
|
||||
// Actual departure — defaults to now when the dialog opens; past is fine.
|
||||
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
||||
// Cancelling is likewise irreversible — confirmed before the mutation fires.
|
||||
const [cancelTarget, setCancelTarget] =
|
||||
useState<TrainScheduleListItem | null>(null);
|
||||
const [editDateSchedule, setEditDateSchedule] =
|
||||
useState<TrainScheduleListItem | null>(null);
|
||||
const [cancelTarget, setCancelTarget] = useState<TrainScheduleListItem | null>(null);
|
||||
const [editDateSchedule, setEditDateSchedule] = useState<TrainScheduleListItem | null>(null);
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [scheduleDate, setScheduleDate] = useState("");
|
||||
const [trainId, setTrainId] = useState("");
|
||||
@@ -156,51 +164,59 @@ export default function TrainScheduleV2ListPage() {
|
||||
const [windowForm, setWindowForm] = useState<WindowFormState | null>(null);
|
||||
// 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(
|
||||
() => (createOpen ? nowLocalDateTime() : ""),
|
||||
[createOpen],
|
||||
const minScheduleDate = useMemo(() => (createOpen ? nowLocalDateTime() : ""), [createOpen]);
|
||||
|
||||
// Yard options for the origin/destination filters (shared routes reference
|
||||
// list, so the choices don't shrink to whatever the current page shows).
|
||||
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
|
||||
const yardOptions = useMemo(
|
||||
() =>
|
||||
(yardsQuery.data ?? []).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code,
|
||||
})),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
|
||||
const resetPage = useCallback(() => {
|
||||
setPagination((prev) =>
|
||||
prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 },
|
||||
);
|
||||
}, [setPagination]);
|
||||
// One Route pill covering both ends. It is the paired `route` type — which
|
||||
// no longer forces both sides to be filled — so filtering by origin alone,
|
||||
// by destination alone, or by several yards per side all still work, and the
|
||||
// two ends read as the one thing an operator is actually picking.
|
||||
const scheduleFilterDefs: FilterDef[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: SCHEDULE_STATUS_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "freightType",
|
||||
label: "Freight",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: FREIGHT_TYPE_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "route",
|
||||
label: "Route",
|
||||
type: "route",
|
||||
options: yardOptions,
|
||||
toParams: routeParams("originStationId", "destinationStationId"),
|
||||
},
|
||||
],
|
||||
[yardOptions],
|
||||
);
|
||||
|
||||
// Search resets the page only once the debounced value settles — resetting
|
||||
// per keystroke would refetch page 1 mid-typing.
|
||||
useEffect(() => {
|
||||
resetPage();
|
||||
}, [debouncedSearch, resetPage]);
|
||||
const controls = useFilters(scheduleFilterDefs, {
|
||||
defaultSort: "createdAt:DESC",
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
// Fully server-driven list: pagination, search, filters, and sort all travel
|
||||
// as query params; the response envelope carries the page + totals.
|
||||
const filters = useMemo<TrainScheduleListFilters>(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
||||
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
|
||||
...(freightFilter !== "ALL" ? { freightType: freightFilter } : {}),
|
||||
...(originFilter !== "ALL" ? { originStationId: originFilter } : {}),
|
||||
...(destinationFilter !== "ALL"
|
||||
? { destinationStationId: destinationFilter }
|
||||
: {}),
|
||||
sortBy,
|
||||
sortOrder: sortDir === "asc" ? "ASC" : "DESC",
|
||||
}),
|
||||
[
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
debouncedSearch,
|
||||
statusFilter,
|
||||
freightFilter,
|
||||
originFilter,
|
||||
destinationFilter,
|
||||
sortBy,
|
||||
sortDir,
|
||||
],
|
||||
);
|
||||
const filters = controls.params as unknown as TrainScheduleListFilters;
|
||||
|
||||
const schedulesQuery = useQuery(
|
||||
api.trainScheduling.scheduleList.queryOptions({
|
||||
@@ -212,14 +228,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
);
|
||||
// Yard options for the origin/destination filters (shared routes reference
|
||||
// list, so the choices don't shrink to whatever the current page shows).
|
||||
const yardsQuery = useQuery(
|
||||
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
|
||||
);
|
||||
const routesQuery = useQuery(
|
||||
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
|
||||
);
|
||||
const routesQuery = useQuery(api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }));
|
||||
const trainsQuery = useQuery(
|
||||
api.trainScheduling.availableTrains.queryOptions({
|
||||
input: { routeId },
|
||||
@@ -236,9 +245,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
}),
|
||||
);
|
||||
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
||||
const dispatchSchedule = useMutation(
|
||||
api.trainScheduling.dispatchSchedule.mutationOptions(),
|
||||
);
|
||||
const dispatchSchedule = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
||||
|
||||
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
|
||||
@@ -253,9 +260,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
const trainYardHint = useMemo(() => {
|
||||
if (!selectedRoute) return "Select a route first";
|
||||
const originLabel =
|
||||
selectedRoute.originYard?.label ??
|
||||
selectedRoute.originYard?.code ??
|
||||
"the route origin yard";
|
||||
selectedRoute.originYard?.label ?? selectedRoute.originYard?.code ?? "the route origin yard";
|
||||
return `All schedulable built trains are shown — those not yet at ${originLabel} or already on future schedules are flagged`;
|
||||
}, [selectedRoute]);
|
||||
|
||||
@@ -267,7 +272,6 @@ export default function TrainScheduleV2ListPage() {
|
||||
// current page, and the meta envelope carries the totals.
|
||||
const schedules = schedulesQuery.data?.items ?? [];
|
||||
const totalSchedules = schedulesQuery.data?.meta.total ?? 0;
|
||||
const pageCount = Math.max(1, schedulesQuery.data?.meta.totalPages ?? 1);
|
||||
|
||||
// Status/weight tiles count the visible page only — board-wide numbers would
|
||||
// need a dedicated summary endpoint now that the list is server-paginated.
|
||||
@@ -287,92 +291,62 @@ export default function TrainScheduleV2ListPage() {
|
||||
return base;
|
||||
}, [schedules]);
|
||||
|
||||
// Corridor filter options: every yard from the shared reference list, sent
|
||||
// to the server as origin/destination station IDs.
|
||||
const yardOptions = useMemo(
|
||||
() =>
|
||||
(yardsQuery.data ?? []).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code,
|
||||
})),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
|
||||
const columns = useMemo((): ColumnDef<TrainScheduleListItem>[] => {
|
||||
const headerClassName = ruleEngineTable.headerCell;
|
||||
const cellClassName = ruleEngineTable.bodyCell;
|
||||
return [
|
||||
{
|
||||
id: "reference",
|
||||
header: "Ref",
|
||||
// Train, reference and status share one identity column — three
|
||||
// stacked lines cost the width of the widest, not three columns.
|
||||
id: "train",
|
||||
header: "Train",
|
||||
size: 170,
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} ff="monospace" c="edr-green.8">
|
||||
{row.original.reference ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
cell: ({ row }) => <TrainIdentityCell schedule={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "date",
|
||||
header: "Departure",
|
||||
size: 110,
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
const { day, time } = splitDate(row.original.scheduleDate);
|
||||
return (
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<CalendarClock size={16} />
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600} lh={1.2}>
|
||||
{day}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{time || "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600} lh={1.2}>
|
||||
{day}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{time || "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: "Route",
|
||||
size: 280,
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={4}>
|
||||
<Stack gap={6}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} lh={1.2}>
|
||||
{row.original.routeName ?? "—"}
|
||||
</Text>
|
||||
{row.original.direction ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={directionColor(row.original.direction)}
|
||||
>
|
||||
<Badge size="xs" variant="light" color={directionColor(row.original.direction)}>
|
||||
{row.original.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
<ShippingLineBadge schedule={row.original} />
|
||||
</Group>
|
||||
<Box maw={220}>
|
||||
<Box maw={260}>
|
||||
<RouteCorridor
|
||||
origin={row.original.origin}
|
||||
destination={row.original.destination}
|
||||
variant="compact"
|
||||
orientation="vertical"
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -384,59 +358,6 @@ export default function TrainScheduleV2ListPage() {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => <FreightTypeBadge freightType={row.original.freightType} />,
|
||||
},
|
||||
{
|
||||
id: "train",
|
||||
header: "Train",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
// Schedules created from the Train Builder show the direction-matched
|
||||
// run number first (falling back to the train code); legacy rows fall
|
||||
// back to their locomotive set.
|
||||
if (row.original.train) {
|
||||
const subtitle = [row.original.trainNumber ? row.original.train.code : null,
|
||||
row.original.train.trainName]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
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.trainNumber ?? row.original.train.code}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
const locos =
|
||||
row.original.locomotives && row.original.locomotives.length > 0
|
||||
? row.original.locomotives
|
||||
: row.original.locomotive
|
||||
? [row.original.locomotive]
|
||||
: [];
|
||||
if (!locos.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Train size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" fw={500}>
|
||||
{locos[0].code}
|
||||
{locos.length > 1 ? ` +${locos.length - 1}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "metrics",
|
||||
header: "Load",
|
||||
@@ -449,15 +370,9 @@ export default function TrainScheduleV2ListPage() {
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => <StatusPill status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size:32,
|
||||
size: 32,
|
||||
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
|
||||
cell: ({ row }) => {
|
||||
const schedule = row.original;
|
||||
@@ -482,9 +397,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
<Menu.Item
|
||||
leftSection={<Navigation size={15} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
|
||||
)
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`)
|
||||
}
|
||||
>
|
||||
Track
|
||||
@@ -561,10 +474,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
toast({ title: "Booking window settings are still loading", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
const built = buildWindowRulePayload(
|
||||
windowForm,
|
||||
selectedRoute?.direction === "EXPORT",
|
||||
);
|
||||
const built = buildWindowRulePayload(windowForm, selectedRoute?.direction === "EXPORT");
|
||||
if ("error" in built) {
|
||||
toast({ title: built.error, variant: "destructive" });
|
||||
return;
|
||||
@@ -632,115 +542,42 @@ export default function TrainScheduleV2ListPage() {
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<FleetToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
<FilterBar
|
||||
defs={scheduleFilterDefs}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search schedules…"
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
<>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
setStatusFilter(v as "ALL" | TrainScheduleStatus);
|
||||
resetPage();
|
||||
}}
|
||||
data={[
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "SCHEDULED", label: "Scheduled" },
|
||||
{ value: "DISPATCHED", label: "Dispatched" },
|
||||
{ value: "ARRIVED", label: "Arrived" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
]}
|
||||
w={150}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={freightFilter}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
setFreightFilter(v as "ALL" | FreightType);
|
||||
resetPage();
|
||||
}}
|
||||
data={[
|
||||
{ value: "ALL", label: "All freight" },
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
{ value: "MIXED", label: "Mixed" },
|
||||
]}
|
||||
w={140}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
placeholder="Origin"
|
||||
searchable
|
||||
value={originFilter}
|
||||
onChange={(v) => {
|
||||
setOriginFilter(v ?? "ALL");
|
||||
resetPage();
|
||||
}}
|
||||
data={[
|
||||
{ value: "ALL", label: "All origins" },
|
||||
...yardOptions,
|
||||
]}
|
||||
w={160}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
placeholder="Destination"
|
||||
searchable
|
||||
value={destinationFilter}
|
||||
onChange={(v) => {
|
||||
setDestinationFilter(v ?? "ALL");
|
||||
resetPage();
|
||||
}}
|
||||
data={[
|
||||
{ value: "ALL", label: "All destinations" },
|
||||
...yardOptions,
|
||||
]}
|
||||
w={170}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={`${sortBy}:${sortDir}`}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
const [by, dir] = v.split(":") as [
|
||||
typeof sortBy,
|
||||
typeof sortDir,
|
||||
];
|
||||
setSortBy(by);
|
||||
setSortDir(dir);
|
||||
resetPage();
|
||||
}}
|
||||
data={[
|
||||
{ value: "createdAt:desc", label: "Newest created" },
|
||||
{ value: "createdAt:asc", label: "Oldest created" },
|
||||
{ value: "scheduledDepartureDate:desc", label: "Departure ↓" },
|
||||
{ value: "scheduledDepartureDate:asc", label: "Departure ↑" },
|
||||
{ value: "reference:asc", label: "Reference ↑" },
|
||||
{ value: "reference:desc", label: "Reference ↓" },
|
||||
]}
|
||||
w={170}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<ExportButton datasetKey="train-schedules" params={filters} size="sm" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
sortOptions={SORT_OPTIONS}
|
||||
viewId="train-schedules"
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
radius="lg"
|
||||
value={viewMode}
|
||||
onChange={(v) => setViewMode(v as FleetViewMode)}
|
||||
data={[
|
||||
{
|
||||
value: "table",
|
||||
label: (
|
||||
<Center>
|
||||
<Table2 size={14} />
|
||||
</Center>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "cards",
|
||||
label: (
|
||||
<Center>
|
||||
<LayoutGrid size={14} />
|
||||
</Center>
|
||||
),
|
||||
},
|
||||
]}
|
||||
aria-label="View mode"
|
||||
/>
|
||||
<ExportButton datasetKey="train-schedules" params={controls.params} size="sm" />
|
||||
</Group>
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
{viewMode === "table" ? (
|
||||
@@ -765,18 +602,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
: undefined
|
||||
}
|
||||
emptyMessage="No train schedules found"
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: totalSchedules,
|
||||
}}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
{...controls.tableProps(totalSchedules)}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={({ table, pagination: footerPagination }) => (
|
||||
<DataTableFooter
|
||||
@@ -799,25 +625,18 @@ export default function TrainScheduleV2ListPage() {
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
onOpen={() =>
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${schedule.id}`,
|
||||
)
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
||||
}
|
||||
onTrack={() =>
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
|
||||
)
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
<RuleEngineListFooter
|
||||
pagination={pagination}
|
||||
pageCount={pageCount}
|
||||
totalCount={totalSchedules}
|
||||
{...toRuleEngineFooterProps(controls, totalSchedules)}
|
||||
itemLabel="schedules"
|
||||
onPaginationChange={setPagination}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
@@ -959,13 +778,12 @@ export default function TrainScheduleV2ListPage() {
|
||||
{dispatchTarget?.trainNumber ?? dispatchTarget?.reference ?? "This train"}
|
||||
</Text>{" "}
|
||||
departs {dispatchTarget?.origin ?? "its origin"} for{" "}
|
||||
{dispatchTarget?.destination ?? "its destination"} and its booking
|
||||
window closes. This cannot be undone.
|
||||
{dispatchTarget?.destination ?? "its destination"} and its booking window closes. This
|
||||
cannot be undone.
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Open the schedule detail first if you want to check for unassigned
|
||||
wagons or cargo not yet marked loaded — those warnings are shown
|
||||
there, not here.
|
||||
Open the schedule detail first if you want to check for unassigned wagons or cargo not
|
||||
yet marked loaded — those warnings are shown there, not here.
|
||||
</Text>
|
||||
<DateTimePicker
|
||||
label="Actual departure"
|
||||
@@ -990,9 +808,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
try {
|
||||
await dispatchSchedule.mutateAsync({
|
||||
id: dispatchTarget.id,
|
||||
payload: dispatchAt
|
||||
? { actualDepartureAt: dispatchAt.toISOString() }
|
||||
: {},
|
||||
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
|
||||
});
|
||||
toast({ title: "Train dispatched" });
|
||||
setDispatchTarget(null);
|
||||
@@ -1026,14 +842,13 @@ export default function TrainScheduleV2ListPage() {
|
||||
<Text span fw={600} c="dark">
|
||||
{cancelTarget?.trainNumber ?? cancelTarget?.reference ?? "This train"}
|
||||
</Text>{" "}
|
||||
will be cancelled and removed from the active schedule board. This
|
||||
cannot be undone.
|
||||
will be cancelled and removed from the active schedule board. This cannot be undone.
|
||||
</Text>
|
||||
{cancelTarget?.bookingsCount ? (
|
||||
<Text size="sm" c="red.7" fw={500}>
|
||||
{cancelTarget.bookingsCount} booking
|
||||
{cancelTarget.bookingsCount === 1 ? "" : "s"} on this train will
|
||||
need to be moved to another schedule.
|
||||
{cancelTarget.bookingsCount === 1 ? "" : "s"} on this train will need to be moved to
|
||||
another schedule.
|
||||
</Text>
|
||||
) : null}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
@@ -1085,6 +900,56 @@ const SHIPPING_LINE_ROW_STYLE = {
|
||||
backgroundColor: "var(--mantine-color-edr-green-0)",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The row's identity: which train is running, under what reference, in what
|
||||
* state. Stacked into one column so the three read as a unit and cost one
|
||||
* column's width between them.
|
||||
*/
|
||||
function TrainIdentityCell({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
// Schedules created from the Train Builder show the direction-matched run
|
||||
// number first (falling back to the train code); legacy rows fall back to
|
||||
// their locomotive set.
|
||||
const locos =
|
||||
schedule.locomotives && schedule.locomotives.length > 0
|
||||
? schedule.locomotives
|
||||
: schedule.locomotive
|
||||
? [schedule.locomotive]
|
||||
: [];
|
||||
|
||||
let title = "—";
|
||||
let subtitle = "";
|
||||
if (schedule.train) {
|
||||
title = schedule.trainNumber ?? schedule.train.code;
|
||||
subtitle = [schedule.trainNumber ? schedule.train.code : null, schedule.train.trainName]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
} else if (locos.length) {
|
||||
title = `${locos[0].code}${locos.length > 1 ? ` +${locos.length - 1}` : ""}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={3} style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Train size={14} color="var(--mantine-color-gray-5)" style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" fw={600} ff="monospace" lh={1.2} truncate>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
{subtitle ? (
|
||||
<Text size="xs" c="dimmed" lh={1.2} truncate>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} ff="monospace" c="edr-green.8" lh={1.2} truncate>
|
||||
{schedule.reference ?? "—"}
|
||||
</Text>
|
||||
<StatusPill status={schedule.status} />
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
if (!schedule.shippingLineCompanyId) return null;
|
||||
return (
|
||||
@@ -1112,12 +977,8 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
value={`${used}/${total}`}
|
||||
label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
|
||||
/>
|
||||
{reserved > used ? (
|
||||
<MetricChip value={reserved} label="reserved" subtle />
|
||||
) : null}
|
||||
{remaining != null ? (
|
||||
<MetricChip value={remaining} label="bookable" subtle />
|
||||
) : null}
|
||||
{reserved > used ? <MetricChip value={reserved} label="reserved" subtle /> : null}
|
||||
{remaining != null ? <MetricChip value={remaining} label="bookable" subtle /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1138,9 +999,7 @@ function MetricChip({
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 8,
|
||||
background: subtle
|
||||
? "var(--mantine-color-gray-1)"
|
||||
: "var(--mantine-color-edr-green-0)",
|
||||
background: subtle ? "var(--mantine-color-gray-1)" : "var(--mantine-color-edr-green-0)",
|
||||
border: `1px solid ${
|
||||
subtle ? "var(--mantine-color-gray-2)" : "var(--mantine-color-edr-green-1)"
|
||||
}`,
|
||||
@@ -1217,11 +1076,7 @@ function ScheduleCard({
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FreightTypeBadge freightType={schedule.freightType} />
|
||||
{schedule.direction ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={directionColor(schedule.direction)}
|
||||
>
|
||||
<Badge size="xs" variant="light" color={directionColor(schedule.direction)}>
|
||||
{schedule.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user