From d0040a685176c4f5ea49e9cbd82c304d97b966d6 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 4 Aug 2026 13:03:39 +0000 Subject: [PATCH 1/3] 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. --- .../contract-document-view-model.builder.ts | 30 +- .../contract-dynamic-template.spec.ts | 21 ++ .../contract-renderer.service.spec.ts | 3 + .../contracts/contract-view-model.builder.ts | 39 +++ .../src/contracts/templates/edr-dynamic.hbs | 12 + .../contract-templates.service.ts | 5 + .../exchange-settings.controller.ts | 22 +- .../exchange-settings.service.ts | 58 +++- .../ContractTemplateEditorPage.tsx | 279 +++++++----------- .../contract_templates/article-html.test.ts | 64 ++++ .../pages/contract_templates/article-html.ts | 125 ++++++++ .../ruleEngine/RuleEngineResourcePage.tsx | 24 +- .../settings/ExchangeRateSettingsCard.tsx | 7 - .../src/services/exchangeSettings.service.ts | 7 +- 14 files changed, 472 insertions(+), 224 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/pages/contract_templates/article-html.test.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/contract_templates/article-html.ts diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index e6fcaf8f7..d4f0c4039 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -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 diff --git a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts index 765c41142..b0e0e4d3d 100644 --- a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts @@ -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'); diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts index b0fc4c8ef..79f5e2f73 100644 --- a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts @@ -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', diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 07f4d25d3..d874fb792 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -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), diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs index 9c4957b2b..5c4c4052b 100644 --- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs @@ -125,6 +125,18 @@ Hazardous cargo {{schedule.hazardousLabel}} + + Cargo type + {{schedule.cargoTypeName}} + Container type + {{schedule.containerType}} + + + Cargo scope + {{schedule.cargoSummary}} + Freight type + {{schedule.freightType}} + Equipment return {{schedule.equipmentReturn}} diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index cb956878d..e94f9470f 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -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", diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts index 782a5b6a0..a6c08a317 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts @@ -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, }; } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts index 4cccdf8ea..e0b670292 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts @@ -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, ) {} + /** 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 { 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 { + // 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 { + // 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, diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx index 17977b743..240a92a13 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx @@ -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([ ...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(null); - const bodyRef = useRef(null); + const quillRef = useRef(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({ - Add structure + Article body - - - - - - - - - - - + + {BODY_HINT} + + (lastFocused.current = "body")}> + + -