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

@@ -166,6 +166,8 @@ export class ContractDocumentViewModelBuilder {
year: 'numeric',
}),
contractYear: new Date().getFullYear(),
contractStartDate: this.formatDate(contract.contractValidFrom),
contractEndDate: this.formatDate(contract.contractValidUntil),
client: {
companyName: contract.company?.name ?? 'Client',
companyAddress: this.valueOrDash(contract.company?.address),
@@ -281,7 +283,8 @@ export class ContractDocumentViewModelBuilder {
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
const firstRoute = this.firstRoute(contract);
const cargoScope = (contract.cargoScope ?? [])[0];
const scope = contract.cargoScope ?? [];
const cargoScope = scope[0];
const cargoName =
cargoScope?.cargoType?.cargoTypeName ||
cargoScope?.cargoFreeText ||
@@ -289,6 +292,28 @@ export class ContractDocumentViewModelBuilder {
? `${cargoScope.containerSize} container`
: 'Container cargo');
// A contract's scope can list several cargo lines (e.g. coffee in 20ft and
// 40ft); name each distinctly rather than collapsing to the first.
const containerType = [
...new Set(scope.map((s) => s.containerSize ?? '').filter(Boolean)),
].join(', ');
const cargoTypeName = [
...new Set(
scope
.map((s) => s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? '')
.filter(Boolean),
),
].join(', ');
const cargoSummary = scope
.map((s) => {
const name = s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? null;
const size = s.containerSize ? `(${s.containerSize})` : null;
const cap = s.quantityCap ? `× ${Number(s.quantityCap)}` : null;
return [name, size, cap].filter(Boolean).join(' ');
})
.filter(Boolean)
.join('; ');
return {
originLabel: this.yardLabel(firstRoute?.originYard),
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
@@ -302,6 +327,9 @@ export class ContractDocumentViewModelBuilder {
scheduledDate: this.formatDate(null),
contractType: this.valueOrDash(contract.contractType),
cargoDescription: this.valueOrDash(cargoName),
cargoTypeName: this.valueOrDash(cargoTypeName),
containerType: this.valueOrDash(containerType),
cargoSummary: this.valueOrDash(cargoSummary),
totalWeightVgm: '—',
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
// A hazardous contract names the declared class + UN number on the

View File

@@ -117,6 +117,9 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
scheduledDate: '—',
contractType: 'GENERAL',
cargoDescription: 'Steel billets',
cargoTypeName: 'Steel billets',
containerType: '—',
cargoSummary: 'Steel billets × 2,800',
totalWeightVgm: '—',
equipmentReturn: '—',
hazardousLabel: 'No',
@@ -192,6 +195,24 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
expect(html).toContain('#1b9e7a');
});
it('shows cargo type and container type in the commercial schedule annex', () => {
const html = renderer.render(dynamicView());
expect(html).toContain('Cargo type');
expect(html).toContain('Container type');
expect(html).toContain('Cargo scope');
expect(html).toContain('Steel billets × 2,800');
});
it('interpolates the cargo/container placeholders inside article text', () => {
const view = dynamicView();
const body =
'Cargo: {{schedule.cargoTypeName}} in {{schedule.containerType}} ' +
'({{schedule.freightType}}). Scope: {{schedule.cargoSummary}}.';
expect(interpolateTemplateText(body, view)).toBe(
'Cargo: Steel billets in — (BULK). Scope: Steel billets × 2,800.',
);
});
it('renders the live rate schedule lane under the pricing article', () => {
const html = renderer.render(dynamicView());
expect(html).toContain('Rate Schedule');

View File

@@ -43,6 +43,9 @@ describe('ContractRendererService', () => {
scheduledDate: '1 January 2026',
contractType: 'NEW',
cargoDescription: 'Container cargo',
cargoTypeName: 'Coffee',
containerType: '40ft',
cargoSummary: 'Coffee (40ft) × 12',
totalWeightVgm: '24 tons',
equipmentReturn: 'RETURN',
hazardousLabel: 'No',

View File

@@ -41,6 +41,13 @@ export interface ContractViewModel {
template: ContractTemplateMeta;
contractDate: string;
contractYear: number;
/**
* The contract's validity window (`contract_valid_from` / `_until`). Distinct
* from `contractDate`, which is the day the document is generated — these are
* the dates the contract is actually in force between. "—" when unset.
*/
contractStartDate: string;
contractEndDate: string;
client: {
companyName: string;
companyAddress: string;
@@ -68,6 +75,16 @@ export interface ContractViewModel {
scheduledDate: string;
contractType: string;
cargoDescription: string;
/**
* The named cargo type on its own (e.g. "Coffee"), separate from
* `cargoDescription` which folds in free text and a container fallback.
* Lets a clause name the commodity without the surrounding prose.
*/
cargoTypeName: string;
/** Container size alone, e.g. "20ft" / "40ft"; "—" for bulk. */
containerType: string;
/** Every cargo line on the contract, e.g. "Coffee (40ft) × 12". */
cargoSummary: string;
totalWeightVgm: string;
equipmentReturn: string;
hazardousLabel: string;
@@ -195,6 +212,21 @@ export class ContractViewModelBuilder {
'Bulk commodity'
: booking.cargoType?.cargoTypeName || 'Container cargo';
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
// A booking may carry both sizes; name each one once, in the order booked.
const containerType = [
...new Set(
(booking.bookingContainers ?? [])
.map(
(line) =>
line.containerType?.label ??
(line.containerType?.sizeFt
? `${line.containerType.sizeFt}ft`
: line.containerSize) ??
'',
)
.filter(Boolean),
),
].join(', ');
return {
originLabel: this.yardLabel(booking.originYard),
@@ -207,6 +239,13 @@ export class ContractViewModelBuilder {
scheduledDate: this.formatDate(booking.scheduledDate),
contractType: this.valueOrDash(booking.contractType),
cargoDescription: this.valueOrDash(cargoName),
cargoTypeName: this.valueOrDash(booking.cargoType?.cargoTypeName),
containerType: this.valueOrDash(containerType),
cargoSummary: this.valueOrDash(
[cargoName, containerType ? `(${containerType})` : null]
.filter(Boolean)
.join(' '),
),
totalWeightVgm:
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
equipmentReturn: this.valueOrDash(booking.equipmentReturn),

View File

@@ -125,6 +125,18 @@
<th>Hazardous cargo</th>
<td>{{schedule.hazardousLabel}}</td>
</tr>
<tr>
<th>Cargo type</th>
<td>{{schedule.cargoTypeName}}</td>
<th>Container type</th>
<td>{{schedule.containerType}}</td>
</tr>
<tr>
<th>Cargo scope</th>
<td>{{schedule.cargoSummary}}</td>
<th>Freight type</th>
<td>{{schedule.freightType}}</td>
</tr>
<tr>
<th>Equipment return</th>
<td>{{schedule.equipmentReturn}}</td>

View File

@@ -239,6 +239,11 @@ export class ContractTemplatesService {
scheduledDate: "—",
contractType: "GENERAL",
cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo",
cargoTypeName: isBulk ? "Steel billets" : "Coffee",
containerType: isBulk ? "—" : "40ft",
cargoSummary: isBulk
? "Steel billets × 2,800"
: "Coffee (40ft) × 12; Sesame (20ft) × 6",
totalWeightVgm: "—",
equipmentReturn: isBulk ? "—" : "With empty return",
hazardousLabel: "No",

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Get, Patch } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser, ExchangeService } from "@edr/api-common";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { FreightAdmin } from "../../common/booking-guards";
@@ -11,10 +11,7 @@ import { ExchangeSettingsService } from "./exchange-settings.service";
@ApiBearerAuth()
@Controller("exchange-settings")
export class ExchangeSettingsController {
constructor(
private readonly service: ExchangeSettingsService,
private readonly exchangeService: ExchangeService,
) {}
constructor(private readonly service: ExchangeSettingsService) {}
@Get()
@FreightAdmin()
@@ -22,24 +19,15 @@ export class ExchangeSettingsController {
summary: "Current USD→ETB fallback rate and CBE feed health",
})
async get() {
const [setting, status] = [
await this.service.get(),
this.exchangeService.getProviderStatus(),
];
const setting = await this.service.get();
const status = this.service.getFeedStatus();
return {
fallbackRate: setting.fallbackRate,
fallbackSource: setting.fallbackSource,
lastSyncedAt: setting.lastSyncedAt,
updatedById: setting.updatedById,
feed: {
rate: status.rate,
source: status.source,
lastSuccessAt: status.lastSuccessAt
? new Date(status.lastSuccessAt).toISOString()
: null,
lastError: status.lastError,
},
feed: status,
};
}

View File

@@ -10,6 +10,18 @@ import { ExchangeSetting } from "./entities/exchange-setting.entity";
*/
const SEED_FALLBACK_RATE = 162.4165;
/** Health of the CBE feed, as surfaced to the backoffice. */
export interface ExchangeFeedStatus {
/** Rate most recently observed, whatever its source. */
rate: number | null;
/** `live` means CBE answered; `stored`/`default` mean it is failing. */
source: "live" | "stored" | null;
/** ISO timestamp of the last successful fetch. */
lastSuccessAt: string | null;
/** Message from the most recent failure, cleared on success. */
lastError: string | null;
}
/**
* Owns the single `exchange_settings` row: the USD→ETB fallback used when the
* CBE endpoint is unreachable.
@@ -22,11 +34,30 @@ const SEED_FALLBACK_RATE = 162.4165;
export class ExchangeSettingsService {
private readonly logger = new Logger(ExchangeSettingsService.name);
/**
* Feed health, recorded from the exchange provider's callbacks rather than
* read off an injected `ExchangeService`. The provider is registered several
* times (bookings, contracts, warehouses), so no single instance sees every
* fetch — and injecting one here would be circular, since those
* registrations inject *this* service.
*/
private feed: ExchangeFeedStatus = {
rate: null,
source: null,
lastSuccessAt: null,
lastError: null,
};
constructor(
@InjectRepository(ExchangeSetting)
private readonly repository: Repository<ExchangeSetting>,
) {}
/** Health of the CBE feed as last observed by any provider instance. */
getFeedStatus(): ExchangeFeedStatus {
return { ...this.feed };
}
/** The settings row, created at the seed rate on first access. */
async get(): Promise<ExchangeSetting> {
const existing = await this.repository.findOne({ where: {} });
@@ -47,15 +78,22 @@ export class ExchangeSettingsService {
* than propagating a database error into a pricing call.
*/
async loadFallbackRate(): Promise<number | null> {
// Only reached when the live fetch failed, so this call is itself the
// signal that the feed is down.
try {
const { fallbackRate } = await this.get();
return Number.isFinite(fallbackRate) && fallbackRate > 0
? fallbackRate
: null;
const usable = Number.isFinite(fallbackRate) && fallbackRate > 0;
this.feed = {
...this.feed,
rate: usable ? fallbackRate : this.feed.rate,
source: "stored",
lastError: this.feed.lastError ?? "CBE endpoint unreachable",
};
return usable ? fallbackRate : null;
} catch (err) {
this.logger.warn(
`Could not read stored exchange fallback: ${(err as Error).message}`,
);
const message = (err as Error).message;
this.feed = { ...this.feed, source: "stored", lastError: message };
this.logger.warn(`Could not read stored exchange fallback: ${message}`);
return null;
}
}
@@ -66,6 +104,14 @@ export class ExchangeSettingsService {
* down, so a working CBE feed takes precedence again.
*/
async saveFallbackRate(rate: number): Promise<void> {
// Only called after a successful fetch, so the feed is confirmed healthy.
this.feed = {
rate,
source: "live",
lastSuccessAt: new Date().toISOString(),
lastError: null,
};
const current = await this.get();
await this.repository.update(current.id, {
fallbackRate: rate,

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 {