mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 14:20:58 +00:00
- 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.
159 lines
5.4 KiB
TypeScript
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;
|
|
}
|
|
}
|