mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 00:08:18 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -22,3 +22,4 @@ export * from "./BookingMileServicesCard";
|
||||
export * from "./BookingCargoCard";
|
||||
export * from "./BookingContractSummaryCard";
|
||||
export * from "./BookingCompanyCard";
|
||||
export * from "./BookingSchedulingWindowCard";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
export const yardLabel = (y?: { label?: string; code?: string } | null) =>
|
||||
y?.label || y?.code || "—";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user