Files
edr-platform/apps/edr-freight-api/src/contracts/contract-article.util.ts
Marshal 5d68a3f7b7 add permanent purge functionality for wagons and routes
- Implemented a  method in  to permanently delete wagons without history.
- Added corresponding permissions for hard delete actions in .
- Updated the UI components to include purge actions, ensuring they are only available to users with the appropriate permissions.
- Created modals for confirming permanent deletions in  and .
- Enhanced API services to handle purge requests for locomotives, wagons, and routes.
- Added tests for the purge functionality in both  and  services to ensure proper behavior and error handling.
2026-08-04 14:07:16 +00:00

159 lines
5.4 KiB
TypeScript

import Handlebars from 'handlebars';
/** One numbered clause of a dynamic article, with optional nested bullets. */
export interface RenderedClause {
text: string;
/**
* Computed outline marker for this clause at its own level: "3" at depth 1,
* "b" at depth 2, "iv" at depth 3, cycling back to arabic at depth 4.
*/
number: string;
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
depth: number;
bullets: string[];
}
/** A dynamic article ready for the Handlebars template. */
export interface RenderedArticle {
number: number;
/** Stable article id from the template (e.g. "pricing") — lets the layout
* inject the live rate schedule table under the pricing article. */
id: string;
title: string;
/** Set (instead of clauses) when the body is a single plain paragraph. */
paragraph?: string;
clauses: RenderedClause[];
}
/**
* Leading outline token on a clause line: "1.", "2)", "1.1", "1.1.1." …
* The token's segment count sets the clause depth; its digits are ignored —
* numbering is recomputed sequentially so stale numbers self-heal.
* A single-segment token requires its "."/")" ("10 tons…" is prose, "10. x"
* is clause ten); multi-segment tokens ("1.1") may omit it. A token may also
* end the line — that is an empty clause still being typed in the editor.
*/
const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
const MAX_CLAUSE_DEPTH = 6;
/** 1 → "a", 2 → "b", … 27 → "aa". */
function toAlpha(n: number): string {
let out = '';
let value = n;
while (value > 0) {
const rem = (value - 1) % 26;
out = String.fromCharCode(97 + rem) + out;
value = Math.floor((value - 1) / 26);
}
return out || 'a';
}
const ROMAN: Array<[number, string]> = [
[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'],
[100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'],
[10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'],
];
/** 1 → "i", 4 → "iv", 9 → "ix". */
function toRoman(n: number): string {
let value = n;
let out = '';
for (const [amount, numeral] of ROMAN) {
while (value >= amount) {
out += numeral;
value -= amount;
}
}
return out || 'i';
}
/**
* Word-processor outline markers, cycling by depth the way Quill's own list
* rendering does: 1. → a. → i. → 1. … Depth 1 keeps plain arabic numerals so
* top-level clauses read as "1.", "2." in the contract; the marker is the
* clause's own counter at its level, NOT a dotted path — "a" under clause 2 is
* "a", not "2.a".
*/
export function clauseMarker(counter: number, depth: number): string {
const style = (depth - 1) % 3;
if (style === 1) return toAlpha(counter);
if (style === 2) return toRoman(counter);
return String(counter);
}
/**
* Parse a template article body into clauses. Format: one clause per line.
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
* sub-clause at that depth — the typed digits are stripped and renumbered
* sequentially, so editing order never leaves stale numbers in the document.
* Lines prefixed with "- " become bullets nested under the preceding clause.
* A body that reduces to a single un-numbered clause without bullets renders
* as a plain paragraph rather than a numbered list of one.
*/
export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph' | 'clauses'> {
const lines = (body ?? '')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const clauses: RenderedClause[] = [];
// counters[i] = current number at depth i+1; truncated when a shallower
// clause arrives so deeper numbering restarts at 1.
const counters: number[] = [];
let sawNumberToken = false;
for (const line of lines) {
if (line.startsWith('- ')) {
const bullet = line.slice(2).trim();
if (clauses.length === 0) {
counters.splice(0, counters.length, 1);
clauses.push({ text: bullet, number: '1', depth: 1, bullets: [] });
} else {
clauses[clauses.length - 1].bullets.push(bullet);
}
continue;
}
const match = CLAUSE_NUMBER_RE.exec(line);
const token = match ? (match[1] ?? match[2]) : null;
let depth = token ? Math.min(token.split('.').length, MAX_CLAUSE_DEPTH) : 1;
// A sub-clause can only sit directly under an existing parent — "1.1.1"
// typed as the first line clamps to whatever level is actually open.
depth = Math.min(depth, counters.length + 1);
if (match) sawNumberToken = true;
counters.splice(depth);
while (counters.length < depth) counters.push(0);
counters[depth - 1] += 1;
clauses.push({
text: match ? line.slice(match[0].length).trim() : line,
number: clauseMarker(counters[depth - 1], depth),
depth,
bullets: [],
});
}
if (clauses.length === 1 && clauses[0].bullets.length === 0 && !sawNumberToken) {
return { paragraph: clauses[0].text, clauses: [] };
}
return { clauses };
}
/**
* Interpolate Handlebars placeholders ({{client.companyName}}, {{contractDate}},
* …) inside admin-authored template text against the contract view model.
* Malformed placeholders must never break document generation — fall back to
* the raw text.
*/
export function interpolateTemplateText(text: string, context: unknown): string {
if (!text || !text.includes('{{')) return text ?? '';
try {
return Handlebars.compile(text)(context);
} catch {
return text;
}
}