fix contrat,wagon allocation wehn a booking is split

This commit is contained in:
Marshal
2026-07-10 22:27:58 +00:00
parent 1121e9ce82
commit 5d8658b5d6
12 changed files with 475 additions and 79 deletions

View File

@@ -3,6 +3,10 @@ import Handlebars from 'handlebars';
/** One numbered clause of a dynamic article, with optional nested bullets. */
export interface RenderedClause {
text: string;
/** Computed outline number, e.g. "3" or "2.1.4". */
number: string;
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
depth: number;
bullets: string[];
}
@@ -16,10 +20,26 @@ export interface RenderedArticle {
}
/**
* Parse a template article body into clauses. Format: one clause per line;
* lines prefixed with "- " become bullets nested under the preceding clause.
* A body that reduces to a single clause without bullets renders as a plain
* paragraph rather than a numbered list of one.
* Leading outline token on a clause line: "1.", "2)", "1.1", "1.1.1." …
* The token's segment count sets the clause depth; its digits are ignored —
* numbering is recomputed sequentially so stale numbers self-heal.
* A single-segment token requires its "."/")" ("10 tons…" is prose, "10. x"
* is clause ten); multi-segment tokens ("1.1") may omit it. A token may also
* end the line — that is an empty clause still being typed in the editor.
*/
const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
const MAX_CLAUSE_DEPTH = 6;
/**
* Parse a template article body into clauses. Format: one clause per line.
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
* sub-clause at that depth — the typed digits are stripped and renumbered
* sequentially, so editing order never leaves stale numbers in the document.
* Lines prefixed with "- " become bullets nested under the preceding clause.
* A body that reduces to a single un-numbered clause without bullets renders
* as a plain paragraph rather than a numbered list of one.
*/
export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph' | 'clauses'> {
const lines = (body ?? '')
@@ -28,20 +48,44 @@ export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph
.filter((line) => line.length > 0);
const clauses: RenderedClause[] = [];
// counters[i] = current number at depth i+1; truncated when a shallower
// clause arrives so deeper numbering restarts at 1.
const counters: number[] = [];
let sawNumberToken = false;
for (const line of lines) {
if (line.startsWith('- ')) {
const bullet = line.slice(2).trim();
if (clauses.length === 0) {
clauses.push({ text: bullet, bullets: [] });
counters.splice(0, counters.length, 1);
clauses.push({ text: bullet, number: '1', depth: 1, bullets: [] });
} else {
clauses[clauses.length - 1].bullets.push(bullet);
}
} else {
clauses.push({ text: line, bullets: [] });
continue;
}
const match = CLAUSE_NUMBER_RE.exec(line);
const token = match ? (match[1] ?? match[2]) : null;
let depth = token ? Math.min(token.split('.').length, MAX_CLAUSE_DEPTH) : 1;
// A sub-clause can only sit directly under an existing parent — "1.1.1"
// typed as the first line clamps to whatever level is actually open.
depth = Math.min(depth, counters.length + 1);
if (match) sawNumberToken = true;
counters.splice(depth);
while (counters.length < depth) counters.push(0);
counters[depth - 1] += 1;
clauses.push({
text: match ? line.slice(match[0].length).trim() : line,
number: counters.slice(0, depth).join('.'),
depth,
bullets: [],
});
}
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
if (clauses.length === 1 && clauses[0].bullets.length === 0 && !sawNumberToken) {
return { paragraph: clauses[0].text, clauses: [] };
}
return { clauses };

View File

