mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
fix contrat,wagon allocation wehn a booking is split
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user