Merge branch 'dev'

This commit is contained in:
Marshal
2026-08-13 14:14:31 +00:00
219 changed files with 13585 additions and 5559 deletions

View File

@@ -19,7 +19,8 @@ import {
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
@@ -792,24 +793,19 @@ export default function BatchBoardPage() {
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<DateInput
<DatePickerInput
type="range"
size="sm"
radius="lg"
placeholder="Departs from"
value={departureFrom}
onChange={(v) => setDepartureFrom(v ? new Date(v) : null)}
placeholder="Departure date range"
value={[departureFrom, departureTo]}
onChange={([from, to]) => {
setDepartureFrom(from ? new Date(from) : null);
setDepartureTo(to ? new Date(to) : null);
}}
presets={getDateRangePresets()}
clearable
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<DateInput
size="sm"
radius="lg"
placeholder="Departs to"
value={departureTo}
onChange={(v) => setDepartureTo(v ? new Date(v) : null)}
clearable
w={140}
w={230}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select

View File

@@ -1,4 +1,5 @@
import {
ActionIcon,
Alert,
Badge,
Box,
@@ -7,6 +8,7 @@ import {
Group,
List,
Loader,
Menu,
Modal,
Paper,
RingProgress,
@@ -19,7 +21,6 @@ import {
import { isAxiosError } from "axios";
import {
AlertTriangle,
ArrowLeft,
CalendarClock,
CheckCircle2,
Clock,
@@ -29,6 +30,7 @@ import {
FileText,
History as HistoryIcon,
LayoutGrid,
MoreHorizontal,
Navigation,
Package,
PackageCheck,
@@ -42,7 +44,7 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { KpiStrip, PageContainer } from "@/components/page";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
@@ -886,284 +888,248 @@ export default function TrainScheduleV2DetailPage() {
return (
<PageContainer>
<Button
component={Link}
to="/dashboard/operations/train-scheduling-v2"
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
w="fit-content"
>
Back to schedules
</Button>
<Paper
radius="xl"
p="xl"
style={{ position: "relative", overflow: "hidden" }}
>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={56} radius="lg" variant="light" color="#F2A516">
<Train size={28} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center" wrap="wrap">
{schedule.reference ? (
<Badge
variant="filled"
color="edr-green"
radius="sm"
style={{ fontWeight: 700, fontFamily: "monospace" }}
>
{schedule.reference}
</Badge>
) : null}
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
{schedule.route?.name ?? "Train schedule"}
</Title>
{schedule.train?.trainName ? (
<Text fw={700} style={{ color: "#0f172a" }}>
{schedule.train.trainName}
</Text>
) : null}
{schedule.train ? (
<Text size="xs" c="dimmed" ff="monospace">
Train {schedule.train.code}
</Text>
) : null}
</Group>
{/* Voyage (train) number and trade direction — the two things
operations identify a run by, so they read at a glance
rather than as small badges among the rest. */}
<Group gap="lg" align="center" wrap="wrap">
{schedule.trainNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Train No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.trainNumber}
</Text>
</Box>
) : null}
{schedule.voyageNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Voyage No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.voyageNumber}
</Text>
</Box>
) : null}
{/* Merging rewrites the consist, so it is offered only while
the departure can still be edited. */}
{canEditBookings ? (
<Button
variant="light"
size="compact-sm"
leftSection={<Merge size={14} />}
onClick={() => setMergeModalOpen(true)}
>
Merge
</Button>
) : null}
{schedule.direction ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Direction
</Text>
<Text
fw={800}
lh={1.1}
tt="uppercase"
style={{
fontSize: 32,
letterSpacing: 0.5,
color:
schedule.direction === "IMPORT"
? "#2E5B96"
: schedule.direction === "EXPORT"
? "#0A6F4D"
: "#0f172a",
}}
>
{schedule.direction}
</Text>
</Box>
) : null}
</Group>
{(schedule.stops?.length ?? 0) >= 3 ||
(schedule.bookings ?? []).some(
(b) => b.tradeDirection === "DOMESTIC",
) ? (
<SegmentOccupancyStrip
stops={schedule.stops ?? []}
bookings={schedule.bookings ?? []}
maxWagons={schedule.maxWagons}
maxGrossTons={schedule.maxGrossWeightTons}
<PageHeader
title={schedule.route?.name ?? "Train schedule"}
backTo="/dashboard/operations/train-scheduling-v2"
breadcrumbs={[
{
label: "Train schedules",
href: "/dashboard/operations/train-scheduling-v2",
},
{ label: schedule.reference ?? "Schedule" },
]}
subtitle={
schedule.train ? (
<Text size="sm" c="dimmed">
{schedule.train.trainName ?? `Train ${schedule.train.code}`}
{schedule.train.trainName ? ` · Train ${schedule.train.code}` : ""}
</Text>
) : undefined
}
meta={
<Group gap={6} wrap="wrap">
{schedule.reference ? (
<Badge
variant="filled"
color="edr-green"
radius="sm"
style={{ fontWeight: 700, fontFamily: "monospace" }}
>
{schedule.reference}
</Badge>
) : null}
<FreightTypeBadge freightType={schedule.freightType} />
<StatusPill status={schedule.status} />
{gatepassApplies && gatepassSecured ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={12} />}
>
Gate pass secured
</Badge>
) : null}
{previewResult ? (
<Badge
radius="sm"
variant="light"
color={previewResult.valid ? "edr-green" : "red"}
leftSection={
<Box
w={8}
h={8}
style={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-red-6)",
}}
/>
) : (
<Box maw={340}>
<RouteCorridor
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
/>
</Box>
)}
<Group gap="sm" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<StatusPill status={schedule.status} />
</Group>
</Stack>
</Group>
<Group gap="sm">
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="gradient"
gradient={{ from: "#0f172a", to: "#334155" }}
radius="lg"
size="sm"
leftSection={<Eye size={16} />}
onClick={() => setVisualization3DOpen(true)}
>
3D Visualization
</Button>
) : null}
{canPrintMarshalling ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<FileText size={16} />}
loading={downloadMarshalling.isPending}
onClick={() => void openMarshallingDocument()}
>
Marshalling PDF
</Button>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<FileText size={16} />}
loading={downloadMarshalling.isPending}
onClick={() =>
void openMarshallingDocument({
title: "Intercity marshalling ready",
variant: "INTERCITY",
})
}
>
Intercity Marshalling
</Button>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
color="edr-green"
radius="lg"
size="sm"
leftSection={<Navigation size={16} />}
>
Track train
</Button>
) : null}
{schedule.windowPhase === "PRE_WINDOW" ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<Clock size={16} />}
onClick={() => setWindowSettingsOpen(true)}
>
Window settings
</Button>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Button
variant="default"
radius="lg"
size="sm"
onClick={() => setMaintenanceOpen(true)}
>
Reschedule train
</Button>
) : null}
{gatepassApplies ? (
gatepassSecured ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<CheckCircle2 size={16} />}
disabled
}
>
Preview {previewResult.valid ? "valid" : "has issues"}
</Badge>
) : null}
</Group>
}
action={
<Group gap="sm" wrap="nowrap">
{/* Merging rewrites the consist, so it is offered only while
the departure can still be edited. */}
{canEditBookings ? (
<Button
variant="light"
size="compact-sm"
leftSection={<Merge size={14} />}
onClick={() => setMergeModalOpen(true)}
>
Merge
</Button>
) : null}
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="gradient"
gradient={{ from: "#0f172a", to: "#334155" }}
radius="lg"
size="compact-sm"
leftSection={<Eye size={16} />}
onClick={() => setVisualization3DOpen(true)}
>
3D Visualization
</Button>
) : null}
<Menu position="bottom-end" width={240} withinPortal>
<Menu.Target>
<ActionIcon variant="default" size="lg" radius="md" aria-label="More actions">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{canPrintMarshalling ? (
<Menu.Item
leftSection={<FileText size={15} />}
disabled={downloadMarshalling.isPending}
onClick={() => void openMarshallingDocument()}
>
Gate pass secured
</Button>
) : (
<Button
color="edr-green"
radius="lg"
size="sm"
leftSection={<FileText size={16} />}
loading={secureGatepass.isPending}
Marshalling PDF
</Menu.Item>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Menu.Item
leftSection={<FileText size={15} />}
disabled={downloadMarshalling.isPending}
onClick={() =>
void openMarshallingDocument({
title: "Intercity marshalling ready",
variant: "INTERCITY",
})
}
>
Intercity Marshalling
</Menu.Item>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Menu.Item
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
leftSection={<Navigation size={15} />}
>
Track train
</Menu.Item>
) : null}
{schedule.windowPhase === "PRE_WINDOW" ? (
<Menu.Item
leftSection={<Clock size={15} />}
onClick={() => setWindowSettingsOpen(true)}
>
Window settings
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item onClick={() => setMaintenanceOpen(true)}>
Reschedule train
</Menu.Item>
) : null}
{gatepassApplies && !gatepassSecured ? (
<Menu.Item
leftSection={<FileText size={15} />}
disabled={secureGatepass.isPending}
onClick={() => secureGatepass.mutate()}
>
Secure gate pass
</Button>
)
) : null}
</Group>
</Menu.Item>
) : null}
</Menu.Dropdown>
</Menu>
</Group>
}
/>
{previewResult ? (
<Badge
size="lg"
radius="sm"
variant="light"
color={previewResult.valid ? "edr-green" : "red"}
leftSection={
<Box
w={8}
h={8}
{/* Ops signage: Train No. / Voyage No. / Direction read at a glance from
across the room, so these stay large rather than folding into the
numeric KpiStrip below. */}
<Paper radius="xl" p="lg">
<Stack gap="md">
<Group gap="lg" align="center" wrap="wrap">
{schedule.trainNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Train No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.trainNumber}
</Text>
</Box>
) : null}
{schedule.voyageNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Voyage No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.voyageNumber}
</Text>
</Box>
) : null}
{schedule.direction ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Direction
</Text>
<Text
fw={800}
lh={1.1}
tt="uppercase"
style={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-red-6)",
fontSize: 32,
letterSpacing: 0.5,
color:
schedule.direction === "IMPORT"
? "#2E5B96"
: schedule.direction === "EXPORT"
? "#0A6F4D"
: "#0f172a",
}}
/>
}
>
Preview {previewResult.valid ? "valid" : "has issues"}
</Badge>
) : null}
>
{schedule.direction}
</Text>
</Box>
) : null}
</Group>
{(schedule.stops?.length ?? 0) >= 3 ||
(schedule.bookings ?? []).some(
(b) => b.tradeDirection === "DOMESTIC",
) ? (
<SegmentOccupancyStrip
stops={schedule.stops ?? []}
bookings={schedule.bookings ?? []}
maxWagons={schedule.maxWagons}
maxGrossTons={schedule.maxGrossWeightTons}
/>
) : (
<Box maw={340}>
<RouteCorridor
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
/>
</Box>
)}
</Stack>
</Paper>

