Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-11 08:36:21 +00:00
39 changed files with 1255 additions and 103 deletions

View File

@@ -0,0 +1,258 @@
import { useEffect, useState } from "react";
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
import { CalendarClock } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { SectionCard } from "./SectionCard";
export interface BookingSchedulingWindowCardProps {
booking: BookingDetail;
}
/** Full date + time — staff read these against the operating clock, so no time is dropped. */
function formatStamp(iso: string | null | undefined): string | null {
if (!iso) return null;
const ms = new Date(iso).getTime();
if (!Number.isFinite(ms)) return null;
return new Date(ms).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/** "in 2h 14m" / "12m ago" — the at-a-glance read next to an absolute stamp. */
function formatRelative(iso: string, nowMs: number): string {
const diff = new Date(iso).getTime() - nowMs;
const past = diff < 0;
const totalMinutes = Math.floor(Math.abs(diff) / 60_000);
const days = Math.floor(totalMinutes / 1440);
const hours = Math.floor((totalMinutes % 1440) / 60);
const minutes = totalMinutes % 60;
const parts: string[] = [];
if (days) parts.push(`${days}d`);
if (hours) parts.push(`${hours}h`);
// Keep minutes when they're the only unit, so sub-hour gaps never read "0".
if (minutes || parts.length === 0) parts.push(`${minutes}m`);
const span = parts.slice(0, 2).join(" ");
return past ? `${span} ago` : `in ${span}`;
}
/**
* Length of a window as "1h 30m" / "45m". Null unless both ends are real and
* ordered — the pay window is configurable per schedule, so this is read off the
* actual stamps rather than assuming any fixed duration.
*/
function formatDuration(
from: string | null | undefined,
to: string | null | undefined,
): string | null {
if (!from || !to) return null;
const fromMs = new Date(from).getTime();
const toMs = new Date(to).getTime();
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return null;
const minutes = Math.round((toMs - fromMs) / 60_000);
if (minutes <= 0) return null;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
if (!hours) return `${rest}m`;
return rest ? `${hours}h ${rest}m` : `${hours}h`;
}
function Row({
label,
value,
hint,
tone,
}: {
label: string;
value: string;
hint?: string | null;
tone?: "muted" | "warning" | "danger";
}) {
const valueColor =
tone === "danger" ? "red.7" : tone === "warning" ? "orange.7" : "dark";
return (
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="md">
<Text size="sm" c="dimmed" style={{ flexShrink: 0 }}>
{label}
</Text>
<Box style={{ textAlign: "right", minWidth: 0 }}>
<Text size="sm" fw={600} c={valueColor}>
{value}
</Text>
{hint ? (
<Text size="xs" c="dimmed">
{hint}
</Text>
) : null}
</Box>
</Group>
);
}
/**
* Backoffice-only staff view of the scheduling clock: which batch/train the
* booking is scheduled for, when its pay window closes, and the train's
* planned vs actual departure/arrival (i.e. when the run actually ended).
*/
export function BookingSchedulingWindowCard({
booking,
}: BookingSchedulingWindowCardProps) {
const schedule = booking.trainScheduleSummary ?? null;
// The pay-window end staff should quote is the drain end (a payment landing
// inside the drain still counts); fall back to the raw deadline if the API
// predates that field.
const payWindowEndsAt = booking.paymentDrainEndsAt ?? booking.paymentDeadline ?? null;
// One shared ticking clock so every relative label in the card stays in sync.
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const interval = setInterval(() => setNowMs(Date.now()), 30_000);
return () => clearInterval(interval);
}, []);
// How long the customer actually had to pay: start → the raw deadline, NOT the
// drain end (the drain is settlement grace, not payable time).
const windowDuration = formatDuration(
booking.selectedForBatchAt,
booking.paymentDeadline,
);
const hasAnything =
Boolean(schedule) ||
Boolean(payWindowEndsAt) ||
Boolean(booking.selectedForBatchAt) ||
Boolean(booking.holdExpiresAt);
if (!hasAnything) return null;
const payWindowClosed = payWindowEndsAt
? new Date(payWindowEndsAt).getTime() <= nowMs
: false;
const trainLabel =
schedule?.trainNumber ??
schedule?.reference ??
(schedule ? "Assigned train" : null);
return (
<SectionCard
icon={CalendarClock}
title="Scheduling & payment window"
subtitle="Staff view — batch allocation and the operating clock"
accent="indigo"
extra={<SchedulingStatusBadge status={booking.schedulingStatus} />}
>
<Stack gap="sm">
{trainLabel ? (
<Row
label="Scheduled on train"
value={trainLabel}
hint={
schedule?.reference && schedule.reference !== trainLabel
? schedule.reference
: null
}
/>
) : (
<Row
label="Scheduled on train"
value="Not yet allocated"
tone="muted"
hint="The booking has not been placed on a train schedule"
/>
)}
{schedule?.status ? (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
Train status
</Text>
<Group gap="xs">
{schedule.windowPhase ? (
<Badge variant="light" color="gray" size="sm">
{schedule.windowPhase.replace(/_/g, " ")}
</Badge>
) : null}
<Badge variant="light" color="indigo" size="sm">
{schedule.status}
</Badge>
</Group>
</Group>
) : null}
{booking.selectedForBatchAt ? (
<Row
label="Payment window started"
value={formatStamp(booking.selectedForBatchAt) ?? "—"}
hint={
windowDuration
? `${windowDuration} window`
: formatRelative(booking.selectedForBatchAt, nowMs)
}
/>
) : null}
{payWindowEndsAt ? (
<Row
label="Payment window ends"
value={formatStamp(payWindowEndsAt) ?? "—"}
tone={payWindowClosed ? "danger" : "warning"}
hint={
payWindowClosed
? `Closed ${formatRelative(payWindowEndsAt, nowMs)}`
: `Closes ${formatRelative(payWindowEndsAt, nowMs)}`
}
/>
) : null}
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Row
label="Wagon hold expires"
value={formatStamp(booking.holdExpiresAt) ?? "—"}
tone="warning"
hint={formatRelative(booking.holdExpiresAt, nowMs)}
/>
) : null}
{schedule ? (
<>
<Row
label="Departure"
value={
formatStamp(schedule.actualDepartureAt) ??
formatStamp(schedule.scheduledDepartureDate) ??
"—"
}
hint={
schedule.actualDepartureAt
? `Actual · planned ${formatStamp(schedule.scheduledDepartureDate) ?? "—"}`
: "Planned"
}
/>
<Row
label={schedule.actualArrivalAt ? "Arrived (trip ended)" : "Arrival"}
value={
formatStamp(schedule.actualArrivalAt) ??
formatStamp(schedule.scheduledArrivalDate) ??
"—"
}
hint={
schedule.actualArrivalAt
? `Actual · planned ${formatStamp(schedule.scheduledArrivalDate) ?? "—"}`
: "Planned — the train has not arrived yet"
}
/>
</>
) : null}
</Stack>
</SectionCard>
);
}

