Compare commits
12 Commits
mai/hermes
...
mai/knuth/
| Author | SHA1 | Date | |
|---|---|---|---|
| f6c8eb5bcf | |||
| 5ba4df9d55 | |||
| 7ca6b2d643 | |||
| ed8af0dca9 | |||
| 293e612582 | |||
| 9d3325bd88 | |||
| 07d2eb472c | |||
| 7cdccd55ae | |||
| d4ed989b8f | |||
| 54fb676db5 | |||
| c3eaa9b1d4 | |||
| 80883eaac5 |
@@ -254,6 +254,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
"deadlines.party.both.label": "beide Seiten",
|
"deadlines.party.both.label": "beide Seiten",
|
||||||
"deadlines.court.set": "vom Gericht bestimmt",
|
"deadlines.court.set": "vom Gericht bestimmt",
|
||||||
"deadlines.court.indirect": "unbestimmt",
|
"deadlines.court.indirect": "unbestimmt",
|
||||||
|
"deadlines.conditional.depends_on": "abhängig von {parent}",
|
||||||
|
"deadlines.conditional.unset": "abhängig von vorgelagertem Ereignis",
|
||||||
"deadlines.optional.badge": "auf Antrag",
|
"deadlines.optional.badge": "auf Antrag",
|
||||||
"deadlines.priority.mandatory": "Pflicht",
|
"deadlines.priority.mandatory": "Pflicht",
|
||||||
"deadlines.priority.recommended": "empfohlen",
|
"deadlines.priority.recommended": "empfohlen",
|
||||||
@@ -325,6 +327,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
"choices.include_ccr.chip": "mit Nichtigkeitswiderklage",
|
"choices.include_ccr.chip": "mit Nichtigkeitswiderklage",
|
||||||
"choices.reset": "Auswahl zurücksetzen",
|
"choices.reset": "Auswahl zurücksetzen",
|
||||||
"choices.commit.error": "Konnte Auswahl nicht speichern",
|
"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)
|
// Trigger-event mode (PR-2 \u2014 youpc-parity)
|
||||||
"deadlines.mode.procedure": "Verfahrensablauf",
|
"deadlines.mode.procedure": "Verfahrensablauf",
|
||||||
"deadlines.mode.event": "Was kommt nach\u2026",
|
"deadlines.mode.event": "Was kommt nach\u2026",
|
||||||
@@ -1498,7 +1508,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
// t-paliad-277 — import-from-project + party-picker.
|
// t-paliad-277 — import-from-project + party-picker.
|
||||||
"submissions.draft.import.button": "Aus Projekt importieren",
|
"submissions.draft.import.button": "Aus Projekt importieren",
|
||||||
"submissions.draft.parties.title": "Parteien",
|
"submissions.draft.parties.title": "Parteien",
|
||||||
"submissions.draft.parties.hint": "Wählen Sie aus, welche Parteien im Schriftsatz genannt werden sollen.",
|
"submissions.draft.parties.hint": "Wählen Sie die im Schriftsatz genannten Parteien oder fügen Sie pro Seite weitere hinzu.",
|
||||||
// t-paliad-276 — DE/EN language toggle on the draft editor.
|
// t-paliad-276 — DE/EN language toggle on the draft editor.
|
||||||
"submissions.draft.language": "Sprache",
|
"submissions.draft.language": "Sprache",
|
||||||
"submissions.draft.language.de": "DE",
|
"submissions.draft.language.de": "DE",
|
||||||
@@ -3352,6 +3362,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
"deadlines.party.both.label": "both parties",
|
"deadlines.party.both.label": "both parties",
|
||||||
"deadlines.court.set": "set by court",
|
"deadlines.court.set": "set by court",
|
||||||
"deadlines.court.indirect": "tbd",
|
"deadlines.court.indirect": "tbd",
|
||||||
|
"deadlines.conditional.depends_on": "depends on {parent}",
|
||||||
|
"deadlines.conditional.unset": "depends on an upstream event",
|
||||||
"deadlines.optional.badge": "on request",
|
"deadlines.optional.badge": "on request",
|
||||||
"deadlines.priority.mandatory": "Mandatory",
|
"deadlines.priority.mandatory": "Mandatory",
|
||||||
"deadlines.priority.recommended": "Recommended",
|
"deadlines.priority.recommended": "Recommended",
|
||||||
@@ -3423,6 +3435,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
"choices.include_ccr.chip": "with nullity counterclaim",
|
"choices.include_ccr.chip": "with nullity counterclaim",
|
||||||
"choices.reset": "Reset choice",
|
"choices.reset": "Reset choice",
|
||||||
"choices.commit.error": "Could not save selection",
|
"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": "Adjusted",
|
||||||
"deadlines.adjusted.reason": "weekend/holiday",
|
"deadlines.adjusted.reason": "weekend/holiday",
|
||||||
"deadlines.adjusted.weekend": "weekend",
|
"deadlines.adjusted.weekend": "weekend",
|
||||||
@@ -4582,7 +4602,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
// t-paliad-277 — import-from-project + party-picker.
|
// t-paliad-277 — import-from-project + party-picker.
|
||||||
"submissions.draft.import.button": "Import from project",
|
"submissions.draft.import.button": "Import from project",
|
||||||
"submissions.draft.parties.title": "Parties",
|
"submissions.draft.parties.title": "Parties",
|
||||||
"submissions.draft.parties.hint": "Select which parties to mention in this submission.",
|
"submissions.draft.parties.hint": "Pick the parties mentioned in this submission, or add more per side.",
|
||||||
// t-paliad-240 — global submissions drafts index page.
|
// t-paliad-240 — global submissions drafts index page.
|
||||||
"submissions.index.title": "Submissions — Paliad",
|
"submissions.index.title": "Submissions — Paliad",
|
||||||
"submissions.index.heading": "Submissions",
|
"submissions.index.heading": "Submissions",
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ interface VariableGroup {
|
|||||||
id: string;
|
id: string;
|
||||||
label: VariableLabel;
|
label: VariableLabel;
|
||||||
keys: string[];
|
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> = {
|
const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||||
@@ -205,33 +212,19 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
|||||||
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
|
"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[] = [
|
const VARIABLE_GROUPS: VariableGroup[] = [
|
||||||
{
|
{
|
||||||
id: "procedural_event",
|
id: "mandant_verfahren",
|
||||||
label: { de: "Verfahrensschritt", en: "Procedural event" },
|
label: { de: "Mandant & Verfahren", en: "Client & proceeding" },
|
||||||
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: [
|
keys: [
|
||||||
"project.title",
|
"project.title",
|
||||||
"project.case_number",
|
"project.case_number",
|
||||||
@@ -246,11 +239,43 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
|||||||
"project.matter_number",
|
"project.matter_number",
|
||||||
"project.reference",
|
"project.reference",
|
||||||
"project.instance_level",
|
"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",
|
id: "deadline",
|
||||||
label: { de: "Frist", en: "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,
|
||||||
keys: [
|
keys: [
|
||||||
"deadline.due_date",
|
"deadline.due_date",
|
||||||
"deadline.due_date_long_de",
|
"deadline.due_date_long_de",
|
||||||
@@ -261,10 +286,11 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "firm",
|
id: "sonstiges",
|
||||||
label: { de: "Kanzlei & Datum", en: "Firm & date" },
|
label: { de: "Sonstiges", en: "Other" },
|
||||||
keys: [
|
keys: [
|
||||||
"firm.name",
|
"firm.name",
|
||||||
|
"firm.signature_block",
|
||||||
"user.display_name",
|
"user.display_name",
|
||||||
"user.email",
|
"user.email",
|
||||||
"user.office",
|
"user.office",
|
||||||
@@ -291,6 +317,29 @@ interface State {
|
|||||||
saveTimer: number | null;
|
saveTimer: number | null;
|
||||||
pendingOverrides: Record<string, string> | null;
|
pendingOverrides: Record<string, string> | null;
|
||||||
inFlight: AbortController | 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 = {
|
const state: State = {
|
||||||
@@ -300,6 +349,11 @@ const state: State = {
|
|||||||
saveTimer: null,
|
saveTimer: null,
|
||||||
pendingOverrides: null,
|
pendingOverrides: null,
|
||||||
inFlight: null,
|
inFlight: null,
|
||||||
|
collapsedGroups: {},
|
||||||
|
addPartyOpen: null,
|
||||||
|
addPartyMode: "manual",
|
||||||
|
addPartySearchHits: [],
|
||||||
|
addPartyBusy: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -607,24 +661,31 @@ function paintImportRow(): void {
|
|||||||
btn.onclick = () => { void onImportFromProject(btn); };
|
btn.onclick = () => { void onImportFromProject(btn); };
|
||||||
}
|
}
|
||||||
|
|
||||||
// t-paliad-277 — multi-select party picker. Lists every party on the
|
// t-paliad-277 / t-paliad-287 — multi-select party picker plus Add-
|
||||||
// draft's project (view.available_parties), grouped by role, with one
|
// Party affordance per side. Lists every party on the draft's project
|
||||||
// checkbox per party. Checked = include in the variable bag. Empty
|
// (view.available_parties), grouped by role, with one checkbox per
|
||||||
// selection falls back to the legacy "include every party" default
|
// party. Each side (Klägerseite / Beklagtenseite / Sonstige) carries
|
||||||
// (consistent with the migration default).
|
// 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.
|
||||||
function paintPartyPicker(): void {
|
function paintPartyPicker(): void {
|
||||||
const block = document.getElementById("submission-draft-parties");
|
const block = document.getElementById("submission-draft-parties");
|
||||||
const list = document.getElementById("submission-draft-parties-list");
|
const list = document.getElementById("submission-draft-parties-list");
|
||||||
if (!block || !list || !state.view) return;
|
if (!block || !list || !state.view) return;
|
||||||
|
|
||||||
const parties = state.view.available_parties ?? [];
|
// t-paliad-287 — picker is now shown even on empty-roster projects so
|
||||||
if (!state.view.draft.project_id || parties.length === 0) {
|
// 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) {
|
||||||
block.style.display = "none";
|
block.style.display = "none";
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
block.style.display = "";
|
block.style.display = "";
|
||||||
|
|
||||||
|
const parties = state.view.available_parties ?? [];
|
||||||
const selected = new Set(state.view.draft.selected_parties ?? []);
|
const selected = new Set(state.view.draft.selected_parties ?? []);
|
||||||
// Empty selection is the implicit "all" default — pre-check every
|
// Empty selection is the implicit "all" default — pre-check every
|
||||||
// party so the lawyer can see what's currently being mentioned and
|
// party so the lawyer can see what's currently being mentioned and
|
||||||
@@ -637,9 +698,13 @@ function paintPartyPicker(): void {
|
|||||||
const grouped = groupPartiesByRole(parties);
|
const grouped = groupPartiesByRole(parties);
|
||||||
let html = "";
|
let html = "";
|
||||||
for (const group of grouped) {
|
for (const group of grouped) {
|
||||||
if (group.parties.length === 0) continue;
|
|
||||||
html += `<fieldset class="submission-draft-parties-group" data-role-bucket="${group.bucket}">`;
|
html += `<fieldset class="submission-draft-parties-group" data-role-bucket="${group.bucket}">`;
|
||||||
html += `<legend>${escapeHtml(group.label)}</legend>`;
|
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) {
|
for (const p of group.parties) {
|
||||||
const checked = effective.has(p.id) ? " checked" : "";
|
const checked = effective.has(p.id) ? " checked" : "";
|
||||||
const chip = p.role
|
const chip = p.role
|
||||||
@@ -658,6 +723,7 @@ function paintPartyPicker(): void {
|
|||||||
html += rep;
|
html += rep;
|
||||||
html += `</label>`;
|
html += `</label>`;
|
||||||
}
|
}
|
||||||
|
html += renderAddPartyControls(group.bucket);
|
||||||
html += `</fieldset>`;
|
html += `</fieldset>`;
|
||||||
}
|
}
|
||||||
list.innerHTML = html;
|
list.innerHTML = html;
|
||||||
@@ -665,6 +731,198 @@ function paintPartyPicker(): void {
|
|||||||
list.querySelectorAll<HTMLInputElement>(".submission-draft-party-check").forEach((inp) => {
|
list.querySelectorAll<HTMLInputElement>(".submission-draft-party-check").forEach((inp) => {
|
||||||
inp.addEventListener("change", () => onPartySelectionChange());
|
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 {
|
interface PartyRoleGroup {
|
||||||
@@ -781,8 +1039,27 @@ function paintVariables(): void {
|
|||||||
let html = "";
|
let html = "";
|
||||||
for (const group of VARIABLE_GROUPS) {
|
for (const group of VARIABLE_GROUPS) {
|
||||||
const groupLabel = isEN() ? group.label.en : group.label.de;
|
const groupLabel = isEN() ? group.label.en : group.label.de;
|
||||||
html += `<section class="submission-draft-var-group" data-group="${group.id}">`;
|
// Re-use the user's prior toggle state across paintVariables calls
|
||||||
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
|
// (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">`;
|
||||||
for (const key of group.keys) {
|
for (const key of group.keys) {
|
||||||
const label = labelFor(key);
|
const label = labelFor(key);
|
||||||
const override = overrides[key];
|
const override = overrides[key];
|
||||||
@@ -813,10 +1090,19 @@ function paintVariables(): void {
|
|||||||
// Visual hint: marker text appears in preview when override is "".
|
// Visual hint: marker text appears in preview when override is "".
|
||||||
void mergedVal;
|
void mergedVal;
|
||||||
}
|
}
|
||||||
|
html += `</div>`;
|
||||||
html += `</section>`;
|
html += `</section>`;
|
||||||
}
|
}
|
||||||
host.innerHTML = html;
|
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) => {
|
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
|
||||||
inp.addEventListener("input", () => onVarChange(inp));
|
inp.addEventListener("input", () => onVarChange(inp));
|
||||||
// t-paliad-274 (B) — focus into a sidebar field highlights every
|
// t-paliad-274 (B) — focus into a sidebar field highlights every
|
||||||
@@ -1021,6 +1307,175 @@ 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> {
|
async function onImportFromProject(btn: HTMLButtonElement): Promise<void> {
|
||||||
if (!state.view) return;
|
if (!state.view) return;
|
||||||
const draftID = state.view.draft.id;
|
const draftID = state.view.draft.id;
|
||||||
|
|||||||
@@ -143,6 +143,25 @@ function writeChoicesToURL(choices: EventChoice[]) {
|
|||||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
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";
|
type ProcedureView = "timeline" | "columns";
|
||||||
let procedureView: ProcedureView = "columns";
|
let procedureView: ProcedureView = "columns";
|
||||||
|
|
||||||
@@ -256,14 +275,33 @@ async function doCalc() {
|
|||||||
anchorOverrides: overrides,
|
anchorOverrides: overrides,
|
||||||
courtId,
|
courtId,
|
||||||
perCardChoices,
|
perCardChoices,
|
||||||
|
includeHidden: showHidden,
|
||||||
});
|
});
|
||||||
if (seq !== calcSeq) return;
|
if (seq !== calcSeq) return;
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
lastResponse = data;
|
lastResponse = data;
|
||||||
renderResults(data);
|
renderResults(data);
|
||||||
|
syncHiddenBadge(data.hiddenCount ?? 0);
|
||||||
showStep(3);
|
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"
|
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
|
||||||
// label from the calc response. Precedence:
|
// label from the calc response. Precedence:
|
||||||
//
|
//
|
||||||
@@ -711,6 +749,20 @@ 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();
|
initViewToggle();
|
||||||
initPerspectiveControls();
|
initPerspectiveControls();
|
||||||
|
|
||||||
|
|||||||
@@ -74,10 +74,11 @@ export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
|
|||||||
states.set(opts.container, state);
|
states.set(opts.container, state);
|
||||||
|
|
||||||
opts.container.addEventListener("click", (e) => {
|
opts.container.addEventListener("click", (e) => {
|
||||||
const target = (e.target as HTMLElement | null)?.closest<HTMLElement>(".event-card-choices-caret");
|
const targetEl = e.target as HTMLElement | null;
|
||||||
if (target) {
|
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
|
||||||
|
if (caret) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openPopover(state, target);
|
openPopover(state, caret);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Outside-click closes the popover.
|
// Outside-click closes the popover.
|
||||||
@@ -158,6 +159,7 @@ function openPopover(state: AttachedState, caret: HTMLElement): void {
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const isHidden = caret.dataset.isHidden === "1";
|
||||||
|
|
||||||
const pop = document.createElement("div");
|
const pop = document.createElement("div");
|
||||||
pop.className = "event-card-choices-popover";
|
pop.className = "event-card-choices-popover";
|
||||||
@@ -165,6 +167,15 @@ function openPopover(state: AttachedState, caret: HTMLElement): void {
|
|||||||
pop.setAttribute("aria-label", t("choices.caret.title"));
|
pop.setAttribute("aria-label", t("choices.caret.title"));
|
||||||
|
|
||||||
const blocks: string[] = [];
|
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)) {
|
if (Array.isArray(offered.appellant)) {
|
||||||
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
|
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
|
||||||
}
|
}
|
||||||
@@ -259,6 +270,23 @@ function renderToggleBlock(state: AttachedState, code: string, kind: "include_cc
|
|||||||
</div>`;
|
</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 {
|
function closePopover(state: AttachedState): void {
|
||||||
if (state.popover) {
|
if (state.popover) {
|
||||||
state.popover.remove();
|
state.popover.remove();
|
||||||
|
|||||||
@@ -67,6 +67,153 @@ 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
|
||||||
|
// resolves (oral hearing scheduled, opposing party's motion received, …).
|
||||||
|
// Mirrors Symptom A (R.109(1) backward-anchor without oral-hearing date)
|
||||||
|
// and Symptom B (R.262(2) without recorded Vertraulichkeitsantrag) from
|
||||||
|
// the issue.
|
||||||
|
describe("deadlineCardHtml — isConditional rendering (t-paliad-289)", () => {
|
||||||
|
test("isConditional + parentRuleName emits 'abhängig von <parent>' chip with click-to-edit", () => {
|
||||||
|
const html = deadlineCardHtml(
|
||||||
|
dl({
|
||||||
|
code: "upc.inf.cfi.translation_request",
|
||||||
|
isConditional: true,
|
||||||
|
parentRuleCode: "upc.inf.cfi.oral",
|
||||||
|
parentRuleName: "Mündliche Verhandlung",
|
||||||
|
}),
|
||||||
|
{ showParty: true, editable: true },
|
||||||
|
);
|
||||||
|
expect(html).toContain("timeline-conditional");
|
||||||
|
expect(html).toContain("abhängig von Mündliche Verhandlung");
|
||||||
|
expect(html).toContain('data-rule-code="upc.inf.cfi.translation_request"');
|
||||||
|
expect(html).toContain('role="button"');
|
||||||
|
expect(html).not.toContain("timeline-court-set");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isConditional with no parentRuleName falls back to generic upstream-event label", () => {
|
||||||
|
const html = deadlineCardHtml(
|
||||||
|
dl({ isConditional: true }),
|
||||||
|
{ showParty: true, editable: true },
|
||||||
|
);
|
||||||
|
expect(html).toContain("timeline-conditional");
|
||||||
|
expect(html).toContain("abhängig von vorgelagertem Ereignis");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isConditional wins over isCourtSet — overlapping cases render conditional chip", () => {
|
||||||
|
// Court-set ancestor without override sets BOTH isCourtSet=true AND
|
||||||
|
// isConditional=true on the wire. The renderer must pick the
|
||||||
|
// conditional chip; otherwise the row keeps the legacy "wird vom
|
||||||
|
// Gericht bestimmt" label and the user can't see WHICH upstream
|
||||||
|
// event blocks them.
|
||||||
|
const html = deadlineCardHtml(
|
||||||
|
dl({
|
||||||
|
isConditional: true,
|
||||||
|
isCourtSet: true,
|
||||||
|
isCourtSetIndirect: true,
|
||||||
|
parentRuleName: "Entscheidung",
|
||||||
|
}),
|
||||||
|
{ showParty: true, editable: true },
|
||||||
|
);
|
||||||
|
expect(html).toContain("abhängig von Entscheidung");
|
||||||
|
expect(html).not.toContain("timeline-court-set");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isConditional=false keeps the normal date span (regression guard)", () => {
|
||||||
|
const html = deadlineCardHtml(dl({ isConditional: false }), { showParty: true });
|
||||||
|
expect(html).toContain("timeline-date");
|
||||||
|
expect(html).not.toContain("timeline-conditional");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Pure column-routing behaviour. Originally pinned by m/paliad#81
|
// Pure column-routing behaviour. Originally pinned by m/paliad#81
|
||||||
// (side + appellant axes), re-framed by m/paliad#88: the column
|
// (side + appellant axes), re-framed by m/paliad#88: the column
|
||||||
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
|
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
|
||||||
|
|||||||
@@ -72,6 +72,29 @@ export interface CalculatedDeadline {
|
|||||||
// page-level appellant axis still applies in that case). The bucketer
|
// page-level appellant axis still applies in that case). The bucketer
|
||||||
// reads this in preference to the page-level appellant.
|
// reads this in preference to the page-level appellant.
|
||||||
appellantContext?: string;
|
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-
|
||||||
|
// anchored rule's forward anchor isn't set, or for optional rules
|
||||||
|
// whose true triggering event sits outside the rule data (e.g.
|
||||||
|
// R.262(2) Erwiderung auf Vertraulichkeitsantrag — anchored on SoC
|
||||||
|
// in the data, but the real trigger is the opposing party's
|
||||||
|
// confidentiality motion). The renderer drops the date column entry
|
||||||
|
// and shows an "abhängig von <parentRuleName>" chip instead.
|
||||||
|
isConditional?: boolean;
|
||||||
|
// parentRuleCode / parentRuleName / parentRuleNameEN surface the
|
||||||
|
// parent rule's identity so the renderer can label the
|
||||||
|
// "abhängig von <parent>" chip on conditional rows. Populated for
|
||||||
|
// every rule with a parent (not just conditional ones), so the
|
||||||
|
// dependency-footer logic can reuse it. Empty for root rules.
|
||||||
|
parentRuleCode?: string;
|
||||||
|
parentRuleName?: string;
|
||||||
|
parentRuleNameEN?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// priorityRendering returns the per-priority UX hints the save-modal
|
// priorityRendering returns the per-priority UX hints the save-modal
|
||||||
@@ -131,6 +154,13 @@ export interface DeadlineResponse {
|
|||||||
// (m/paliad#81)
|
// (m/paliad#81)
|
||||||
triggerEventLabel?: string;
|
triggerEventLabel?: string;
|
||||||
triggerEventLabelEN?: 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 {
|
export interface CourtRow {
|
||||||
@@ -160,6 +190,11 @@ export interface CalcParams {
|
|||||||
choice_kind: string;
|
choice_kind: string;
|
||||||
choice_value: 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> = {
|
const PARTY_CLASS: Record<string, string> = {
|
||||||
@@ -175,10 +210,20 @@ export function escAttr(s: string): string {
|
|||||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pure-string HTML escape — keeps the module testable in bun test
|
||||||
|
// (plain Node, no jsdom). Used to be backed by document.createElement,
|
||||||
|
// which forced fixtures to leave any field that flowed through it
|
||||||
|
// empty just to exercise unrelated branches; the regex form is safe
|
||||||
|
// for arbitrary text including the per-rule name strings that the
|
||||||
|
// conditional-row chip ("abhängig von <parent>") now exposes.
|
||||||
|
// (t-paliad-289)
|
||||||
export function escHtml(s: string): string {
|
export function escHtml(s: string): string {
|
||||||
const d = document.createElement("div");
|
return s
|
||||||
d.textContent = s;
|
.replace(/&/g, "&")
|
||||||
return d.innerHTML;
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(dateStr: string): string {
|
export function formatDate(dateStr: string): string {
|
||||||
@@ -279,28 +324,76 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
|||||||
const editAttrs = editable
|
const editAttrs = editable
|
||||||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
|
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
|
||||||
: "";
|
: "";
|
||||||
const courtLabelKey = dl.isCourtSetIndirect
|
// Conditional rows (t-paliad-289) replace the date column with an
|
||||||
? "deadlines.court.indirect"
|
// "abhängig von <parent>" chip. The chip remains click-to-edit so
|
||||||
: "deadlines.court.set";
|
// the user can pin a real date once known (e.g. once the oral
|
||||||
const dateStr = dl.isCourtSet
|
// hearing date is set, or the opposing party's Vertraulichkeits-
|
||||||
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
|
// antrag arrives) — the same data-rule-code wiring fires the
|
||||||
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
// existing inline date editor. IsConditional wins over IsCourtSet:
|
||||||
|
// they overlap (court-set ancestor without override produces both),
|
||||||
|
// and "abhängig von <parent>" is the clearer user-facing signal.
|
||||||
|
const parentLabel = (getLang() === "en"
|
||||||
|
? (dl.parentRuleNameEN || dl.parentRuleName)
|
||||||
|
: dl.parentRuleName) || "";
|
||||||
|
let dateStr: string;
|
||||||
|
if (dl.isConditional) {
|
||||||
|
const chipText = parentLabel
|
||||||
|
? tDyn("deadlines.conditional.depends_on").replace("{parent}", escHtml(parentLabel))
|
||||||
|
: t("deadlines.conditional.unset");
|
||||||
|
dateStr = `<span class="timeline-conditional frist-date-edit"${editAttrs}>${chipText}</span>`;
|
||||||
|
} else if (dl.isCourtSet) {
|
||||||
|
const courtLabelKey = dl.isCourtSetIndirect
|
||||||
|
? "deadlines.court.indirect"
|
||||||
|
: "deadlines.court.set";
|
||||||
|
dateStr = `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`;
|
||||||
|
} else {
|
||||||
|
dateStr = `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Slice 9 (t-paliad-195): the legacy boolean pair is gone — read
|
// t-paliad-293 — iconified state markers. The card surface speaks
|
||||||
// priority directly. Optional badge fires only on 'optional'
|
// "cut the tree of possibilities": each card carries 0–N small icons
|
||||||
// priority (RoP.151-style opt-in deadlines).
|
// in the title row that summarise its decision state at a glance.
|
||||||
const mandatoryBadge = dl.priority === "optional"
|
// The text "optional" badge that used to sit inline next to the name
|
||||||
? '<span class="optional-badge">optional</span>'
|
// 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("");
|
||||||
|
|
||||||
// t-paliad-265 — caret affordance + chip indicator when this rule
|
// t-paliad-265 — caret affordance + chip indicator when this rule
|
||||||
// offers per-card choices and the user has made a pick. The popover
|
// offers per-card choices and the user has made a pick. The popover
|
||||||
// open/commit lifecycle lives in client/views/event-card-choices.ts;
|
// open/commit lifecycle lives in client/views/event-card-choices.ts;
|
||||||
// the data-* attributes here are the wire contract between the two.
|
// the data-* attributes here are the wire contract between the two.
|
||||||
const choicesHtml = dl.code !== "" && dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0
|
//
|
||||||
|
// 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
|
||||||
? `<button type="button" class="event-card-choices-caret"
|
? `<button type="button" class="event-card-choices-caret"
|
||||||
data-submission-code="${escAttr(dl.code)}"
|
data-submission-code="${escAttr(dl.code)}"
|
||||||
data-choices-offered="${escAttr(JSON.stringify(dl.choicesOffered))}"
|
data-choices-offered="${escAttr(JSON.stringify(offeredForCaret))}"
|
||||||
|
data-is-hidden="${dl.isHidden ? "1" : "0"}"
|
||||||
aria-label="${escAttr(t("choices.caret.title"))}"
|
aria-label="${escAttr(t("choices.caret.title"))}"
|
||||||
title="${escAttr(t("choices.caret.title"))}">▾</button>`
|
title="${escAttr(t("choices.caret.title"))}">▾</button>`
|
||||||
: "";
|
: "";
|
||||||
@@ -354,7 +447,7 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
|||||||
return `<div class="timeline-item-header">
|
return `<div class="timeline-item-header">
|
||||||
<span class="timeline-name">
|
<span class="timeline-name">
|
||||||
${dlName}
|
${dlName}
|
||||||
${mandatoryBadge}
|
${stateIconsHtml}
|
||||||
${chipHtml}
|
${chipHtml}
|
||||||
</span>
|
</span>
|
||||||
${dateStr}
|
${dateStr}
|
||||||
@@ -449,8 +542,20 @@ export function wireDateEditClicks(
|
|||||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||||
let html = '<div class="timeline">';
|
let html = '<div class="timeline">';
|
||||||
for (const dl of data.deadlines) {
|
for (const dl of data.deadlines) {
|
||||||
|
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.
|
||||||
|
dl.isConditional ? "timeline-item--conditional" : "",
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
html += `
|
html += `
|
||||||
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
|
<div class="${itemClasses}">
|
||||||
<div class="timeline-dot-col">
|
<div class="timeline-dot-col">
|
||||||
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
||||||
<div class="timeline-line"></div>
|
<div class="timeline-line"></div>
|
||||||
@@ -629,7 +734,17 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
|||||||
const mirrorTag = showMirrorTag && dl.party === "both"
|
const mirrorTag = showMirrorTag && dl.party === "both"
|
||||||
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
|
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||||
: "";
|
: "";
|
||||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
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" : "",
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
return `<div class="${itemClasses}">
|
||||||
${deadlineCardHtml(dl, cardOpts)}
|
${deadlineCardHtml(dl, cardOpts)}
|
||||||
${mirrorTag}
|
${mirrorTag}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -680,6 +795,7 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
|||||||
perCardChoices: params.perCardChoices && params.perCardChoices.length > 0
|
perCardChoices: params.perCardChoices && params.perCardChoices.length > 0
|
||||||
? params.perCardChoices
|
? params.perCardChoices
|
||||||
: undefined,
|
: undefined,
|
||||||
|
includeHidden: params.includeHidden ? true : undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
|||||||
@@ -1021,10 +1021,13 @@ export type I18nKey =
|
|||||||
| "choices.include_ccr.title"
|
| "choices.include_ccr.title"
|
||||||
| "choices.include_ccr.true"
|
| "choices.include_ccr.true"
|
||||||
| "choices.reset"
|
| "choices.reset"
|
||||||
|
| "choices.show_hidden.count"
|
||||||
|
| "choices.show_hidden.label"
|
||||||
| "choices.skip.false"
|
| "choices.skip.false"
|
||||||
| "choices.skip.title"
|
| "choices.skip.title"
|
||||||
| "choices.skip.true"
|
| "choices.skip.true"
|
||||||
| "choices.skipped.chip"
|
| "choices.skipped.chip"
|
||||||
|
| "choices.unhide.chip"
|
||||||
| "common.cancel"
|
| "common.cancel"
|
||||||
| "common.close"
|
| "common.close"
|
||||||
| "common.forbidden"
|
| "common.forbidden"
|
||||||
@@ -1235,6 +1238,8 @@ export type I18nKey =
|
|||||||
| "deadlines.col.title"
|
| "deadlines.col.title"
|
||||||
| "deadlines.complete.action"
|
| "deadlines.complete.action"
|
||||||
| "deadlines.complete.confirm"
|
| "deadlines.complete.confirm"
|
||||||
|
| "deadlines.conditional.depends_on"
|
||||||
|
| "deadlines.conditional.unset"
|
||||||
| "deadlines.court.indirect"
|
| "deadlines.court.indirect"
|
||||||
| "deadlines.court.label"
|
| "deadlines.court.label"
|
||||||
| "deadlines.court.set"
|
| "deadlines.court.set"
|
||||||
@@ -2616,6 +2621,8 @@ export type I18nKey =
|
|||||||
| "search.no_results"
|
| "search.no_results"
|
||||||
| "search.placeholder"
|
| "search.placeholder"
|
||||||
| "sidebar.resize.title"
|
| "sidebar.resize.title"
|
||||||
|
| "state.hidden.tooltip"
|
||||||
|
| "state.optional.tooltip"
|
||||||
| "submissions.draft.action.delete"
|
| "submissions.draft.action.delete"
|
||||||
| "submissions.draft.action.export"
|
| "submissions.draft.action.export"
|
||||||
| "submissions.draft.action.new"
|
| "submissions.draft.action.new"
|
||||||
|
|||||||
@@ -3332,7 +3332,11 @@ input[type="range"]::-moz-range-thumb {
|
|||||||
.timeline-item {
|
.timeline-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-height: 4rem;
|
/* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item:last-child .timeline-line {
|
.timeline-item:last-child .timeline-line {
|
||||||
@@ -3373,19 +3377,37 @@ input[type="range"]::-moz-range-thumb {
|
|||||||
|
|
||||||
.timeline-content {
|
.timeline-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding-bottom: 1rem;
|
/* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item-header {
|
.timeline-item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-name {
|
.timeline-name {
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 500;
|
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 {
|
.timeline-date {
|
||||||
@@ -3471,15 +3493,37 @@ input[type="range"]::-moz-range-thumb {
|
|||||||
color: var(--status-neutral-fg-3);
|
color: var(--status-neutral-fg-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.optional-badge {
|
/* t-paliad-293 — compact state icons in the card title row. They
|
||||||
font-size: 0.68rem;
|
* replace the legacy `.optional-badge` text chip and add a uniform
|
||||||
font-weight: 500;
|
* language for the per-card decision state ("cut the tree of
|
||||||
padding: 0.05rem 0.4rem;
|
* possibilities"). Each icon carries its own modifier so the tint
|
||||||
border-radius: 99px;
|
* matches the state semantic. The glyph itself is the primary signal;
|
||||||
background: var(--status-amber-bg);
|
* 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 {
|
||||||
color: var(--status-amber-fg);
|
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
|
/* 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
|
* the card header next to the date; the chip surfaces the active pick
|
||||||
* inline with the title; the popover is body-attached and positioned
|
* inline with the title; the popover is body-attached and positioned
|
||||||
@@ -3535,6 +3579,96 @@ input[type="range"]::-moz-range-thumb {
|
|||||||
opacity: 0.55;
|
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
|
||||||
|
column makes the conditional state explicit; the dotted border on
|
||||||
|
the content panel + slight desaturation reinforces it at glance so
|
||||||
|
the row reads as "pending an upstream input" rather than as a real
|
||||||
|
scheduled item. The frist-date-edit affordance on the chip still
|
||||||
|
wires through — the user can pin a concrete date once the anchor
|
||||||
|
resolves. */
|
||||||
|
.timeline-item--conditional .timeline-content,
|
||||||
|
.fr-col-item--conditional {
|
||||||
|
border: 1px dashed var(--color-border, #d4d4d4);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.35rem 0.55rem;
|
||||||
|
background: var(--color-bg-soft, #fafafa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item--conditional .timeline-name,
|
||||||
|
.fr-col-item--conditional .timeline-name {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-conditional {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.event-card-choices-popover {
|
.event-card-choices-popover {
|
||||||
background: var(--color-bg, #fff);
|
background: var(--color-bg, #fff);
|
||||||
border: 1px solid var(--color-border, #d4d4d4);
|
border: 1px solid var(--color-border, #d4d4d4);
|
||||||
@@ -6399,6 +6533,194 @@ dialog.modal::backdrop {
|
|||||||
margin-left: 0.25rem;
|
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 {
|
.checklist-instance-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
|
|||||||
@@ -172,10 +172,13 @@ export function renderSubmissionDraft(): string {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* t-paliad-277: multi-select party picker.
|
{/* t-paliad-277 / t-paliad-287: multi-select party
|
||||||
|
picker plus per-side Add-Party affordance.
|
||||||
Populated from view.available_parties; checkbox
|
Populated from view.available_parties; checkbox
|
||||||
per party, grouped by role. Hidden when no
|
per party, grouped by role. Hidden when no
|
||||||
project or no parties on the project. */}
|
project is attached; visible even on empty
|
||||||
|
rosters so the lawyer can use Add Party to
|
||||||
|
populate. */}
|
||||||
<div
|
<div
|
||||||
id="submission-draft-parties"
|
id="submission-draft-parties"
|
||||||
className="submission-draft-parties"
|
className="submission-draft-parties"
|
||||||
|
|||||||
@@ -233,6 +233,19 @@ export function renderVerfahrensablauf(): string {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Visual divider — keeps the perspective block (most-
|
{/* Visual divider — keeps the perspective block (most-
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
// wins (what-if exploration overrides the saved state).
|
// wins (what-if exploration overrides the saved state).
|
||||||
ProjectID string `json:"projectId,omitempty"`
|
ProjectID string `json:"projectId,omitempty"`
|
||||||
PerCardChoices []services.UpsertEventChoiceInput `json:"perCardChoices,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 {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||||
@@ -109,6 +115,7 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
PerCardAppellant: addendum.PerCardAppellant,
|
PerCardAppellant: addendum.PerCardAppellant,
|
||||||
SkipRules: addendum.SkipRules,
|
SkipRules: addendum.SkipRules,
|
||||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||||
|
IncludeHidden: req.IncludeHidden,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||||
|
|||||||
@@ -458,6 +458,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
|||||||
// t-paliad-139 — set unit_role on a member.
|
// t-paliad-139 — set unit_role on a member.
|
||||||
protected.HandleFunc("PATCH /api/partner-units/{id}/members/{user_id}/role", handleSetUnitMemberRole)
|
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)
|
protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty)
|
||||||
|
|
||||||
// Phase F — Appointments (appointments)
|
// Phase F — Appointments (appointments)
|
||||||
|
|||||||
@@ -701,6 +701,31 @@ func handleCreateParty(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, p)
|
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}
|
// DELETE /api/parties/{id}
|
||||||
func handleDeleteParty(w http.ResponseWriter, r *http.Request) {
|
func handleDeleteParty(w http.ResponseWriter, r *http.Request) {
|
||||||
if !requireDB(w) {
|
if !requireDB(w) {
|
||||||
|
|||||||
@@ -207,6 +207,44 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
|
|||||||
return rules, nil
|
return rules, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for the
|
||||||
|
// given id set, keyed by id. Returns nil, nil for an empty input set so
|
||||||
|
// callers can blindly forward whatever they accumulated. Inactive rows
|
||||||
|
// are included — the conditional-label resolution in fristenrechner.go
|
||||||
|
// surfaces the trigger event's display name even when the catalog row
|
||||||
|
// has been retired, which is preferable to silently falling back to
|
||||||
|
// the (wrong) parent_id name.
|
||||||
|
//
|
||||||
|
// Used by FristenrechnerService.Calculate to redirect a conditional
|
||||||
|
// rule's "abhängig von …" chip from parent_id to trigger_event_id —
|
||||||
|
// the actual semantic anchor for rules whose data-model parent is the
|
||||||
|
// proceeding root but whose real trigger sits in the trigger_events
|
||||||
|
// catalog (e.g. R.262(2) Erwiderung auf Vertraulichkeitsantrag → the
|
||||||
|
// opposing party's confidentiality application). See m/paliad#126.
|
||||||
|
func (s *DeadlineRuleService) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
query, args, err := sqlx.In(
|
||||||
|
`SELECT id, code, name, name_de, description, is_active, created_at
|
||||||
|
FROM paliad.trigger_events
|
||||||
|
WHERE id IN (?)`, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build trigger_events IN query: %w", err)
|
||||||
|
}
|
||||||
|
query = s.db.Rebind(query)
|
||||||
|
|
||||||
|
var rows []models.TriggerEvent
|
||||||
|
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||||
|
return nil, fmt.Errorf("load trigger_events by ids %v: %w", ids, err)
|
||||||
|
}
|
||||||
|
out := make(map[int64]models.TriggerEvent, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.ID] = r
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListByTriggerEvent returns active rules scoped to a single trigger
|
// ListByTriggerEvent returns active rules scoped to a single trigger
|
||||||
// event — the Pipeline-C surface added by Phase 3 Slice 3 (mig 085).
|
// event — the Pipeline-C surface added by Phase 3 Slice 3 (mig 085).
|
||||||
// These rules carry proceeding_type_id IS NULL (event-rooted) and have
|
// These rules carry proceeding_type_id IS NULL (event-rooted) and have
|
||||||
|
|||||||
@@ -89,6 +89,35 @@ type UIDeadline struct {
|
|||||||
// is computed off a parent date that the COURT sets, not by the
|
// is computed off a parent date that the COURT sets, not by the
|
||||||
// court itself.
|
// court itself.
|
||||||
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
|
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
|
||||||
|
// IsConditional signals the rule's anchor is uncertain — no
|
||||||
|
// concrete date can be projected. Set when the rule depends on:
|
||||||
|
// - a court-set ancestor whose date isn't anchored
|
||||||
|
// (overlaps with IsCourtSetIndirect; the two are kept
|
||||||
|
// distinct because IsCourtSet wraps a specific UX message
|
||||||
|
// "wird vom Gericht bestimmt", whereas IsConditional is
|
||||||
|
// the broader "render as 'abhängig von <parent>'" signal)
|
||||||
|
// - timing='before' rules whose forward anchor isn't set
|
||||||
|
// (e.g. R.109(1) Antrag auf Simultanübersetzung 1 month
|
||||||
|
// before the oral hearing — without the hearing date, the
|
||||||
|
// backward arithmetic against the trigger date is meaningless)
|
||||||
|
// - optional opposing-side rules whose true triggering event
|
||||||
|
// hasn't been recorded for this project (e.g. R.262(2)
|
||||||
|
// Erwiderung auf Vertraulichkeitsantrag — the data-model
|
||||||
|
// parent is the SoC, but the real trigger is the opposing
|
||||||
|
// party's confidentiality motion which may never happen)
|
||||||
|
// When true, DueDate and OriginalDate are empty and the frontend
|
||||||
|
// renders an "abhängig von <ParentRuleName>" chip in place of a
|
||||||
|
// date. Suppressed by an explicit user anchor (IsOverridden wins).
|
||||||
|
// (t-paliad-289)
|
||||||
|
IsConditional bool `json:"isConditional,omitempty"`
|
||||||
|
// ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the
|
||||||
|
// parent's identity so the frontend can render
|
||||||
|
// "abhängig von <ParentRuleName>" when IsConditional=true.
|
||||||
|
// Populated whenever the rule has a parent_id, not only when
|
||||||
|
// conditional — keeps the wire shape stable. Empty for root rules.
|
||||||
|
ParentRuleCode string `json:"parentRuleCode,omitempty"`
|
||||||
|
ParentRuleName string `json:"parentRuleName,omitempty"`
|
||||||
|
ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"`
|
||||||
IsOverridden bool `json:"isOverridden,omitempty"`
|
IsOverridden bool `json:"isOverridden,omitempty"`
|
||||||
// ChoicesOffered surfaces paliad.deadline_rules.choices_offered for
|
// ChoicesOffered surfaces paliad.deadline_rules.choices_offered for
|
||||||
// the rule so the frontend knows whether to render the per-event-card
|
// the rule so the frontend knows whether to render the per-event-card
|
||||||
@@ -102,6 +131,12 @@ type UIDeadline struct {
|
|||||||
// Frontend bucketer prefers this over the page-level appellant when
|
// Frontend bucketer prefers this over the page-level appellant when
|
||||||
// non-empty. (t-paliad-265)
|
// non-empty. (t-paliad-265)
|
||||||
AppellantContext string `json:"appellantContext,omitempty"`
|
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.
|
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
|
||||||
@@ -137,6 +172,14 @@ type UIResponse struct {
|
|||||||
// is the appealable first-instance decision (m/paliad#81).
|
// is the appealable first-instance decision (m/paliad#81).
|
||||||
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
||||||
TriggerEventLabelEN string `json:"triggerEventLabelEN,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.
|
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
|
||||||
@@ -214,6 +257,19 @@ type CalcOptions struct {
|
|||||||
PerCardAppellant map[string]string
|
PerCardAppellant map[string]string
|
||||||
SkipRules map[string]struct{}
|
SkipRules map[string]struct{}
|
||||||
IncludeCCRFor 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.
|
// Calculate renders the full UI timeline for a proceeding type + trigger date.
|
||||||
@@ -372,6 +428,60 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
|||||||
courtSet := make(map[uuid.UUID]bool, len(rules))
|
courtSet := make(map[uuid.UUID]bool, len(rules))
|
||||||
deadlines := make([]UIDeadline, 0, len(rules))
|
deadlines := make([]UIDeadline, 0, len(rules))
|
||||||
|
|
||||||
|
// Pre-pass: identify rules flagged is_court_set=true in the data so
|
||||||
|
// order-of-evaluation in sequence_order doesn't matter for the
|
||||||
|
// parent-court-set check below. Without this, a rule processed
|
||||||
|
// earlier than its court-set parent (e.g. R.109(1) Antrag auf
|
||||||
|
// Simultanübersetzung sequence_order=45 vs. Mündliche Verhandlung
|
||||||
|
// sequence_order=50 in upc.inf.cfi) misses the court-set propagation
|
||||||
|
// and computes a meaningless date — for timing='before' rules, that
|
||||||
|
// produces a backward offset from the trigger date, which has no
|
||||||
|
// semantic relationship to the rule. (t-paliad-289)
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.IsCourtSet {
|
||||||
|
courtSet[r.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleByID lets the conditional-rendering branches resolve a parent
|
||||||
|
// rule's display fields (submission_code, name, name_en) for the
|
||||||
|
// "abhängig von <ParentRuleName>" chip without re-scanning the
|
||||||
|
// rules slice on every iteration.
|
||||||
|
ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
ruleByID[r.ID] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggerEventByID powers the trigger-event override on the
|
||||||
|
// conditional-label chip (m/paliad#126 / t-paliad-294). When a
|
||||||
|
// rule carries a real paliad.trigger_events row, that catalog
|
||||||
|
// event — not the rule's parent_id — is the rule's actual
|
||||||
|
// semantic anchor. The override fires below when stamping
|
||||||
|
// ParentRule* on the wire so the chip reads e.g.
|
||||||
|
// abhängig von Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit
|
||||||
|
// for R.262(2) Erwiderung auf Vertraulichkeitsantrag — instead of
|
||||||
|
// the (misleading) parent_id-derived "abhängig von Klageerhebung".
|
||||||
|
//
|
||||||
|
// Bulk-loaded in one round-trip; trees in the live corpus carry at
|
||||||
|
// most a handful of trigger_event_id-bearing rules (2 today on
|
||||||
|
// upc.inf.cfi), so the IN(...) is small.
|
||||||
|
var triggerIDs []int64
|
||||||
|
seenTrigger := make(map[int64]struct{}, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.TriggerEventID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seenTrigger[*r.TriggerEventID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenTrigger[*r.TriggerEventID] = struct{}{}
|
||||||
|
triggerIDs = append(triggerIDs, *r.TriggerEventID)
|
||||||
|
}
|
||||||
|
triggerEventByID, err := s.rules.LoadTriggerEventsByIDs(ctx, triggerIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Per-event-card overlays (t-paliad-265). Empty/nil maps are safe
|
// Per-event-card overlays (t-paliad-265). Empty/nil maps are safe
|
||||||
// for membership tests; the engine reads them but doesn't mutate.
|
// for membership tests; the engine reads them but doesn't mutate.
|
||||||
skipRules := opts.SkipRules
|
skipRules := opts.SkipRules
|
||||||
@@ -381,6 +491,13 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
|||||||
// child rule's parent has already been classified — so descendant
|
// child rule's parent has already been classified — so descendant
|
||||||
// suppression is a one-pass parent_id lookup.
|
// suppression is a one-pass parent_id lookup.
|
||||||
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
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
|
// appellantContext maps a rule UUID to the appellant value that
|
||||||
// applies to its descendants. A rule that has its own PerCardAppellant
|
// applies to its descendants. A rule that has its own PerCardAppellant
|
||||||
// pick stamps itself with that value; a rule whose parent has a
|
// pick stamps itself with that value; a rule whose parent has a
|
||||||
@@ -403,10 +520,22 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
|||||||
// this rule (or one of its ancestors) as "don't consider for
|
// this rule (or one of its ancestors) as "don't consider for
|
||||||
// this case". Drop the row entirely AND record the rule ID so
|
// this case". Drop the row entirely AND record the rule ID so
|
||||||
// descendants suppress too.
|
// 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 r.SubmissionCode != nil {
|
||||||
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
|
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
|
||||||
skippedIDs[r.ID] = struct{}{}
|
hiddenCount++
|
||||||
continue
|
if !opts.IncludeHidden {
|
||||||
|
skippedIDs[r.ID] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
isHidden = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r.ParentID != nil {
|
if r.ParentID != nil {
|
||||||
@@ -442,6 +571,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
|||||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||||
AppellantContext: ctxVal,
|
AppellantContext: ctxVal,
|
||||||
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
|
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
|
||||||
|
IsHidden: isHidden,
|
||||||
}
|
}
|
||||||
if r.SubmissionCode != nil {
|
if r.SubmissionCode != nil {
|
||||||
d.Code = *r.SubmissionCode
|
d.Code = *r.SubmissionCode
|
||||||
@@ -464,21 +594,58 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
|||||||
d.NotesEN = *r.DeadlineNotesEn
|
d.NotesEN = *r.DeadlineNotesEn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the parent rule once so every conditional-rendering
|
||||||
|
// branch (incl. the optional-not-recorded path below) can stamp
|
||||||
|
// ParentRule* on the wire without re-scanning. Populated even
|
||||||
|
// for non-conditional rows — the frontend dependency-footer
|
||||||
|
// ("Folgt aus …") already consumes this on regular projected
|
||||||
|
// rows. (t-paliad-289)
|
||||||
|
var parentRule *models.DeadlineRule
|
||||||
|
if r.ParentID != nil {
|
||||||
|
if pr, ok := ruleByID[*r.ParentID]; ok {
|
||||||
|
parentRule = &pr
|
||||||
|
if pr.SubmissionCode != nil {
|
||||||
|
d.ParentRuleCode = *pr.SubmissionCode
|
||||||
|
}
|
||||||
|
d.ParentRuleName = pr.Name
|
||||||
|
d.ParentRuleNameEN = pr.NameEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger-event override on the user-facing dependency identity
|
||||||
|
// (m/paliad#126 / t-paliad-294). When a rule has a real
|
||||||
|
// trigger_event_id, that catalog event is the actual semantic
|
||||||
|
// anchor — not the parent_id node, which is only the calc-time
|
||||||
|
// arithmetic anchor. R.262(2) Erwiderung auf Vertraulichkeits-
|
||||||
|
// antrag is the canonical case: parent_id resolves to the SoC
|
||||||
|
// ("Klageerhebung"), but the real triggering event is the
|
||||||
|
// opposing party's confidentiality application. Generalises to
|
||||||
|
// any rule whose trigger_event_id is set (e.g. R.6(2)
|
||||||
|
// translations_lodge → judge-rapporteur's order).
|
||||||
|
//
|
||||||
|
// Only the user-facing wire fields shift; parentRule (and the
|
||||||
|
// parent_id chain that feeds parentIsCourtSet / the calc-time
|
||||||
|
// date arithmetic below) stays anchored on the rule tree —
|
||||||
|
// that's still the right calc semantic. parentRule is NOT
|
||||||
|
// reassigned here.
|
||||||
|
if r.TriggerEventID != nil {
|
||||||
|
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
|
||||||
|
d.ParentRuleCode = te.Code
|
||||||
|
d.ParentRuleName = te.NameDE
|
||||||
|
d.ParentRuleNameEN = te.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Propagate court-set status from a parent rule whose date the
|
// Propagate court-set status from a parent rule whose date the
|
||||||
// court determines: if the anchor itself has no real date,
|
// court determines: if the anchor itself has no real date,
|
||||||
// nothing downstream can be computed either — UNLESS the user
|
// nothing downstream can be computed either — UNLESS the user
|
||||||
// has supplied an override date for the parent (which they can
|
// has supplied an override date for the parent (which they can
|
||||||
// once they know the real decision date).
|
// once they know the real decision date).
|
||||||
parentOverridden := false
|
parentOverridden := false
|
||||||
if r.ParentID != nil && courtSet[*r.ParentID] {
|
if r.ParentID != nil && courtSet[*r.ParentID] && parentRule != nil {
|
||||||
for _, prev := range rules {
|
if parentRule.SubmissionCode != nil {
|
||||||
if prev.ID == *r.ParentID {
|
if _, ok := overrideDates[*parentRule.SubmissionCode]; ok {
|
||||||
if prev.SubmissionCode != nil {
|
parentOverridden = true
|
||||||
if _, ok := overrideDates[*prev.SubmissionCode]; ok {
|
|
||||||
parentOverridden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -530,6 +697,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
|||||||
// "unbestimmt", not "wird vom Gericht bestimmt".
|
// "unbestimmt", not "wird vom Gericht bestimmt".
|
||||||
d.IsCourtSet = true
|
d.IsCourtSet = true
|
||||||
d.IsCourtSetIndirect = true
|
d.IsCourtSetIndirect = true
|
||||||
|
d.IsConditional = true
|
||||||
d.DueDate = ""
|
d.DueDate = ""
|
||||||
d.OriginalDate = ""
|
d.OriginalDate = ""
|
||||||
courtSet[r.ID] = true
|
courtSet[r.ID] = true
|
||||||
@@ -563,6 +731,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
|||||||
// itself isn't a court action.
|
// itself isn't a court action.
|
||||||
d.IsCourtSet = true
|
d.IsCourtSet = true
|
||||||
d.IsCourtSetIndirect = true
|
d.IsCourtSetIndirect = true
|
||||||
|
d.IsConditional = true
|
||||||
d.DueDate = ""
|
d.DueDate = ""
|
||||||
d.OriginalDate = ""
|
d.OriginalDate = ""
|
||||||
courtSet[r.ID] = true
|
courtSet[r.ID] = true
|
||||||
@@ -595,9 +764,19 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
|||||||
// should say "unbestimmt", not "wird vom Gericht bestimmt":
|
// should say "unbestimmt", not "wird vom Gericht bestimmt":
|
||||||
// the date isn't directly determined by the court, it's
|
// the date isn't directly determined by the court, it's
|
||||||
// derived from a date the court sets.
|
// derived from a date the court sets.
|
||||||
|
//
|
||||||
|
// timing='before' rules end up here too — a rule with
|
||||||
|
// "1 Monat VOR der mündlichen Verhandlung" (R.109(1)) has the
|
||||||
|
// oral hearing as its parent; if the hearing date isn't set,
|
||||||
|
// the backward arithmetic against the trigger date is
|
||||||
|
// meaningless. The pre-pass above ensures courtSet[oral.ID]
|
||||||
|
// is true even when the oral hearing rule is processed later
|
||||||
|
// in sequence_order. IsConditional surfaces the "abhängig
|
||||||
|
// von <ParentRuleName>" UX. (t-paliad-289)
|
||||||
if parentIsCourtSet {
|
if parentIsCourtSet {
|
||||||
d.IsCourtSet = true
|
d.IsCourtSet = true
|
||||||
d.IsCourtSetIndirect = true
|
d.IsCourtSetIndirect = true
|
||||||
|
d.IsConditional = true
|
||||||
d.DueDate = ""
|
d.DueDate = ""
|
||||||
d.OriginalDate = ""
|
d.OriginalDate = ""
|
||||||
courtSet[r.ID] = true
|
courtSet[r.ID] = true
|
||||||
@@ -700,6 +879,42 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
|||||||
d.DueDate = adjusted.Format("2006-01-02")
|
d.DueDate = adjusted.Format("2006-01-02")
|
||||||
d.WasAdjusted = wasAdj
|
d.WasAdjusted = wasAdj
|
||||||
d.AdjustmentReason = reason
|
d.AdjustmentReason = reason
|
||||||
|
|
||||||
|
// Optional-on-the-other-side detection (t-paliad-289 Symptom B).
|
||||||
|
// Rules with priority='optional' AND primary_party='both' whose
|
||||||
|
// data-model parent is the proceeding's trigger anchor (parent
|
||||||
|
// has parent_id=NULL and is not court-set, i.e. the SoC root
|
||||||
|
// rule) represent a rule whose REAL triggering event sits
|
||||||
|
// outside the rule data — e.g. R.262(2) Erwiderung auf
|
||||||
|
// Vertraulichkeitsantrag anchors on SoC in the data, but the
|
||||||
|
// real trigger is the opposing party's confidentiality motion
|
||||||
|
// which may never happen. Without an explicit anchor on the
|
||||||
|
// rule itself (user clicks "Datum setzen" after the motion
|
||||||
|
// arrives), the projection must NOT claim a concrete date.
|
||||||
|
//
|
||||||
|
// In the live corpus this catches confidentiality_response;
|
||||||
|
// every other optional+both rule has a court-set ancestor and
|
||||||
|
// is already caught by the parentIsCourtSet branches above.
|
||||||
|
// Suppressed when IsOverridden (the user has anchored the rule
|
||||||
|
// — the date is real) or when the rule has already been marked
|
||||||
|
// IsConditional by an earlier branch.
|
||||||
|
if !d.IsOverridden && !d.IsConditional &&
|
||||||
|
r.Priority == "optional" &&
|
||||||
|
r.PrimaryParty != nil && *r.PrimaryParty == "both" &&
|
||||||
|
parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet {
|
||||||
|
d.IsConditional = true
|
||||||
|
d.DueDate = ""
|
||||||
|
d.OriginalDate = ""
|
||||||
|
d.WasAdjusted = false
|
||||||
|
d.AdjustmentReason = nil
|
||||||
|
// Mark this rule's ID as having an uncertain anchor so
|
||||||
|
// rules chaining off it also surface conditional via the
|
||||||
|
// parentIsCourtSet path (no rule currently chains off
|
||||||
|
// confidentiality_response in the live corpus, but the
|
||||||
|
// extension keeps the propagation semantics consistent).
|
||||||
|
courtSet[r.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
if r.SubmissionCode != nil {
|
if r.SubmissionCode != nil {
|
||||||
computed[*r.SubmissionCode] = adjusted
|
computed[*r.SubmissionCode] = adjusted
|
||||||
}
|
}
|
||||||
@@ -712,6 +927,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
|||||||
ProceedingNameEN: pickedProceeding.NameEN,
|
ProceedingNameEN: pickedProceeding.NameEN,
|
||||||
TriggerDate: triggerDateStr,
|
TriggerDate: triggerDateStr,
|
||||||
Deadlines: deadlines,
|
Deadlines: deadlines,
|
||||||
|
HiddenCount: hiddenCount,
|
||||||
}
|
}
|
||||||
// Sub-track routing keeps the user-picked proceeding's identity,
|
// Sub-track routing keeps the user-picked proceeding's identity,
|
||||||
// so the trigger-event label rides on `pickedProceeding` (e.g.
|
// so the trigger-event label rides on `pickedProceeding` (e.g.
|
||||||
|
|||||||
@@ -450,3 +450,182 @@ func TestUIDeadline_WireShape_Slice8(t *testing.T) {
|
|||||||
t.Logf("warning: no upc.inf.cfi rule had conditionExpr populated — verify mig 084 ran")
|
t.Logf("warning: no upc.inf.cfi rule had conditionExpr populated — verify mig 084 ran")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// t-paliad-289: rules anchored on uncertain triggers must render as
|
||||||
|
// conditional (IsConditional=true, empty DueDate, ParentRule* populated)
|
||||||
|
// rather than fabricating a date off the trigger.
|
||||||
|
//
|
||||||
|
// Three pillars from the issue:
|
||||||
|
// - Symptom A: R.109(1) Antrag auf Simultanübersetzung (timing='before',
|
||||||
|
// parent=Mündliche Verhandlung which is court-set). Pre-fix the rule
|
||||||
|
// computed a meaningless "1 month before today" because sequence_order
|
||||||
|
// places translation_request (45) before oral (50), so the parent
|
||||||
|
// hadn't been classified as court-set yet. The new pre-pass in
|
||||||
|
// Calculate seeds courtSet from is_court_set=true on the data, so
|
||||||
|
// order-of-evaluation no longer matters.
|
||||||
|
// - R.118(4) cons_orders (parent=Entscheidung, court-set) — already
|
||||||
|
// worked via the legacy IsCourtSetIndirect path; assertion ensures
|
||||||
|
// the new IsConditional flag rides alongside it.
|
||||||
|
// - Symptom B: R.262(2) confidentiality_response (priority='optional',
|
||||||
|
// primary_party='both', parent=SoC which is the trigger anchor).
|
||||||
|
// The data-model parent is "always certain" but the real triggering
|
||||||
|
// event (opposing party's confidentiality motion) sits outside the
|
||||||
|
// rule data — render conditional until the user anchors the rule.
|
||||||
|
func TestUIDeadline_IsConditional_UncertainAnchors(t *testing.T) {
|
||||||
|
url := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||||
|
}
|
||||||
|
if err := db.ApplyMigrations(url); err != nil {
|
||||||
|
t.Fatalf("apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
pool, err := sqlx.Connect("postgres", url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
holidays := NewHolidayService(pool)
|
||||||
|
rules := NewDeadlineRuleService(pool)
|
||||||
|
courts := NewCourtService(pool)
|
||||||
|
svc := NewFristenrechnerService(rules, holidays, courts)
|
||||||
|
|
||||||
|
resp, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Calculate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
byCode := map[string]UIDeadline{}
|
||||||
|
for _, d := range resp.Deadlines {
|
||||||
|
byCode[d.Code] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
code string
|
||||||
|
wantConditional bool
|
||||||
|
wantParentCode string
|
||||||
|
}{
|
||||||
|
// Symptom A — backward-anchored on the court-set oral hearing.
|
||||||
|
// Pre-pass fix: order-of-evaluation no longer matters. These
|
||||||
|
// rules have no trigger_event_id, so ParentRuleCode stays on
|
||||||
|
// the parent_id-derived value.
|
||||||
|
{"upc.inf.cfi.translation_request", true, "upc.inf.cfi.oral"},
|
||||||
|
{"upc.inf.cfi.interpreter_cost", true, "upc.inf.cfi.oral"},
|
||||||
|
// R.118(4) chain — parent=decision (court-set). No trigger_event_id.
|
||||||
|
{"upc.inf.cfi.cons_orders", true, "upc.inf.cfi.decision"},
|
||||||
|
// Symptom B — optional + both, data-model parent is SoC but the
|
||||||
|
// real trigger is the opposing party's confidentiality application.
|
||||||
|
// m/paliad#126 / t-paliad-294: ParentRuleCode now reflects the
|
||||||
|
// trigger_events catalog row (id=25), NOT the parent_id chain.
|
||||||
|
{"upc.inf.cfi.confidentiality_response", true, "application_to_request_confidentiality_from_the_public"},
|
||||||
|
// Negative control — mandatory rule anchored on SoC must keep
|
||||||
|
// its concrete date (no IsConditional, real DueDate). No
|
||||||
|
// trigger_event_id, so parent_id-derived code stays.
|
||||||
|
{"upc.inf.cfi.sod", false, "upc.inf.cfi.soc"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.code, func(t *testing.T) {
|
||||||
|
d, ok := byCode[c.code]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("rule %s missing from response", c.code)
|
||||||
|
}
|
||||||
|
if d.IsConditional != c.wantConditional {
|
||||||
|
t.Errorf("IsConditional = %v, want %v", d.IsConditional, c.wantConditional)
|
||||||
|
}
|
||||||
|
if c.wantConditional {
|
||||||
|
if d.DueDate != "" {
|
||||||
|
t.Errorf("DueDate = %q, want empty (conditional)", d.DueDate)
|
||||||
|
}
|
||||||
|
if d.ParentRuleCode != c.wantParentCode {
|
||||||
|
t.Errorf("ParentRuleCode = %q, want %q", d.ParentRuleCode, c.wantParentCode)
|
||||||
|
}
|
||||||
|
if d.ParentRuleName == "" {
|
||||||
|
t.Errorf("ParentRuleName empty for conditional rule")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if d.DueDate == "" {
|
||||||
|
t.Errorf("non-conditional rule has empty DueDate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// m/paliad#126 / t-paliad-294: the conditional chip for R.262(2)
|
||||||
|
// reads from the trigger_events catalog (id=25), so the user sees
|
||||||
|
// the actual semantic anchor instead of the parent_id-derived
|
||||||
|
// "Klageerhebung". Pin the exact DE + EN strings so a future
|
||||||
|
// rename of the catalog row surfaces here.
|
||||||
|
t.Run("R.262(2) conditional label uses trigger_event_id, not parent_id", func(t *testing.T) {
|
||||||
|
d, ok := byCode["upc.inf.cfi.confidentiality_response"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("confidentiality_response missing from response")
|
||||||
|
}
|
||||||
|
const wantNameDE = "Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit"
|
||||||
|
const wantNameEN = "Application to request confidentiality from the public"
|
||||||
|
if d.ParentRuleName != wantNameDE {
|
||||||
|
t.Errorf("ParentRuleName = %q, want %q (trigger_events.name_de for id=25)", d.ParentRuleName, wantNameDE)
|
||||||
|
}
|
||||||
|
if d.ParentRuleNameEN != wantNameEN {
|
||||||
|
t.Errorf("ParentRuleNameEN = %q, want %q (trigger_events.name for id=25)", d.ParentRuleNameEN, wantNameEN)
|
||||||
|
}
|
||||||
|
// Negative guard — neither label should leak the SoC ("Klageerhebung"),
|
||||||
|
// which is the regression the fix exists to prevent.
|
||||||
|
if d.ParentRuleName == "Klageerhebung" || d.ParentRuleNameEN == "Statement of Claim" {
|
||||||
|
t.Errorf("conditional label still resolves via parent_id (SoC); fix regressed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generalisation guard — translations_lodge also carries a real
|
||||||
|
// trigger_event_id (113 = judge-rapporteur's order). Its
|
||||||
|
// conditional chip should reference the order, not its parent_id
|
||||||
|
// (Zwischenverfahren). Locks in the "any rule with trigger_event_id
|
||||||
|
// uses THAT, not parent_id" contract from m/paliad#126.
|
||||||
|
t.Run("translations_lodge conditional label uses trigger_event_id", func(t *testing.T) {
|
||||||
|
d, ok := byCode["upc.inf.cfi.translations_lodge"]
|
||||||
|
if !ok {
|
||||||
|
t.Skip("upc.inf.cfi.translations_lodge missing from response — data drift?")
|
||||||
|
}
|
||||||
|
if !d.IsConditional {
|
||||||
|
t.Skipf("translations_lodge IsConditional=false in current corpus; trigger-event override is only user-visible on conditional rows. Skip but keep the generalisation guard.")
|
||||||
|
}
|
||||||
|
if d.ParentRuleName == "Zwischenverfahren" {
|
||||||
|
t.Errorf("translations_lodge still labelled via parent_id (Zwischenverfahren); should follow trigger_event_id=113")
|
||||||
|
}
|
||||||
|
if d.ParentRuleCode != "order_of_the_judge_rapporteur_to_lodge_translations" {
|
||||||
|
t.Errorf("ParentRuleCode = %q, want trigger_events.code for id=113", d.ParentRuleCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Override path: when the user anchors the oral hearing, the
|
||||||
|
// backward-anchored R.109(1) flips back to a concrete date and
|
||||||
|
// IsConditional clears. This is the click-to-edit unblock.
|
||||||
|
t.Run("override on court-set parent clears IsConditional", func(t *testing.T) {
|
||||||
|
resp2, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-05-25", CalcOptions{
|
||||||
|
AnchorOverrides: map[string]string{
|
||||||
|
"upc.inf.cfi.oral": "2027-03-01",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Calculate with override: %v", err)
|
||||||
|
}
|
||||||
|
var tr UIDeadline
|
||||||
|
for _, d := range resp2.Deadlines {
|
||||||
|
if d.Code == "upc.inf.cfi.translation_request" {
|
||||||
|
tr = d
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tr.IsConditional {
|
||||||
|
t.Errorf("translation_request IsConditional=true after oral override; want false")
|
||||||
|
}
|
||||||
|
if tr.DueDate == "" {
|
||||||
|
t.Errorf("translation_request DueDate empty after oral override")
|
||||||
|
}
|
||||||
|
// 1 month before 2027-03-01 = ~2027-02-01 (with weekend bump).
|
||||||
|
if tr.DueDate < "2027-01-25" || tr.DueDate > "2027-02-05" {
|
||||||
|
t.Errorf("translation_request DueDate=%q not within expected 2027-01-25..2027-02-05 window", tr.DueDate)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,59 @@ type CreatePartyInput struct {
|
|||||||
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
|
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.
|
// ListForProject returns all Parties for the Project, visibility-checked.
|
||||||
func (s *PartyService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Party, error) {
|
func (s *PartyService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Party, error) {
|
||||||
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
||||||
|
|||||||
@@ -97,6 +97,58 @@ func TestApplyLookaheadCap_NoCapWhenUnderLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// t-paliad-289: conditional rows (Status="conditional", Date=nil) must
|
||||||
|
// pass through applyLookaheadCap untouched — they're not "future
|
||||||
|
// predicted" rows by either Status or Date semantics, so they belong in
|
||||||
|
// the pass-through bucket alongside court_set / undated rows. The cap
|
||||||
|
// must NOT consume one of its slots for a conditional row, and the
|
||||||
|
// row must survive even when projTotal exceeds the cap.
|
||||||
|
func TestApplyLookaheadCap_ConditionalRowsPassThrough(t *testing.T) {
|
||||||
|
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
rows := []TimelineEvent{
|
||||||
|
// Three predicted future — cap=2 means the third drops.
|
||||||
|
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "f1", Title: "F1"},
|
||||||
|
{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "f2", Title: "F2"},
|
||||||
|
{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "f3", Title: "F3"},
|
||||||
|
// Two conditional — must survive uncapped, must NOT count
|
||||||
|
// against projTotal / projShown.
|
||||||
|
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c1", Title: "C1",
|
||||||
|
DependsOnRuleCode: "p1", DependsOnRuleName: "Parent 1"},
|
||||||
|
{Kind: "projected", Status: "conditional", IsConditional: true, RuleCode: "c2", Title: "C2",
|
||||||
|
DependsOnRuleCode: "p2", DependsOnRuleName: "Parent 2"},
|
||||||
|
}
|
||||||
|
|
||||||
|
kept, total, shown, overdue := applyLookaheadCap(rows, 2)
|
||||||
|
if total != 3 {
|
||||||
|
t.Errorf("ProjectedTotal = %d, want 3 (conditionals must not count)", total)
|
||||||
|
}
|
||||||
|
if shown != 2 {
|
||||||
|
t.Errorf("ProjectedShown = %d, want 2", shown)
|
||||||
|
}
|
||||||
|
if overdue != 0 {
|
||||||
|
t.Errorf("PredictedOverdue = %d, want 0", overdue)
|
||||||
|
}
|
||||||
|
// 2 predicted (capped) + 2 conditional pass-through = 4 rows.
|
||||||
|
if len(kept) != 4 {
|
||||||
|
t.Errorf("kept rows = %d, want 4", len(kept))
|
||||||
|
}
|
||||||
|
keptTitles := map[string]bool{}
|
||||||
|
for _, r := range kept {
|
||||||
|
keptTitles[r.Title] = true
|
||||||
|
}
|
||||||
|
for _, want := range []string{"F1", "F2", "C1", "C2"} {
|
||||||
|
if !keptTitles[want] {
|
||||||
|
t.Errorf("expected kept row %q missing", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if keptTitles["F3"] {
|
||||||
|
t.Errorf("F3 should have been dropped (cap=2)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRuleAnchorKind(t *testing.T) {
|
func TestRuleAnchorKind(t *testing.T) {
|
||||||
hearing := "hearing"
|
hearing := "hearing"
|
||||||
decision := "decision"
|
decision := "decision"
|
||||||
|
|||||||
@@ -147,6 +147,17 @@ type TimelineEvent struct {
|
|||||||
// checkbox). At parent-node levels, rows with BubbleUp=true survive
|
// checkbox). At parent-node levels, rows with BubbleUp=true survive
|
||||||
// the levelPolicy kind/status filter unconditionally.
|
// the levelPolicy kind/status filter unconditionally.
|
||||||
BubbleUp bool `json:"bubble_up,omitempty"`
|
BubbleUp bool `json:"bubble_up,omitempty"`
|
||||||
|
|
||||||
|
// IsConditional marks projected rows whose anchor is uncertain —
|
||||||
|
// the projection layer mirrors UIDeadline.IsConditional from the
|
||||||
|
// fristenrechner so the SmartTimeline can render an "abhängig von
|
||||||
|
// <parent>" chip in place of the date column. When true, Date is
|
||||||
|
// nil and DependsOnRuleCode / DependsOnRuleName carry the parent
|
||||||
|
// reference (already populated by annotateDependsOn for projected
|
||||||
|
// rows; for conditional rows we additionally fall back to the
|
||||||
|
// UIDeadline-supplied ParentRule* when the parent has no
|
||||||
|
// computed date). Status is set to "conditional". (t-paliad-289)
|
||||||
|
IsConditional bool `json:"is_conditional,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LaneInfo describes one column in the parent-node aggregated view.
|
// LaneInfo describes one column in the parent-node aggregated view.
|
||||||
@@ -933,12 +944,13 @@ func (s *ProjectionService) computeProjections(
|
|||||||
Title: ruleDisplayName(rule, ui, lang(opts.Lang)),
|
Title: ruleDisplayName(rule, ui, lang(opts.Lang)),
|
||||||
RuleCode: ui.Code,
|
RuleCode: ui.Code,
|
||||||
DeadlineRuleParty: ui.Party,
|
DeadlineRuleParty: ui.Party,
|
||||||
|
IsConditional: ui.IsConditional,
|
||||||
}
|
}
|
||||||
idCopy := ruleID
|
idCopy := ruleID
|
||||||
ev.DeadlineRuleID = &idCopy
|
ev.DeadlineRuleID = &idCopy
|
||||||
|
|
||||||
// Date — UIDeadline.DueDate is YYYY-MM-DD when set, "" for
|
// Date — UIDeadline.DueDate is YYYY-MM-DD when set, "" for
|
||||||
// court-set rules whose date isn't bound yet.
|
// court-set / conditional rules whose date isn't bound yet.
|
||||||
if ui.DueDate != "" {
|
if ui.DueDate != "" {
|
||||||
if t, perr := time.Parse("2006-01-02", ui.DueDate); perr == nil {
|
if t, perr := time.Parse("2006-01-02", ui.DueDate); perr == nil {
|
||||||
dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
@@ -946,7 +958,38 @@ func (s *ProjectionService) computeProjections(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conditional rows from the fristenrechner (t-paliad-289):
|
||||||
|
// pre-stamp the dependency reference here so the row carries
|
||||||
|
// the "abhängig von <parent>" payload even when the parent has
|
||||||
|
// no computed date for annotateDependsOn to pick up later.
|
||||||
|
// annotateDependsOn won't overwrite a non-empty DependsOnRuleCode,
|
||||||
|
// and the parent's actual date (if anchored elsewhere) still
|
||||||
|
// flows into DependsOnDate via the actuals-first preference.
|
||||||
|
if ui.IsConditional && ui.ParentRuleCode != "" {
|
||||||
|
ev.DependsOnRuleCode = ui.ParentRuleCode
|
||||||
|
switch lang(opts.Lang) {
|
||||||
|
case "en":
|
||||||
|
if ui.ParentRuleNameEN != "" {
|
||||||
|
ev.DependsOnRuleName = ui.ParentRuleNameEN
|
||||||
|
} else {
|
||||||
|
ev.DependsOnRuleName = ui.ParentRuleName
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
ev.DependsOnRuleName = ui.ParentRuleName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
case ui.IsConditional:
|
||||||
|
// Anchor uncertain (court-set ancestor without override,
|
||||||
|
// backward-anchor without forward date, or optional event
|
||||||
|
// not recorded). Surface as conditional so the frontend
|
||||||
|
// renders "abhängig von <parent>" in place of a date.
|
||||||
|
// Conditional rows must not carry a date even if the
|
||||||
|
// calculator left one — clear it to match the wire contract.
|
||||||
|
// (t-paliad-289)
|
||||||
|
ev.Date = nil
|
||||||
|
ev.Status = "conditional"
|
||||||
case ui.IsCourtSet && ev.Date == nil:
|
case ui.IsCourtSet && ev.Date == nil:
|
||||||
// Pure court-set rule — date is bound by the court at
|
// Pure court-set rule — date is bound by the court at
|
||||||
// hearing/decision time. Surface as undated court_set.
|
// hearing/decision time. Surface as undated court_set.
|
||||||
|
|||||||
@@ -315,11 +315,11 @@ func buildDocumentXML() string {
|
|||||||
body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})")
|
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}}")
|
body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}")
|
||||||
|
|
||||||
headerSubsection(&b, "Frist")
|
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
|
||||||
body0(&b, "Frist-Bezeichnung: {{deadline.title}}")
|
// {{deadline.*}} placeholders stay resolvable in the variable bag
|
||||||
body0(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
// for custom templates that want them, but the default HL skeleton
|
||||||
body0(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
// no longer renders them in the submission body: the deadline is
|
||||||
body0(&b, "Berechnet aus: {{deadline.computed_from}} · Quelle: {{deadline.source}}")
|
// internal/admin context and has no place in a court-bound document.
|
||||||
|
|
||||||
heading(&b, "HLpat-Heading-H2", "I. Sachverhalt")
|
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.]")
|
body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]")
|
||||||
@@ -349,7 +349,6 @@ func buildDocumentXML() string {
|
|||||||
body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}")
|
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, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||||
body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}")
|
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, "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 — 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}}")
|
body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}")
|
||||||
|
|||||||
@@ -137,14 +137,19 @@ const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|||||||
</w:styles>`
|
</w:styles>`
|
||||||
|
|
||||||
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
|
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
|
||||||
// case caption + parties + submission heading + deadline + a single
|
// case caption + parties + submission heading + a single neutral body
|
||||||
// neutral body block. Mirrors the variable bag from SubmissionVarsService
|
// block. Mirrors the variable bag from SubmissionVarsService (firm.* /
|
||||||
// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
|
// today.* / user.* / project.* / parties.* / rule.*) without baking in
|
||||||
// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
|
// DE-LG-Klageerwiderung-specific structure. A lawyer customising this
|
||||||
// structure. A lawyer customising this template for a UPC SoC, EPO
|
// template for a UPC SoC, EPO opposition, or DPMA appeal replaces the
|
||||||
// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
|
// [Schriftsatztext] block and renames the party labels — every
|
||||||
// renames the party labels — every placeholder still resolves regardless
|
// placeholder still resolves regardless of the submission_code chosen.
|
||||||
// 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.
|
||||||
//
|
//
|
||||||
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
|
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
|
||||||
// (format-preserving, single-run) substitution catches it. The
|
// (format-preserving, single-run) substitution catches it. The
|
||||||
@@ -194,11 +199,12 @@ func buildDocumentXML() string {
|
|||||||
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
|
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
|
||||||
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
|
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
|
||||||
|
|
||||||
heading2(&b, "Frist")
|
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
|
||||||
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
|
// {{deadline.*}} placeholders stay resolvable in the variable bag
|
||||||
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
// (lawyer can still drop them into a custom paragraph) but the
|
||||||
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
// default skeleton no longer renders them in the submission body:
|
||||||
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
|
// the deadline is internal/admin context and has no place in a
|
||||||
|
// document going out to court.
|
||||||
|
|
||||||
heading2(&b, "Schriftsatztext")
|
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.]")
|
plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
|
||||||
@@ -217,7 +223,7 @@ func buildDocumentXML() string {
|
|||||||
// the bare {{today}} alias. A lawyer customising the template can
|
// the bare {{today}} alias. A lawyer customising the template can
|
||||||
// delete this block; the renderer round-trips it cleanly today.
|
// delete this block; the renderer round-trips it cleanly today.
|
||||||
heading2(&b, "Locale-aware variants (SKELETON)")
|
heading2(&b, "Locale-aware variants (SKELETON)")
|
||||||
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
|
plain(&b, "EN long date: {{today.long_en}}")
|
||||||
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_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, "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}}")
|
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
|
||||||
|
|||||||
Reference in New Issue
Block a user