fix contrat,wagon allocation wehn a booking is split

This commit is contained in:
Marshal
2026-07-10 22:27:58 +00:00
parent 1121e9ce82
commit 5d8658b5d6
12 changed files with 475 additions and 79 deletions

View File

@@ -35,6 +35,7 @@ import {
Hash,
ListOrdered,
ListPlus,
ListTree,
Mail,
MapPin,
Package,
@@ -60,7 +61,7 @@ import {
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
const BODY_HINT =
'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Use the buttons above to drop a placeholder at the cursor — it is filled from the contract when the document is generated.';
'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.';
interface ArticleDraft {
id?: string;
@@ -253,6 +254,10 @@ function unknownTokens(text: string): string[] {
interface ParsedClause {
text: string;
/** Computed outline number, e.g. "3" or "2.1.4". */
number: string;
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
depth: number;
bullets: string[];
}
@@ -262,28 +267,93 @@ interface ParsedBody {
clauses: ParsedClause[];
}
/**
* Leading outline token on a clause line ("1. ", "2.1 ", "1.1.1) ") — its
* segment count sets the depth; the digits themselves are recomputed. Single
* segment requires "."/")" so prose like "10 tons…" is untouched; a token may
* end the line (empty clause still being typed).
*/
const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
/** Depth of the outline token in a CLAUSE_NUMBER_RE match, else null. */
function matchDepth(match: RegExpExecArray | null): number | null {
if (!match) return null;
const token = match[1] ?? match[2];
return Math.min(token.split(".").length, MAX_CLAUSE_DEPTH);
}
/** Deepest supported sub-clause level. */
const MAX_CLAUSE_DEPTH = 6;
/**
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
* line, "- " nests a bullet under the previous clause, and a single bullet-less
* clause renders as a plain paragraph instead of a numbered list of one.
* line; a leading outline number ("2. ", "2.1 ") nests the line as a sub-clause
* at that depth and is renumbered sequentially; "- " nests a bullet under the
* previous clause; a single un-numbered bullet-less clause renders as a plain
* paragraph instead of a numbered list of one.
*/
function parseArticleBody(body: string): ParsedBody {
const clauses: ParsedClause[] = [];
const counters: number[] = [];
let sawNumberToken = false;
for (const raw of body.split("\n")) {
const line = raw.trim();
if (!line) continue;
if (line.startsWith("- ") && clauses.length > 0) {
clauses[clauses.length - 1].bullets.push(line.slice(2).trim());
} else {
clauses.push({ text: line.replace(/^- /, ""), bullets: [] });
continue;
}
const cleaned = line.replace(/^- /, "");
const match = CLAUSE_NUMBER_RE.exec(cleaned);
let depth = matchDepth(match) ?? 1;
// A sub-clause can only sit directly under an existing parent.
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 ? cleaned.slice(match[0].length).trim() : cleaned,
number: counters.slice(0, depth).join("."),
depth,
bullets: [],
});
}
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
if (
clauses.length === 1 &&
clauses[0].bullets.length === 0 &&
!sawNumberToken
) {
return { paragraph: clauses[0].text, clauses: [] };
}
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);
@@ -632,13 +702,57 @@ function ArticleEditorModal({
});
};
const insertLinePrefix = (prefix: string) => {
/**
* 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;
const start = el?.selectionStart ?? body.length;
// Start the snippet on its own line unless the caret already is.
const needsNewline = start > 0 && body[start - 1] !== "\n";
lastFocused.current = "body";
insertAtCursor(`${needsNewline ? "\n" : ""}${prefix}`);
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 {
// Sub-clause nests one level under the clause the caret is on/above;
// New clause always starts a fresh top-level number.
const above = parseArticleBody(before);
const lastDepth = above.paragraph
? 1
: (above.clauses[above.clauses.length - 1]?.depth ?? 0);
const depth =
kind === "sub" ? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH) : 1;
// Digits are placeholders — renumberBody assigns the real value.
prefix = `${Array.from({ length: depth }, () => "1").join(".")}. `;
}
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);
});
};
const parsed = useMemo(() => parseArticleBody(body), [body]);
@@ -724,26 +838,60 @@ function ArticleEditorModal({
))}
</Menu.Dropdown>
</Menu>
<Tooltip label="Start a new numbered clause" withArrow>
</Group>
</Box>
<Box>
<Text size="sm" fw={500} mb={4}>
Add structure
</Text>
<Group gap={6} wrap="wrap">
<Tooltip
label="New line with the next clause number typed for you (1., 2., 3., …)"
withArrow
>
<Button
variant="default"
variant="light"
color="edr-green"
size="compact-sm"
radius="md"
leftSection={<ListOrdered size={13} />}
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertLinePrefix("")}
onClick={() => insertStructuredLine("clause")}
>
New clause
</Button>
</Tooltip>
<Tooltip label="Nest a bullet under the previous clause" withArrow>
<Tooltip
label="Numbered point under the current clause — 1.1, then 1.1.1 if clicked again"
withArrow
>
<Button
variant="default"
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={() => insertLinePrefix("- ")}
onClick={() => insertStructuredLine("bullet")}
>
Bullet
</Button>
@@ -804,10 +952,10 @@ function ArticleEditorModal({
</Text>
)}
{parsed.clauses.map((clause, i) => (
<Box key={i}>
<Box key={i} pl={(clause.depth - 1) * 20}>
<Text size="sm">
<Text component="span" fw={600} c="edr-green.7">
{i + 1}.{" "}
{clause.number}.{" "}
</Text>
<HighlightedText text={clause.text} />
</Text>