View File

@@ -22,3 +22,4 @@ export * from "./BookingMileServicesCard";
export * from "./BookingCargoCard";
export * from "./BookingContractSummaryCard";
export * from "./BookingCompanyCard";
export * from "./BookingSchedulingWindowCard";

View File

@@ -44,9 +44,8 @@ const clampInt = (v: number | string, max: number): number => {
/**
* NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field
* for actions that move real wagons; omit it for a transfer REQUEST, which may
* legitimately ask for more than the yard holds today (OCC fulfils it in
* instalments) — the slider then just tracks the current value.
* to the wagons on hand; omitting it leaves the field unbounded and the slider
* simply tracks the current value.
*/
const QuantityField = ({
value,
@@ -434,9 +433,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
{availableCount} available
</Badge>
</Group>
{/* No max: the request may exceed what the yard holds
today — OCC fulfils it in instalments. */}
<QuantityField value={transferQty} onChange={setTransferQty} />
{/* Capped at the wagons actually available in this yard
right now (uncoupled + Available) — a request may not
ask for more than the yard can hand over. */}
<QuantityField
value={transferQty}
onChange={setTransferQty}
max={availableCount}
/>
</div>
<Select
label="Destination yard"

View File

@@ -40,6 +40,7 @@ import {
BookingCompanyCard,
BookingContractSummaryCard,
BookingContainerUnitsCard,
BookingSchedulingWindowCard,
BookingDocumentsPanel,
BookingTrucksPanel,
ContractOrdersPanel,
@@ -246,6 +247,7 @@ export default function BookingRequestDetailPage() {
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingSchedulingWindowCard booking={booking} />
<BookingPricingSummary booking={booking} />
<Box id="warehouse-payments">
<WarehouseInfoCard

View File

@@ -102,6 +102,7 @@ const FleetResourcePage = () => {
const currentYardId = listFilterValues.currentYardId;
const availability = listFilterValues.availability;
const trainNumber = listFilterValues.trainNumber;
const trainId = listFilterValues.trainId;
if (status && status !== "ALL") {
(filters as { status?: string }).status = status;
}
@@ -114,6 +115,9 @@ const FleetResourcePage = () => {
if (trainNumber && trainNumber !== "ALL") {
(filters as { trainNumber?: string }).trainNumber = trainNumber;
}
if (trainId && trainId !== "ALL") {
filters.trainId = trainId;
}
// Wagons only: narrow the fleet to one wagon type (the API filters on it).
const wagonTypeId = listFilterValues.wagonTypeId;
if (wagonTypeId && wagonTypeId !== "ALL") {
@@ -191,6 +195,11 @@ const FleetResourcePage = () => {
const { data: drivers = [] } = useQuery(
api.fleet.list.queryOptions({ input: { slug: "drivers" } }),
);
// Wagons-only: "Train" list filter needs every train's code to pick from.
const { data: trains = [], isLoading: trainsLoading } = useQuery({
...api.trains.list.queryOptions(),
enabled: slug === "wagons",
});
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
@@ -247,6 +256,9 @@ const FleetResourcePage = () => {
const yardOpts = (yards as Array<{ id: string; label?: string; code?: string }>).map(
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
);
const trainOpts = (trains as Array<{ id: string; code: string; trainName?: string | null }>).map(
(t) => ({ value: t.id, label: t.trainName ? `${t.code} - ${t.trainName}` : t.code }),
);
// Carries capacity + trailer configuration so picking a truck type can
// pre-fill the vehicle's capacity and drop the trailer plate on a rigid type.
@@ -274,8 +286,9 @@ const FleetResourcePage = () => {
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
containers: containerOpts,
yards: yardOpts,
trains: trainOpts,
};
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]);
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]);
const listFilterSelects = useMemo(() => {
if (!config?.listFilters?.length) return null;
@@ -327,7 +340,8 @@ const FleetResourcePage = () => {
truckTypesLoading ||
wagonsLoading ||
containersLoading ||
yardsLoading;
yardsLoading ||
trainsLoading;
const filteredRows = useMemo(() => {
if (!config) return allRows;

View File

@@ -33,7 +33,8 @@ export type FleetDynamicOptions =
| "truckTypes"
| "wagons"
| "containers"
| "yards";
| "yards"
| "trains";
/**
* A dynamic select option that can carry the record it came from. Picking a
@@ -324,6 +325,12 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
allLabel: "All trains",
options: TRAIN_RUN_FILTER_OPTIONS,
},
{
key: "trainId",
label: "Train",
allLabel: "All trains",
dynamicOptions: "trains",
},
],
cardTitleKey: "wagonNumber",
cardSubtitleKey: "currentYard",

View File

@@ -35,6 +35,7 @@ import {
STATUS_META,
TransferProgress,
TransferStatusBadge,
stripHtmlToText,
wagonTypeLabel,
yardLabel,
} from "./wagon-transfer-ui";
@@ -119,9 +120,9 @@ function RequestItem({ request }: { request: WagonTransferRequest }) {
{wagonTypeLabel(request.wagonType)}
</Badge>
</Group>
{request.reason ? (
{stripHtmlToText(request.reason) ? (
<Text size="xs" c="dimmed" lineClamp={1}>
{request.reason}
{stripHtmlToText(request.reason)}
</Text>
) : null}
</Stack>

View File

@@ -1,3 +1,4 @@
import { Freight } from "@edr/types";
import {
Alert,
Button,
@@ -41,6 +42,30 @@ function useTransferOptions(enabled: boolean) {
};
}
/**
* Wagons the source yard can hand over right now — AVAILABLE and not coupled to
* a built train. Mirrors `countAvailable` on the API, which rejects any request
* asking for more than this, so the field must not let one be filed.
*/
function useAvailableCount(
enabled: boolean,
fromYardId: string | null,
wagonTypeId: string | null,
) {
const { data: wagons = [] } = useQuery({
...api.wagons.list.queryOptions({ input: {} }),
enabled: enabled && Boolean(fromYardId && wagonTypeId),
});
if (!fromYardId || !wagonTypeId) return null;
return wagons.filter(
(w) =>
w.currentYardId === fromYardId &&
w.wagonTypeId === wagonTypeId &&
w.status === Freight.WagonStatus.Available &&
!w.trainId,
).length;
}
export interface TransferRequestFormModalProps {
opened: boolean;
onClose: () => void;
@@ -54,9 +79,9 @@ export interface TransferRequestFormModalProps {
}
/**
* File a wagon-transfer request. The count is deliberately NOT capped by what
* the source yard holds today — OCC fulfils in instalments, so asking for 50
* where 20 sit is a normal request.
* File a wagon-transfer request. The count is capped by what the source yard
* has available right now; the API enforces the same ceiling, so a larger ask
* is rejected rather than queued.
*/
export function TransferRequestFormModal({
opened,
@@ -83,10 +108,23 @@ export function TransferRequestFormModal({
const create = useMutation(api.wagonTransferRequests.create.mutationOptions());
const available = useAvailableCount(opened, fromYardId, wagonTypeId);
// A prefilled outstanding count (or a count typed before the yard was picked)
// can exceed what the chosen source yard actually has — pull it back down so
// the field never holds a value the API would reject.
useEffect(() => {
if (available == null) return;
setQuantity((q) => (Number(q) > available ? available : q));
}, [available]);
const sameYard = Boolean(fromYardId && fromYardId === toYardId);
const overAvailable = available != null && Number(quantity) > available;
const valid =
Boolean(fromYardId && toYardId && wagonTypeId && reason.trim()) &&
!sameYard &&
!overAvailable &&
available !== 0 &&
Number(quantity) >= 1;
const submit = async () => {
@@ -155,10 +193,25 @@ export function TransferRequestFormModal({
/>
<NumberInput
label="How many"
description="Can exceed what the yard holds today — OCC delivers in instalments"
description={
available == null
? "Pick a source yard and wagon type to see what is available"
: `${available} wagon(s) available in the source yard`
}
min={1}
max={available ?? undefined}
clampBehavior={available == null ? "none" : "strict"}
allowNegative={false}
value={quantity}
onChange={setQuantity}
disabled={available === 0}
error={
available === 0
? "This yard has no wagons of that type available"
: overAvailable
? `Only ${available} available`
: undefined
}
required
/>
<Textarea

View File

@@ -10,6 +10,7 @@ import {
Tabs,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
@@ -31,6 +32,7 @@ import { useMutation } from "@tanstack/react-query";
import { useAuth } from "@/auth/useAuth";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { sanitizeHtml } from "@/shared/lib/sanitize";
import { api } from "@/services/api";
import type {
TransferRequestListFilter,
@@ -55,6 +57,7 @@ import {
fmtDateTime,
isOpenRequest,
outstandingOn,
stripHtmlToText,
wagonTypeLabel,
yardLabel,
} from "./wagon-transfer-ui";
@@ -108,6 +111,9 @@ export default function WagonTransfersPage() {
const [closingShort, setClosingShort] = useState<WagonTransferRequest | null>(
null,
);
const [viewingReason, setViewingReason] = useState<WagonTransferRequest | null>(
null,
);
const filter: TransferRequestListFilter = useMemo(
() => ({
@@ -197,11 +203,29 @@ export default function WagonTransfersPage() {
{
id: "reason",
header: () => <span>Reason</span>,
cell: ({ row }) => (
<Text size="sm" c="dimmed" lineClamp={2} maw={260}>
{row.original.reason || "—"}
</Text>
),
cell: ({ row }) => {
const text = stripHtmlToText(row.original.reason);
return text ? (
<UnstyledButton
onClick={() => setViewingReason(row.original)}
data-stop-row-click
>
<Text
size="sm"
c="dimmed"
lineClamp={2}
maw={260}
style={{ textAlign: "left", textDecoration: "underline dotted" }}
>
{text}
</Text>
</UnstyledButton>
) : (
<Text size="sm" c="dimmed">
</Text>
);
},
},
{
id: "filed",
@@ -522,6 +546,33 @@ export default function WagonTransfersPage() {
</Stack>
)}
</Modal>
<Modal
opened={Boolean(viewingReason)}
onClose={() => setViewingReason(null)}
radius="md"
title="Reason"
>
{!viewingReason ? null : (
<Stack gap="sm">
<Text size="sm" fw={600}>
{yardLabel(viewingReason.fromYard)}{" "}
<ArrowRight
size={13}
className="inline-block opacity-60"
/>{" "}
{yardLabel(viewingReason.toYard)} ·{" "}
{wagonTypeLabel(viewingReason.wagonType)} ·{" "}
{viewingReason.quantity} wagon(s)
</Text>
<Box
className="text-sm [&_p]:my-2 [&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-5 [&_ul]:pl-5"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(viewingReason.reason ?? ""),
}}
/>
</Stack>
)}
</Modal>
</PageContainer>
);
}

View File

@@ -3,6 +3,16 @@ import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core";
import type { WagonTransferRequest } from "@/services/wagon.service";
/** Reason/note fields come from a rich-text editor and store HTML — this
* gives a plain-text preview for list/table contexts (full formatting is
* shown via `sanitizeHtml` + `dangerouslySetInnerHTML` where there's room). */
export const stripHtmlToText = (html?: string | null): string =>
(html ?? "")
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
export const yardLabel = (y?: { label?: string; code?: string } | null) =>
y?.label || y?.code || "—";

View File

@@ -131,6 +131,20 @@ export interface BookingFile {
size?: number;
}
/** The allocated train's identity, window phase, and planned/actual clock. */
export interface BookingTrainScheduleSummary {
id: string;
reference: string | null;
trainNumber: string | null;
status: string | null;
scheduledDepartureDate: string | null;
scheduledArrivalDate: string | null;
actualDepartureAt: string | null;
actualArrivalAt: string | null;
windowPhase: string | null;
paymentPhaseEndsAt: string | null;
}
export interface BookingDetail {
id: string;
reference: string;
@@ -188,6 +202,22 @@ export interface BookingDetail {
wagonsRequired?: number | null;
scheduledAt?: string | null;
trainScheduleId?: string | null;
/** Operational status of the allocated train (null until scheduled). */
trainScheduleStatus?: string | null;
/** The allocated train's identity + clock, attached by the detail endpoint. */
trainScheduleSummary?: BookingTrainScheduleSummary | null;
/**
* When the batch engine picked this booking and opened its pay window — the
* start paired with `paymentDeadline` (both are set and cleared together).
*/
selectedForBatchAt?: string | null;
/** End of this booking's pay window (batch/offer deadline). */
paymentDeadline?: string | null;
/**
* End of the pay window including the settlement drain tail — the deadline
* staff should quote, since a payment landing inside the drain still counts.
*/
paymentDrainEndsAt?: string | null;
pnrCode?: string | null;
firstMilePickupAddress?: string | null;
lastMileDeliveryAddress?: string | null;