mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +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
|
* Origin → destination corridor visual: two anchored stops joined by a rail
|
||||||
* line. `variant="compact"` is for dense table rows; `default` for cards.
|
* 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({
|
export function RouteCorridor({
|
||||||
origin,
|
origin,
|
||||||
destination,
|
destination,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
|
orientation = "horizontal",
|
||||||
onDark = false,
|
onDark = false,
|
||||||
}: {
|
}: {
|
||||||
origin?: string | null;
|
origin?: string | null;
|
||||||
destination?: string | null;
|
destination?: string | null;
|
||||||
variant?: "default" | "compact";
|
variant?: "default" | "compact";
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
onDark?: boolean;
|
onDark?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const compact = variant === "compact";
|
const compact = variant === "compact";
|
||||||
@@ -218,6 +222,48 @@ export function RouteCorridor({
|
|||||||
const strong = onDark ? "white" : "var(--mantine-color-gray-8)";
|
const strong = onDark ? "white" : "var(--mantine-color-gray-8)";
|
||||||
const lineColor = onDark ? "rgba(255,255,255,0.4)" : "var(--mantine-color-gray-3)";
|
const lineColor = onDark ? "rgba(255,255,255,0.4)" : "var(--mantine-color-gray-3)";
|
||||||
const accent = onDark ? "white" : freightBrand.primary;
|
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 (
|
return (
|
||||||
<Group gap={compact ? 6 : 8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
<Group gap={compact ? 6 : 8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
Center,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
Modal,
|
Modal,
|
||||||
|
SegmentedControl,
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -19,7 +21,6 @@ import {
|
|||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { DateTimePicker } from "@mantine/dates";
|
import { DateTimePicker } from "@mantine/dates";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -27,27 +28,33 @@ import {
|
|||||||
CalendarClock,
|
CalendarClock,
|
||||||
Clock,
|
Clock,
|
||||||
Eye,
|
Eye,
|
||||||
|
LayoutGrid,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Navigation,
|
Navigation,
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
Send,
|
Send,
|
||||||
|
Table2,
|
||||||
Train,
|
Train,
|
||||||
Weight,
|
Weight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
import {
|
||||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
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 { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||||
import {
|
import { directionColor, directionRowStyle } from "@/components/trainBuilder/trainStatus";
|
||||||
directionColor,
|
|
||||||
directionRowStyle,
|
|
||||||
} from "@/components/trainBuilder/trainStatus";
|
|
||||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||||
import CreateScheduleWindowFields, {
|
import CreateScheduleWindowFields, {
|
||||||
buildWindowRulePayload,
|
buildWindowRulePayload,
|
||||||
@@ -56,10 +63,7 @@ import CreateScheduleWindowFields, {
|
|||||||
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
|
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
|
||||||
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
|
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
|
||||||
import { ExportButton } from "@/components/export/ExportButton";
|
import { ExportButton } from "@/components/export/ExportButton";
|
||||||
import {
|
import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals";
|
||||||
RouteCorridor,
|
|
||||||
StatusPill,
|
|
||||||
} from "@/components/trainScheduling/scheduleVisuals";
|
|
||||||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { formatRouteLabel } from "@/services/routes.service";
|
import { formatRouteLabel } from "@/services/routes.service";
|
||||||
@@ -68,12 +72,34 @@ import { useAuth } from "@/auth/useAuth";
|
|||||||
import { FREIGHT_PERMS, canCreateSchedule, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, canCreateSchedule, hasPermission } from "@/lib/permissions";
|
||||||
import type {
|
import type {
|
||||||
CreateScheduleWindowRulePayload,
|
CreateScheduleWindowRulePayload,
|
||||||
FreightType,
|
|
||||||
TrainScheduleListFilters,
|
TrainScheduleListFilters,
|
||||||
TrainScheduleListItem,
|
TrainScheduleListItem,
|
||||||
TrainScheduleStatus,
|
|
||||||
} from "@/types/trainScheduling";
|
} 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. */
|
/** `min` for a `datetime-local` input: now, in the browser's local zone. */
|
||||||
const nowLocalDateTime = () => {
|
const nowLocalDateTime = () => {
|
||||||
@@ -115,34 +141,16 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
const canCreate = canCreateSchedule(user);
|
const canCreate = canCreateSchedule(user);
|
||||||
const canDispatch = hasPermission(user, FREIGHT_PERMS.trainScheduling.dispatch);
|
const canDispatch = hasPermission(user, FREIGHT_PERMS.trainScheduling.dispatch);
|
||||||
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
|
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 [createOpen, setCreateOpen] = useState(false);
|
||||||
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
|
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
|
||||||
// Dispatch is irreversible from this screen, so it goes through an explicit
|
// Dispatch is irreversible from this screen, so it goes through an explicit
|
||||||
// confirmation.
|
// confirmation.
|
||||||
const [dispatchTarget, setDispatchTarget] =
|
const [dispatchTarget, setDispatchTarget] = useState<TrainScheduleListItem | null>(null);
|
||||||
useState<TrainScheduleListItem | null>(null);
|
|
||||||
// Actual departure — defaults to now when the dialog opens; past is fine.
|
// Actual departure — defaults to now when the dialog opens; past is fine.
|
||||||
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
||||||
// Cancelling is likewise irreversible — confirmed before the mutation fires.
|
// Cancelling is likewise irreversible — confirmed before the mutation fires.
|
||||||
const [cancelTarget, setCancelTarget] =
|
const [cancelTarget, setCancelTarget] = useState<TrainScheduleListItem | null>(null);
|
||||||
useState<TrainScheduleListItem | null>(null);
|
const [editDateSchedule, setEditDateSchedule] = useState<TrainScheduleListItem | null>(null);
|
||||||
const [editDateSchedule, setEditDateSchedule] =
|
|
||||||
useState<TrainScheduleListItem | null>(null);
|
|
||||||
const [routeId, setRouteId] = useState("");
|
const [routeId, setRouteId] = useState("");
|
||||||
const [scheduleDate, setScheduleDate] = useState("");
|
const [scheduleDate, setScheduleDate] = useState("");
|
||||||
const [trainId, setTrainId] = useState("");
|
const [trainId, setTrainId] = useState("");
|
||||||
@@ -156,51 +164,59 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
const [windowForm, setWindowForm] = useState<WindowFormState | null>(null);
|
const [windowForm, setWindowForm] = useState<WindowFormState | null>(null);
|
||||||
// Recomputed each time the create modal opens so a long-lived tab can't keep
|
// Recomputed each time the create modal opens so a long-lived tab can't keep
|
||||||
// offering a stale "now" as the earliest selectable departure.
|
// offering a stale "now" as the earliest selectable departure.
|
||||||
const minScheduleDate = useMemo(
|
const minScheduleDate = useMemo(() => (createOpen ? nowLocalDateTime() : ""), [createOpen]);
|
||||||
() => (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(() => {
|
// One Route pill covering both ends. It is the paired `route` type — which
|
||||||
setPagination((prev) =>
|
// no longer forces both sides to be filled — so filtering by origin alone,
|
||||||
prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 },
|
// 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.
|
||||||
}, [setPagination]);
|
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
|
const controls = useFilters(scheduleFilterDefs, {
|
||||||
// per keystroke would refetch page 1 mid-typing.
|
defaultSort: "createdAt:DESC",
|
||||||
useEffect(() => {
|
pageSize: 10,
|
||||||
resetPage();
|
});
|
||||||
}, [debouncedSearch, resetPage]);
|
|
||||||
|
|
||||||
// Fully server-driven list: pagination, search, filters, and sort all travel
|
// Fully server-driven list: pagination, search, filters, and sort all travel
|
||||||
// as query params; the response envelope carries the page + totals.
|
// as query params; the response envelope carries the page + totals.
|
||||||
const filters = useMemo<TrainScheduleListFilters>(
|
const filters = controls.params as unknown as 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 schedulesQuery = useQuery(
|
const schedulesQuery = useQuery(
|
||||||
api.trainScheduling.scheduleList.queryOptions({
|
api.trainScheduling.scheduleList.queryOptions({
|
||||||
@@ -212,14 +228,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// Yard options for the origin/destination filters (shared routes reference
|
const routesQuery = useQuery(api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }));
|
||||||
// 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 trainsQuery = useQuery(
|
const trainsQuery = useQuery(
|
||||||
api.trainScheduling.availableTrains.queryOptions({
|
api.trainScheduling.availableTrains.queryOptions({
|
||||||
input: { routeId },
|
input: { routeId },
|
||||||
@@ -236,9 +245,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
||||||
const dispatchSchedule = useMutation(
|
const dispatchSchedule = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||||
api.trainScheduling.dispatchSchedule.mutationOptions(),
|
|
||||||
);
|
|
||||||
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
||||||
|
|
||||||
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
|
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
|
||||||
@@ -253,9 +260,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
const trainYardHint = useMemo(() => {
|
const trainYardHint = useMemo(() => {
|
||||||
if (!selectedRoute) return "Select a route first";
|
if (!selectedRoute) return "Select a route first";
|
||||||
const originLabel =
|
const originLabel =
|
||||||
selectedRoute.originYard?.label ??
|
selectedRoute.originYard?.label ?? selectedRoute.originYard?.code ?? "the route origin yard";
|
||||||
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`;
|
return `All schedulable built trains are shown — those not yet at ${originLabel} or already on future schedules are flagged`;
|
||||||
}, [selectedRoute]);
|
}, [selectedRoute]);
|
||||||
|
|
||||||
@@ -267,7 +272,6 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
// current page, and the meta envelope carries the totals.
|
// current page, and the meta envelope carries the totals.
|
||||||
const schedules = schedulesQuery.data?.items ?? [];
|
const schedules = schedulesQuery.data?.items ?? [];
|
||||||
const totalSchedules = schedulesQuery.data?.meta.total ?? 0;
|
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
|
// Status/weight tiles count the visible page only — board-wide numbers would
|
||||||
// need a dedicated summary endpoint now that the list is server-paginated.
|
// need a dedicated summary endpoint now that the list is server-paginated.
|
||||||
@@ -287,92 +291,62 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
return base;
|
return base;
|
||||||
}, [schedules]);
|
}, [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 columns = useMemo((): ColumnDef<TrainScheduleListItem>[] => {
|
||||||
const headerClassName = ruleEngineTable.headerCell;
|
const headerClassName = ruleEngineTable.headerCell;
|
||||||
const cellClassName = ruleEngineTable.bodyCell;
|
const cellClassName = ruleEngineTable.bodyCell;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "reference",
|
// Train, reference and status share one identity column — three
|
||||||
header: "Ref",
|
// stacked lines cost the width of the widest, not three columns.
|
||||||
|
id: "train",
|
||||||
|
header: "Train",
|
||||||
|
size: 170,
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <TrainIdentityCell schedule={row.original} />,
|
||||||
<Text size="sm" fw={600} ff="monospace" c="edr-green.8">
|
|
||||||
{row.original.reference ?? "—"}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "date",
|
id: "date",
|
||||||
header: "Departure",
|
header: "Departure",
|
||||||
|
size: 110,
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { day, time } = splitDate(row.original.scheduleDate);
|
const { day, time } = splitDate(row.original.scheduleDate);
|
||||||
return (
|
return (
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Stack gap={0}>
|
||||||
<Box
|
<Text size="sm" fw={600} lh={1.2}>
|
||||||
style={{
|
{day}
|
||||||
display: "flex",
|
</Text>
|
||||||
alignItems: "center",
|
<Text size="xs" c="dimmed" lh={1.2}>
|
||||||
justifyContent: "center",
|
{time || "—"}
|
||||||
width: 34,
|
</Text>
|
||||||
height: 34,
|
</Stack>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "route",
|
id: "route",
|
||||||
header: "Route",
|
header: "Route",
|
||||||
|
size: 280,
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Stack gap={4}>
|
<Stack gap={6}>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<Text size="sm" fw={600} lh={1.2}>
|
<Text size="sm" fw={600} lh={1.2}>
|
||||||
{row.original.routeName ?? "—"}
|
{row.original.routeName ?? "—"}
|
||||||
</Text>
|
</Text>
|
||||||
{row.original.direction ? (
|
{row.original.direction ? (
|
||||||
<Badge
|
<Badge size="xs" variant="light" color={directionColor(row.original.direction)}>
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
color={directionColor(row.original.direction)}
|
|
||||||
>
|
|
||||||
{row.original.direction}
|
{row.original.direction}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
<ShippingLineBadge schedule={row.original} />
|
<ShippingLineBadge schedule={row.original} />
|
||||||
</Group>
|
</Group>
|
||||||
<Box maw={220}>
|
<Box maw={260}>
|
||||||
<RouteCorridor
|
<RouteCorridor
|
||||||
origin={row.original.origin}
|
origin={row.original.origin}
|
||||||
destination={row.original.destination}
|
destination={row.original.destination}
|
||||||
variant="compact"
|
variant="compact"
|
||||||
|
orientation="vertical"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -384,59 +358,6 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => <FreightTypeBadge freightType={row.original.freightType} />,
|
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",
|
id: "metrics",
|
||||||
header: "Load",
|
header: "Load",
|
||||||
@@ -449,15 +370,9 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "status",
|
|
||||||
header: "Status",
|
|
||||||
meta: { headerClassName, cellClassName },
|
|
||||||
cell: ({ row }) => <StatusPill status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
size:32,
|
size: 32,
|
||||||
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
|
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const schedule = row.original;
|
const schedule = row.original;
|
||||||
@@ -482,9 +397,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Navigation size={15} />}
|
leftSection={<Navigation size={15} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(
|
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`)
|
||||||
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Track
|
Track
|
||||||
@@ -561,10 +474,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
toast({ title: "Booking window settings are still loading", variant: "destructive" });
|
toast({ title: "Booking window settings are still loading", variant: "destructive" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const built = buildWindowRulePayload(
|
const built = buildWindowRulePayload(windowForm, selectedRoute?.direction === "EXPORT");
|
||||||
windowForm,
|
|
||||||
selectedRoute?.direction === "EXPORT",
|
|
||||||
);
|
|
||||||
if ("error" in built) {
|
if ("error" in built) {
|
||||||
toast({ title: built.error, variant: "destructive" });
|
toast({ title: built.error, variant: "destructive" });
|
||||||
return;
|
return;
|
||||||
@@ -632,115 +542,42 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
<Card p={0}>
|
<Card p={0}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
<FleetToolbar
|
<FilterBar
|
||||||
search={search}
|
defs={scheduleFilterDefs}
|
||||||
onSearchChange={setSearch}
|
controls={controls}
|
||||||
searchPlaceholder="Search schedules…"
|
searchPlaceholder="Search schedules…"
|
||||||
viewMode={viewMode}
|
sortOptions={SORT_OPTIONS}
|
||||||
onViewModeChange={setViewMode}
|
viewId="train-schedules"
|
||||||
filters={
|
>
|
||||||
<>
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Select
|
<SegmentedControl
|
||||||
size="sm"
|
size="xs"
|
||||||
radius="lg"
|
radius="lg"
|
||||||
value={statusFilter}
|
value={viewMode}
|
||||||
onChange={(v) => {
|
onChange={(v) => setViewMode(v as FleetViewMode)}
|
||||||
if (!v) return;
|
data={[
|
||||||
setStatusFilter(v as "ALL" | TrainScheduleStatus);
|
{
|
||||||
resetPage();
|
value: "table",
|
||||||
}}
|
label: (
|
||||||
data={[
|
<Center>
|
||||||
{ value: "ALL", label: "All statuses" },
|
<Table2 size={14} />
|
||||||
{ value: "DRAFT", label: "Draft" },
|
</Center>
|
||||||
{ value: "SCHEDULED", label: "Scheduled" },
|
),
|
||||||
{ value: "DISPATCHED", label: "Dispatched" },
|
},
|
||||||
{ value: "ARRIVED", label: "Arrived" },
|
{
|
||||||
{ value: "CANCELLED", label: "Cancelled" },
|
value: "cards",
|
||||||
]}
|
label: (
|
||||||
w={150}
|
<Center>
|
||||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
<LayoutGrid size={14} />
|
||||||
/>
|
</Center>
|
||||||
<Select
|
),
|
||||||
size="sm"
|
},
|
||||||
radius="lg"
|
]}
|
||||||
value={freightFilter}
|
aria-label="View mode"
|
||||||
onChange={(v) => {
|
/>
|
||||||
if (!v) return;
|
<ExportButton datasetKey="train-schedules" params={controls.params} size="sm" />
|
||||||
setFreightFilter(v as "ALL" | FreightType);
|
</Group>
|
||||||
resetPage();
|
</FilterBar>
|
||||||
}}
|
|
||||||
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" />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{viewMode === "table" ? (
|
{viewMode === "table" ? (
|
||||||
@@ -765,18 +602,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
emptyMessage="No train schedules found"
|
emptyMessage="No train schedules found"
|
||||||
pagination={{
|
{...controls.tableProps(totalSchedules)}
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount,
|
|
||||||
totalCount: totalSchedules,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
manualPagination: true,
|
|
||||||
pageCount,
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
}}
|
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
footer={({ table, pagination: footerPagination }) => (
|
footer={({ table, pagination: footerPagination }) => (
|
||||||
<DataTableFooter
|
<DataTableFooter
|
||||||
@@ -799,25 +625,18 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
key={schedule.id}
|
key={schedule.id}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
onOpen={() =>
|
onOpen={() =>
|
||||||
navigate(
|
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
||||||
`/dashboard/operations/train-scheduling-v2/${schedule.id}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onTrack={() =>
|
onTrack={() =>
|
||||||
navigate(
|
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`)
|
||||||
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
<RuleEngineListFooter
|
<RuleEngineListFooter
|
||||||
pagination={pagination}
|
{...toRuleEngineFooterProps(controls, totalSchedules)}
|
||||||
pageCount={pageCount}
|
|
||||||
totalCount={totalSchedules}
|
|
||||||
itemLabel="schedules"
|
itemLabel="schedules"
|
||||||
onPaginationChange={setPagination}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@@ -959,13 +778,12 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
{dispatchTarget?.trainNumber ?? dispatchTarget?.reference ?? "This train"}
|
{dispatchTarget?.trainNumber ?? dispatchTarget?.reference ?? "This train"}
|
||||||
</Text>{" "}
|
</Text>{" "}
|
||||||
departs {dispatchTarget?.origin ?? "its origin"} for{" "}
|
departs {dispatchTarget?.origin ?? "its origin"} for{" "}
|
||||||
{dispatchTarget?.destination ?? "its destination"} and its booking
|
{dispatchTarget?.destination ?? "its destination"} and its booking window closes. This
|
||||||
window closes. This cannot be undone.
|
cannot be undone.
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Open the schedule detail first if you want to check for unassigned
|
Open the schedule detail first if you want to check for unassigned wagons or cargo not
|
||||||
wagons or cargo not yet marked loaded — those warnings are shown
|
yet marked loaded — those warnings are shown there, not here.
|
||||||
there, not here.
|
|
||||||
</Text>
|
</Text>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
label="Actual departure"
|
label="Actual departure"
|
||||||
@@ -990,9 +808,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
try {
|
try {
|
||||||
await dispatchSchedule.mutateAsync({
|
await dispatchSchedule.mutateAsync({
|
||||||
id: dispatchTarget.id,
|
id: dispatchTarget.id,
|
||||||
payload: dispatchAt
|
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
|
||||||
? { actualDepartureAt: dispatchAt.toISOString() }
|
|
||||||
: {},
|
|
||||||
});
|
});
|
||||||
toast({ title: "Train dispatched" });
|
toast({ title: "Train dispatched" });
|
||||||
setDispatchTarget(null);
|
setDispatchTarget(null);
|
||||||
@@ -1026,14 +842,13 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
<Text span fw={600} c="dark">
|
<Text span fw={600} c="dark">
|
||||||
{cancelTarget?.trainNumber ?? cancelTarget?.reference ?? "This train"}
|
{cancelTarget?.trainNumber ?? cancelTarget?.reference ?? "This train"}
|
||||||
</Text>{" "}
|
</Text>{" "}
|
||||||
will be cancelled and removed from the active schedule board. This
|
will be cancelled and removed from the active schedule board. This cannot be undone.
|
||||||
cannot be undone.
|
|
||||||
</Text>
|
</Text>
|
||||||
{cancelTarget?.bookingsCount ? (
|
{cancelTarget?.bookingsCount ? (
|
||||||
<Text size="sm" c="red.7" fw={500}>
|
<Text size="sm" c="red.7" fw={500}>
|
||||||
{cancelTarget.bookingsCount} booking
|
{cancelTarget.bookingsCount} booking
|
||||||
{cancelTarget.bookingsCount === 1 ? "" : "s"} on this train will
|
{cancelTarget.bookingsCount === 1 ? "" : "s"} on this train will need to be moved to
|
||||||
need to be moved to another schedule.
|
another schedule.
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
@@ -1085,6 +900,56 @@ const SHIPPING_LINE_ROW_STYLE = {
|
|||||||
backgroundColor: "var(--mantine-color-edr-green-0)",
|
backgroundColor: "var(--mantine-color-edr-green-0)",
|
||||||
} as const;
|
} 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 }) {
|
function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||||
if (!schedule.shippingLineCompanyId) return null;
|
if (!schedule.shippingLineCompanyId) return null;
|
||||||
return (
|
return (
|
||||||
@@ -1112,12 +977,8 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
|||||||
value={`${used}/${total}`}
|
value={`${used}/${total}`}
|
||||||
label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
|
label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
|
||||||
/>
|
/>
|
||||||
{reserved > used ? (
|
{reserved > used ? <MetricChip value={reserved} label="reserved" subtle /> : null}
|
||||||
<MetricChip value={reserved} label="reserved" subtle />
|
{remaining != null ? <MetricChip value={remaining} label="bookable" subtle /> : null}
|
||||||
) : null}
|
|
||||||
{remaining != null ? (
|
|
||||||
<MetricChip value={remaining} label="bookable" subtle />
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1138,9 +999,7 @@ function MetricChip({
|
|||||||
style={{
|
style={{
|
||||||
padding: "2px 8px",
|
padding: "2px 8px",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
background: subtle
|
background: subtle ? "var(--mantine-color-gray-1)" : "var(--mantine-color-edr-green-0)",
|
||||||
? "var(--mantine-color-gray-1)"
|
|
||||||
: "var(--mantine-color-edr-green-0)",
|
|
||||||
border: `1px solid ${
|
border: `1px solid ${
|
||||||
subtle ? "var(--mantine-color-gray-2)" : "var(--mantine-color-edr-green-1)"
|
subtle ? "var(--mantine-color-gray-2)" : "var(--mantine-color-edr-green-1)"
|
||||||
}`,
|
}`,
|
||||||
@@ -1217,11 +1076,7 @@ function ScheduleCard({
|
|||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<FreightTypeBadge freightType={schedule.freightType} />
|
<FreightTypeBadge freightType={schedule.freightType} />
|
||||||
{schedule.direction ? (
|
{schedule.direction ? (
|
||||||
<Badge
|
<Badge size="xs" variant="light" color={directionColor(schedule.direction)}>
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
color={directionColor(schedule.direction)}
|
|
||||||
>
|
|
||||||
{schedule.direction}
|
{schedule.direction}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user