From 5d8658b5d613c1a7935b4b85a9e7f9c60d928c32 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 22:27:58 +0000 Subject: [PATCH] fix contrat,wagon allocation wehn a booking is split --- .../src/contracts/contract-article.util.ts | 60 +++++- .../contract-dynamic-template.spec.ts | 34 ++++ .../templates/_partials/dynamic_articles.hbs | 3 +- .../contracts/templates/_partials/styles.hbs | 19 +- .../ContractTemplateEditorPage.tsx | 186 ++++++++++++++++-- .../portal/src/pages/MyPortalPage/hooks.ts | 12 ++ .../components/StatusHero.tsx | 53 +++-- .../bookings/BookingDetailPage/constants.ts | 119 +++++++++++ .../bookings/BookingDetailPage/index.tsx | 24 ++- .../pages/bookings/BookingDetailPage/utils.ts | 21 ++ .../contracts/new-contract-form/schema.ts | 6 - .../new-contract-form/step3-cargo-scope.tsx | 17 -- 12 files changed, 475 insertions(+), 79 deletions(-) diff --git a/apps/edr-freight-api/src/contracts/contract-article.util.ts b/apps/edr-freight-api/src/contracts/contract-article.util.ts index 5f0c86a6c..6aacab16c 100644 --- a/apps/edr-freight-api/src/contracts/contract-article.util.ts +++ b/apps/edr-freight-api/src/contracts/contract-article.util.ts @@ -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 { const lines = (body ?? '') @@ -28,20 +48,44 @@ export function parseArticleBody(body: string): Pick 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 }; diff --git a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts index 032030243..49fb3416a 100644 --- a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts @@ -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', () => { diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs index 717fbde8f..bec620655 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs @@ -6,7 +6,8 @@ {{else}}
    {{#each clauses}} -
  1. +
  2. + {{number}}. {{text}} {{#if bullets.length}}
      diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs index 0207ff9fb..118606065 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -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; diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx index 2d635fe58..6f63d68f2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx @@ -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({ ))} - + + + + + + Add structure + + + - + + + + @@ -804,10 +952,10 @@ function ArticleEditorModal({ )} {parsed.clauses.map((clause, i) => ( - + - {i + 1}.{" "} + {clause.number}.{" "} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts index 5e051cce9..eaec70ed3 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts @@ -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; + }, }), ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index ba8ca3db7..16d412703 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -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 ?? ( )} @@ -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; }) { - 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({ } >
      - {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}
      ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index e4fac9ca7..ae3d20e4e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -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 = {}; +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 diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx index a06a89ca0..9cb059503 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx @@ -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() { ); } - return ; + return ( + + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts index 031a2d38c..432dcf8f5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts @@ -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); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts index 6ffa29b8c..4a57c120f 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts @@ -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 diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx index 51b9bcc93..26b455258 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx @@ -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 && ( - ( - - )} - /> - )} )}