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.
This commit is contained in:
Marshal
2026-08-04 14:07:16 +00:00
parent 8cf49aa1cc
commit 5d68a3f7b7
22 changed files with 806 additions and 20 deletions

View File

@@ -3,7 +3,10 @@ import Handlebars from 'handlebars';
/** One numbered clause of a dynamic article, with optional nested bullets. */
export interface RenderedClause {
text: string;
/** Computed outline number, e.g. "3" or "2.1.4". */
/**
* 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;
@@ -35,6 +38,51 @@ 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
@@ -82,7 +130,7 @@ export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph
clauses.push({
text: match ? line.slice(match[0].length).trim() : line,
number: counters.slice(0, depth).join('.'),
number: clauseMarker(counters[depth - 1], depth),
depth,
bullets: [],
});

View File

@@ -27,18 +27,32 @@ describe('parseArticleBody', () => {
expect(parsed.clauses).toEqual([]);
});
it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => {
it('nests sub-clauses by outline token and marks each level 1. → a. → i.', () => {
const parsed = parseArticleBody(
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
);
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
['1', 1, 'Scope'],
['1.1', 2, 'Rail transport'],
['1.1.1', 3, 'Wagon supply'],
['a', 2, 'Rail transport'],
['i', 3, 'Wagon supply'],
['2', 1, 'Payment'],
]);
});
it('cycles markers back to arabic at depth 4 and counts each level on its own', () => {
const parsed = parseArticleBody(
'1. One\n1.1 Alpha\n1.2 Beta\n1.2.1 Roman one\n1.2.2 Roman two\n1.2.2.1 Deep',
);
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
['1', 1],
['a', 2],
['b', 2],
['i', 3],
['ii', 3],
['1', 4],
]);
});
it('clamps a sub-clause with no open parent to the next available level', () => {
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([