enhance contract document rendering with detailed cargo information

- Updated ContractDocumentViewModelBuilder to include cargoTypeName, containerType, and cargoSummary in the schedule.
- Modified contract dynamic template tests to validate the new cargo fields.
- Enhanced contract renderer service tests to reflect changes in cargo data structure.
- Updated contract view model interface to include new cargo-related fields.
- Improved dynamic template rendering to display cargo type and container type.
- Refactored exchange settings controller and service to streamline error handling and feed status management.
- Introduced article HTML conversion functions to support Quill editor integration for structured article editing.
- Added tests for article HTML conversion to ensure correct round-trip processing of clauses and bullets.
This commit is contained in:
Marshal
2026-08-04 13:03:39 +00:00
parent 45216c5624
commit d0040a6851
14 changed files with 472 additions and 224 deletions

View File

@@ -21,20 +21,23 @@ import {
Title,
Tooltip,
} from "@mantine/core";
import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css";
import {
AlertTriangle,
ArrowDown,
ArrowLeftRight,
ArrowUp,
Boxes,
Building2,
Container,
CalendarClock,
CalendarDays,
CalendarRange,
ChevronDown,
Coins,
Hash,
ListOrdered,
ListPlus,
ListTree,
Mail,
MapPin,
Package,
@@ -58,9 +61,10 @@ import {
useUpdateContractTemplate,
} from "@/hooks/contract-templates/useContractTemplates";
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
import { bodyToHtml, htmlToBody } from "./article-html";
const BODY_HINT =
'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.';
"Each paragraph becomes a numbered clause (1., 2., …) — use Indent to nest it as a sub-clause (1.1, 1.1.1). The bullet list makes • points under the clause above. Numbering is assigned when the document is generated, so it always comes out sequential. Placeholders are filled from the contract.";
interface ArticleDraft {
id?: string;
@@ -170,6 +174,42 @@ const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
icon: Package,
hint: "Description of the cargo",
},
{
token: "{{schedule.cargoTypeName}}",
label: "Cargo type",
icon: Package,
hint: "Named commodity on its own, e.g. Coffee",
},
{
token: "{{schedule.containerType}}",
label: "Container type",
icon: Container,
hint: "Container size, e.g. 20ft / 40ft — dash for bulk",
},
{
token: "{{schedule.cargoSummary}}",
label: "Cargo summary",
icon: Boxes,
hint: "Every cargo line, e.g. Coffee (40ft) × 12",
},
{
token: "{{schedule.tradeDirection}}",
label: "Trade direction",
icon: ArrowLeftRight,
hint: "IMPORT / EXPORT / DOMESTIC",
},
{
token: "{{schedule.freightType}}",
label: "Freight type",
icon: Boxes,
hint: "CONTAINER or BULK",
},
{
token: "{{schedule.hazardousLabel}}",
label: "Hazardous",
icon: AlertTriangle,
hint: "Declared hazard class + UN number, or No",
},
{
token: "{{schedule.totalWeightVgm}}",
label: "Total weight",
@@ -237,6 +277,22 @@ const ALL_PLACEHOLDERS: PlaceholderDef[] = [
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
];
/**
* Deliberately narrow toolbar: the stored body carries STRUCTURE only (clause
* depth + bullets), which is what the contract renderer numbers and lays out.
* Bold/colour/font would be dropped on save, so they are not offered —
* an author never loses formatting they were allowed to apply.
*/
const QUILL_MODULES = {
toolbar: [
[{ list: "ordered" }, { list: "bullet" }],
[{ indent: "-1" }, { indent: "+1" }],
["clean"],
],
};
const QUILL_FORMATS = ["list", "indent"];
const KNOWN_TOKENS = new Set<string>([
...ALL_PLACEHOLDERS.map((p) => p.token),
// Still filled by the renderer, just no longer offered as an insert button.
@@ -326,31 +382,6 @@ function parseArticleBody(body: string): ParsedBody {
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);
@@ -674,88 +705,46 @@ function ArticleEditorModal({
}: ArticleEditorModalProps) {
const [title, setTitle] = useState(initial.title);
const [body, setBody] = useState(initial.body);
// Quill is uncontrolled-ish: it owns its own DOM, so seed it once from the
// stored body and let onChange convert edits back rather than re-deriving
// HTML from `body` on every keystroke (which would fight the caret).
const [html, setHtml] = useState(() => bodyToHtml(initial.body));
const titleRef = useRef<HTMLInputElement>(null);
const bodyRef = useRef<HTMLTextAreaElement>(null);
const quillRef = useRef<ReactQuill>(null);
// Placeholders drop into whichever field held the cursor last (body default).
const lastFocused = useRef<"title" | "body">("body");
const insertAtCursor = (snippet: string) => {
const isTitle = lastFocused.current === "title";
const el = isTitle ? titleRef.current : bodyRef.current;
const value = isTitle ? title : body;
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? start;
const next = value.slice(0, start) + snippet + value.slice(end);
if (isTitle) setTitle(next);
else setBody(next);
// Refocus and place the caret right after the inserted snippet once the
// controlled re-render has flushed.
requestAnimationFrame(() => {
if (!el) return;
el.focus();
const caret = start + snippet.length;
el.setSelectionRange(caret, caret);
});
/** Body is the source of truth for saving/preview; HTML is the editor view. */
const applyHtml = (nextHtml: string) => {
setHtml(nextHtml);
setBody(htmlToBody(nextHtml));
};
/**
* 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;
lastFocused.current = "body";
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 {
// New clause always starts a fresh top-level number. Sub-clause nests
// one level under a clause (1 → 1.1) but adds a SIBLING when the caret
// is already on a sub-clause (1.1 → 1.2 → 1.3, not ever-deeper) — a
// third level is reached by typing its number (e.g. "1.1.1 ") directly.
const above = parseArticleBody(before);
const lastDepth = above.paragraph
? 1
: (above.clauses[above.clauses.length - 1]?.depth ?? 0);
const depth =
kind === "sub"
? lastDepth <= 1
? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH)
: lastDepth
: 1;
// Digits are placeholders — renumberBody assigns the real value.
prefix = `${Array.from({ length: depth }, () => "1").join(".")}. `;
const insertAtCursor = (snippet: string) => {
if (lastFocused.current === "title") {
const el = titleRef.current;
const start = el?.selectionStart ?? title.length;
const end = el?.selectionEnd ?? start;
setTitle(title.slice(0, start) + snippet + title.slice(end));
requestAnimationFrame(() => {
if (!el) return;
el.focus();
const caret = start + snippet.length;
el.setSelectionRange(caret, caret);
});
return;
}
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);
});
// Quill tracks its own selection; insert there so the token lands where the
// author was typing instead of at the end of the document.
const editor = quillRef.current?.getEditor();
if (!editor) return;
const range = editor.getSelection(true);
const at = range?.index ?? editor.getLength();
editor.deleteText(at, range?.length ?? 0);
editor.insertText(at, snippet, "user");
editor.setSelection(at + snippet.length, 0);
applyHtml(editor.root.innerHTML);
};
const parsed = useMemo(() => parseArticleBody(body), [body]);
@@ -846,78 +835,24 @@ function ArticleEditorModal({
<Box>
<Text size="sm" fw={500} mb={4}>
Add structure
Article body
</Text>
<Group gap={6} wrap="wrap">
<Tooltip
label="New line with the next clause number typed for you (1., 2., 3., …)"
withArrow
>
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="md"
leftSection={<ListOrdered size={13} />}
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertStructuredLine("clause")}
>
New clause
</Button>
</Tooltip>
<Tooltip
label="Numbered point under the current clause — 1.1, then 1.2, 1.3 on each click. For a deeper level type its number yourself (e.g. 1.1.1 )"
withArrow
>
<Button
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={() => insertStructuredLine("bullet")}
>
Bullet
</Button>
</Tooltip>
</Group>
<Text size="xs" c="dimmed" mb={6}>
{BODY_HINT}
</Text>
<Box onFocusCapture={() => (lastFocused.current = "body")}>
<ReactQuill
ref={quillRef}
theme="snow"
value={html}
onChange={applyHtml}
modules={QUILL_MODULES}
formats={QUILL_FORMATS}
placeholder="Write the article — each paragraph becomes a numbered clause."
/>
</Box>
</Box>
<Textarea
ref={bodyRef}
label="Article body"
description={BODY_HINT}
value={body}
onChange={(event) => setBody(event.currentTarget.value)}
onFocus={() => (lastFocused.current = "body")}
autosize
minRows={12}
maxRows={22}
styles={{
input: { fontFamily: "ui-monospace, monospace", fontSize: 13 },
}}
required
/>
{unknown.length > 0 && (
<Group gap={6} wrap="nowrap" align="flex-start">
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-red-600" />

View File

@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { bodyToHtml, htmlToBody } from "./article-html";
describe("article body ↔ Quill HTML", () => {
it("round-trips clauses and bullets unchanged", () => {
const body = [
"Provide written instructions for each shipment.",
"Prepare all necessary documents.",
"- Commercial invoice",
"- Packing list",
"Pay 100% in advance.",
].join("\n");
expect(htmlToBody(bodyToHtml(body))).toBe(body);
});
it("keeps a single paragraph a single paragraph", () => {
const body = "The contract is valid once signed by both parties.";
expect(htmlToBody(bodyToHtml(body))).toBe(body);
});
it("preserves placeholders verbatim through a round trip", () => {
const body = "Valid until August 31, {{contractYear}} for {{client.companyName}}.";
expect(htmlToBody(bodyToHtml(body))).toBe(body);
});
it("escapes and restores characters that are HTML-significant", () => {
const body = "Rates < 100 & > 50 apply to the Client's cargo.";
expect(htmlToBody(bodyToHtml(body))).toBe(body);
});
it("maps Quill indent classes onto sub-clause depth", () => {
const html =
"<p>Top level clause.</p>" +
'<p class="ql-indent-1">Nested one level.</p>' +
'<p class="ql-indent-2">Nested two levels.</p>';
expect(htmlToBody(html)).toBe(
["Top level clause.", "1.1. Nested one level.", "1.1.1. Nested two levels."].join(
"\n",
),
);
});
it("turns Quill bullet lists into '- ' lines", () => {
const html = "<p>Documents:</p><ul><li>Invoice</li><li>Waybill</li></ul>";
expect(htmlToBody(html)).toBe("Documents:\n- Invoice\n- Waybill");
});
it("treats an ordered list as clause lines, not bullets", () => {
const html = "<ol><li>First clause.</li><li>Second clause.</li></ol>";
expect(htmlToBody(html)).toBe("1. First clause.\n1. Second clause.");
});
it("normalises the nbsp Quill inserts and drops empty blocks", () => {
const html = "<p>Payment&nbsp;in advance.</p><p><br></p><p></p>";
expect(htmlToBody(html)).toBe("Payment in advance.");
});
it("returns an empty editor for an empty body", () => {
expect(bodyToHtml("")).toBe("<p><br></p>");
expect(htmlToBody("<p><br></p>")).toBe("");
});
});

View File

@@ -0,0 +1,125 @@
/**
* Bridge between the Quill editor (HTML) and the stored article body, which is
* the structural plain text the server parses into numbered clauses
* (`parseArticleBody` in contract-article.util.ts): one clause per line, a
* leading outline token ("2.", "2.1") for depth, and "- " for bullets.
*
* Quill owns presentation; the body format owns structure. Converting on the
* way in and out keeps the renderer, the server-side renumbering, and the
* generated PDF working exactly as before.
*/
const ESCAPES: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
};
const escapeHtml = (text: string): string =>
text.replace(/[&<>]/g, (c) => ESCAPES[c]);
/**
* Body text → Quill HTML. Clauses become `<p>` carrying their outline token so
* the author sees the real numbering; bullets become a `<ul>` under the clause
* they belong to.
*/
export function bodyToHtml(body: string): string {
const lines = (body ?? "").split("\n").filter((l) => l.trim().length > 0);
if (lines.length === 0) return "<p><br></p>";
const out: string[] = [];
let inList = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("- ")) {
if (!inList) {
out.push("<ul>");
inList = true;
}
out.push(`<li>${escapeHtml(trimmed.slice(2).trim())}</li>`);
continue;
}
if (inList) {
out.push("</ul>");
inList = false;
}
out.push(`<p>${escapeHtml(trimmed)}</p>`);
}
if (inList) out.push("</ul>");
return out.join("");
}
/**
* Quill HTML → body text. `<li>` inside a `<ul>` becomes a "- " bullet; every
* other block becomes its own line. Quill's indent classes
* (`ql-indent-1`, …) map onto sub-clause depth, so indenting in the toolbar
* produces "1.1"-style nesting — the exact digits are placeholders, the server
* renumbers them.
*
* Runs through DOMParser rather than regex: the input is real HTML from a
* contenteditable, and entity handling (&amp;, &nbsp;) has to be right or the
* text lands in the PDF mangled.
*/
export function htmlToBody(html: string): string {
if (!html) return "";
const doc = new DOMParser().parseFromString(
`<div id="root">${html}</div>`,
"text/html",
);
const root = doc.getElementById("root");
if (!root) return "";
const lines: string[] = [];
const textOf = (el: Element): string =>
//   is the nbsp Quill inserts for trailing spaces — plain space in the
// stored body, otherwise it survives into the contract text.
(el.textContent ?? "").replace(/ /g, " ").trim();
const indentOf = (el: Element): number => {
const match = /ql-indent-(\d+)/.exec(el.className ?? "");
return match ? Number(match[1]) : 0;
};
const walk = (node: Element, insideList: boolean) => {
for (const child of Array.from(node.children)) {
const tag = child.tagName.toLowerCase();
if (tag === "ul" || tag === "ol") {
// <ol> is authored numbering; the body format numbers clauses itself,
// so an ordered list is clause lines, not bullets.
walk(child, tag === "ul");
continue;
}
if (tag === "li") {
const text = textOf(child);
if (!text) continue;
if (insideList) {
lines.push(`- ${text}`);
} else {
const depth = indentOf(child) + 1;
lines.push(`${Array.from({ length: depth }, () => "1").join(".")}. ${text}`);
}
continue;
}
if (tag === "p" || tag === "div") {
const text = textOf(child);
if (text) {
const depth = indentOf(child);
lines.push(
depth > 0
? `${Array.from({ length: depth + 1 }, () => "1").join(".")}. ${text}`
: text,
);
}
continue;
}
// Anything else (blockquote, heading, stray span): keep its text on a
// line rather than dropping the author's words.
const text = textOf(child);
if (text) lines.push(text);
}
};
walk(root, false);
return lines.join("\n");
}

View File

@@ -222,10 +222,10 @@ const RuleEngineResourcePage = () => {
// instead of mutating: the rate keeps its current value until an approver
// applies the change. DRAFT rates still edit directly.
const isRates = config?.slug === "rates";
const [rateError, setRateError] = useState<string | null>(null);
// No error modal here: the workflow falls back to a toast when no handler is
// passed, which keeps failures visible without a dialog to dismiss.
const rateChangeWorkflow = useRateChangeWorkflow(
Boolean(isRates && canView),
setRateError,
);
const canApproveRates = Boolean(isRates && canApproveRuleEngineChange(user, "rates"));
/** rateId → its pending change, for the row badge. */
@@ -265,8 +265,10 @@ const RuleEngineResourcePage = () => {
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
const { data: cargoLeafOptions, isLoading: cargoLeafOptionsLoading } =
useCargoLeafOptions(usesCargoTypeField);
// No "None" on rates: a rate's container scope is either a real type or the
// field is hidden entirely, so offering None only invites an unscoped rate.
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
useContainerTypeOptions(false, usesContainerTypeField);
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
useLiveRateOptions(usesLiveRateField);
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
@@ -695,22 +697,6 @@ const RuleEngineResourcePage = () => {
/>
) : null}
<Modal
opened={rateError != null}
onClose={() => setRateError(null)}
title="Cannot save rate change"
centered
>
<Text size="sm" c="red">
{rateError}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="light" onClick={() => setRateError(null)}>
Close
</Button>
</Group>
</Modal>
<Modal
opened={priorityError != null}
onClose={() => setPriorityError(null)}

View File

@@ -24,18 +24,11 @@ function feedLabel(source: ExchangeRateSource | null): {
switch (source) {
case "live":
return { live: true, text: "CBE reachable — using the live rate" };
case "cache":
return { live: true, text: "Using the rate cached from CBE" };
case "stored":
return {
live: false,
text: "CBE unreachable — using the fallback rate below",
};
case "default":
return {
live: false,
text: "CBE unreachable and no rate stored — using the built-in default",
};
default:
return { live: true, text: "No rate requested yet since the last restart" };
}

View File

@@ -5,8 +5,11 @@ import type { ApiResponse } from "@/types/apiResponse";
const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE;
/** Where the rate the API last served came from. */
export type ExchangeRateSource = "live" | "cache" | "stored" | "default";
/**
* Where the rate the API last served came from. `live` means CBE answered;
* `stored` means it is failing and the fallback is in use.
*/
export type ExchangeRateSource = "live" | "stored";
/** Health of the CBE exchange-rate feed. */
export interface ExchangeFeedStatus {