mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 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("");
|
||||
});
|
||||
});
|
||||
@@ -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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
};
|
||||
|
||||
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 (&, ) 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");
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user