mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix contrat,wagon allocation wehn a booking is split
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user