Compare commits
1 Commits
mai/hermes
...
mai/knuth/
| Author | SHA1 | Date | |
|---|---|---|---|
| a28a72679a |
@@ -327,14 +327,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"choices.include_ccr.chip": "mit Nichtigkeitswiderklage",
|
||||
"choices.reset": "Auswahl zurücksetzen",
|
||||
"choices.commit.error": "Konnte Auswahl nicht speichern",
|
||||
// t-paliad-290 (m/paliad#122) — re-surface hidden optional cards.
|
||||
"choices.show_hidden.label": "Ausgeblendete anzeigen",
|
||||
"choices.show_hidden.count": "Ausgeblendete ({n})",
|
||||
"choices.unhide.chip": "Wieder einblenden",
|
||||
// t-paliad-293 \u2014 iconified state markers on the Verfahrensablauf
|
||||
// event cards. Tooltip-only text; the glyph is the primary signal.
|
||||
"state.optional.tooltip": "Optionales Ereignis",
|
||||
"state.hidden.tooltip": "Ausgeblendet \u2014 \u00fcber Optionen-Men\u00fc wieder einblenden",
|
||||
// Trigger-event mode (PR-2 \u2014 youpc-parity)
|
||||
"deadlines.mode.procedure": "Verfahrensablauf",
|
||||
"deadlines.mode.event": "Was kommt nach\u2026",
|
||||
@@ -449,10 +441,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.label": "Seite:",
|
||||
"deadlines.side.claimant": "Klägerseite",
|
||||
"deadlines.side.defendant": "Beklagtenseite",
|
||||
"deadlines.side.undefined": "Nicht festgelegt",
|
||||
"deadlines.side.both": "Beide",
|
||||
"deadlines.side.from_project": "Aus Akte:",
|
||||
"deadlines.side.override": "Andere Seite wählen",
|
||||
"deadlines.side.hint": "Wählen Sie eine Seite, um die Spalten zu fokussieren.",
|
||||
"deadlines.appellant.label": "Berufung durch:",
|
||||
"deadlines.appellant.claimant": "Klägerseite",
|
||||
"deadlines.appellant.defendant": "Beklagtenseite",
|
||||
@@ -1508,7 +1499,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// t-paliad-277 — import-from-project + party-picker.
|
||||
"submissions.draft.import.button": "Aus Projekt importieren",
|
||||
"submissions.draft.parties.title": "Parteien",
|
||||
"submissions.draft.parties.hint": "Wählen Sie die im Schriftsatz genannten Parteien oder fügen Sie pro Seite weitere hinzu.",
|
||||
"submissions.draft.parties.hint": "Wählen Sie aus, welche Parteien im Schriftsatz genannt werden sollen.",
|
||||
// t-paliad-276 — DE/EN language toggle on the draft editor.
|
||||
"submissions.draft.language": "Sprache",
|
||||
"submissions.draft.language.de": "DE",
|
||||
@@ -3435,14 +3426,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"choices.include_ccr.chip": "with nullity counterclaim",
|
||||
"choices.reset": "Reset choice",
|
||||
"choices.commit.error": "Could not save selection",
|
||||
// t-paliad-290 (m/paliad#122) — re-surface hidden optional cards.
|
||||
"choices.show_hidden.label": "Show hidden",
|
||||
"choices.show_hidden.count": "Hidden ({n})",
|
||||
"choices.unhide.chip": "Show again",
|
||||
// t-paliad-293 — iconified state markers on the Verfahrensablauf
|
||||
// event cards. Tooltip-only text; the glyph is the primary signal.
|
||||
"state.optional.tooltip": "Optional event",
|
||||
"state.hidden.tooltip": "Hidden — restore via the options menu",
|
||||
"deadlines.adjusted": "Adjusted",
|
||||
"deadlines.adjusted.reason": "weekend/holiday",
|
||||
"deadlines.adjusted.weekend": "weekend",
|
||||
@@ -3564,10 +3547,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.label": "Side:",
|
||||
"deadlines.side.claimant": "Claimant",
|
||||
"deadlines.side.defendant": "Defendant",
|
||||
"deadlines.side.undefined": "Undefined",
|
||||
"deadlines.side.both": "Both",
|
||||
"deadlines.side.from_project": "From case:",
|
||||
"deadlines.side.override": "Choose other side",
|
||||
"deadlines.side.hint": "Pick a side to focus the columns.",
|
||||
"deadlines.appellant.label": "Appeal filed by:",
|
||||
"deadlines.appellant.claimant": "Claimant",
|
||||
"deadlines.appellant.defendant": "Defendant",
|
||||
@@ -4602,7 +4584,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// t-paliad-277 — import-from-project + party-picker.
|
||||
"submissions.draft.import.button": "Import from project",
|
||||
"submissions.draft.parties.title": "Parties",
|
||||
"submissions.draft.parties.hint": "Pick the parties mentioned in this submission, or add more per side.",
|
||||
"submissions.draft.parties.hint": "Select which parties to mention in this submission.",
|
||||
// t-paliad-240 — global submissions drafts index page.
|
||||
"submissions.index.title": "Submissions — Paliad",
|
||||
"submissions.index.heading": "Submissions",
|
||||
|
||||
@@ -137,13 +137,6 @@ interface VariableGroup {
|
||||
id: string;
|
||||
label: VariableLabel;
|
||||
keys: string[];
|
||||
// t-paliad-287 — render with a click-to-toggle disclosure caret; the
|
||||
// initial state is collapsed iff collapsedByDefault. Used for the
|
||||
// Frist section which lawyers rarely need to override (the variables
|
||||
// stay resolvable in the bag for the few templates that still want
|
||||
// them, but render no body content by default).
|
||||
collapsible?: boolean;
|
||||
collapsedByDefault?: boolean;
|
||||
}
|
||||
|
||||
const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||
@@ -212,19 +205,33 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
|
||||
};
|
||||
|
||||
// t-paliad-287 — variable groups restructured into four lawyer-facing
|
||||
// sections: Mandant/Verfahren up top (the case identity), then Parteien
|
||||
// (where the picker UI lives — this group only carries the manual
|
||||
// {{parties.*}} overrides for power-users), then Frist collapsed by
|
||||
// default (the deadline.* keys still resolve in the bag but the default
|
||||
// templates don't render them in the body any more), then Sonstiges for
|
||||
// the firm/date/user trim. The legacy procedural_event/rule namespaces
|
||||
// fold into Mandant/Verfahren so the lawyer reads them in their natural
|
||||
// context.
|
||||
const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
{
|
||||
id: "mandant_verfahren",
|
||||
label: { de: "Mandant & Verfahren", en: "Client & proceeding" },
|
||||
id: "procedural_event",
|
||||
label: { de: "Verfahrensschritt", en: "Procedural event" },
|
||||
keys: [
|
||||
"procedural_event.name",
|
||||
"procedural_event.legal_source_pretty",
|
||||
"procedural_event.primary_party",
|
||||
"procedural_event.event_kind",
|
||||
"procedural_event.code",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "parties",
|
||||
label: { de: "Mandanten & Parteien", en: "Clients & parties" },
|
||||
keys: [
|
||||
"parties.claimant.name",
|
||||
"parties.claimant.representative",
|
||||
"parties.defendant.name",
|
||||
"parties.defendant.representative",
|
||||
"parties.other.name",
|
||||
"parties.other.representative",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "project",
|
||||
label: { de: "Verfahren", en: "Proceeding" },
|
||||
keys: [
|
||||
"project.title",
|
||||
"project.case_number",
|
||||
@@ -239,43 +246,11 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
"project.matter_number",
|
||||
"project.reference",
|
||||
"project.instance_level",
|
||||
"procedural_event.name",
|
||||
"procedural_event.legal_source_pretty",
|
||||
"procedural_event.primary_party",
|
||||
"procedural_event.event_kind",
|
||||
"procedural_event.code",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "parties",
|
||||
label: { de: "Parteien (Variablen)", en: "Parties (variables)" },
|
||||
// Manual overrides for {{parties.<role>.*}} placeholders — power-
|
||||
// user escape hatch when the lawyer wants the rendered string to
|
||||
// differ from the picker selection (e.g. honourific prefix on
|
||||
// representative). Collapsed by default because the picker above
|
||||
// is the canonical surface; these rows exist only as a safety
|
||||
// valve.
|
||||
collapsible: true,
|
||||
collapsedByDefault: true,
|
||||
keys: [
|
||||
"parties.claimant.name",
|
||||
"parties.claimant.representative",
|
||||
"parties.defendant.name",
|
||||
"parties.defendant.representative",
|
||||
"parties.other.name",
|
||||
"parties.other.representative",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "deadline",
|
||||
label: { de: "Frist (intern)", en: "Deadline (internal)" },
|
||||
// t-paliad-287 — the {{deadline.*}} placeholders no longer render
|
||||
// in the default skeleton body (internal context that doesn't
|
||||
// belong in a court-bound submission). The values still resolve
|
||||
// here so a custom template can pick them up if needed; collapsed
|
||||
// because most drafts never touch them.
|
||||
collapsible: true,
|
||||
collapsedByDefault: true,
|
||||
label: { de: "Frist", en: "Deadline" },
|
||||
keys: [
|
||||
"deadline.due_date",
|
||||
"deadline.due_date_long_de",
|
||||
@@ -286,11 +261,10 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sonstiges",
|
||||
label: { de: "Sonstiges", en: "Other" },
|
||||
id: "firm",
|
||||
label: { de: "Kanzlei & Datum", en: "Firm & date" },
|
||||
keys: [
|
||||
"firm.name",
|
||||
"firm.signature_block",
|
||||
"user.display_name",
|
||||
"user.email",
|
||||
"user.office",
|
||||
@@ -317,29 +291,6 @@ interface State {
|
||||
saveTimer: number | null;
|
||||
pendingOverrides: Record<string, string> | null;
|
||||
inFlight: AbortController | null;
|
||||
// t-paliad-287 — per-section collapse memory. Sticky across repaints
|
||||
// so autosave (which calls paintVariables) doesn't snap an open
|
||||
// section shut. Seeded lazily from VARIABLE_GROUPS.collapsedByDefault.
|
||||
collapsedGroups: Record<string, boolean>;
|
||||
// t-paliad-287 — which side the Add-Party panel is currently open for
|
||||
// (one panel can be open at a time; clicking the other side's button
|
||||
// toggles). null means closed.
|
||||
addPartyOpen: PartySide | null;
|
||||
addPartyMode: "manual" | "search";
|
||||
addPartySearchHits: PartySearchHit[];
|
||||
addPartyBusy: boolean;
|
||||
}
|
||||
|
||||
type PartySide = "claimant" | "defendant" | "other";
|
||||
|
||||
interface PartySearchHit {
|
||||
id: string;
|
||||
project_id: string;
|
||||
project_title: string;
|
||||
project_reference?: string | null;
|
||||
name: string;
|
||||
role?: string;
|
||||
representative?: string;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
@@ -349,11 +300,6 @@ const state: State = {
|
||||
saveTimer: null,
|
||||
pendingOverrides: null,
|
||||
inFlight: null,
|
||||
collapsedGroups: {},
|
||||
addPartyOpen: null,
|
||||
addPartyMode: "manual",
|
||||
addPartySearchHits: [],
|
||||
addPartyBusy: false,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -661,31 +607,24 @@ function paintImportRow(): void {
|
||||
btn.onclick = () => { void onImportFromProject(btn); };
|
||||
}
|
||||
|
||||
// t-paliad-277 / t-paliad-287 — multi-select party picker plus Add-
|
||||
// Party affordance per side. Lists every party on the draft's project
|
||||
// (view.available_parties), grouped by role, with one checkbox per
|
||||
// party. Each side (Klägerseite / Beklagtenseite / Sonstige) carries
|
||||
// an "+ Partei hinzufügen" button that opens an inline panel with two
|
||||
// modes: manual entry (creates a fresh paliad.parties row) or DB
|
||||
// picker (searches every visible project, clones the row into THIS
|
||||
// project on selection). Empty selection still falls back to the
|
||||
// legacy "include every party" default.
|
||||
// t-paliad-277 — multi-select party picker. Lists every party on the
|
||||
// draft's project (view.available_parties), grouped by role, with one
|
||||
// checkbox per party. Checked = include in the variable bag. Empty
|
||||
// selection falls back to the legacy "include every party" default
|
||||
// (consistent with the migration default).
|
||||
function paintPartyPicker(): void {
|
||||
const block = document.getElementById("submission-draft-parties");
|
||||
const list = document.getElementById("submission-draft-parties-list");
|
||||
if (!block || !list || !state.view) return;
|
||||
|
||||
// t-paliad-287 — picker is now shown even on empty-roster projects so
|
||||
// the lawyer can use Add Party to populate. Still hidden when there
|
||||
// is no project attached (no row to attach a party to).
|
||||
if (!state.view.draft.project_id) {
|
||||
const parties = state.view.available_parties ?? [];
|
||||
if (!state.view.draft.project_id || parties.length === 0) {
|
||||
block.style.display = "none";
|
||||
list.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
block.style.display = "";
|
||||
|
||||
const parties = state.view.available_parties ?? [];
|
||||
const selected = new Set(state.view.draft.selected_parties ?? []);
|
||||
// Empty selection is the implicit "all" default — pre-check every
|
||||
// party so the lawyer can see what's currently being mentioned and
|
||||
@@ -698,13 +637,9 @@ function paintPartyPicker(): void {
|
||||
const grouped = groupPartiesByRole(parties);
|
||||
let html = "";
|
||||
for (const group of grouped) {
|
||||
if (group.parties.length === 0) continue;
|
||||
html += `<fieldset class="submission-draft-parties-group" data-role-bucket="${group.bucket}">`;
|
||||
html += `<legend>${escapeHtml(group.label)}</legend>`;
|
||||
if (group.parties.length === 0) {
|
||||
html += `<p class="submission-draft-parties-empty">${escapeHtml(
|
||||
isEN() ? "No parties yet." : "Noch keine Parteien.",
|
||||
)}</p>`;
|
||||
}
|
||||
for (const p of group.parties) {
|
||||
const checked = effective.has(p.id) ? " checked" : "";
|
||||
const chip = p.role
|
||||
@@ -723,7 +658,6 @@ function paintPartyPicker(): void {
|
||||
html += rep;
|
||||
html += `</label>`;
|
||||
}
|
||||
html += renderAddPartyControls(group.bucket);
|
||||
html += `</fieldset>`;
|
||||
}
|
||||
list.innerHTML = html;
|
||||
@@ -731,198 +665,6 @@ function paintPartyPicker(): void {
|
||||
list.querySelectorAll<HTMLInputElement>(".submission-draft-party-check").forEach((inp) => {
|
||||
inp.addEventListener("change", () => onPartySelectionChange());
|
||||
});
|
||||
wireAddPartyControls(list);
|
||||
}
|
||||
|
||||
// renderAddPartyControls emits the per-side "+ Add party" button and
|
||||
// (when expanded) the inline panel offering manual entry OR DB search.
|
||||
// Sticky panel state lives in state.addPartyOpen so a repaint after
|
||||
// search-fetch / autosave / language-switch doesn't snap the panel
|
||||
// shut mid-edit.
|
||||
function renderAddPartyControls(side: PartySide): string {
|
||||
const open = state.addPartyOpen === side;
|
||||
const mode = state.addPartyMode;
|
||||
const sideLabel = sideLabelFor(side);
|
||||
const btnLabel = isEN()
|
||||
? `+ Add party (${sideLabel})`
|
||||
: `+ Partei hinzufügen (${sideLabel})`;
|
||||
|
||||
let html = `<div class="submission-draft-addparty">`;
|
||||
html += `<button type="button" class="btn-small btn-secondary submission-draft-addparty-toggle"`;
|
||||
html += ` data-side="${side}" aria-expanded="${open ? "true" : "false"}">`;
|
||||
html += escapeHtml(btnLabel);
|
||||
html += `</button>`;
|
||||
|
||||
if (!open) {
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// Tabs — manual / search.
|
||||
html += `<div class="submission-draft-addparty-panel">`;
|
||||
html += `<div class="submission-draft-addparty-tabs" role="tablist">`;
|
||||
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
|
||||
if (mode === "manual") html += ` submission-draft-addparty-tab--active`;
|
||||
html += `" data-tab="manual" data-side="${side}" aria-selected="${mode === "manual"}">`;
|
||||
html += escapeHtml(isEN() ? "Manual entry" : "Manuell");
|
||||
html += `</button>`;
|
||||
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
|
||||
if (mode === "search") html += ` submission-draft-addparty-tab--active`;
|
||||
html += `" data-tab="search" data-side="${side}" aria-selected="${mode === "search"}">`;
|
||||
html += escapeHtml(isEN() ? "From DB" : "Aus DB übernehmen");
|
||||
html += `</button>`;
|
||||
html += `</div>`;
|
||||
|
||||
if (mode === "manual") {
|
||||
html += renderAddPartyManualForm(side);
|
||||
} else {
|
||||
html += renderAddPartySearchPanel(side);
|
||||
}
|
||||
|
||||
html += `</div></div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderAddPartyManualForm(side: PartySide): string {
|
||||
const defaultRole = defaultRoleFor(side);
|
||||
const busyCls = state.addPartyBusy ? " submission-draft-addparty-form--busy" : "";
|
||||
let html = `<form class="submission-draft-addparty-form${busyCls}" data-side="${side}" data-mode="manual">`;
|
||||
html += `<label class="submission-draft-addparty-field">`;
|
||||
html += `<span>${escapeHtml(isEN() ? "Name" : "Name")}</span>`;
|
||||
html += `<input type="text" name="name" required class="entity-form-input"`;
|
||||
html += ` placeholder="${escapeHtml(isEN() ? "Acme Inc." : "z. B. Acme GmbH")}" />`;
|
||||
html += `</label>`;
|
||||
html += `<label class="submission-draft-addparty-field">`;
|
||||
html += `<span>${escapeHtml(isEN() ? "Role" : "Rolle")}</span>`;
|
||||
html += `<input type="text" name="role" class="entity-form-input"`;
|
||||
html += ` value="${escapeHtml(defaultRole)}"`;
|
||||
html += ` placeholder="${escapeHtml(isEN() ? "claimant / defendant / intervenor / …" : "Klägerin / Beklagte / Streithelferin / …")}" />`;
|
||||
html += `</label>`;
|
||||
html += `<label class="submission-draft-addparty-field">`;
|
||||
html += `<span>${escapeHtml(isEN() ? "Representative (optional)" : "Vertreter:in (optional)")}</span>`;
|
||||
html += `<input type="text" name="representative" class="entity-form-input"`;
|
||||
html += ` placeholder="${escapeHtml(isEN() ? "Dr. Müller, …" : "RA Dr. Müller, …")}" />`;
|
||||
html += `</label>`;
|
||||
html += `<div class="submission-draft-addparty-actions">`;
|
||||
html += `<button type="submit" class="btn-small btn-primary"${state.addPartyBusy ? " disabled" : ""}>`;
|
||||
html += escapeHtml(isEN() ? "Add party" : "Hinzufügen");
|
||||
html += `</button>`;
|
||||
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
|
||||
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
|
||||
html += `</button>`;
|
||||
html += `</div>`;
|
||||
html += `</form>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderAddPartySearchPanel(side: PartySide): string {
|
||||
let html = `<div class="submission-draft-addparty-search" data-side="${side}" data-mode="search">`;
|
||||
html += `<input type="search" class="entity-form-input submission-draft-addparty-search-input"`;
|
||||
html += ` data-side="${side}"`;
|
||||
html += ` placeholder="${escapeHtml(
|
||||
isEN()
|
||||
? "Search across projects (name or representative)…"
|
||||
: "In allen Projekten suchen (Name oder Vertreter)…",
|
||||
)}" />`;
|
||||
html += renderPartySearchResultsList();
|
||||
html += `<p class="submission-draft-addparty-search-hint">${escapeHtml(
|
||||
isEN()
|
||||
? "Picking a row clones it as a fresh party on this project — no typing."
|
||||
: "Auswählen kopiert die Partei in dieses Projekt — kein erneutes Tippen.",
|
||||
)}</p>`;
|
||||
html += `<div class="submission-draft-addparty-actions">`;
|
||||
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
|
||||
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
|
||||
html += `</button>`;
|
||||
html += `</div>`;
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function wireAddPartyControls(root: HTMLElement): void {
|
||||
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const side = (btn.dataset.side as PartySide) ?? "other";
|
||||
if (state.addPartyOpen === side) {
|
||||
// Toggle off.
|
||||
state.addPartyOpen = null;
|
||||
state.addPartySearchHits = [];
|
||||
} else {
|
||||
state.addPartyOpen = side;
|
||||
state.addPartyMode = "manual";
|
||||
state.addPartySearchHits = [];
|
||||
}
|
||||
paintPartyPicker();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = btn.dataset.tab;
|
||||
if (tab !== "manual" && tab !== "search") return;
|
||||
state.addPartyMode = tab;
|
||||
if (tab === "manual") state.addPartySearchHits = [];
|
||||
paintPartyPicker();
|
||||
if (tab === "search") {
|
||||
// Pre-load most-recent matches with empty query so the lawyer
|
||||
// sees options without typing first.
|
||||
void runPartySearch("");
|
||||
}
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-cancel").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.addPartyOpen = null;
|
||||
state.addPartySearchHits = [];
|
||||
paintPartyPicker();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLFormElement>(".submission-draft-addparty-form").forEach((form) => {
|
||||
form.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
const side = (form.dataset.side as PartySide) ?? "other";
|
||||
const data = new FormData(form);
|
||||
const name = String(data.get("name") ?? "").trim();
|
||||
if (!name) return;
|
||||
const role = String(data.get("role") ?? "").trim();
|
||||
const representative = String(data.get("representative") ?? "").trim();
|
||||
void onAddPartyManualSubmit(side, { name, role, representative });
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLInputElement>(".submission-draft-addparty-search-input").forEach((inp) => {
|
||||
let timer: number | null = null;
|
||||
inp.addEventListener("input", () => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
void runPartySearch(inp.value.trim());
|
||||
}, 200);
|
||||
});
|
||||
// Pre-load on first render of the search tab.
|
||||
if (state.addPartyMode === "search" && state.addPartySearchHits.length === 0) {
|
||||
void runPartySearch("");
|
||||
}
|
||||
});
|
||||
root.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const hitID = li.dataset.hitId;
|
||||
if (!hitID) return;
|
||||
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
|
||||
if (!hit) return;
|
||||
const side = state.addPartyOpen ?? "other";
|
||||
void onAddPartySearchPick(side, hit);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sideLabelFor(side: PartySide): string {
|
||||
if (side === "claimant") return isEN() ? "Claimant side" : "Klägerseite";
|
||||
if (side === "defendant") return isEN() ? "Defendant side" : "Beklagtenseite";
|
||||
return isEN() ? "Other parties" : "Weitere Parteien";
|
||||
}
|
||||
|
||||
function defaultRoleFor(side: PartySide): string {
|
||||
if (side === "claimant") return isEN() ? "claimant" : "Klägerin";
|
||||
if (side === "defendant") return isEN() ? "defendant" : "Beklagte";
|
||||
return "";
|
||||
}
|
||||
|
||||
interface PartyRoleGroup {
|
||||
@@ -1039,27 +781,8 @@ function paintVariables(): void {
|
||||
let html = "";
|
||||
for (const group of VARIABLE_GROUPS) {
|
||||
const groupLabel = isEN() ? group.label.en : group.label.de;
|
||||
// Re-use the user's prior toggle state across paintVariables calls
|
||||
// (autosave / language switch trigger a repaint). Default sticky
|
||||
// state lives in state.collapsedGroups; on first render the
|
||||
// collapsedByDefault flag seeds it.
|
||||
if (!Object.prototype.hasOwnProperty.call(state.collapsedGroups, group.id)) {
|
||||
state.collapsedGroups[group.id] = !!(group.collapsible && group.collapsedByDefault);
|
||||
}
|
||||
const collapsed = !!state.collapsedGroups[group.id];
|
||||
const collapsibleCls = group.collapsible ? " submission-draft-var-group--collapsible" : "";
|
||||
const collapsedCls = collapsed ? " submission-draft-var-group--collapsed" : "";
|
||||
html += `<section class="submission-draft-var-group${collapsibleCls}${collapsedCls}" data-group="${group.id}">`;
|
||||
if (group.collapsible) {
|
||||
html += `<button type="button" class="submission-draft-var-group-toggle"`;
|
||||
html += ` data-toggle-group="${escapeHtml(group.id)}" aria-expanded="${collapsed ? "false" : "true"}">`;
|
||||
html += `<span class="submission-draft-var-group-caret" aria-hidden="true">▸</span>`;
|
||||
html += `<span class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</span>`;
|
||||
html += `</button>`;
|
||||
} else {
|
||||
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
|
||||
}
|
||||
html += `<div class="submission-draft-var-group-body">`;
|
||||
html += `<section class="submission-draft-var-group" data-group="${group.id}">`;
|
||||
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
|
||||
for (const key of group.keys) {
|
||||
const label = labelFor(key);
|
||||
const override = overrides[key];
|
||||
@@ -1090,19 +813,10 @@ function paintVariables(): void {
|
||||
// Visual hint: marker text appears in preview when override is "".
|
||||
void mergedVal;
|
||||
}
|
||||
html += `</div>`;
|
||||
html += `</section>`;
|
||||
}
|
||||
host.innerHTML = html;
|
||||
|
||||
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-group-toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const id = btn.dataset.toggleGroup;
|
||||
if (!id) return;
|
||||
state.collapsedGroups[id] = !state.collapsedGroups[id];
|
||||
paintVariables();
|
||||
});
|
||||
});
|
||||
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
|
||||
inp.addEventListener("input", () => onVarChange(inp));
|
||||
// t-paliad-274 (B) — focus into a sidebar field highlights every
|
||||
@@ -1307,175 +1021,6 @@ async function onPartySelectionChange(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function runPartySearch(query: string): Promise<void> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set("q", query);
|
||||
const resp = await fetch(`/api/parties/search?${params.toString()}`);
|
||||
if (!resp.ok) throw new Error(`search ${resp.status}`);
|
||||
const data = (await resp.json()) as { results: PartySearchHit[] };
|
||||
// Filter out parties already on THIS project — picking one of them
|
||||
// would be a no-op clone that doubles the row.
|
||||
const existingIDs = new Set(
|
||||
(state.view?.available_parties ?? []).map((p) => p.id),
|
||||
);
|
||||
state.addPartySearchHits = (data.results ?? []).filter((h) => !existingIDs.has(h.id));
|
||||
|
||||
// Refresh ONLY the results <ul> in place — repainting the whole
|
||||
// picker would steal focus from the search input on every
|
||||
// keystroke. The input keeps its value/selection and the lawyer
|
||||
// can keep typing.
|
||||
const ul = document.querySelector<HTMLUListElement>(
|
||||
".submission-draft-addparty-search-results",
|
||||
);
|
||||
if (ul) {
|
||||
ul.outerHTML = renderPartySearchResultsList();
|
||||
const fresh = document.querySelector<HTMLUListElement>(
|
||||
".submission-draft-addparty-search-results",
|
||||
);
|
||||
if (fresh) {
|
||||
fresh.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const hitID = li.dataset.hitId;
|
||||
if (!hitID) return;
|
||||
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
|
||||
if (!hit) return;
|
||||
const side = state.addPartyOpen ?? "other";
|
||||
void onAddPartySearchPick(side, hit);
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// First load (panel just opened) — full picker paint to wire up
|
||||
// every control. Subsequent keystroke updates take the cheaper
|
||||
// path above.
|
||||
paintPartyPicker();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("submission-draft party-search:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPartySearchResultsList(): string {
|
||||
let html = `<ul class="submission-draft-addparty-search-results">`;
|
||||
if (state.addPartySearchHits.length === 0) {
|
||||
html += `<li class="submission-draft-addparty-search-empty">${escapeHtml(
|
||||
isEN() ? "No matches." : "Keine Treffer.",
|
||||
)}</li>`;
|
||||
} else {
|
||||
for (const hit of state.addPartySearchHits) {
|
||||
const ref = hit.project_reference
|
||||
? `<span class="submission-draft-addparty-search-projref">${escapeHtml(hit.project_reference)}</span>`
|
||||
: "";
|
||||
const role = hit.role
|
||||
? `<span class="submission-draft-party-chip">${escapeHtml(hit.role)}</span>`
|
||||
: "";
|
||||
const rep = hit.representative
|
||||
? `<span class="submission-draft-addparty-search-rep">${escapeHtml(
|
||||
(isEN() ? "Repr.: " : "Vertr.: ") + hit.representative,
|
||||
)}</span>`
|
||||
: "";
|
||||
html += `<li class="submission-draft-addparty-search-row" data-hit-id="${escapeHtml(hit.id)}">`;
|
||||
html += `<span class="submission-draft-addparty-search-name">${escapeHtml(hit.name)}</span>`;
|
||||
html += role;
|
||||
html += rep;
|
||||
html += `<span class="submission-draft-addparty-search-projwrap">`;
|
||||
html += escapeHtml(isEN() ? "Project: " : "Projekt: ");
|
||||
html += `<span class="submission-draft-addparty-search-proj">${escapeHtml(hit.project_title)}</span>`;
|
||||
html += ref;
|
||||
html += `</span>`;
|
||||
html += `</li>`;
|
||||
}
|
||||
}
|
||||
html += `</ul>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
async function onAddPartyManualSubmit(
|
||||
side: PartySide,
|
||||
payload: { name: string; role: string; representative: string },
|
||||
): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const projectID = state.view.draft.project_id;
|
||||
if (!projectID) return;
|
||||
// Disable the submit button in-place rather than repainting the form
|
||||
// mid-flight (a repaint would blow away the lawyer's typed values on
|
||||
// error and reset focus). The post-success/-error repaint runs once
|
||||
// the call settles.
|
||||
const submitBtn = document.querySelector<HTMLButtonElement>(
|
||||
`.submission-draft-addparty-form[data-side="${side}"] button[type="submit"]`,
|
||||
);
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
state.addPartyBusy = true;
|
||||
try {
|
||||
const body: Record<string, unknown> = { name: payload.name };
|
||||
if (payload.role) body.role = payload.role;
|
||||
if (payload.representative) body.representative = payload.representative;
|
||||
const resp = await fetch(`/api/projects/${projectID}/parties`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`create party ${resp.status}`);
|
||||
const created = (await resp.json()) as { id: string };
|
||||
await refreshDraftViewAndSelect(created.id);
|
||||
state.addPartyOpen = null;
|
||||
setSaveStatus(isEN() ? "Party added" : "Partei hinzugefügt");
|
||||
state.addPartyBusy = false;
|
||||
paintPartyPicker();
|
||||
} catch (err) {
|
||||
console.error("submission-draft add-party manual:", err);
|
||||
setSaveStatus(isEN() ? "Add party failed" : "Hinzufügen fehlgeschlagen", true);
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
state.addPartyBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddPartySearchPick(side: PartySide, hit: PartySearchHit): Promise<void> {
|
||||
// DB picks clone the row into the current project — the simplest
|
||||
// semantics that survive paliad.parties' project_id-NOT-NULL schema.
|
||||
// The lawyer asked for "no manual re-typing"; this honours that
|
||||
// without bending the data model.
|
||||
await onAddPartyManualSubmit(side, {
|
||||
name: hit.name,
|
||||
role: hit.role ?? defaultRoleFor(side),
|
||||
representative: hit.representative ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
// refreshDraftViewAndSelect refetches the editor payload (so
|
||||
// available_parties picks up the new row) and ensures the newly-added
|
||||
// party is checked in selected_parties. If the lawyer was on the
|
||||
// implicit-all default (empty selected_parties), the new party comes
|
||||
// in pre-selected via the "empty=all" rule and no PATCH is needed.
|
||||
async function refreshDraftViewAndSelect(newPartyID: string): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const draftID = state.view.draft.id;
|
||||
const view = state.view.draft.project_id
|
||||
? await fetchView(state.view.draft.project_id, state.view.draft.submission_code, draftID)
|
||||
: await fetchGlobalView(draftID);
|
||||
state.view = view;
|
||||
|
||||
// If the previous draft had a non-empty selected_parties subset,
|
||||
// explicitly add the new party so it isn't silently dropped from the
|
||||
// submission. Empty selected_parties = "all" → no PATCH needed.
|
||||
const currentSel = state.view.draft.selected_parties ?? [];
|
||||
if (currentSel.length > 0 && !currentSel.includes(newPartyID)) {
|
||||
const next = [...currentSel, newPartyID];
|
||||
try {
|
||||
const patched = await patchDraft({ selected_parties: next });
|
||||
state.view = patched;
|
||||
} catch (err) {
|
||||
console.error("submission-draft select new party:", err);
|
||||
}
|
||||
}
|
||||
|
||||
paintImportRow();
|
||||
paintPartyPicker();
|
||||
paintVariables();
|
||||
paintPreview();
|
||||
}
|
||||
|
||||
async function onImportFromProject(btn: HTMLButtonElement): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const draftID = state.view.draft.id;
|
||||
|
||||
@@ -143,25 +143,6 @@ function writeChoicesToURL(choices: EventChoice[]) {
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Show-hidden toggle state (t-paliad-290 / m/paliad#122). When ON, the
|
||||
// calculator re-surfaces cards whose submission_code is in the active
|
||||
// skipRules set; they render faded with a "Wieder einblenden" chip.
|
||||
// URL-driven via ?show_hidden=1 so a shared link or reload preserves
|
||||
// the visibility. Default OFF — m's not asking to see hidden by
|
||||
// default, just to be able to.
|
||||
function readShowHiddenFromURL(): boolean {
|
||||
return new URLSearchParams(window.location.search).get("show_hidden") === "1";
|
||||
}
|
||||
|
||||
function writeShowHiddenToURL(on: boolean) {
|
||||
const url = new URL(window.location.href);
|
||||
if (on) url.searchParams.set("show_hidden", "1");
|
||||
else url.searchParams.delete("show_hidden");
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
let showHidden = readShowHiddenFromURL();
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
@@ -275,33 +256,14 @@ async function doCalc() {
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
perCardChoices,
|
||||
includeHidden: showHidden,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
if (!data) return;
|
||||
lastResponse = data;
|
||||
renderResults(data);
|
||||
syncHiddenBadge(data.hiddenCount ?? 0);
|
||||
showStep(3);
|
||||
}
|
||||
|
||||
// syncHiddenBadge updates the "Ausgeblendete (N)" count next to the
|
||||
// toggle. Visible regardless of toggle state so the user knows whether
|
||||
// there's anything to re-surface even when the toggle is OFF. Hides the
|
||||
// whole row when the projection has zero hidden cards — no clutter on
|
||||
// a project that's never used the skip feature. (t-paliad-290)
|
||||
function syncHiddenBadge(count: number) {
|
||||
const row = document.getElementById("show-hidden-row");
|
||||
const badge = document.getElementById("show-hidden-count");
|
||||
if (!row || !badge) return;
|
||||
if (count <= 0) {
|
||||
row.style.display = "none";
|
||||
return;
|
||||
}
|
||||
row.style.display = "";
|
||||
badge.textContent = tDyn("choices.show_hidden.count").replace("{n}", String(count));
|
||||
}
|
||||
|
||||
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
|
||||
// label from the calc response. Precedence:
|
||||
//
|
||||
@@ -535,17 +497,7 @@ async function fetchProjectOurSide(projectID: string): Promise<ProjectOurSide |
|
||||
function sideLabelI18n(s: Side): string {
|
||||
if (s === "claimant") return t("deadlines.side.claimant");
|
||||
if (s === "defendant") return t("deadlines.side.defendant");
|
||||
return t("deadlines.side.undefined");
|
||||
}
|
||||
|
||||
// syncSideHintVisibility shows the "pick a side" hint chip only while
|
||||
// currentSide is unset (m/paliad#120). When the user has picked
|
||||
// claimant / defendant the columns are already focused, so the prompt
|
||||
// would be misleading.
|
||||
function syncSideHintVisibility() {
|
||||
const hint = document.getElementById("side-hint");
|
||||
if (!hint) return;
|
||||
hint.style.display = currentSide === null ? "" : "none";
|
||||
return t("deadlines.side.both");
|
||||
}
|
||||
|
||||
// renderSideChip swaps the radio cluster for a read-only chip showing
|
||||
@@ -569,9 +521,6 @@ function showSideRadioCluster() {
|
||||
if (!cluster || !chip) return;
|
||||
cluster.style.display = "";
|
||||
chip.style.display = "none";
|
||||
// Cluster re-appears after override → re-evaluate hint visibility so
|
||||
// we don't leave a stale "pick a side" prompt above a checked radio.
|
||||
syncSideHintVisibility();
|
||||
}
|
||||
|
||||
// applySidePrefill takes a project's our_side, maps it to the side axis,
|
||||
@@ -657,7 +606,6 @@ function initPerspectiveControls() {
|
||||
currentAppellant = readAppellantFromURL();
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
syncSideHintVisibility();
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
@@ -665,7 +613,6 @@ function initPerspectiveControls() {
|
||||
const v = input.value;
|
||||
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeSideToURL(currentSide);
|
||||
syncSideHintVisibility();
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
@@ -749,20 +696,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// t-paliad-290 — show-hidden toggle. Hydrate from URL, wire change
|
||||
// to URL + recalc (the backend reshapes the response — we can't just
|
||||
// re-render lastResponse since the hidden rows aren't in it when the
|
||||
// toggle was OFF).
|
||||
const showHiddenCb = document.getElementById("show-hidden-toggle") as HTMLInputElement | null;
|
||||
if (showHiddenCb) {
|
||||
showHiddenCb.checked = showHidden;
|
||||
showHiddenCb.addEventListener("change", () => {
|
||||
showHidden = showHiddenCb.checked;
|
||||
writeShowHiddenToURL(showHidden);
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
initViewToggle();
|
||||
initPerspectiveControls();
|
||||
|
||||
|
||||
@@ -74,11 +74,10 @@ export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
|
||||
states.set(opts.container, state);
|
||||
|
||||
opts.container.addEventListener("click", (e) => {
|
||||
const targetEl = e.target as HTMLElement | null;
|
||||
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
|
||||
if (caret) {
|
||||
const target = (e.target as HTMLElement | null)?.closest<HTMLElement>(".event-card-choices-caret");
|
||||
if (target) {
|
||||
e.stopPropagation();
|
||||
openPopover(state, caret);
|
||||
openPopover(state, target);
|
||||
return;
|
||||
}
|
||||
// Outside-click closes the popover.
|
||||
@@ -159,7 +158,6 @@ function openPopover(state: AttachedState, caret: HTMLElement): void {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const isHidden = caret.dataset.isHidden === "1";
|
||||
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "event-card-choices-popover";
|
||||
@@ -167,15 +165,6 @@ function openPopover(state: AttachedState, caret: HTMLElement): void {
|
||||
pop.setAttribute("aria-label", t("choices.caret.title"));
|
||||
|
||||
const blocks: string[] = [];
|
||||
// t-paliad-293: hidden-card prominence. When the user opens the
|
||||
// popover on a re-surfaced hidden card, "Wieder einblenden" is the
|
||||
// most likely intent — surface it as a single high-contrast action
|
||||
// at the top of the popover (rather than burying it under the skip
|
||||
// toggle's reset link). Clicking it clears the `skip` choice, which
|
||||
// is the same wire effect as the legacy inline chip from t-paliad-290.
|
||||
if (isHidden) {
|
||||
blocks.push(renderUnhideBlock());
|
||||
}
|
||||
if (Array.isArray(offered.appellant)) {
|
||||
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
|
||||
}
|
||||
@@ -270,23 +259,6 @@ function renderToggleBlock(state: AttachedState, code: string, kind: "include_cc
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderUnhideBlock is the popover's prominent "Wieder einblenden"
|
||||
// action — surfaced only when the caret is opened on a re-surfaced
|
||||
// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches
|
||||
// the same `clear` action as the skip-block reset link below, but
|
||||
// labelled in the user's terms ("restore this card" rather than
|
||||
// "reset skip choice"). Drops out of the popover automatically on
|
||||
// non-hidden cards so the popover stays minimal. (t-paliad-293)
|
||||
function renderUnhideBlock(): string {
|
||||
const label = t("choices.unhide.chip");
|
||||
return `<div class="event-card-choices-block event-card-choices-block--unhide">
|
||||
<button type="button"
|
||||
data-choice-action="clear"
|
||||
data-choice-kind="skip"
|
||||
class="event-card-choices-unhide-btn">${escHtml(label)}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function closePopover(state: AttachedState): void {
|
||||
if (state.popover) {
|
||||
state.popover.remove();
|
||||
|
||||
@@ -67,92 +67,6 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-293 (m/paliad#125): the "Wieder einblenden" affordance
|
||||
// moved from an inline chip in the card header into the caret popover
|
||||
// to fix horizontal-scroll on narrow viewports (the long German label
|
||||
// pushed the card past its column width). The renderer now signals
|
||||
// hidden state two ways: (1) a 👁⃠ state-icon in the title row and
|
||||
// (2) data-is-hidden="1" on the caret button so event-card-choices.ts
|
||||
// can surface the prominent "Wieder einblenden" popover entry when
|
||||
// the user opens the menu. The legacy `.event-card-choices-unhide`
|
||||
// inline chip class must NOT appear in the output.
|
||||
describe("deadlineCardHtml — isHidden surfaces state-icon + caret hint (t-paliad-293)", () => {
|
||||
test("isHidden=true emits the hidden state-icon", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).toContain("timeline-state-icon--hidden");
|
||||
});
|
||||
|
||||
test("isHidden=true with choicesOffered.skip annotates the caret with data-is-hidden=\"1\"", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).toContain('data-is-hidden="1"');
|
||||
expect(html).toContain("event-card-choices-caret");
|
||||
});
|
||||
|
||||
test("isHidden=false (default) suppresses the state-icon and reports data-is-hidden=\"0\"", () => {
|
||||
const html = deadlineCardHtml(
|
||||
dl({ choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).not.toContain("timeline-state-icon--hidden");
|
||||
expect(html).toContain('data-is-hidden="0"');
|
||||
});
|
||||
|
||||
test("isHidden=true with empty choicesOffered still emits caret with synthesized skip offer (defensive)", () => {
|
||||
// Edge case: admin edits the rule's choices_offered after a user
|
||||
// has already saved a `skip=true` choice. Without the fallback
|
||||
// the card would re-surface as hidden with no popover entrypoint
|
||||
// — the user would have no way to un-hide it. The renderer
|
||||
// synthesizes a `{skip:[true,false]}` offer so the prominent
|
||||
// "Wieder einblenden" button still renders in the popover.
|
||||
const html = deadlineCardHtml(dl({ isHidden: true }), { showParty: true });
|
||||
expect(html).toContain("event-card-choices-caret");
|
||||
expect(html).toContain('data-is-hidden="1"');
|
||||
expect(html).toContain("data-choices-offered=\"{"skip":[true,false]}\"");
|
||||
});
|
||||
|
||||
test("isHidden=false with empty choicesOffered suppresses caret (regression guard)", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true });
|
||||
expect(html).not.toContain("event-card-choices-caret");
|
||||
});
|
||||
|
||||
test("legacy inline `.event-card-choices-unhide` class is no longer emitted", () => {
|
||||
// Pinned to catch a regression that would re-introduce the
|
||||
// horizontal-scroll surface that motivated the move. The popover
|
||||
// now uses `.event-card-choices-unhide-btn` (with the -btn suffix)
|
||||
// inside the body-attached popover dom node — never in the card
|
||||
// header HTML the renderer returns.
|
||||
const html = deadlineCardHtml(
|
||||
dl({ isHidden: true, choicesOffered: { skip: [true, false] } }),
|
||||
{ showParty: true },
|
||||
);
|
||||
expect(html).not.toContain('class="event-card-choices-unhide"');
|
||||
expect(html).not.toMatch(/event-card-choices-unhide(?!-btn)/);
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-293: the `optional` priority used to render an inline text
|
||||
// badge in the card title. The overhaul replaces it with a ⊙ state
|
||||
// icon so the title row stays compact on narrow viewports. Tooltip is
|
||||
// driven by the `state.optional.tooltip` i18n key.
|
||||
describe("deadlineCardHtml — optional priority renders the state icon (t-paliad-293)", () => {
|
||||
test("priority='optional' emits the timeline-state-icon--optional marker", () => {
|
||||
const html = deadlineCardHtml(dl({ priority: "optional" }), { showParty: true });
|
||||
expect(html).toContain("timeline-state-icon--optional");
|
||||
expect(html).not.toContain("optional-badge");
|
||||
});
|
||||
|
||||
test("priority='mandatory' (default) omits the optional marker", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true });
|
||||
expect(html).not.toContain("timeline-state-icon--optional");
|
||||
});
|
||||
});
|
||||
|
||||
// t-paliad-289 — isConditional rules render an "abhängig von <parent>"
|
||||
// chip in place of the date column, and the chip keeps the click-to-edit
|
||||
// affordance so the user can pin a real date once the upstream anchor
|
||||
@@ -213,7 +127,6 @@ describe("deadlineCardHtml — isConditional rendering (t-paliad-289)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Pure column-routing behaviour. Originally pinned by m/paliad#81
|
||||
// (side + appellant axes), re-framed by m/paliad#88: the column
|
||||
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
|
||||
|
||||
@@ -72,11 +72,6 @@ export interface CalculatedDeadline {
|
||||
// page-level appellant axis still applies in that case). The bucketer
|
||||
// reads this in preference to the page-level appellant.
|
||||
appellantContext?: string;
|
||||
// isHidden (t-paliad-290 / m/paliad#122): server-side flag set when
|
||||
// a previously-hidden card is re-surfaced via the "Ausgeblendete
|
||||
// anzeigen" toggle. The renderer fades the card and exposes an
|
||||
// inline "Wieder einblenden" chip that deletes the skip choice.
|
||||
isHidden?: boolean;
|
||||
// isConditional (t-paliad-289): the rule's anchor is uncertain, so
|
||||
// no concrete date is projected. Set by the calculator when the rule
|
||||
// depends on a court-set ancestor without override, when a backward-
|
||||
@@ -154,13 +149,6 @@ export interface DeadlineResponse {
|
||||
// (m/paliad#81)
|
||||
triggerEventLabel?: string;
|
||||
triggerEventLabelEN?: string;
|
||||
// hiddenCount (t-paliad-290 / m/paliad#122): number of rules that
|
||||
// would have been hidden in this projection (i.e. their
|
||||
// submission_code is in skipRules and they passed the condition_expr
|
||||
// gate). Surfaces the "Ausgeblendete (N)" badge on the toggle even
|
||||
// when the toggle is OFF — so users know there's something to
|
||||
// re-surface.
|
||||
hiddenCount?: number;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -190,11 +178,6 @@ export interface CalcParams {
|
||||
choice_kind: string;
|
||||
choice_value: string;
|
||||
}>;
|
||||
// includeHidden (t-paliad-290): when true the calculator returns
|
||||
// previously-skipped rules as faded cards instead of dropping them.
|
||||
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is
|
||||
// ON.
|
||||
includeHidden?: boolean;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
@@ -350,50 +333,21 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
dateStr = `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
}
|
||||
|
||||
// t-paliad-293 — iconified state markers. The card surface speaks
|
||||
// "cut the tree of possibilities": each card carries 0–N small icons
|
||||
// in the title row that summarise its decision state at a glance.
|
||||
// The text "optional" badge that used to sit inline next to the name
|
||||
// is now a ⊙ icon (state.optional). Hidden cards get a 👁⃠ eye-slash
|
||||
// marker. Conditional cards already have the date-column chip; the
|
||||
// marker is redundant in the title row. CCR-included / appellant
|
||||
// picks remain on the chip row (event-card-choices-chip) — see below.
|
||||
// Tooltips are i18n-driven so they read in the user's language.
|
||||
const stateIcons: string[] = [];
|
||||
if (dl.priority === "optional") {
|
||||
stateIcons.push(
|
||||
`<span class="timeline-state-icon timeline-state-icon--optional" role="img" aria-label="${escAttr(t("state.optional.tooltip"))}" title="${escAttr(t("state.optional.tooltip"))}">⊙</span>`,
|
||||
);
|
||||
}
|
||||
if (dl.isHidden) {
|
||||
stateIcons.push(
|
||||
`<span class="timeline-state-icon timeline-state-icon--hidden" role="img" aria-label="${escAttr(t("state.hidden.tooltip"))}" title="${escAttr(t("state.hidden.tooltip"))}">👁⃠</span>`,
|
||||
);
|
||||
}
|
||||
const stateIconsHtml = stateIcons.join("");
|
||||
// Slice 9 (t-paliad-195): the legacy boolean pair is gone — read
|
||||
// priority directly. Optional badge fires only on 'optional'
|
||||
// priority (RoP.151-style opt-in deadlines).
|
||||
const mandatoryBadge = dl.priority === "optional"
|
||||
? '<span class="optional-badge">optional</span>'
|
||||
: "";
|
||||
|
||||
// t-paliad-265 — caret affordance + chip indicator when this rule
|
||||
// offers per-card choices and the user has made a pick. The popover
|
||||
// open/commit lifecycle lives in client/views/event-card-choices.ts;
|
||||
// the data-* attributes here are the wire contract between the two.
|
||||
//
|
||||
// t-paliad-293 — hidden cards always expose the caret so the user
|
||||
// can un-hide via the popover's "Wieder einblenden" entry. Normally
|
||||
// a hidden card was hidden via a skip choice, so `choicesOffered.skip`
|
||||
// is present. Defensive fallback: if a rule's `choices_offered` was
|
||||
// edited away after the skip entry was saved, the user would lose
|
||||
// the un-hide path entirely. Synthesize a `{skip:[true,false]}`
|
||||
// offer for the popover in that edge case so the prominent
|
||||
// "Wieder einblenden" button still renders.
|
||||
const offeredForCaret = (dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0)
|
||||
? dl.choicesOffered
|
||||
: (dl.isHidden ? { skip: [true, false] } : null);
|
||||
const showCaret = dl.code !== "" && offeredForCaret !== null;
|
||||
const choicesHtml = showCaret
|
||||
const choicesHtml = dl.code !== "" && dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0
|
||||
? `<button type="button" class="event-card-choices-caret"
|
||||
data-submission-code="${escAttr(dl.code)}"
|
||||
data-choices-offered="${escAttr(JSON.stringify(offeredForCaret))}"
|
||||
data-is-hidden="${dl.isHidden ? "1" : "0"}"
|
||||
data-choices-offered="${escAttr(JSON.stringify(dl.choicesOffered))}"
|
||||
aria-label="${escAttr(t("choices.caret.title"))}"
|
||||
title="${escAttr(t("choices.caret.title"))}">▾</button>`
|
||||
: "";
|
||||
@@ -447,7 +401,7 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
return `<div class="timeline-item-header">
|
||||
<span class="timeline-name">
|
||||
${dlName}
|
||||
${stateIconsHtml}
|
||||
${mandatoryBadge}
|
||||
${chipHtml}
|
||||
</span>
|
||||
${dateStr}
|
||||
@@ -545,10 +499,6 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
|
||||
const itemClasses = [
|
||||
"timeline-item",
|
||||
dl.isRootEvent ? "timeline-root" : "",
|
||||
// t-paliad-290: re-surfaced hidden cards render faded via the
|
||||
// shared timeline-item--hidden modifier (same modifier the columns
|
||||
// view uses; see fr-col-item--hidden below).
|
||||
dl.isHidden ? "timeline-item--hidden" : "",
|
||||
// t-paliad-289: dotted-border + faded styling for conditional rows
|
||||
// so the "abhängig von <parent>" state is visually distinct from
|
||||
// both anchored deadlines and direct court-set rows.
|
||||
@@ -737,9 +687,6 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
const itemClasses = [
|
||||
"fr-col-item",
|
||||
dl.isRootEvent ? "fr-col-root" : "",
|
||||
// t-paliad-290: re-surfaced hidden cards render faded via the
|
||||
// shared fr-col-item--hidden modifier.
|
||||
dl.isHidden ? "fr-col-item--hidden" : "",
|
||||
// t-paliad-289: same conditional treatment as the linear
|
||||
// timeline-item — dotted border + faded styling.
|
||||
dl.isConditional ? "fr-col-item--conditional" : "",
|
||||
@@ -795,7 +742,6 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
||||
perCardChoices: params.perCardChoices && params.perCardChoices.length > 0
|
||||
? params.perCardChoices
|
||||
: undefined,
|
||||
includeHidden: params.includeHidden ? true : undefined,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -1021,13 +1021,10 @@ export type I18nKey =
|
||||
| "choices.include_ccr.title"
|
||||
| "choices.include_ccr.true"
|
||||
| "choices.reset"
|
||||
| "choices.show_hidden.count"
|
||||
| "choices.show_hidden.label"
|
||||
| "choices.skip.false"
|
||||
| "choices.skip.title"
|
||||
| "choices.skip.true"
|
||||
| "choices.skipped.chip"
|
||||
| "choices.unhide.chip"
|
||||
| "common.cancel"
|
||||
| "common.close"
|
||||
| "common.forbidden"
|
||||
@@ -1465,13 +1462,12 @@ export type I18nKey =
|
||||
| "deadlines.search.placeholder"
|
||||
| "deadlines.search.results.count"
|
||||
| "deadlines.search.results.count_one"
|
||||
| "deadlines.side.both"
|
||||
| "deadlines.side.claimant"
|
||||
| "deadlines.side.defendant"
|
||||
| "deadlines.side.from_project"
|
||||
| "deadlines.side.hint"
|
||||
| "deadlines.side.label"
|
||||
| "deadlines.side.override"
|
||||
| "deadlines.side.undefined"
|
||||
| "deadlines.source.caldav"
|
||||
| "deadlines.source.fristenrechner"
|
||||
| "deadlines.source.imported"
|
||||
@@ -2621,8 +2617,6 @@ export type I18nKey =
|
||||
| "search.no_results"
|
||||
| "search.placeholder"
|
||||
| "sidebar.resize.title"
|
||||
| "state.hidden.tooltip"
|
||||
| "state.optional.tooltip"
|
||||
| "submissions.draft.action.delete"
|
||||
| "submissions.draft.action.export"
|
||||
| "submissions.draft.action.new"
|
||||
|
||||
@@ -1917,11 +1917,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
.fristen-row.is-active .fristen-row-num {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
/* Lime is high-luminance; foreground stays midnight in both themes via
|
||||
--color-accent-dark (light: midnight by default, dark: midnight
|
||||
explicit). Using --color-text here would flip to cream in dark mode
|
||||
and collapse contrast on lime. */
|
||||
color: var(--color-accent-dark);
|
||||
color: var(--color-text, #111);
|
||||
}
|
||||
|
||||
.fristen-row.is-prefilled .fristen-row-num {
|
||||
@@ -3332,11 +3328,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
/* t-paliad-293: tighter min-height. Previously 4rem — too much
|
||||
vertical air per card on long projections. Title row + meta row
|
||||
fits comfortably in 2.75rem; longer cards (with notes expanded
|
||||
or adjusted-date banners) still grow naturally. */
|
||||
min-height: 2.75rem;
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
.timeline-item:last-child .timeline-line {
|
||||
@@ -3377,37 +3369,19 @@ input[type="range"]::-moz-range-thumb {
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
/* t-paliad-293: tighter inter-card gutter. Was 1rem; 0.6rem keeps
|
||||
the dotted-connector line readable without bloating long
|
||||
projections. */
|
||||
padding-bottom: 0.6rem;
|
||||
min-width: 0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timeline-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
/* t-paliad-293: allow shrink + wrap so a long title plus the state
|
||||
icons + caret never push the card past its column. Combined with
|
||||
min-width:0 on the name, no inline child can blow the row width
|
||||
on 375/414/768 viewports. */
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.timeline-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
/* min-width:0 lets the name shrink and wrap inside its flex parent
|
||||
— otherwise overflow:hidden in an ancestor would clip it but the
|
||||
flex item would still demand its intrinsic width. */
|
||||
min-width: 0;
|
||||
/* Word-break on long German compounds (Vertraulichkeitswiderklage …)
|
||||
so they wrap mid-word rather than pushing the date column off-
|
||||
screen. (t-paliad-293) */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
@@ -3493,37 +3467,15 @@ input[type="range"]::-moz-range-thumb {
|
||||
color: var(--status-neutral-fg-3);
|
||||
}
|
||||
|
||||
/* t-paliad-293 — compact state icons in the card title row. They
|
||||
* replace the legacy `.optional-badge` text chip and add a uniform
|
||||
* language for the per-card decision state ("cut the tree of
|
||||
* possibilities"). Each icon carries its own modifier so the tint
|
||||
* matches the state semantic. The glyph itself is the primary signal;
|
||||
* the i18n tooltip on the span carries the accessible description. */
|
||||
.timeline-state-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
cursor: help;
|
||||
user-select: none;
|
||||
/* Cancel the wrapper fade so the marker stays legible inside
|
||||
.timeline-item--hidden which fades the whole content panel. */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.timeline-state-icon--optional {
|
||||
.optional-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 99px;
|
||||
background: var(--status-amber-bg);
|
||||
color: var(--status-amber-fg);
|
||||
}
|
||||
|
||||
.timeline-state-icon--hidden {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* t-paliad-265 — per-event-card optional choices. The caret sits in
|
||||
* the card header next to the date; the chip surfaces the active pick
|
||||
* inline with the title; the popover is body-attached and positioned
|
||||
@@ -3579,66 +3531,6 @@ input[type="range"]::-moz-range-thumb {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* t-paliad-290 (m/paliad#122) — re-surfaced "hidden" cards. The user
|
||||
* has previously marked these optional events as "Überspringen"; the
|
||||
* "Ausgeblendete anzeigen" toggle on /tools/verfahrensablauf returns
|
||||
* them with a faded + dotted-border treatment so they're visually
|
||||
* distinct from the active timeline. The inline "Wieder einblenden"
|
||||
* chip cancels the skip on click. */
|
||||
.timeline-item--hidden .timeline-content,
|
||||
.fr-col-item--hidden {
|
||||
opacity: 0.55;
|
||||
border: 1px dotted var(--color-border, #d4d4d4);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
/* t-paliad-293 — prominent "Wieder einblenden" entry inside the caret
|
||||
* popover. Surfaced only when the caret is opened on a hidden card
|
||||
* (data-is-hidden="1"). Used to be an inline chip in the card header,
|
||||
* but that caused horizontal scroll on narrow viewports (m/paliad#125)
|
||||
* because its German label is wide ("Wieder einblenden") and the
|
||||
* card header is a non-wrapping flex row. Moving it into the popover
|
||||
* removes the surface entirely and matches m's "actions live in the
|
||||
* caret menu" framing. */
|
||||
.event-card-choices-block--unhide {
|
||||
/* No top border separator — this block sits at the top of the
|
||||
popover with the highest visual priority. */
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.event-card-choices-unhide-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-accent, #c6f41c);
|
||||
background: var(--color-accent, #c6f41c);
|
||||
/* Match the active-option pin (lime fg → midnight text) so the
|
||||
button reads against the lime in both light and dark themes
|
||||
(m/paliad#123). */
|
||||
color: var(--color-accent-dark);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.event-card-choices-unhide-btn:hover,
|
||||
.event-card-choices-unhide-btn:focus-visible {
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.show-hidden-count {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
/* t-paliad-289: rules whose anchor is uncertain (court-set ancestor
|
||||
without override, backward-anchor with unset forward date, optional
|
||||
event not recorded). The "abhängig von <parent>" chip on the date
|
||||
@@ -3668,7 +3560,6 @@ input[type="range"]::-moz-range-thumb {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
.event-card-choices-popover {
|
||||
background: var(--color-bg, #fff);
|
||||
border: 1px solid var(--color-border, #d4d4d4);
|
||||
@@ -3716,10 +3607,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
.event-card-choices-option--active {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
/* Foreground stays midnight in both themes — --color-text would flip
|
||||
to cream in dark mode and leave the active "Berufung durch …"
|
||||
chip unreadable on lime (m/paliad#123). */
|
||||
color: var(--color-accent-dark);
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -3852,22 +3740,6 @@ input[type="range"]::-moz-range-thumb {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* "Pick a side" hint that sits next to the side-radio cluster while
|
||||
currentSide is null (m/paliad#120). Both columns still render every
|
||||
rule in that state — the chip just nudges the user that picking a
|
||||
side focuses their column. Hidden by JS once a side is picked. */
|
||||
.side-radio-cluster {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.side-hint {
|
||||
color: var(--color-text-muted, #666);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Read-only auto-fill chip for #side-row. Renders when ?project=<id>
|
||||
resolves a project whose our_side is set: shows the inferred side
|
||||
with a small "Andere Seite wählen" override link that swaps the row
|
||||
@@ -6533,194 +6405,6 @@ dialog.modal::backdrop {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* t-paliad-287 — collapsible variable-group section (Frist + Parteien
|
||||
override). The toggle button is the section header; clicking it
|
||||
flips state.collapsedGroups[id] and re-renders. The visible caret
|
||||
rotates via the parent's --collapsed class. */
|
||||
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0 0 0.5rem 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #c6f41c);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.submission-draft-var-group-caret {
|
||||
display: inline-block;
|
||||
transition: transform 120ms ease;
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.submission-draft-var-group--collapsible:not(.submission-draft-var-group--collapsed)
|
||||
.submission-draft-var-group-caret {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.submission-draft-var-group--collapsed .submission-draft-var-group-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* t-paliad-287 — Add Party affordance per side. */
|
||||
.submission-draft-addparty {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-panel {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-tab {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.85em;
|
||||
border-radius: 4px 4px 0 0;
|
||||
color: var(--color-text-muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-tab--active {
|
||||
color: var(--color-text);
|
||||
border-bottom-color: var(--color-accent, #c6f41c);
|
||||
background: var(--color-bg-lime-tint, #f0fac6);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-form--busy {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-field > span {
|
||||
font-size: 0.82em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-results {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-row {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-row:hover {
|
||||
background: var(--color-bg-lime-tint, #f0fac6);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-empty {
|
||||
padding: 0.6rem;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-rep {
|
||||
font-size: 0.78em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-projwrap {
|
||||
font-size: 0.78em;
|
||||
color: var(--color-text-muted);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-proj {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-projref {
|
||||
margin-left: 0.3rem;
|
||||
padding: 0 0.4em;
|
||||
border-radius: 3px;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-hint {
|
||||
font-size: 0.78em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.submission-draft-parties-empty {
|
||||
font-size: 0.82em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.checklist-instance-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
@@ -8321,7 +8005,7 @@ dialog.modal::backdrop {
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
color: var(--color-text);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -16251,7 +15935,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
@@ -16859,7 +16543,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
.smart-timeline-anchor-submit {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border: 1px solid var(--color-accent, #c6f41c);
|
||||
color: var(--color-accent-dark);
|
||||
color: var(--color-text, #333);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
@@ -17797,7 +17481,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
.admin-rules-chip.active {
|
||||
background: var(--color-accent, #BFF355);
|
||||
border-color: var(--color-accent, #BFF355);
|
||||
color: var(--color-accent-dark);
|
||||
color: var(--color-text, #000);
|
||||
}
|
||||
|
||||
.admin-rules-pill {
|
||||
|
||||
@@ -172,13 +172,10 @@ export function renderSubmissionDraft(): string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-277 / t-paliad-287: multi-select party
|
||||
picker plus per-side Add-Party affordance.
|
||||
{/* t-paliad-277: multi-select party picker.
|
||||
Populated from view.available_parties; checkbox
|
||||
per party, grouped by role. Hidden when no
|
||||
project is attached; visible even on empty
|
||||
rosters so the lawyer can use Add Party to
|
||||
populate. */}
|
||||
project or no parties on the project. */}
|
||||
<div
|
||||
id="submission-draft-parties"
|
||||
className="submission-draft-parties"
|
||||
|
||||
@@ -190,18 +190,9 @@ export function renderVerfahrensablauf(): string {
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
|
||||
<span data-i18n="deadlines.side.both">Beide</span>
|
||||
</label>
|
||||
</div>
|
||||
{/* Prompt shown while the user hasn't picked a side
|
||||
(m/paliad#120). Hidden by client when side is
|
||||
claimant or defendant. Both columns still
|
||||
render every rule in this state — picking a
|
||||
side just focuses the user's column. */}
|
||||
<span className="side-hint" id="side-hint"
|
||||
data-i18n="deadlines.side.hint">
|
||||
Wählen Sie eine Seite, um die Spalten zu fokussieren.
|
||||
</span>
|
||||
</div>
|
||||
{/* Auto-fill chip — populated by the client when a
|
||||
?project=<id> URL resolves a project with our_side
|
||||
@@ -233,19 +224,6 @@ export function renderVerfahrensablauf(): string {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/* Show-hidden toggle (t-paliad-290 / m/paliad#122).
|
||||
Re-surfaces optional cards the user has previously
|
||||
marked "Überspringen" via the per-card popover.
|
||||
The row hides itself when the projection has no
|
||||
hidden cards (handled in client/verfahrensablauf.ts).
|
||||
Default OFF; URL state ?show_hidden=1. */}
|
||||
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
|
||||
<label className="fristen-view-option">
|
||||
<input type="checkbox" id="show-hidden-toggle" />
|
||||
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
|
||||
</label>
|
||||
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite"> </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual divider — keeps the perspective block (most-
|
||||
|
||||
@@ -63,12 +63,6 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// wins (what-if exploration overrides the saved state).
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
PerCardChoices []services.UpsertEventChoiceInput `json:"perCardChoices,omitempty"`
|
||||
// t-paliad-290 (m/paliad#122): re-surface previously-hidden
|
||||
// optional cards. When true the calculator marks skipped rows
|
||||
// with UIDeadline.IsHidden instead of dropping them; descendants
|
||||
// stay in the result list. Default false preserves the legacy
|
||||
// suppression. HiddenCount on the response is independent.
|
||||
IncludeHidden bool `json:"includeHidden,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -115,7 +109,6 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
PerCardAppellant: addendum.PerCardAppellant,
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||
|
||||
@@ -458,7 +458,6 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// t-paliad-139 — set unit_role on a member.
|
||||
protected.HandleFunc("PATCH /api/partner-units/{id}/members/{user_id}/role", handleSetUnitMemberRole)
|
||||
|
||||
protected.HandleFunc("GET /api/parties/search", handlePartiesSearch)
|
||||
protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty)
|
||||
|
||||
// Phase F — Appointments (appointments)
|
||||
|
||||
@@ -701,31 +701,6 @@ func handleCreateParty(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
// GET /api/parties/search?q=...
|
||||
//
|
||||
// Cross-project party picker for the submission-draft editor
|
||||
// (t-paliad-287). Returns up to 25 parties from every project the
|
||||
// caller can see, matched by case-insensitive substring on name or
|
||||
// representative. Empty q returns the 20 most-recently-updated rows so
|
||||
// the picker isn't blank on first open. Visibility is enforced in the
|
||||
// service layer via the same predicate every project-scoped read uses.
|
||||
func handlePartiesSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
hits, err := dbSvc.parties.Search(r.Context(), uid, q, 25)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": hits})
|
||||
}
|
||||
|
||||
// DELETE /api/parties/{id}
|
||||
func handleDeleteParty(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
|
||||
@@ -131,12 +131,6 @@ type UIDeadline struct {
|
||||
// Frontend bucketer prefers this over the page-level appellant when
|
||||
// non-empty. (t-paliad-265)
|
||||
AppellantContext string `json:"appellantContext,omitempty"`
|
||||
// IsHidden marks a card the user has previously hidden via a
|
||||
// skip choice. Only ever true when CalcOptions.IncludeHidden is
|
||||
// set — the toggle re-surfaces these rows so the user can either
|
||||
// keep them faded for context or un-hide them via the inline
|
||||
// "Wieder einblenden" chip. (t-paliad-290 / m/paliad#122)
|
||||
IsHidden bool `json:"isHidden,omitempty"`
|
||||
}
|
||||
|
||||
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
|
||||
@@ -172,14 +166,6 @@ type UIResponse struct {
|
||||
// is the appealable first-instance decision (m/paliad#81).
|
||||
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
||||
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
||||
// HiddenCount is the number of rules whose submission_code is in
|
||||
// CalcOptions.SkipRules AND whose condition_expr gate passes —
|
||||
// i.e. how many rows the user has hidden in this projection
|
||||
// regardless of the IncludeHidden toggle state. The frontend uses
|
||||
// this to render the "Ausgeblendete (N)" badge on the toggle even
|
||||
// when the toggle is OFF (so users know there's something to
|
||||
// re-surface). (t-paliad-290 / m/paliad#122)
|
||||
HiddenCount int `json:"hiddenCount"`
|
||||
}
|
||||
|
||||
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
|
||||
@@ -257,19 +243,6 @@ type CalcOptions struct {
|
||||
PerCardAppellant map[string]string
|
||||
SkipRules map[string]struct{}
|
||||
IncludeCCRFor map[string]struct{}
|
||||
|
||||
// IncludeHidden re-surfaces rules whose submission_code is in
|
||||
// SkipRules (t-paliad-290 / m/paliad#122). When true:
|
||||
// - Skipped rules are NOT dropped from the result; they render
|
||||
// with UIDeadline.IsHidden=true so the frontend can fade them.
|
||||
// - Descendant suppression is bypassed (the skipped parent is
|
||||
// present in the result, so children compute their dates off
|
||||
// it as if the user had never hidden it).
|
||||
// Default false preserves the original skip semantic (drop rule +
|
||||
// suppress descendants). HiddenCount on UIResponse is independent
|
||||
// of this flag — it always reflects the number of hide-eligible
|
||||
// rows so the toggle's count badge stays accurate.
|
||||
IncludeHidden bool
|
||||
}
|
||||
|
||||
// Calculate renders the full UI timeline for a proceeding type + trigger date.
|
||||
@@ -461,13 +434,6 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
// child rule's parent has already been classified — so descendant
|
||||
// suppression is a one-pass parent_id lookup.
|
||||
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
||||
// hiddenCount counts rows whose submission_code is in skipRules
|
||||
// AND that pass the condition_expr gate — i.e. rows the user has
|
||||
// hidden in this projection. Surfaced on UIResponse.HiddenCount so
|
||||
// the frontend's "Ausgeblendete (N)" badge stays accurate even when
|
||||
// IncludeHidden is off and the rows aren't in the result list.
|
||||
// (t-paliad-290 / m/paliad#122)
|
||||
hiddenCount := 0
|
||||
// appellantContext maps a rule UUID to the appellant value that
|
||||
// applies to its descendants. A rule that has its own PerCardAppellant
|
||||
// pick stamps itself with that value; a rule whose parent has a
|
||||
@@ -490,22 +456,10 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
// this rule (or one of its ancestors) as "don't consider for
|
||||
// this case". Drop the row entirely AND record the rule ID so
|
||||
// descendants suppress too.
|
||||
//
|
||||
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
|
||||
// we re-surface the directly-skipped row (faded via IsHidden)
|
||||
// instead of dropping it. Descendants are NOT cascade-suppressed
|
||||
// in that mode either — the un-suppressed parent computes its
|
||||
// date normally, so children compute off it as usual. Either
|
||||
// way we count the hide for the toggle's badge.
|
||||
var isHidden bool
|
||||
if r.SubmissionCode != nil {
|
||||
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
|
||||
hiddenCount++
|
||||
if !opts.IncludeHidden {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
isHidden = true
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if r.ParentID != nil {
|
||||
@@ -541,7 +495,6 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
AppellantContext: ctxVal,
|
||||
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
|
||||
IsHidden: isHidden,
|
||||
}
|
||||
if r.SubmissionCode != nil {
|
||||
d.Code = *r.SubmissionCode
|
||||
@@ -873,7 +826,6 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
ProceedingNameEN: pickedProceeding.NameEN,
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
HiddenCount: hiddenCount,
|
||||
}
|
||||
// Sub-track routing keeps the user-picked proceeding's identity,
|
||||
// so the trigger-event label rides on `pickedProceeding` (e.g.
|
||||
|
||||
@@ -38,59 +38,6 @@ type CreatePartyInput struct {
|
||||
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
|
||||
}
|
||||
|
||||
// PartySearchHit is one row of the cross-project party search — a real
|
||||
// paliad.parties row enriched with the parent project's title and
|
||||
// reference so the picker can render context the lawyer needs to
|
||||
// disambiguate identically-named parties on different cases
|
||||
// (t-paliad-287).
|
||||
type PartySearchHit struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
ProjectTitle string `db:"project_title" json:"project_title"`
|
||||
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Role *string `db:"role" json:"role,omitempty"`
|
||||
Representative *string `db:"representative" json:"representative,omitempty"`
|
||||
}
|
||||
|
||||
// Search returns parties from every project the caller can see, matched
|
||||
// by case-insensitive substring on name OR representative. Empty query
|
||||
// returns the 20 most recently-updated parties so the picker isn't
|
||||
// blank on first open. Capped at 25 rows; the frontend doesn't paginate
|
||||
// (the typical PA looks for one party they remember by name, not browses).
|
||||
//
|
||||
// Visibility is enforced inline via visibilityPredicatePositional —
|
||||
// invisible projects' parties never surface in the result set.
|
||||
func (s *PartyService) Search(ctx context.Context, userID uuid.UUID, query string, limit int) ([]PartySearchHit, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 25
|
||||
}
|
||||
q := strings.TrimSpace(query)
|
||||
args := []any{userID}
|
||||
conds := []string{visibilityPredicatePositional("p", 1)}
|
||||
if q != "" {
|
||||
args = append(args, "%"+q+"%")
|
||||
conds = append(conds,
|
||||
fmt.Sprintf(`(pa.name ILIKE $%d OR COALESCE(pa.representative,'') ILIKE $%d)`,
|
||||
len(args), len(args)))
|
||||
}
|
||||
args = append(args, limit)
|
||||
sqlStr := `
|
||||
SELECT pa.id, pa.project_id, p.title AS project_title,
|
||||
p.reference AS project_reference,
|
||||
pa.name, pa.role, pa.representative
|
||||
FROM paliad.parties pa
|
||||
JOIN paliad.projects p ON p.id = pa.project_id
|
||||
WHERE ` + strings.Join(conds, " AND ") + `
|
||||
ORDER BY pa.updated_at DESC
|
||||
LIMIT $` + fmt.Sprintf("%d", len(args))
|
||||
hits := []PartySearchHit{}
|
||||
if err := s.db.SelectContext(ctx, &hits, sqlStr, args...); err != nil {
|
||||
return nil, fmt.Errorf("search parties: %w", err)
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
// ListForProject returns all Parties for the Project, visibility-checked.
|
||||
func (s *PartyService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Party, error) {
|
||||
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
||||
|
||||
@@ -315,11 +315,11 @@ func buildDocumentXML() string {
|
||||
body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})")
|
||||
body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}")
|
||||
|
||||
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
|
||||
// {{deadline.*}} placeholders stay resolvable in the variable bag
|
||||
// for custom templates that want them, but the default HL skeleton
|
||||
// no longer renders them in the submission body: the deadline is
|
||||
// internal/admin context and has no place in a court-bound document.
|
||||
headerSubsection(&b, "Frist")
|
||||
body0(&b, "Frist-Bezeichnung: {{deadline.title}}")
|
||||
body0(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
body0(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
body0(&b, "Berechnet aus: {{deadline.computed_from}} · Quelle: {{deadline.source}}")
|
||||
|
||||
heading(&b, "HLpat-Heading-H2", "I. Sachverhalt")
|
||||
body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]")
|
||||
@@ -349,6 +349,7 @@ func buildDocumentXML() string {
|
||||
body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}")
|
||||
body1(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||
body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}")
|
||||
body1(&b, "Deadline EN long: {{deadline.due_date_long_en}}")
|
||||
body1(&b, "Procedural event name (DE): {{procedural_event.name_de}} · (EN): {{procedural_event.name_en}}")
|
||||
body1(&b, "Rule legacy aliases — name: {{rule.name}}, name_de: {{rule.name_de}}, name_en: {{rule.name_en}}")
|
||||
body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}")
|
||||
|
||||
@@ -137,19 +137,14 @@ const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
</w:styles>`
|
||||
|
||||
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
|
||||
// case caption + parties + submission heading + a single neutral body
|
||||
// block. Mirrors the variable bag from SubmissionVarsService (firm.* /
|
||||
// today.* / user.* / project.* / parties.* / rule.*) without baking in
|
||||
// DE-LG-Klageerwiderung-specific structure. A lawyer customising this
|
||||
// template for a UPC SoC, EPO opposition, or DPMA appeal replaces the
|
||||
// [Schriftsatztext] block and renames the party labels — every
|
||||
// placeholder still resolves regardless of the submission_code chosen.
|
||||
//
|
||||
// The {{deadline.*}} placeholders are deliberately NOT rendered by the
|
||||
// default skeleton (t-paliad-287). The deadline is internal context for
|
||||
// the lawyer, not text that belongs in a court-bound submission. The
|
||||
// keys stay resolvable in the bag so a custom template can still
|
||||
// reference them where it actually wants them.
|
||||
// case caption + parties + submission heading + deadline + a single
|
||||
// neutral body block. Mirrors the variable bag from SubmissionVarsService
|
||||
// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
|
||||
// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
|
||||
// structure. A lawyer customising this template for a UPC SoC, EPO
|
||||
// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
|
||||
// renames the party labels — every placeholder still resolves regardless
|
||||
// of the submission_code chosen.
|
||||
//
|
||||
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
|
||||
// (format-preserving, single-run) substitution catches it. The
|
||||
@@ -199,12 +194,11 @@ func buildDocumentXML() string {
|
||||
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
|
||||
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
|
||||
|
||||
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
|
||||
// {{deadline.*}} placeholders stay resolvable in the variable bag
|
||||
// (lawyer can still drop them into a custom paragraph) but the
|
||||
// default skeleton no longer renders them in the submission body:
|
||||
// the deadline is internal/admin context and has no place in a
|
||||
// document going out to court.
|
||||
heading2(&b, "Frist")
|
||||
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
|
||||
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
|
||||
|
||||
heading2(&b, "Schriftsatztext")
|
||||
plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
|
||||
@@ -223,7 +217,7 @@ func buildDocumentXML() string {
|
||||
// the bare {{today}} alias. A lawyer customising the template can
|
||||
// delete this block; the renderer round-trips it cleanly today.
|
||||
heading2(&b, "Locale-aware variants (SKELETON)")
|
||||
plain(&b, "EN long date: {{today.long_en}}")
|
||||
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
|
||||
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
|
||||
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
|
||||
|
||||
Reference in New Issue
Block a user