@@ -26,6 +26,40 @@ describe('parseArticleBody', () => {
expect(parsed.paragraph).toBe('This Agreement becomes effective when signed.');
expect(parsed.clauses).toEqual([]);
});
it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => {
const parsed = parseArticleBody(
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
);
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
['1', 1, 'Scope'],
['1.1', 2, 'Rail transport'],
['1.1.1', 3, 'Wagon supply'],
['2', 1, 'Payment'],
]);
});
it('clamps a sub-clause with no open parent to the next available level', () => {
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
['1', 1],
['2', 1],
]);
});
it('leaves prose that merely starts with a number un-tokenized', () => {
const parsed = parseArticleBody('10 tons is the minimum load.\nPayment in advance.');
expect(parsed.clauses.map((c) => c.text)).toEqual([
'10 tons is the minimum load.',
'Payment in advance.',
]);
});
it('keeps a single explicitly numbered line as a clause, not a paragraph', () => {
const parsed = parseArticleBody('1. Only clause.');
expect(parsed.paragraph).toBeUndefined();
expect(parsed.clauses.map((c) => [c.number, c.text])).toEqual([['1', 'Only clause.']]);
});
});
describe('interpolateTemplateText', () => {

View File

@@ -6,7 +6,8 @@
{{else}}
<ol class="clauses">
{{#each clauses}}
<li>
<li class="clause depth-{{depth}}">
<span class="clause-no">{{number}}.</span>
{{text}}
{{#if bullets.length}}
<ul class="clause-bullets">

View File

@@ -282,28 +282,27 @@
.article-name { color: #0e5b45; }
.article-paragraph { margin: 4px 0 0; }
ol.clauses {
counter-reset: clause;
list-style: none;
margin: 6px 0 0;
padding-left: 0;
}
ol.clauses > li {
counter-increment: clause;
ol.clauses > li.clause {
margin-bottom: 6px;
padding-left: 24px;
position: relative;
text-align: justify;
}
ol.clauses > li::before {
ol.clauses .clause-no {
color: #0e5b45;
content: counter(clause) ".";
font-family: Arial, sans-serif;
font-size: 9.5pt;
font-weight: 700;
left: 0;
position: absolute;
top: 1px;
margin-right: 6px;
}
/* Sub-clause indentation: each outline level steps in. */
ol.clauses > li.depth-2 { padding-left: 20px; }
ol.clauses > li.depth-3 { padding-left: 40px; }
ol.clauses > li.depth-4 { padding-left: 60px; }
ol.clauses > li.depth-5 { padding-left: 80px; }
ol.clauses > li.depth-6 { padding-left: 100px; }
ul.clause-bullets {
margin: 5px 0 2px;
padding-left: 16px;

View File

@@ -35,6 +35,7 @@ import {
Hash,
ListOrdered,
ListPlus,
ListTree,
Mail,
MapPin,
Package,
@@ -60,7 +61,7 @@ import {
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
const BODY_HINT =
'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Use the buttons above to drop a placeholder at the cursor — it is filled from the contract when the document is generated.';
'One clause per line. Use "New clause" for the next number (1., 2., …), "Sub-clause" for a nested number (1.1, then 1.1.1), and "Bullet" for a • point — the number or bullet is typed for you, just add the text. Placeholders are filled from the contract when the document is generated.';
interface ArticleDraft {
id?: string;
@@ -253,6 +254,10 @@ function unknownTokens(text: string): string[] {
interface ParsedClause {
text: string;
/** Computed outline number, e.g. "3" or "2.1.4". */
number: string;
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
depth: number;
bullets: string[];
}
@@ -262,28 +267,93 @@ interface ParsedBody {
clauses: ParsedClause[];
}
/**
* Leading outline token on a clause line ("1. ", "2.1 ", "1.1.1) ") — its
* segment count sets the depth; the digits themselves are recomputed. Single
* segment requires "."/")" so prose like "10 tons…" is untouched; a token may
* end the line (empty clause still being typed).
*/
const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
/** Depth of the outline token in a CLAUSE_NUMBER_RE match, else null. */
function matchDepth(match: RegExpExecArray | null): number | null {
if (!match) return null;
const token = match[1] ?? match[2];
return Math.min(token.split(".").length, MAX_CLAUSE_DEPTH);
}
/** Deepest supported sub-clause level. */
const MAX_CLAUSE_DEPTH = 6;
/**
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
* line, "- " nests a bullet under the previous clause, and a single bullet-less
* clause renders as a plain paragraph instead of a numbered list of one.
* line; a leading outline number ("2. ", "2.1 ") nests the line as a sub-clause
* at that depth and is renumbered sequentially; "- " nests a bullet under the
* previous clause; a single un-numbered bullet-less clause renders as a plain
* paragraph instead of a numbered list of one.
*/
function parseArticleBody(body: string): ParsedBody {
const clauses: ParsedClause[] = [];
const counters: number[] = [];
let sawNumberToken = false;
for (const raw of body.split("\n")) {
const line = raw.trim();
if (!line) continue;
if (line.startsWith("- ") && clauses.length > 0) {
clauses[clauses.length - 1].bullets.push(line.slice(2).trim());
} else {
clauses.push({ text: line.replace(/^- /, ""), bullets: [] });
continue;
}
const cleaned = line.replace(/^- /, "");
const match = CLAUSE_NUMBER_RE.exec(cleaned);
let depth = matchDepth(match) ?? 1;
// A sub-clause can only sit directly under an existing parent.
depth = Math.min(depth, counters.length + 1);
if (match) sawNumberToken = true;
counters.splice(depth);
while (counters.length < depth) counters.push(0);
counters[depth - 1] += 1;
clauses.push({
text: match ? cleaned.slice(match[0].length).trim() : cleaned,
number: counters.slice(0, depth).join("."),
depth,
bullets: [],
});
}
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
if (
clauses.length === 1 &&
clauses[0].bullets.length === 0 &&
!sawNumberToken
) {
return { paragraph: clauses[0].text, clauses: [] };
}
return { clauses };
}
/**
* Rewrite the leading outline tokens in a body so every numbered clause line
* carries its computed sequential number (stale numbers self-heal). Lines
* without a number token and bullet lines pass through untouched.
*/
function renumberBody(body: string): string {
const counters: number[] = [];
return body
.split("\n")
.map((raw) => {
const line = raw.trim();
if (!line || line.startsWith("- ")) return raw;
const match = CLAUSE_NUMBER_RE.exec(line);
let depth = matchDepth(match) ?? 1;
depth = Math.min(depth, counters.length + 1);
counters.splice(depth);
while (counters.length < depth) counters.push(0);
counters[depth - 1] += 1;
if (!match) return raw;
const number = counters.slice(0, depth).join(".");
return `${number}. ${line.slice(match[0].length).trim()}`;
})
.join("\n");
}
/** Render clause text with {{placeholders}} highlighted as green chips. */
function HighlightedText({ text }: { text: string }) {
const parts = text.split(/(\{\{[^{}]+\}\})/g);
@@ -632,13 +702,57 @@ function ArticleEditorModal({
});
};
const insertLinePrefix = (prefix: string) => {
/**
* Insert a structured line (clause / sub-clause / bullet) on a fresh line
* below the one the caret is on. Clause lines get their outline number typed
* in automatically ("3. ", "3.1. ", …) and every numbered line in the body is
* renumbered so the text always matches the preview.
*/
const insertStructuredLine = (kind: "clause" | "sub" | "bullet") => {
const el = bodyRef.current;
const start = el?.selectionStart ?? body.length;
// Start the snippet on its own line unless the caret already is.
const needsNewline = start > 0 && body[start - 1] !== "\n";
lastFocused.current = "body";
insertAtCursor(`${needsNewline ? "\n" : ""}${prefix}`);
const caret = el?.selectionStart ?? body.length;
// Structured lines never split a sentence — insert after the caret's line.
const lineEnd = body.indexOf("\n", caret);
const insertAt = lineEnd === -1 ? body.length : lineEnd;
const before = body.slice(0, insertAt);
const after = body.slice(insertAt); // "" or starts with "\n"
let prefix: string;
if (kind === "bullet") {
prefix = "- ";
} else {
// Sub-clause nests one level under the clause the caret is on/above;
// New clause always starts a fresh top-level number.
const above = parseArticleBody(before);
const lastDepth = above.paragraph
? 1
: (above.clauses[above.clauses.length - 1]?.depth ?? 0);
const depth =
kind === "sub" ? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH) : 1;
// Digits are placeholders — renumberBody assigns the real value.
prefix = `${Array.from({ length: depth }, () => "1").join(".")}. `;
}
const beforeLines = before.length > 0 ? before.split("\n") : [];
const afterLines =
after.length > 0 ? after.slice(1).split("\n") : [];
const insertedIdx = beforeLines.length;
const joined = [...beforeLines, prefix, ...afterLines].join("\n");
const next = kind === "bullet" ? joined : renumberBody(joined);
setBody(next);
// Caret lands at the end of the inserted line, ready for typing.
const caretTarget = next
.split("\n")
.slice(0, insertedIdx + 1)
.join("\n").length;
requestAnimationFrame(() => {
const field = bodyRef.current;
if (!field) return;
field.focus();
field.setSelectionRange(caretTarget, caretTarget);
});
};
const parsed = useMemo(() => parseArticleBody(body), [body]);
@@ -724,26 +838,60 @@ function ArticleEditorModal({
))}
</Menu.Dropdown>
</Menu>
<Tooltip label="Start a new numbered clause" withArrow>
</Group>
</Box>
<Box>
<Text size="sm" fw={500} mb={4}>
Add structure
</Text>
<Group gap={6} wrap="wrap">
<Tooltip
label="New line with the next clause number typed for you (1., 2., 3., …)"
withArrow
>
<Button
variant="default"
variant="light"
color="edr-green"
size="compact-sm"
radius="md"
leftSection={<ListOrdered size={13} />}
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertLinePrefix("")}
onClick={() => insertStructuredLine("clause")}
>
New clause
</Button>
</Tooltip>
<Tooltip label="Nest a bullet under the previous clause" withArrow>
<Tooltip
label="Numbered point under the current clause — 1.1, then 1.1.1 if clicked again"
withArrow
>
<Button
variant="default"
variant="light"
color="edr-green"
size="compact-sm"
radius="md"
leftSection={<ListTree size={13} />}
disabled={body.trim().length === 0}
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertStructuredLine("sub")}
>
Sub-clause
</Button>
</Tooltip>
<Tooltip
label="New line with a bullet (•) under the current clause"
withArrow
>
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="md"
leftSection={<ListPlus size={13} />}
disabled={body.trim().length === 0}
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertLinePrefix("- ")}
onClick={() => insertStructuredLine("bullet")}
>
Bullet
</Button>
@@ -804,10 +952,10 @@ function ArticleEditorModal({
</Text>
)}
{parsed.clauses.map((clause, i) => (
<Box key={i}>
<Box key={i} pl={(clause.depth - 1) * 20}>
<Text size="sm">
<Text component="span" fw={600} c="edr-green.7">
{i + 1}.{" "}
{clause.number}.{" "}
</Text>
<HighlightedText text={clause.text} />
</Text>

View File

@@ -3,6 +3,7 @@ import { Freight } from "@edr/types";
import useAuth from "@/hooks/useAuth";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { api } from "@/services/api";
import { isBookingLive } from "@/pages/bookings/BookingDetailPage/utils";
import { ACTIVE_STATUSES } from "./constants";
export function useMyPortalData(selectedProfileId?: string) {
@@ -24,6 +25,17 @@ export function useMyPortalData(selectedProfileId?: string) {
sortOrder: "DESC",
companyProfileId: selectedProfileId,
},
// The home tiles read each booking's status directly, but staff/system
// transitions (operations accepting an order, batch selection, clearance
// review) never push here. Poll while any booking is still live so those
// changes surface — e.g. an accepted order leaving "Operation request
// under review" — and stop once everything has settled.
refetchInterval: (query) => {
const items =
(query.state.data as { items?: Freight.IBooking[] } | undefined)
?.items ?? [];
return items.some((b) => isBookingLive(b.status)) ? 30_000 : false;
},
}),
);

View File

@@ -3,7 +3,15 @@ import { Check, MoveRight } from "lucide-react";
import type { Freight } from "@edr/types";
import { ARRIVAL_STAGE, PROGRESS_STAGES, STATUS_MAP, resolveStage } from "../constants";
import {
ARRIVAL_STAGE,
CONTRACT_ARRIVAL_STAGE,
CONTRACT_PROGRESS_STAGES,
PROGRESS_STAGES,
STATUS_MAP,
resolveContractStage,
resolveStage,
} from "../constants";
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
import { SectionCard } from "./layout";
@@ -65,7 +73,19 @@ export function StatusHero({
children?: React.ReactNode;
}) {
const status = booking.status;
const stage = resolveStage(booking);
// Contract-drawdown bookings (initiated under a contract) follow a dedicated
// wizard — initiated → submitted → accepted → payment → … — instead of the
// direct booking's Request/Approval/Contract stages.
const isContractDrawdown = Boolean(booking.contractId) && !isNegative(status);
const stages = isContractDrawdown
? CONTRACT_PROGRESS_STAGES
: PROGRESS_STAGES;
const arrivalStage = isContractDrawdown
? CONTRACT_ARRIVAL_STAGE
: ARRIVAL_STAGE;
const stage = isContractDrawdown
? resolveContractStage(booking)
: resolveStage(booking);
// Contract-drawdown instance in the clearance gate: it was INITIATED with one
// click (no cargo/date yet), not submitted through the wizard.
const isInitiatedInstance =
@@ -75,7 +95,7 @@ export function StatusHero({
// headline is overridden here. Bookings with a per-booking journey carry the
// ARRIVED status themselves and use its own STATUS_MAP copy.
const cfg =
stage === ARRIVAL_STAGE && STATUS_MAP[status]?.stage !== ARRIVAL_STAGE
stage === arrivalStage && STATUS_MAP[status]?.stage !== ARRIVAL_STAGE
? {
title: "Train arrived at destination",
description:
@@ -89,7 +109,14 @@ export function StatusHero({
"Upload the required clearance documents to start the review. Once the review is finalized you can book your shipment.",
stage,
}
: (STATUS_MAP[status] ?? STATUS_MAP.DRAFT);
: isContractDrawdown && status === "FULLY_EXECUTED"
? {
title: "Accepted by operations",
description:
"Operations accepted your order. Complete payment once your train is selected to secure the slot.",
stage,
}
: (STATUS_MAP[status] ?? STATUS_MAP.DRAFT);
const negative = isNegative(status);
const draft = isDraftLike(status);
@@ -132,13 +159,9 @@ export function StatusHero({
{children ?? (
<ProgressTracker
current={stage}
stages={stages}
tone={draft ? "ink" : "green"}
negative={negative}
// Contract drawdowns are initiated with one click, not submitted
// through the wizard — relabel the stage for them.
labelOverrides={
booking.contractId ? { 1: "Initiated" } : undefined
}
/>
)}
</SectionCard>
@@ -147,16 +170,16 @@ export function StatusHero({
function ProgressTracker({
current,
stages = PROGRESS_STAGES,
tone = "green",
labelOverrides,
}: {
current: number;
/** Which stage set to render — direct or contract-drawdown. */
stages?: typeof PROGRESS_STAGES;
tone?: "green" | "ink";
negative?: boolean;
/** Per-stage-index label replacements (e.g. "Submitted" → "Initiated"). */
labelOverrides?: Record<number, string>;
}) {
const last = PROGRESS_STAGES.length - 1;
const last = stages.length - 1;
const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371";
const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4";
@@ -172,7 +195,7 @@ function ProgressTracker({
}
>
<div className="flex items-start" style={{ minWidth: 640 }}>
{PROGRESS_STAGES.map((stage, idx) => {
{stages.map((stage, idx) => {
const state =
idx < current ? "done" : idx === current ? "active" : "idle";
const Icon = stage.icon;
@@ -246,7 +269,7 @@ function ProgressTracker({
ta="center"
c={state === "idle" ? "#9AA8B5" : "#10202F"}
>
{labelOverrides?.[idx] ?? stage.label}
{stage.label}
</Text>
</div>
);

View File

@@ -95,6 +95,125 @@ export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex(
(s) => s.label === "Arrival",
);
/**
* Progress stages for a CONTRACT-DRAWDOWN booking (created under a contract via
* initiate → clearance → book). These bookings never pass through the direct
* wizard's Request/Approval/Contract stages — the contract is already executed.
* Their journey is: initiated (bare instance in clearance) → submitted (booking
* completed, sent to operations) → accepted (operations accepted) → payment →
* loading → transit → arrival → unloading → complete.
*/
export const CONTRACT_PROGRESS_STAGES = [
{
// The instance was initiated with one click and is going through per-booking
// clearance (upload → review → ready). One-time drawdowns without clearance
// start here too until they are booked.
label: "Initiated",
icon: FileText,
statuses: [
"DRAFT",
"CHANGES_REQUESTED",
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",
],
},
{
// The customer (or GL) completed the booking — cargo + shipment day — and it
// is submitted to operations for acceptance.
label: "Submitted",
icon: ClipboardCheck,
statuses: [
"OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED",
"OPERATION_PRICE_PENDING_CONFIRM",
"OPERATION_REQUESTED",
],
},
{
// Operations accepted the order — it now sits in the batch holding pool
// awaiting a train and its pay window.
label: "Accepted",
icon: ShieldCheck,
statuses: ["FULLY_EXECUTED", "READY_FOR_ASSIGNMENT"],
},
{
label: "Payment",
icon: ShieldCheck,
statuses: [
"SELECTED_FOR_BATCH",
"PAYMENT_VERIFICATION_IN_PROGRESS",
"EXPIRED",
"PRICE_CHANGED_PENDING_CONFIRM",
],
},
{
label: "Loading",
icon: Ship,
statuses: [
"PAID",
"PNR_GENERATED",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
"WAGON_ASSIGNED",
"INVOICED",
"ROAD_DISPATCH_PENDING",
],
},
{
label: "In Transit",
icon: Train,
statuses: ["IN_TRANSIT"],
},
{
// Lights up from the assigned train's ARRIVED state while the booking is
// still IN_TRANSIT — no status of its own (see resolveContractStage).
label: "Arrival",
icon: MapPin,
statuses: [],
},
{
label: "Unloading",
icon: PackageOpen,
statuses: ["ARRIVED"],
},
{
label: "Complete",
icon: PackageCheck,
statuses: ["COMPLETED", "DELIVERED"],
},
];
/** Contract-drawdown Arrival stage index. */
export const CONTRACT_ARRIVAL_STAGE = CONTRACT_PROGRESS_STAGES.findIndex(
(s) => s.label === "Arrival",
);
/** status → contract-drawdown stage index, derived from the stage array. */
const CONTRACT_STAGE_BY_STATUS: Record<string, number> = {};
CONTRACT_PROGRESS_STAGES.forEach((stage, index) => {
stage.statuses.forEach((status) => {
CONTRACT_STAGE_BY_STATUS[status] = index;
});
});
/**
* Contract-drawdown stage for a booking, factoring in the assigned train's
* status the same way {@link resolveStage} does for direct bookings.
*/
export function resolveContractStage(booking: {
status: string;
trainScheduleStatus?: string | null;
}): number {
if (
booking.status === "IN_TRANSIT" &&
booking.trainScheduleStatus === "ARRIVED"
) {
return CONTRACT_ARRIVAL_STAGE;
}
return CONTRACT_STAGE_BY_STATUS[booking.status] ?? 0;
}
/**
* Stage for a booking, factoring in the assigned train's operational status:
* a booking with per-booking journey data reaches ARRIVED (the Unloading

View File

@@ -2,6 +2,7 @@ import { Box, Center, Loader, Stack, Text } from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle } from "lucide-react";
import { useParams } from "react-router-dom";
import type { Freight } from "@edr/types";
import { api } from "@/services/api";
@@ -9,7 +10,7 @@ import { ChangesRequestedView } from "./ChangesRequestedView";
import { DraftBookingView } from "./DraftBookingView";
import { PageShell, SectionCard } from "./components/layout";
import { ReadonlyBookingView } from "./ReadonlyBookingView";
import { isDraftLike } from "./utils";
import { isBookingLive, isDraftLike } from "./utils";
export default function BookingDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -21,7 +22,22 @@ export default function BookingDetailPage() {
isError,
error,
} = useQuery(
api.bookings.get.queryOptions({ input: { id: id! }, enabled: !!id }),
api.bookings.get.queryOptions({
input: { id: id! },
enabled: !!id,
// Staff/system transitions (operations accepting an order, batch
// selection, clearance review, transit) happen without any customer
// action and can't push to this open page. Poll while the booking is
// still live so those changes surface — e.g. an accepted operation
// request leaving the "under review" state — and stop once it settles.
refetchInterval: (query) =>
isBookingLive(
(query.state.data as Freight.IBooking | undefined)?.status,
)
? 20_000
: false,
refetchOnWindowFocus: true,
}),
);
const refetchBooking = () => {
@@ -94,5 +110,7 @@ export default function BookingDetailPage() {
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />
);
}
return <ReadonlyBookingView booking={booking} onBookingUpdated={refetchBooking} />;
return (
<ReadonlyBookingView booking={booking} onBookingUpdated={refetchBooking} />
);
}

View File

@@ -6,6 +6,27 @@ export const isNegative = (s: string) => s === "CANCELLED" || s === "REJECTED";
export const isDraftLike = (s: string) =>
s === "DRAFT" || s === "CHANGES_REQUESTED";
/**
* Terminal booking statuses — nothing changes server-side once a booking lands
* here, so the customer view has no reason to keep polling.
*/
const SETTLED_STATUSES = new Set([
"COMPLETED",
"DELIVERED",
"CANCELLED",
"REJECTED",
]);
/**
* True while a booking can still change from a staff/system action the customer
* did not trigger (operations accepting an order, batch selection, clearance
* review, transit progress). Used to poll the customer-facing booking queries so
* those transitions surface without a manual reload — e.g. an accepted operation
* request flipping out of "under review".
*/
export const isBookingLive = (s?: string | null) =>
!!s && !SETTLED_STATUSES.has(s);
export function fmtDate(value?: string | null) {
if (!value) return "—";
const d = new Date(value);

View File

@@ -254,12 +254,6 @@ export const contractFormSchema = z
path: ["cargoTypePath"],
message: "Select a commodity.",
});
} else if (!data.cargoFreeText?.trim()) {
ctx.addIssue({
code: "custom",
path: ["cargoFreeText"],
message: "Describe the cargo.",
});
}
}
// GENERAL contracts are uncapped: no quantity cap is collected, so the

View File

@@ -10,7 +10,6 @@ import {
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import type { Freight } from "@edr/types";
import {
@@ -202,22 +201,6 @@ export function Step3CargoScope({
/>
)}
{parentId && commodityOptions.length > 0 && (
<Controller
name="cargoFreeText"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
label="Cargo description *"
placeholder="e.g. Charcoal, Wheat, etc."
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
</Stack>
)}