View File

@@ -143,6 +143,9 @@ export default function TrainScheduleV2ListPage() {
const [scheduleDate, setScheduleDate] = useState("");
const [trainId, setTrainId] = useState("");
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
// "" = a normal customer train; an id dedicates the departure to that
// shipping line and hides it from every customer-facing view.
const [shippingLineCompanyId, setShippingLineCompanyId] = useState("");
// Booking window for the schedule being created: off = inherit the live global
// rules (the default), on = the values in `windowForm` are frozen onto it.
const [configureWindow, setConfigureWindow] = useState(false);
@@ -219,6 +222,15 @@ export default function TrainScheduleV2ListPage() {
enabled: Boolean(routeId),
}),
);
// For the create modal's dedication picker. 100 covers every line EDR deals
// with; fetched only while the modal is open.
const shippingLinesQuery = useQuery(
api.shippingLineCompanies.list.queryOptions({
input: { page: 1, limit: 100 },
enabled: createOpen,
staleTime: 5 * 60_000,
}),
);
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
const dispatchSchedule = useMutation(
api.trainScheduling.dispatchSchedule.mutationOptions(),
@@ -559,12 +571,14 @@ export default function TrainScheduleV2ListPage() {
scheduleDate: new Date(scheduleDate).toISOString(),
trainId,
reverseWagonOrder,
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
...(windowRule ? { windowRule } : {}),
},
});
toast({ title: "Train schedule created" });
showScheduleWarnings(created.warnings);
setReverseWagonOrder(false);
setShippingLineCompanyId("");
setConfigureWindow(false);
setWindowForm(null);
setCreateOpen(false);
@@ -857,6 +871,19 @@ export default function TrainScheduleV2ListPage() {
: "Select a route first"
}
/>
<Select
label="Shipping line (optional)"
description="Dedicate this departure to one shipping line. The train is then hidden from customers and shown only in that line's portal."
placeholder="None — normal customer train"
clearable
searchable
data={(shippingLinesQuery.data?.items ?? [])
.filter((line) => line.status === "active")
.map((line) => ({ value: line.id, label: line.name }))}
value={shippingLineCompanyId || null}
onChange={(v) => setShippingLineCompanyId(v ?? "")}
comboboxProps={{ withinPortal: true }}
/>
<Checkbox
label="Reverse wagon order"
description="Place wagons on the train in reverse — the physically-last wagon becomes position 1. Composition and allocations are unchanged; only the order flips. Applies every time this schedule's wagon plan is built."