Compare commits
21 Commits
mai/cronus
...
mai/hermes
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ca6b2d643 | |||
| ed8af0dca9 | |||
| 293e612582 | |||
| 9d3325bd88 | |||
| 18d2e743ba | |||
| 07d2eb472c | |||
| 7cdccd55ae | |||
| d4ed989b8f | |||
| 54fb676db5 | |||
| c3eaa9b1d4 | |||
| 80883eaac5 | |||
| 5e17de6e07 | |||
| 0e1f62e375 | |||
| cca5e72c57 | |||
| 4d923562f5 | |||
| c70914c2a0 | |||
| 016ac2532a | |||
| 0b1653c2bf | |||
| a6cf6ff4c9 | |||
| 191d8e7268 | |||
| cb44b3b8cc |
126
frontend/src/client/filter-bar/compute-effective.test.ts
Normal file
126
frontend/src/client/filter-bar/compute-effective.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// Unit tests for the FilterBar's computeEffective() overlay. These pin
|
||||
// the contract that any chip the user clicks ends up as a predicate the
|
||||
// server can see — the t-paliad-283 regression had four sources picking
|
||||
// up zero narrowing for /views/any because the bar's chip click didn't
|
||||
// produce a non-empty `filter.predicates` for that source.
|
||||
//
|
||||
// Run with `bun test`.
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { computeEffective } from "./index";
|
||||
import type { FilterSpec, RenderSpec } from "../views/types";
|
||||
import type { BarState } from "./types";
|
||||
|
||||
// Mirrors paliad.user_views row {slug: "any"} — the saved Custom View
|
||||
// that triggered the t-paliad-283 regression report.
|
||||
const ANY_VIEW_FILTER: FilterSpec = {
|
||||
version: 1,
|
||||
sources: ["deadline", "appointment", "project_event", "approval_request"],
|
||||
scope: { projects: { mode: "all_visible" } },
|
||||
time: { field: "auto", horizon: "past_30d" },
|
||||
};
|
||||
|
||||
const ANY_VIEW_RENDER: RenderSpec = {
|
||||
shape: "list",
|
||||
list: { sort: "date_asc", density: "comfortable" },
|
||||
};
|
||||
|
||||
describe("filter-bar/computeEffective — /views/any (all 4 sources)", () => {
|
||||
test("empty state leaves base spec intact (no overlays)", () => {
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, {});
|
||||
expect(eff.filter.sources).toEqual([
|
||||
"deadline", "appointment", "project_event", "approval_request",
|
||||
]);
|
||||
expect(eff.filter.time).toEqual({ field: "auto", horizon: "past_30d" });
|
||||
// predicates may be {} (the bar zero-fills it) but never carries a
|
||||
// stray narrowing on any source — that would silently filter
|
||||
// results the user never asked to filter.
|
||||
for (const src of ANY_VIEW_FILTER.sources) {
|
||||
expect(eff.filter.predicates?.[src]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("deadline_status chip narrows deadline predicate", () => {
|
||||
const state: BarState = { deadline_status: ["pending"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.deadline?.status).toEqual(["pending"]);
|
||||
});
|
||||
|
||||
test("appointment_type chip narrows appointment predicate", () => {
|
||||
const state: BarState = { appointment_type: ["hearing"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.appointment?.appointment_types).toEqual(["hearing"]);
|
||||
});
|
||||
|
||||
test("approval_viewer_role chip narrows approval predicate", () => {
|
||||
const state: BarState = { approval_viewer_role: "any_visible" };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.approval_request?.viewer_role).toBe("any_visible");
|
||||
});
|
||||
|
||||
test("approval_status chip narrows approval predicate", () => {
|
||||
const state: BarState = { approval_status: ["pending", "approved"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.approval_request?.status).toEqual(["pending", "approved"]);
|
||||
});
|
||||
|
||||
test("approval_entity_type chip narrows approval predicate", () => {
|
||||
const state: BarState = { approval_entity_type: ["deadline"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.approval_request?.entity_types).toEqual(["deadline"]);
|
||||
});
|
||||
|
||||
test("project_event_kind chip narrows project_event predicate", () => {
|
||||
const state: BarState = { project_event_kind: ["deadline_created"] };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.predicates?.project_event?.event_types).toEqual(["deadline_created"]);
|
||||
});
|
||||
|
||||
test("time chip overrides base horizon", () => {
|
||||
const state: BarState = { time: { horizon: "past_7d" } };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.time.horizon).toBe("past_7d");
|
||||
expect(eff.filter.time.field).toBe("auto"); // preserved from base
|
||||
});
|
||||
|
||||
test("personal_only chip flips scope flag", () => {
|
||||
const state: BarState = { personal_only: true };
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.scope.personal_only).toBe(true);
|
||||
});
|
||||
|
||||
test("multiple chips combine into the same effective spec", () => {
|
||||
const state: BarState = {
|
||||
time: { horizon: "past_7d" },
|
||||
deadline_status: ["pending"],
|
||||
appointment_type: ["hearing"],
|
||||
approval_status: ["pending"],
|
||||
project_event_kind: ["deadline_created"],
|
||||
};
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
|
||||
expect(eff.filter.time.horizon).toBe("past_7d");
|
||||
expect(eff.filter.predicates?.deadline?.status).toEqual(["pending"]);
|
||||
expect(eff.filter.predicates?.appointment?.appointment_types).toEqual(["hearing"]);
|
||||
expect(eff.filter.predicates?.approval_request?.status).toEqual(["pending"]);
|
||||
expect(eff.filter.predicates?.project_event?.event_types).toEqual(["deadline_created"]);
|
||||
});
|
||||
|
||||
test("overlay does not mutate the caller's base filter", () => {
|
||||
const base: FilterSpec = JSON.parse(JSON.stringify(ANY_VIEW_FILTER));
|
||||
const state: BarState = { deadline_status: ["pending"], time: { horizon: "past_7d" } };
|
||||
computeEffective(base, ANY_VIEW_RENDER, state);
|
||||
// The bar deep-clones; the base must come back unchanged so a
|
||||
// second click doesn't compound the previous click's overlay.
|
||||
expect(base).toEqual(ANY_VIEW_FILTER);
|
||||
});
|
||||
|
||||
test("inbox-only axes do not affect a /views/any spec (no inbox axis exposed)", () => {
|
||||
// /views/any's axes don't include unread_only or inbox_focus, so
|
||||
// those keys never appear in state. Verify that even if they did,
|
||||
// the bar's overlay doesn't silently mutate sources or predicates
|
||||
// in a way that would break a 4-source Custom View.
|
||||
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, {});
|
||||
expect(eff.filter.sources).toHaveLength(4);
|
||||
expect(eff.filter.unread_only ?? false).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -254,6 +254,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.party.both.label": "beide Seiten",
|
||||
"deadlines.court.set": "vom Gericht bestimmt",
|
||||
"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.priority.mandatory": "Pflicht",
|
||||
"deadlines.priority.recommended": "empfohlen",
|
||||
@@ -325,6 +327,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"choices.include_ccr.chip": "mit Nichtigkeitswiderklage",
|
||||
"choices.reset": "Auswahl zurücksetzen",
|
||||
"choices.commit.error": "Konnte Auswahl nicht speichern",
|
||||
// t-paliad-290 (m/paliad#122) — re-surface hidden optional cards.
|
||||
"choices.show_hidden.label": "Ausgeblendete anzeigen",
|
||||
"choices.show_hidden.count": "Ausgeblendete ({n})",
|
||||
"choices.unhide.chip": "Wieder einblenden",
|
||||
// t-paliad-293 \u2014 iconified state markers on the Verfahrensablauf
|
||||
// event cards. Tooltip-only text; the glyph is the primary signal.
|
||||
"state.optional.tooltip": "Optionales Ereignis",
|
||||
"state.hidden.tooltip": "Ausgeblendet \u2014 \u00fcber Optionen-Men\u00fc wieder einblenden",
|
||||
// Trigger-event mode (PR-2 \u2014 youpc-parity)
|
||||
"deadlines.mode.procedure": "Verfahrensablauf",
|
||||
"deadlines.mode.event": "Was kommt nach\u2026",
|
||||
@@ -439,9 +449,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.label": "Seite:",
|
||||
"deadlines.side.claimant": "Klägerseite",
|
||||
"deadlines.side.defendant": "Beklagtenseite",
|
||||
"deadlines.side.both": "Beide",
|
||||
"deadlines.side.undefined": "Nicht festgelegt",
|
||||
"deadlines.side.from_project": "Aus Akte:",
|
||||
"deadlines.side.override": "Andere Seite wählen",
|
||||
"deadlines.side.hint": "Wählen Sie eine Seite, um die Spalten zu fokussieren.",
|
||||
"deadlines.appellant.label": "Berufung durch:",
|
||||
"deadlines.appellant.claimant": "Klägerseite",
|
||||
"deadlines.appellant.defendant": "Beklagtenseite",
|
||||
@@ -1497,7 +1508,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// t-paliad-277 — import-from-project + party-picker.
|
||||
"submissions.draft.import.button": "Aus Projekt importieren",
|
||||
"submissions.draft.parties.title": "Parteien",
|
||||
"submissions.draft.parties.hint": "Wählen Sie 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.
|
||||
"submissions.draft.language": "Sprache",
|
||||
"submissions.draft.language.de": "DE",
|
||||
@@ -3351,6 +3362,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.party.both.label": "both parties",
|
||||
"deadlines.court.set": "set by court",
|
||||
"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.priority.mandatory": "Mandatory",
|
||||
"deadlines.priority.recommended": "Recommended",
|
||||
@@ -3422,6 +3435,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"choices.include_ccr.chip": "with nullity counterclaim",
|
||||
"choices.reset": "Reset choice",
|
||||
"choices.commit.error": "Could not save selection",
|
||||
// t-paliad-290 (m/paliad#122) — re-surface hidden optional cards.
|
||||
"choices.show_hidden.label": "Show hidden",
|
||||
"choices.show_hidden.count": "Hidden ({n})",
|
||||
"choices.unhide.chip": "Show again",
|
||||
// t-paliad-293 — iconified state markers on the Verfahrensablauf
|
||||
// event cards. Tooltip-only text; the glyph is the primary signal.
|
||||
"state.optional.tooltip": "Optional event",
|
||||
"state.hidden.tooltip": "Hidden — restore via the options menu",
|
||||
"deadlines.adjusted": "Adjusted",
|
||||
"deadlines.adjusted.reason": "weekend/holiday",
|
||||
"deadlines.adjusted.weekend": "weekend",
|
||||
@@ -3543,9 +3564,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.side.label": "Side:",
|
||||
"deadlines.side.claimant": "Claimant",
|
||||
"deadlines.side.defendant": "Defendant",
|
||||
"deadlines.side.both": "Both",
|
||||
"deadlines.side.undefined": "Undefined",
|
||||
"deadlines.side.from_project": "From case:",
|
||||
"deadlines.side.override": "Choose other side",
|
||||
"deadlines.side.hint": "Pick a side to focus the columns.",
|
||||
"deadlines.appellant.label": "Appeal filed by:",
|
||||
"deadlines.appellant.claimant": "Claimant",
|
||||
"deadlines.appellant.defendant": "Defendant",
|
||||
@@ -4580,7 +4602,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// t-paliad-277 — import-from-project + party-picker.
|
||||
"submissions.draft.import.button": "Import from project",
|
||||
"submissions.draft.parties.title": "Parties",
|
||||
"submissions.draft.parties.hint": "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.
|
||||
"submissions.index.title": "Submissions — Paliad",
|
||||
"submissions.index.heading": "Submissions",
|
||||
|
||||
@@ -137,6 +137,13 @@ interface VariableGroup {
|
||||
id: string;
|
||||
label: VariableLabel;
|
||||
keys: string[];
|
||||
// t-paliad-287 — render with a click-to-toggle disclosure caret; the
|
||||
// initial state is collapsed iff collapsedByDefault. Used for the
|
||||
// Frist section which lawyers rarely need to override (the variables
|
||||
// stay resolvable in the bag for the few templates that still want
|
||||
// them, but render no body content by default).
|
||||
collapsible?: boolean;
|
||||
collapsedByDefault?: boolean;
|
||||
}
|
||||
|
||||
const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||
@@ -205,33 +212,19 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
|
||||
};
|
||||
|
||||
// t-paliad-287 — variable groups restructured into four lawyer-facing
|
||||
// sections: Mandant/Verfahren up top (the case identity), then Parteien
|
||||
// (where the picker UI lives — this group only carries the manual
|
||||
// {{parties.*}} overrides for power-users), then Frist collapsed by
|
||||
// default (the deadline.* keys still resolve in the bag but the default
|
||||
// templates don't render them in the body any more), then Sonstiges for
|
||||
// the firm/date/user trim. The legacy procedural_event/rule namespaces
|
||||
// fold into Mandant/Verfahren so the lawyer reads them in their natural
|
||||
// context.
|
||||
const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
{
|
||||
id: "procedural_event",
|
||||
label: { de: "Verfahrensschritt", en: "Procedural event" },
|
||||
keys: [
|
||||
"procedural_event.name",
|
||||
"procedural_event.legal_source_pretty",
|
||||
"procedural_event.primary_party",
|
||||
"procedural_event.event_kind",
|
||||
"procedural_event.code",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "parties",
|
||||
label: { de: "Mandanten & Parteien", en: "Clients & parties" },
|
||||
keys: [
|
||||
"parties.claimant.name",
|
||||
"parties.claimant.representative",
|
||||
"parties.defendant.name",
|
||||
"parties.defendant.representative",
|
||||
"parties.other.name",
|
||||
"parties.other.representative",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "project",
|
||||
label: { de: "Verfahren", en: "Proceeding" },
|
||||
id: "mandant_verfahren",
|
||||
label: { de: "Mandant & Verfahren", en: "Client & proceeding" },
|
||||
keys: [
|
||||
"project.title",
|
||||
"project.case_number",
|
||||
@@ -246,11 +239,43 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
"project.matter_number",
|
||||
"project.reference",
|
||||
"project.instance_level",
|
||||
"procedural_event.name",
|
||||
"procedural_event.legal_source_pretty",
|
||||
"procedural_event.primary_party",
|
||||
"procedural_event.event_kind",
|
||||
"procedural_event.code",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "parties",
|
||||
label: { de: "Parteien (Variablen)", en: "Parties (variables)" },
|
||||
// Manual overrides for {{parties.<role>.*}} placeholders — power-
|
||||
// user escape hatch when the lawyer wants the rendered string to
|
||||
// differ from the picker selection (e.g. honourific prefix on
|
||||
// representative). Collapsed by default because the picker above
|
||||
// is the canonical surface; these rows exist only as a safety
|
||||
// valve.
|
||||
collapsible: true,
|
||||
collapsedByDefault: true,
|
||||
keys: [
|
||||
"parties.claimant.name",
|
||||
"parties.claimant.representative",
|
||||
"parties.defendant.name",
|
||||
"parties.defendant.representative",
|
||||
"parties.other.name",
|
||||
"parties.other.representative",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "deadline",
|
||||
label: { de: "Frist", 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: [
|
||||
"deadline.due_date",
|
||||
"deadline.due_date_long_de",
|
||||
@@ -261,10 +286,11 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "firm",
|
||||
label: { de: "Kanzlei & Datum", en: "Firm & date" },
|
||||
id: "sonstiges",
|
||||
label: { de: "Sonstiges", en: "Other" },
|
||||
keys: [
|
||||
"firm.name",
|
||||
"firm.signature_block",
|
||||
"user.display_name",
|
||||
"user.email",
|
||||
"user.office",
|
||||
@@ -291,6 +317,29 @@ interface State {
|
||||
saveTimer: number | null;
|
||||
pendingOverrides: Record<string, string> | null;
|
||||
inFlight: AbortController | null;
|
||||
// t-paliad-287 — per-section collapse memory. Sticky across repaints
|
||||
// so autosave (which calls paintVariables) doesn't snap an open
|
||||
// section shut. Seeded lazily from VARIABLE_GROUPS.collapsedByDefault.
|
||||
collapsedGroups: Record<string, boolean>;
|
||||
// t-paliad-287 — which side the Add-Party panel is currently open for
|
||||
// (one panel can be open at a time; clicking the other side's button
|
||||
// toggles). null means closed.
|
||||
addPartyOpen: PartySide | null;
|
||||
addPartyMode: "manual" | "search";
|
||||
addPartySearchHits: PartySearchHit[];
|
||||
addPartyBusy: boolean;
|
||||
}
|
||||
|
||||
type PartySide = "claimant" | "defendant" | "other";
|
||||
|
||||
interface PartySearchHit {
|
||||
id: string;
|
||||
project_id: string;
|
||||
project_title: string;
|
||||
project_reference?: string | null;
|
||||
name: string;
|
||||
role?: string;
|
||||
representative?: string;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
@@ -300,6 +349,11 @@ const state: State = {
|
||||
saveTimer: null,
|
||||
pendingOverrides: null,
|
||||
inFlight: null,
|
||||
collapsedGroups: {},
|
||||
addPartyOpen: null,
|
||||
addPartyMode: "manual",
|
||||
addPartySearchHits: [],
|
||||
addPartyBusy: false,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -607,24 +661,31 @@ function paintImportRow(): void {
|
||||
btn.onclick = () => { void onImportFromProject(btn); };
|
||||
}
|
||||
|
||||
// t-paliad-277 — multi-select party picker. Lists every party on the
|
||||
// draft's project (view.available_parties), grouped by role, with one
|
||||
// checkbox per party. Checked = include in the variable bag. Empty
|
||||
// selection falls back to the legacy "include every party" default
|
||||
// (consistent with the migration default).
|
||||
// t-paliad-277 / t-paliad-287 — multi-select party picker plus Add-
|
||||
// Party affordance per side. Lists every party on the draft's project
|
||||
// (view.available_parties), grouped by role, with one checkbox per
|
||||
// party. Each side (Klägerseite / Beklagtenseite / Sonstige) carries
|
||||
// an "+ Partei hinzufügen" button that opens an inline panel with two
|
||||
// modes: manual entry (creates a fresh paliad.parties row) or DB
|
||||
// picker (searches every visible project, clones the row into THIS
|
||||
// project on selection). Empty selection still falls back to the
|
||||
// legacy "include every party" default.
|
||||
function paintPartyPicker(): void {
|
||||
const block = document.getElementById("submission-draft-parties");
|
||||
const list = document.getElementById("submission-draft-parties-list");
|
||||
if (!block || !list || !state.view) return;
|
||||
|
||||
const parties = state.view.available_parties ?? [];
|
||||
if (!state.view.draft.project_id || parties.length === 0) {
|
||||
// t-paliad-287 — picker is now shown even on empty-roster projects so
|
||||
// the lawyer can use Add Party to populate. Still hidden when there
|
||||
// is no project attached (no row to attach a party to).
|
||||
if (!state.view.draft.project_id) {
|
||||
block.style.display = "none";
|
||||
list.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
block.style.display = "";
|
||||
|
||||
const parties = state.view.available_parties ?? [];
|
||||
const selected = new Set(state.view.draft.selected_parties ?? []);
|
||||
// Empty selection is the implicit "all" default — pre-check every
|
||||
// party so the lawyer can see what's currently being mentioned and
|
||||
@@ -637,9 +698,13 @@ function paintPartyPicker(): void {
|
||||
const grouped = groupPartiesByRole(parties);
|
||||
let html = "";
|
||||
for (const group of grouped) {
|
||||
if (group.parties.length === 0) continue;
|
||||
html += `<fieldset class="submission-draft-parties-group" data-role-bucket="${group.bucket}">`;
|
||||
html += `<legend>${escapeHtml(group.label)}</legend>`;
|
||||
if (group.parties.length === 0) {
|
||||
html += `<p class="submission-draft-parties-empty">${escapeHtml(
|
||||
isEN() ? "No parties yet." : "Noch keine Parteien.",
|
||||
)}</p>`;
|
||||
}
|
||||
for (const p of group.parties) {
|
||||
const checked = effective.has(p.id) ? " checked" : "";
|
||||
const chip = p.role
|
||||
@@ -658,6 +723,7 @@ function paintPartyPicker(): void {
|
||||
html += rep;
|
||||
html += `</label>`;
|
||||
}
|
||||
html += renderAddPartyControls(group.bucket);
|
||||
html += `</fieldset>`;
|
||||
}
|
||||
list.innerHTML = html;
|
||||
@@ -665,6 +731,198 @@ function paintPartyPicker(): void {
|
||||
list.querySelectorAll<HTMLInputElement>(".submission-draft-party-check").forEach((inp) => {
|
||||
inp.addEventListener("change", () => onPartySelectionChange());
|
||||
});
|
||||
wireAddPartyControls(list);
|
||||
}
|
||||
|
||||
// renderAddPartyControls emits the per-side "+ Add party" button and
|
||||
// (when expanded) the inline panel offering manual entry OR DB search.
|
||||
// Sticky panel state lives in state.addPartyOpen so a repaint after
|
||||
// search-fetch / autosave / language-switch doesn't snap the panel
|
||||
// shut mid-edit.
|
||||
function renderAddPartyControls(side: PartySide): string {
|
||||
const open = state.addPartyOpen === side;
|
||||
const mode = state.addPartyMode;
|
||||
const sideLabel = sideLabelFor(side);
|
||||
const btnLabel = isEN()
|
||||
? `+ Add party (${sideLabel})`
|
||||
: `+ Partei hinzufügen (${sideLabel})`;
|
||||
|
||||
let html = `<div class="submission-draft-addparty">`;
|
||||
html += `<button type="button" class="btn-small btn-secondary submission-draft-addparty-toggle"`;
|
||||
html += ` data-side="${side}" aria-expanded="${open ? "true" : "false"}">`;
|
||||
html += escapeHtml(btnLabel);
|
||||
html += `</button>`;
|
||||
|
||||
if (!open) {
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// Tabs — manual / search.
|
||||
html += `<div class="submission-draft-addparty-panel">`;
|
||||
html += `<div class="submission-draft-addparty-tabs" role="tablist">`;
|
||||
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
|
||||
if (mode === "manual") html += ` submission-draft-addparty-tab--active`;
|
||||
html += `" data-tab="manual" data-side="${side}" aria-selected="${mode === "manual"}">`;
|
||||
html += escapeHtml(isEN() ? "Manual entry" : "Manuell");
|
||||
html += `</button>`;
|
||||
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
|
||||
if (mode === "search") html += ` submission-draft-addparty-tab--active`;
|
||||
html += `" data-tab="search" data-side="${side}" aria-selected="${mode === "search"}">`;
|
||||
html += escapeHtml(isEN() ? "From DB" : "Aus DB übernehmen");
|
||||
html += `</button>`;
|
||||
html += `</div>`;
|
||||
|
||||
if (mode === "manual") {
|
||||
html += renderAddPartyManualForm(side);
|
||||
} else {
|
||||
html += renderAddPartySearchPanel(side);
|
||||
}
|
||||
|
||||
html += `</div></div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderAddPartyManualForm(side: PartySide): string {
|
||||
const defaultRole = defaultRoleFor(side);
|
||||
const busyCls = state.addPartyBusy ? " submission-draft-addparty-form--busy" : "";
|
||||
let html = `<form class="submission-draft-addparty-form${busyCls}" data-side="${side}" data-mode="manual">`;
|
||||
html += `<label class="submission-draft-addparty-field">`;
|
||||
html += `<span>${escapeHtml(isEN() ? "Name" : "Name")}</span>`;
|
||||
html += `<input type="text" name="name" required class="entity-form-input"`;
|
||||
html += ` placeholder="${escapeHtml(isEN() ? "Acme Inc." : "z. B. Acme GmbH")}" />`;
|
||||
html += `</label>`;
|
||||
html += `<label class="submission-draft-addparty-field">`;
|
||||
html += `<span>${escapeHtml(isEN() ? "Role" : "Rolle")}</span>`;
|
||||
html += `<input type="text" name="role" class="entity-form-input"`;
|
||||
html += ` value="${escapeHtml(defaultRole)}"`;
|
||||
html += ` placeholder="${escapeHtml(isEN() ? "claimant / defendant / intervenor / …" : "Klägerin / Beklagte / Streithelferin / …")}" />`;
|
||||
html += `</label>`;
|
||||
html += `<label class="submission-draft-addparty-field">`;
|
||||
html += `<span>${escapeHtml(isEN() ? "Representative (optional)" : "Vertreter:in (optional)")}</span>`;
|
||||
html += `<input type="text" name="representative" class="entity-form-input"`;
|
||||
html += ` placeholder="${escapeHtml(isEN() ? "Dr. Müller, …" : "RA Dr. Müller, …")}" />`;
|
||||
html += `</label>`;
|
||||
html += `<div class="submission-draft-addparty-actions">`;
|
||||
html += `<button type="submit" class="btn-small btn-primary"${state.addPartyBusy ? " disabled" : ""}>`;
|
||||
html += escapeHtml(isEN() ? "Add party" : "Hinzufügen");
|
||||
html += `</button>`;
|
||||
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
|
||||
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
|
||||
html += `</button>`;
|
||||
html += `</div>`;
|
||||
html += `</form>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderAddPartySearchPanel(side: PartySide): string {
|
||||
let html = `<div class="submission-draft-addparty-search" data-side="${side}" data-mode="search">`;
|
||||
html += `<input type="search" class="entity-form-input submission-draft-addparty-search-input"`;
|
||||
html += ` data-side="${side}"`;
|
||||
html += ` placeholder="${escapeHtml(
|
||||
isEN()
|
||||
? "Search across projects (name or representative)…"
|
||||
: "In allen Projekten suchen (Name oder Vertreter)…",
|
||||
)}" />`;
|
||||
html += renderPartySearchResultsList();
|
||||
html += `<p class="submission-draft-addparty-search-hint">${escapeHtml(
|
||||
isEN()
|
||||
? "Picking a row clones it as a fresh party on this project — no typing."
|
||||
: "Auswählen kopiert die Partei in dieses Projekt — kein erneutes Tippen.",
|
||||
)}</p>`;
|
||||
html += `<div class="submission-draft-addparty-actions">`;
|
||||
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
|
||||
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
|
||||
html += `</button>`;
|
||||
html += `</div>`;
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function wireAddPartyControls(root: HTMLElement): void {
|
||||
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const side = (btn.dataset.side as PartySide) ?? "other";
|
||||
if (state.addPartyOpen === side) {
|
||||
// Toggle off.
|
||||
state.addPartyOpen = null;
|
||||
state.addPartySearchHits = [];
|
||||
} else {
|
||||
state.addPartyOpen = side;
|
||||
state.addPartyMode = "manual";
|
||||
state.addPartySearchHits = [];
|
||||
}
|
||||
paintPartyPicker();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = btn.dataset.tab;
|
||||
if (tab !== "manual" && tab !== "search") return;
|
||||
state.addPartyMode = tab;
|
||||
if (tab === "manual") state.addPartySearchHits = [];
|
||||
paintPartyPicker();
|
||||
if (tab === "search") {
|
||||
// Pre-load most-recent matches with empty query so the lawyer
|
||||
// sees options without typing first.
|
||||
void runPartySearch("");
|
||||
}
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-cancel").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.addPartyOpen = null;
|
||||
state.addPartySearchHits = [];
|
||||
paintPartyPicker();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLFormElement>(".submission-draft-addparty-form").forEach((form) => {
|
||||
form.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
const side = (form.dataset.side as PartySide) ?? "other";
|
||||
const data = new FormData(form);
|
||||
const name = String(data.get("name") ?? "").trim();
|
||||
if (!name) return;
|
||||
const role = String(data.get("role") ?? "").trim();
|
||||
const representative = String(data.get("representative") ?? "").trim();
|
||||
void onAddPartyManualSubmit(side, { name, role, representative });
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLInputElement>(".submission-draft-addparty-search-input").forEach((inp) => {
|
||||
let timer: number | null = null;
|
||||
inp.addEventListener("input", () => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
void runPartySearch(inp.value.trim());
|
||||
}, 200);
|
||||
});
|
||||
// Pre-load on first render of the search tab.
|
||||
if (state.addPartyMode === "search" && state.addPartySearchHits.length === 0) {
|
||||
void runPartySearch("");
|
||||
}
|
||||
});
|
||||
root.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const hitID = li.dataset.hitId;
|
||||
if (!hitID) return;
|
||||
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
|
||||
if (!hit) return;
|
||||
const side = state.addPartyOpen ?? "other";
|
||||
void onAddPartySearchPick(side, hit);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sideLabelFor(side: PartySide): string {
|
||||
if (side === "claimant") return isEN() ? "Claimant side" : "Klägerseite";
|
||||
if (side === "defendant") return isEN() ? "Defendant side" : "Beklagtenseite";
|
||||
return isEN() ? "Other parties" : "Weitere Parteien";
|
||||
}
|
||||
|
||||
function defaultRoleFor(side: PartySide): string {
|
||||
if (side === "claimant") return isEN() ? "claimant" : "Klägerin";
|
||||
if (side === "defendant") return isEN() ? "defendant" : "Beklagte";
|
||||
return "";
|
||||
}
|
||||
|
||||
interface PartyRoleGroup {
|
||||
@@ -781,8 +1039,27 @@ function paintVariables(): void {
|
||||
let html = "";
|
||||
for (const group of VARIABLE_GROUPS) {
|
||||
const groupLabel = isEN() ? group.label.en : group.label.de;
|
||||
html += `<section class="submission-draft-var-group" data-group="${group.id}">`;
|
||||
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
|
||||
// Re-use the user's prior toggle state across paintVariables calls
|
||||
// (autosave / language switch trigger a repaint). Default sticky
|
||||
// state lives in state.collapsedGroups; on first render the
|
||||
// collapsedByDefault flag seeds it.
|
||||
if (!Object.prototype.hasOwnProperty.call(state.collapsedGroups, group.id)) {
|
||||
state.collapsedGroups[group.id] = !!(group.collapsible && group.collapsedByDefault);
|
||||
}
|
||||
const collapsed = !!state.collapsedGroups[group.id];
|
||||
const collapsibleCls = group.collapsible ? " submission-draft-var-group--collapsible" : "";
|
||||
const collapsedCls = collapsed ? " submission-draft-var-group--collapsed" : "";
|
||||
html += `<section class="submission-draft-var-group${collapsibleCls}${collapsedCls}" data-group="${group.id}">`;
|
||||
if (group.collapsible) {
|
||||
html += `<button type="button" class="submission-draft-var-group-toggle"`;
|
||||
html += ` data-toggle-group="${escapeHtml(group.id)}" aria-expanded="${collapsed ? "false" : "true"}">`;
|
||||
html += `<span class="submission-draft-var-group-caret" aria-hidden="true">▸</span>`;
|
||||
html += `<span class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</span>`;
|
||||
html += `</button>`;
|
||||
} else {
|
||||
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
|
||||
}
|
||||
html += `<div class="submission-draft-var-group-body">`;
|
||||
for (const key of group.keys) {
|
||||
const label = labelFor(key);
|
||||
const override = overrides[key];
|
||||
@@ -813,10 +1090,19 @@ function paintVariables(): void {
|
||||
// Visual hint: marker text appears in preview when override is "".
|
||||
void mergedVal;
|
||||
}
|
||||
html += `</div>`;
|
||||
html += `</section>`;
|
||||
}
|
||||
host.innerHTML = html;
|
||||
|
||||
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-group-toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const id = btn.dataset.toggleGroup;
|
||||
if (!id) return;
|
||||
state.collapsedGroups[id] = !state.collapsedGroups[id];
|
||||
paintVariables();
|
||||
});
|
||||
});
|
||||
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
|
||||
inp.addEventListener("input", () => onVarChange(inp));
|
||||
// t-paliad-274 (B) — focus into a sidebar field highlights every
|
||||
@@ -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> {
|
||||
if (!state.view) return;
|
||||
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);
|
||||
}
|
||||
|
||||
// Show-hidden toggle state (t-paliad-290 / m/paliad#122). When ON, the
|
||||
// calculator re-surfaces cards whose submission_code is in the active
|
||||
// skipRules set; they render faded with a "Wieder einblenden" chip.
|
||||
// URL-driven via ?show_hidden=1 so a shared link or reload preserves
|
||||
// the visibility. Default OFF — m's not asking to see hidden by
|
||||
// default, just to be able to.
|
||||
function readShowHiddenFromURL(): boolean {
|
||||
return new URLSearchParams(window.location.search).get("show_hidden") === "1";
|
||||
}
|
||||
|
||||
function writeShowHiddenToURL(on: boolean) {
|
||||
const url = new URL(window.location.href);
|
||||
if (on) url.searchParams.set("show_hidden", "1");
|
||||
else url.searchParams.delete("show_hidden");
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
let showHidden = readShowHiddenFromURL();
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
@@ -256,14 +275,33 @@ async function doCalc() {
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
perCardChoices,
|
||||
includeHidden: showHidden,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
if (!data) return;
|
||||
lastResponse = data;
|
||||
renderResults(data);
|
||||
syncHiddenBadge(data.hiddenCount ?? 0);
|
||||
showStep(3);
|
||||
}
|
||||
|
||||
// syncHiddenBadge updates the "Ausgeblendete (N)" count next to the
|
||||
// toggle. Visible regardless of toggle state so the user knows whether
|
||||
// there's anything to re-surface even when the toggle is OFF. Hides the
|
||||
// whole row when the projection has zero hidden cards — no clutter on
|
||||
// a project that's never used the skip feature. (t-paliad-290)
|
||||
function syncHiddenBadge(count: number) {
|
||||
const row = document.getElementById("show-hidden-row");
|
||||
const badge = document.getElementById("show-hidden-count");
|
||||
if (!row || !badge) return;
|
||||
if (count <= 0) {
|
||||
row.style.display = "none";
|
||||
return;
|
||||
}
|
||||
row.style.display = "";
|
||||
badge.textContent = tDyn("choices.show_hidden.count").replace("{n}", String(count));
|
||||
}
|
||||
|
||||
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
|
||||
// label from the calc response. Precedence:
|
||||
//
|
||||
@@ -497,7 +535,17 @@ async function fetchProjectOurSide(projectID: string): Promise<ProjectOurSide |
|
||||
function sideLabelI18n(s: Side): string {
|
||||
if (s === "claimant") return t("deadlines.side.claimant");
|
||||
if (s === "defendant") return t("deadlines.side.defendant");
|
||||
return t("deadlines.side.both");
|
||||
return t("deadlines.side.undefined");
|
||||
}
|
||||
|
||||
// syncSideHintVisibility shows the "pick a side" hint chip only while
|
||||
// currentSide is unset (m/paliad#120). When the user has picked
|
||||
// claimant / defendant the columns are already focused, so the prompt
|
||||
// would be misleading.
|
||||
function syncSideHintVisibility() {
|
||||
const hint = document.getElementById("side-hint");
|
||||
if (!hint) return;
|
||||
hint.style.display = currentSide === null ? "" : "none";
|
||||
}
|
||||
|
||||
// renderSideChip swaps the radio cluster for a read-only chip showing
|
||||
@@ -521,6 +569,9 @@ function showSideRadioCluster() {
|
||||
if (!cluster || !chip) return;
|
||||
cluster.style.display = "";
|
||||
chip.style.display = "none";
|
||||
// Cluster re-appears after override → re-evaluate hint visibility so
|
||||
// we don't leave a stale "pick a side" prompt above a checked radio.
|
||||
syncSideHintVisibility();
|
||||
}
|
||||
|
||||
// applySidePrefill takes a project's our_side, maps it to the side axis,
|
||||
@@ -606,6 +657,7 @@ function initPerspectiveControls() {
|
||||
currentAppellant = readAppellantFromURL();
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
syncSideHintVisibility();
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
@@ -613,6 +665,7 @@ function initPerspectiveControls() {
|
||||
const v = input.value;
|
||||
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeSideToURL(currentSide);
|
||||
syncSideHintVisibility();
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
@@ -696,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();
|
||||
initPerspectiveControls();
|
||||
|
||||
|
||||
@@ -74,10 +74,11 @@ export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
|
||||
states.set(opts.container, state);
|
||||
|
||||
opts.container.addEventListener("click", (e) => {
|
||||
const target = (e.target as HTMLElement | null)?.closest<HTMLElement>(".event-card-choices-caret");
|
||||
if (target) {
|
||||
const targetEl = e.target as HTMLElement | null;
|
||||
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
|
||||
if (caret) {
|
||||
e.stopPropagation();
|
||||
openPopover(state, target);
|
||||
openPopover(state, caret);
|
||||
return;
|
||||
}
|
||||
// Outside-click closes the popover.
|
||||
@@ -158,6 +159,7 @@ function openPopover(state: AttachedState, caret: HTMLElement): void {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const isHidden = caret.dataset.isHidden === "1";
|
||||
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "event-card-choices-popover";
|
||||
@@ -165,6 +167,15 @@ function openPopover(state: AttachedState, caret: HTMLElement): void {
|
||||
pop.setAttribute("aria-label", t("choices.caret.title"));
|
||||
|
||||
const blocks: string[] = [];
|
||||
// t-paliad-293: hidden-card prominence. When the user opens the
|
||||
// popover on a re-surfaced hidden card, "Wieder einblenden" is the
|
||||
// most likely intent — surface it as a single high-contrast action
|
||||
// at the top of the popover (rather than burying it under the skip
|
||||
// toggle's reset link). Clicking it clears the `skip` choice, which
|
||||
// is the same wire effect as the legacy inline chip from t-paliad-290.
|
||||
if (isHidden) {
|
||||
blocks.push(renderUnhideBlock());
|
||||
}
|
||||
if (Array.isArray(offered.appellant)) {
|
||||
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
|
||||
}
|
||||
@@ -259,6 +270,23 @@ function renderToggleBlock(state: AttachedState, code: string, kind: "include_cc
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderUnhideBlock is the popover's prominent "Wieder einblenden"
|
||||
// action — surfaced only when the caret is opened on a re-surfaced
|
||||
// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches
|
||||
// the same `clear` action as the skip-block reset link below, but
|
||||
// labelled in the user's terms ("restore this card" rather than
|
||||
// "reset skip choice"). Drops out of the popover automatically on
|
||||
// non-hidden cards so the popover stays minimal. (t-paliad-293)
|
||||
function renderUnhideBlock(): string {
|
||||
const label = t("choices.unhide.chip");
|
||||
return `<div class="event-card-choices-block event-card-choices-block--unhide">
|
||||
<button type="button"
|
||||
data-choice-action="clear"
|
||||
data-choice-kind="skip"
|
||||
class="event-card-choices-unhide-btn">${escHtml(label)}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function closePopover(state: AttachedState): void {
|
||||
if (state.popover) {
|
||||
state.popover.remove();
|
||||
|
||||
@@ -66,7 +66,13 @@ export interface FilterSpec {
|
||||
sources: DataSource[];
|
||||
scope: ScopeSpec;
|
||||
time: TimeSpec;
|
||||
predicates?: Partial<Record<DataSource, Predicates>>;
|
||||
// Per-source narrowing. Flat shape — one entry per data source. The
|
||||
// Go side (internal/services/filter_spec.go: FilterSpec.Predicates)
|
||||
// mirrors this exactly; the previous Partial<Record<DataSource,
|
||||
// Predicates>> spelling was a latent contract bug (t-paliad-283)
|
||||
// where every chip click sent a single-nested shape the server
|
||||
// unmarshalled to no-op.
|
||||
predicates?: Predicates;
|
||||
// Inbox unread-only overlay (t-paliad-249). When true, the view
|
||||
// service drops project_event rows older than the caller's
|
||||
// users.inbox_seen_at cursor. Pending approval_requests always
|
||||
|
||||
@@ -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
|
||||
// (side + appellant axes), re-framed by m/paliad#88: the column
|
||||
// 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
|
||||
// reads this in preference to the page-level appellant.
|
||||
appellantContext?: string;
|
||||
// isHidden (t-paliad-290 / m/paliad#122): server-side flag set when
|
||||
// a previously-hidden card is re-surfaced via the "Ausgeblendete
|
||||
// anzeigen" toggle. The renderer fades the card and exposes an
|
||||
// inline "Wieder einblenden" chip that deletes the skip choice.
|
||||
isHidden?: boolean;
|
||||
// isConditional (t-paliad-289): the rule's anchor is uncertain, so
|
||||
// no concrete date is projected. Set by the calculator when the rule
|
||||
// depends on a court-set ancestor without override, when a backward-
|
||||
// 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
|
||||
@@ -131,6 +154,13 @@ export interface DeadlineResponse {
|
||||
// (m/paliad#81)
|
||||
triggerEventLabel?: string;
|
||||
triggerEventLabelEN?: string;
|
||||
// hiddenCount (t-paliad-290 / m/paliad#122): number of rules that
|
||||
// would have been hidden in this projection (i.e. their
|
||||
// submission_code is in skipRules and they passed the condition_expr
|
||||
// gate). Surfaces the "Ausgeblendete (N)" badge on the toggle even
|
||||
// when the toggle is OFF — so users know there's something to
|
||||
// re-surface.
|
||||
hiddenCount?: number;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -160,6 +190,11 @@ export interface CalcParams {
|
||||
choice_kind: string;
|
||||
choice_value: string;
|
||||
}>;
|
||||
// includeHidden (t-paliad-290): when true the calculator returns
|
||||
// previously-skipped rules as faded cards instead of dropping them.
|
||||
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is
|
||||
// ON.
|
||||
includeHidden?: boolean;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
@@ -175,10 +210,20 @@ export function escAttr(s: string): string {
|
||||
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 {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
@@ -279,28 +324,76 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
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"))}"`
|
||||
: "";
|
||||
const courtLabelKey = dl.isCourtSetIndirect
|
||||
? "deadlines.court.indirect"
|
||||
: "deadlines.court.set";
|
||||
const dateStr = dl.isCourtSet
|
||||
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
|
||||
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
// Conditional rows (t-paliad-289) replace the date column with an
|
||||
// "abhängig von <parent>" chip. The chip remains click-to-edit so
|
||||
// the user can pin a real date once known (e.g. once the oral
|
||||
// hearing date is set, or the opposing party's Vertraulichkeits-
|
||||
// antrag arrives) — the same data-rule-code wiring fires the
|
||||
// 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
|
||||
// priority directly. Optional badge fires only on 'optional'
|
||||
// priority (RoP.151-style opt-in deadlines).
|
||||
const mandatoryBadge = dl.priority === "optional"
|
||||
? '<span class="optional-badge">optional</span>'
|
||||
: "";
|
||||
// t-paliad-293 — iconified state markers. The card surface speaks
|
||||
// "cut the tree of possibilities": each card carries 0–N small icons
|
||||
// in the title row that summarise its decision state at a glance.
|
||||
// The text "optional" badge that used to sit inline next to the name
|
||||
// is now a ⊙ icon (state.optional). Hidden cards get a 👁⃠ eye-slash
|
||||
// marker. Conditional cards already have the date-column chip; the
|
||||
// marker is redundant in the title row. CCR-included / appellant
|
||||
// picks remain on the chip row (event-card-choices-chip) — see below.
|
||||
// Tooltips are i18n-driven so they read in the user's language.
|
||||
const stateIcons: string[] = [];
|
||||
if (dl.priority === "optional") {
|
||||
stateIcons.push(
|
||||
`<span class="timeline-state-icon timeline-state-icon--optional" role="img" aria-label="${escAttr(t("state.optional.tooltip"))}" title="${escAttr(t("state.optional.tooltip"))}">⊙</span>`,
|
||||
);
|
||||
}
|
||||
if (dl.isHidden) {
|
||||
stateIcons.push(
|
||||
`<span class="timeline-state-icon timeline-state-icon--hidden" role="img" aria-label="${escAttr(t("state.hidden.tooltip"))}" title="${escAttr(t("state.hidden.tooltip"))}">👁⃠</span>`,
|
||||
);
|
||||
}
|
||||
const stateIconsHtml = stateIcons.join("");
|
||||
|
||||
// t-paliad-265 — caret affordance + chip indicator when this rule
|
||||
// offers per-card choices and the user has made a pick. The popover
|
||||
// open/commit lifecycle lives in client/views/event-card-choices.ts;
|
||||
// the data-* attributes here are the wire contract between the two.
|
||||
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"
|
||||
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"))}"
|
||||
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">
|
||||
<span class="timeline-name">
|
||||
${dlName}
|
||||
${mandatoryBadge}
|
||||
${stateIconsHtml}
|
||||
${chipHtml}
|
||||
</span>
|
||||
${dateStr}
|
||||
@@ -449,8 +542,20 @@ export function wireDateEditClicks(
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
let html = '<div class="timeline">';
|
||||
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 += `
|
||||
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
|
||||
<div class="${itemClasses}">
|
||||
<div class="timeline-dot-col">
|
||||
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
||||
<div class="timeline-line"></div>
|
||||
@@ -629,7 +734,17 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
const mirrorTag = showMirrorTag && dl.party === "both"
|
||||
? `<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)}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
@@ -680,6 +795,7 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
||||
perCardChoices: params.perCardChoices && params.perCardChoices.length > 0
|
||||
? params.perCardChoices
|
||||
: undefined,
|
||||
includeHidden: params.includeHidden ? true : undefined,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -1021,10 +1021,13 @@ export type I18nKey =
|
||||
| "choices.include_ccr.title"
|
||||
| "choices.include_ccr.true"
|
||||
| "choices.reset"
|
||||
| "choices.show_hidden.count"
|
||||
| "choices.show_hidden.label"
|
||||
| "choices.skip.false"
|
||||
| "choices.skip.title"
|
||||
| "choices.skip.true"
|
||||
| "choices.skipped.chip"
|
||||
| "choices.unhide.chip"
|
||||
| "common.cancel"
|
||||
| "common.close"
|
||||
| "common.forbidden"
|
||||
@@ -1235,6 +1238,8 @@ export type I18nKey =
|
||||
| "deadlines.col.title"
|
||||
| "deadlines.complete.action"
|
||||
| "deadlines.complete.confirm"
|
||||
| "deadlines.conditional.depends_on"
|
||||
| "deadlines.conditional.unset"
|
||||
| "deadlines.court.indirect"
|
||||
| "deadlines.court.label"
|
||||
| "deadlines.court.set"
|
||||
@@ -1460,12 +1465,13 @@ export type I18nKey =
|
||||
| "deadlines.search.placeholder"
|
||||
| "deadlines.search.results.count"
|
||||
| "deadlines.search.results.count_one"
|
||||
| "deadlines.side.both"
|
||||
| "deadlines.side.claimant"
|
||||
| "deadlines.side.defendant"
|
||||
| "deadlines.side.from_project"
|
||||
| "deadlines.side.hint"
|
||||
| "deadlines.side.label"
|
||||
| "deadlines.side.override"
|
||||
| "deadlines.side.undefined"
|
||||
| "deadlines.source.caldav"
|
||||
| "deadlines.source.fristenrechner"
|
||||
| "deadlines.source.imported"
|
||||
@@ -2615,6 +2621,8 @@ export type I18nKey =
|
||||
| "search.no_results"
|
||||
| "search.placeholder"
|
||||
| "sidebar.resize.title"
|
||||
| "state.hidden.tooltip"
|
||||
| "state.optional.tooltip"
|
||||
| "submissions.draft.action.delete"
|
||||
| "submissions.draft.action.export"
|
||||
| "submissions.draft.action.new"
|
||||
|
||||
@@ -1917,7 +1917,11 @@ input[type="range"]::-moz-range-thumb {
|
||||
.fristen-row.is-active .fristen-row-num {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text, #111);
|
||||
/* Lime is high-luminance; foreground stays midnight in both themes via
|
||||
--color-accent-dark (light: midnight by default, dark: midnight
|
||||
explicit). Using --color-text here would flip to cream in dark mode
|
||||
and collapse contrast on lime. */
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.fristen-row.is-prefilled .fristen-row-num {
|
||||
@@ -3328,7 +3332,11 @@ input[type="range"]::-moz-range-thumb {
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
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 {
|
||||
@@ -3369,19 +3377,37 @@ input[type="range"]::-moz-range-thumb {
|
||||
|
||||
.timeline-content {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
/* min-width:0 lets the name shrink and wrap inside its flex parent
|
||||
— otherwise overflow:hidden in an ancestor would clip it but the
|
||||
flex item would still demand its intrinsic width. */
|
||||
min-width: 0;
|
||||
/* Word-break on long German compounds (Vertraulichkeitswiderklage …)
|
||||
so they wrap mid-word rather than pushing the date column off-
|
||||
screen. (t-paliad-293) */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
@@ -3467,15 +3493,37 @@ input[type="range"]::-moz-range-thumb {
|
||||
color: var(--status-neutral-fg-3);
|
||||
}
|
||||
|
||||
.optional-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 99px;
|
||||
background: var(--status-amber-bg);
|
||||
/* t-paliad-293 — compact state icons in the card title row. They
|
||||
* replace the legacy `.optional-badge` text chip and add a uniform
|
||||
* language for the per-card decision state ("cut the tree of
|
||||
* possibilities"). Each icon carries its own modifier so the tint
|
||||
* matches the state semantic. The glyph itself is the primary signal;
|
||||
* the i18n tooltip on the span carries the accessible description. */
|
||||
.timeline-state-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
cursor: help;
|
||||
user-select: none;
|
||||
/* Cancel the wrapper fade so the marker stays legible inside
|
||||
.timeline-item--hidden which fades the whole content panel. */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.timeline-state-icon--optional {
|
||||
color: var(--status-amber-fg);
|
||||
}
|
||||
|
||||
.timeline-state-icon--hidden {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* t-paliad-265 — per-event-card optional choices. The caret sits in
|
||||
* the card header next to the date; the chip surfaces the active pick
|
||||
* inline with the title; the popover is body-attached and positioned
|
||||
@@ -3531,6 +3579,96 @@ input[type="range"]::-moz-range-thumb {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* t-paliad-290 (m/paliad#122) — re-surfaced "hidden" cards. The user
|
||||
* has previously marked these optional events as "Überspringen"; the
|
||||
* "Ausgeblendete anzeigen" toggle on /tools/verfahrensablauf returns
|
||||
* them with a faded + dotted-border treatment so they're visually
|
||||
* distinct from the active timeline. The inline "Wieder einblenden"
|
||||
* chip cancels the skip on click. */
|
||||
.timeline-item--hidden .timeline-content,
|
||||
.fr-col-item--hidden {
|
||||
opacity: 0.55;
|
||||
border: 1px dotted var(--color-border, #d4d4d4);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
/* t-paliad-293 — prominent "Wieder einblenden" entry inside the caret
|
||||
* popover. Surfaced only when the caret is opened on a hidden card
|
||||
* (data-is-hidden="1"). Used to be an inline chip in the card header,
|
||||
* but that caused horizontal scroll on narrow viewports (m/paliad#125)
|
||||
* because its German label is wide ("Wieder einblenden") and the
|
||||
* card header is a non-wrapping flex row. Moving it into the popover
|
||||
* removes the surface entirely and matches m's "actions live in the
|
||||
* caret menu" framing. */
|
||||
.event-card-choices-block--unhide {
|
||||
/* No top border separator — this block sits at the top of the
|
||||
popover with the highest visual priority. */
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.event-card-choices-unhide-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-accent, #c6f41c);
|
||||
background: var(--color-accent, #c6f41c);
|
||||
/* Match the active-option pin (lime fg → midnight text) so the
|
||||
button reads against the lime in both light and dark themes
|
||||
(m/paliad#123). */
|
||||
color: var(--color-accent-dark);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.event-card-choices-unhide-btn:hover,
|
||||
.event-card-choices-unhide-btn:focus-visible {
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.show-hidden-count {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
/* t-paliad-289: rules whose anchor is uncertain (court-set ancestor
|
||||
without override, backward-anchor with unset forward date, optional
|
||||
event not recorded). The "abhängig von <parent>" chip on the date
|
||||
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 {
|
||||
background: var(--color-bg, #fff);
|
||||
border: 1px solid var(--color-border, #d4d4d4);
|
||||
@@ -3578,7 +3716,10 @@ input[type="range"]::-moz-range-thumb {
|
||||
.event-card-choices-option--active {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
color: var(--color-text);
|
||||
/* Foreground stays midnight in both themes — --color-text would flip
|
||||
to cream in dark mode and leave the active "Berufung durch …"
|
||||
chip unreadable on lime (m/paliad#123). */
|
||||
color: var(--color-accent-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -3711,6 +3852,22 @@ input[type="range"]::-moz-range-thumb {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* "Pick a side" hint that sits next to the side-radio cluster while
|
||||
currentSide is null (m/paliad#120). Both columns still render every
|
||||
rule in that state — the chip just nudges the user that picking a
|
||||
side focuses their column. Hidden by JS once a side is picked. */
|
||||
.side-radio-cluster {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.side-hint {
|
||||
color: var(--color-text-muted, #666);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Read-only auto-fill chip for #side-row. Renders when ?project=<id>
|
||||
resolves a project whose our_side is set: shows the inferred side
|
||||
with a small "Andere Seite wählen" override link that swaps the row
|
||||
@@ -6376,6 +6533,194 @@ dialog.modal::backdrop {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* t-paliad-287 — collapsible variable-group section (Frist + Parteien
|
||||
override). The toggle button is the section header; clicking it
|
||||
flips state.collapsedGroups[id] and re-renders. The visible caret
|
||||
rotates via the parent's --collapsed class. */
|
||||
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0 0 0.5rem 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #c6f41c);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.submission-draft-var-group-caret {
|
||||
display: inline-block;
|
||||
transition: transform 120ms ease;
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.submission-draft-var-group--collapsible:not(.submission-draft-var-group--collapsed)
|
||||
.submission-draft-var-group-caret {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.submission-draft-var-group--collapsed .submission-draft-var-group-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* t-paliad-287 — Add Party affordance per side. */
|
||||
.submission-draft-addparty {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-panel {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-tab {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.85em;
|
||||
border-radius: 4px 4px 0 0;
|
||||
color: var(--color-text-muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-tab--active {
|
||||
color: var(--color-text);
|
||||
border-bottom-color: var(--color-accent, #c6f41c);
|
||||
background: var(--color-bg-lime-tint, #f0fac6);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-form--busy {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-field > span {
|
||||
font-size: 0.82em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-results {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-row {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-row:hover {
|
||||
background: var(--color-bg-lime-tint, #f0fac6);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-empty {
|
||||
padding: 0.6rem;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-rep {
|
||||
font-size: 0.78em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-projwrap {
|
||||
font-size: 0.78em;
|
||||
color: var(--color-text-muted);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-proj {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-projref {
|
||||
margin-left: 0.3rem;
|
||||
padding: 0 0.4em;
|
||||
border-radius: 3px;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-addparty-search-hint {
|
||||
font-size: 0.78em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.submission-draft-parties-empty {
|
||||
font-size: 0.82em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.checklist-instance-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
@@ -7976,7 +8321,7 @@ dialog.modal::backdrop {
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
color: var(--color-accent-dark);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -15906,7 +16251,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
color: var(--color-accent-dark);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
@@ -16514,7 +16859,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
.smart-timeline-anchor-submit {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border: 1px solid var(--color-accent, #c6f41c);
|
||||
color: var(--color-text, #333);
|
||||
color: var(--color-accent-dark);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
@@ -17452,7 +17797,7 @@ dialog.quick-add-sheet::backdrop {
|
||||
.admin-rules-chip.active {
|
||||
background: var(--color-accent, #BFF355);
|
||||
border-color: var(--color-accent, #BFF355);
|
||||
color: var(--color-text, #000);
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.admin-rules-pill {
|
||||
|
||||
@@ -172,10 +172,13 @@ export function renderSubmissionDraft(): string {
|
||||
/>
|
||||
</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
|
||||
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
|
||||
id="submission-draft-parties"
|
||||
className="submission-draft-parties"
|
||||
|
||||
@@ -190,9 +190,18 @@ export function renderVerfahrensablauf(): string {
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.both">Beide</span>
|
||||
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
|
||||
</label>
|
||||
</div>
|
||||
{/* Prompt shown while the user hasn't picked a side
|
||||
(m/paliad#120). Hidden by client when side is
|
||||
claimant or defendant. Both columns still
|
||||
render every rule in this state — picking a
|
||||
side just focuses the user's column. */}
|
||||
<span className="side-hint" id="side-hint"
|
||||
data-i18n="deadlines.side.hint">
|
||||
Wählen Sie eine Seite, um die Spalten zu fokussieren.
|
||||
</span>
|
||||
</div>
|
||||
{/* Auto-fill chip — populated by the client when a
|
||||
?project=<id> URL resolves a project with our_side
|
||||
@@ -224,6 +233,19 @@ export function renderVerfahrensablauf(): string {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/* Show-hidden toggle (t-paliad-290 / m/paliad#122).
|
||||
Re-surfaces optional cards the user has previously
|
||||
marked "Überspringen" via the per-card popover.
|
||||
The row hides itself when the projection has no
|
||||
hidden cards (handled in client/verfahrensablauf.ts).
|
||||
Default OFF; URL state ?show_hidden=1. */}
|
||||
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
|
||||
<label className="fristen-view-option">
|
||||
<input type="checkbox" id="show-hidden-toggle" />
|
||||
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
|
||||
</label>
|
||||
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite"> </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual divider — keeps the perspective block (most-
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
-- Rollback of mig 132 (t-paliad-284 Wave 1 + m/paliad#116).
|
||||
--
|
||||
-- Reverses §0 (R.104/R.105 citation backfill) + §1..§11 (11 Tier 1
|
||||
-- INSERTs) + §12 (T1.12 re-anchor of upc.pi.cfi.response).
|
||||
--
|
||||
-- Does NOT reverse §13b (Q6 archived-litigation cleanup) — those rows
|
||||
-- were already in lifecycle_state='archived' before deletion and are not
|
||||
-- surfaced by any product code path. Restoring them would require the
|
||||
-- pre-mig-132 backup. Leaving them gone is the correct rollback choice;
|
||||
-- emergency restore goes via mig 123 backup snapshot.
|
||||
--
|
||||
-- DOES restore §13a (re-add the deadline_rule_audit.rule_id FK) so the
|
||||
-- audit-table schema returns to its pre-mig-132 shape on rollback. Any
|
||||
-- orphan audit rows accumulated under mig 132 (rule_id pointing at
|
||||
-- now-deleted rules) would block the FK re-add; the rollback DELETE
|
||||
-- below removes them first.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 132 down: rollback Wave 1 Tier 1 rule additions + R.105 citation backfill + T1.12 re-anchor (t-paliad-284 / m/paliad#116)',
|
||||
true);
|
||||
|
||||
-- §12 down — un-re-anchor upc.pi.cfi.response back to its broken root state.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = NULL,
|
||||
is_court_set = false,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.pi.cfi.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true
|
||||
AND rule_code = 'RoP.211.2';
|
||||
|
||||
-- §1..§11 down — delete the 11 Tier 1 INSERTs by submission_code.
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code IN (
|
||||
'upc.inf.cfi.cmo_review',
|
||||
'upc.inf.cfi.confidentiality_response',
|
||||
'upc.apl.order.response_orders', -- delete child first (FK to grounds_orders)
|
||||
'upc.apl.order.grounds_orders',
|
||||
'upc.inf.cfi.cons_orders',
|
||||
'upc.inf.cfi.rectification',
|
||||
'upc.pi.cfi.deficiency',
|
||||
'upc.pi.cfi.merits_start',
|
||||
'upc.inf.cfi.translation_request',
|
||||
'upc.inf.cfi.interpreter_cost',
|
||||
'upc.inf.cfi.translations_lodge'
|
||||
)
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- §0 down — clear the R.104/R.105 citation on upc.inf.cfi.interim.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
rule_codes = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.104'
|
||||
AND legal_source = 'UPC.RoP.104';
|
||||
|
||||
-- §13a down — re-add the deadline_rule_audit.rule_id FK with the
|
||||
-- original ON DELETE CASCADE shape. Purge any orphan audit rows first
|
||||
-- (audit entries pointing at rule_ids that no longer exist in
|
||||
-- deadline_rules) so the FK re-add doesn't fail validation.
|
||||
DELETE FROM paliad.deadline_rule_audit a
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr WHERE dr.id = a.rule_id
|
||||
);
|
||||
|
||||
ALTER TABLE paliad.deadline_rule_audit
|
||||
ADD CONSTRAINT deadline_rule_audit_rule_id_fkey
|
||||
FOREIGN KEY (rule_id) REFERENCES paliad.deadline_rules(id) ON DELETE CASCADE;
|
||||
659
internal/db/migrations/132_wave1_tier1_rule_additions.up.sql
Normal file
659
internal/db/migrations/132_wave1_tier1_rule_additions.up.sql
Normal file
@@ -0,0 +1,659 @@
|
||||
-- t-paliad-284 Wave 1 + m/paliad#116 — Tier 1 deadline-rule additions
|
||||
-- (12 high-frequency procedural events) + UPC RoP R.104/R.105 Interim
|
||||
-- Conference citation backfill + Q6 archived-litigation cleanup.
|
||||
--
|
||||
-- Source: docs/research-deadlines-completeness-2026-05-25.md
|
||||
-- • §10 Tier 1 table (T1.1 .. T1.12)
|
||||
-- • §3.1 missing-rules catalogue (per-rule statutory citations)
|
||||
-- • §9.7 / Q6 (drop the _archived_litigation.* rows — m's design ack
|
||||
-- locked in 2026-05-25)
|
||||
--
|
||||
-- m's report (2026-05-25 17:12) also explicitly named "Zwischenverfahren /
|
||||
-- Interim Conference 105" as missing a rule citation. The audit does not
|
||||
-- list R.105 as a Tier 1 item (the row upc.inf.cfi.interim already exists
|
||||
-- as a court-set anchor), so the fix is to BACKFILL rule_code/legal_source
|
||||
-- on that row rather than to insert a new rule. Done here as a separate
|
||||
-- §0 section, with both RoP.104 (Aims of the interim conference) and
|
||||
-- RoP.105 (Holding of the interim conference) cited via rule_codes[].
|
||||
--
|
||||
-- Wave 2 Slice A primitives (mig 128: working_days unit + combine_op +
|
||||
-- timing='before' backward snap in deadline_calculator.go) are used by:
|
||||
-- • T1.8 upc.pi.cfi.merits_start — 31d OR 20wd, combine_op=max
|
||||
-- • T1.9 upc.inf.cfi.translation_request — 1 month BEFORE oral hearing
|
||||
-- • T1.10 upc.inf.cfi.interpreter_cost — 2 weeks BEFORE oral hearing
|
||||
-- Wave 2 Slice A landed mig 128 (`deadline_rules_unit_check`) — these
|
||||
-- rules are no longer blocked.
|
||||
--
|
||||
-- Slot 132 reserved: 127 brunel Wave 0, 128 knuth W2-A, 129 demeter,
|
||||
-- 130 atlas, 131 artemis → 132 this migration.
|
||||
--
|
||||
-- Idempotency:
|
||||
-- • INSERTs guarded with `WHERE NOT EXISTS (... submission_code = ...)`
|
||||
-- so re-applying matches zero rows on the second run.
|
||||
-- • UPDATEs guarded with `WHERE` clauses that match the pre-fix row
|
||||
-- state only (mig 095 convention).
|
||||
-- • DELETE guarded by lifecycle_state='archived' AND prefix — repeats
|
||||
-- match zero rows after first run.
|
||||
--
|
||||
-- audit_reason set_config is required at the top (mig 079 trigger on
|
||||
-- paliad.deadline_rules raises EXCEPTION 'audit reason required' for
|
||||
-- any INSERT / UPDATE / DELETE without it).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 132: t-paliad-284 Wave 1 + m/paliad#116 — Tier 1 deadline-rule additions (12 rules) from curie''s audit §10 + UPC RoP R.104/105 Interim Conference citation backfill (m''s 2026-05-25 17:12 report) + Q6 archived-litigation cleanup (audit §9.7)',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- §0 R.104/R.105 — Backfill citation on the existing Interim Conference row.
|
||||
-- m's report flagged that `upc.inf.cfi.interim` (Zwischenverfahren) renders
|
||||
-- with no rule reference at /admin/rules. The row exists as a court-set
|
||||
-- anchor (duration=0, parent_id=NULL, primary_party='court'). The
|
||||
-- governing UPC Rules of Procedure are:
|
||||
-- • R.104 — Aims of the interim conference (the substantive rule)
|
||||
-- • R.105 — Holding of the interim conference (procedural)
|
||||
-- Both cited via the rule_codes[] array; rule_code/legal_source carry
|
||||
-- the primary citation (R.104 — Aims).
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.104',
|
||||
legal_source = 'UPC.RoP.104',
|
||||
rule_codes = ARRAY['RoP.104', 'RoP.105'],
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code IS NULL
|
||||
AND legal_source IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- §1 T1.1 upc.inf.cfi.cmo_review — Review of case-management order.
|
||||
-- 15 days from CMO service. UPC RoP R.333.2: "Any party adversely
|
||||
-- affected by a case management order may within 15 days of service
|
||||
-- of the order apply to the panel for a review." Routine in busy LDs
|
||||
-- (Munich CMO traffic ~weekly). Anchor: the Interim Conference row,
|
||||
-- which is where CMOs are typically issued.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8, -- upc.inf.cfi
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.cmo_review',
|
||||
'Überprüfung Verfahrensanordnung',
|
||||
'Review of Case-Management Order',
|
||||
'both', 15, 'days', 'after', 'RoP.333.2', 'UPC.RoP.333.2',
|
||||
'optional', false, 'published', true, 42,
|
||||
'Frist 15 Tage ab Zustellung der Verfahrensanordnung (R.333.2). Jede beschwerte Partei kann beim Spruchkörper Überprüfung beantragen.',
|
||||
'15-day period from service of the case-management order (R.333.2). Any adversely-affected party may apply to the panel for a review.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.cmo_review'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §2 T1.2 upc.inf.cfi.confidentiality_response — Response to opposing
|
||||
-- party's confidentiality application. 14 days from receipt of the
|
||||
-- opposing party's R.262.2 application: "Within 14 days of service
|
||||
-- … the other party may lodge an Application to the contrary."
|
||||
-- Trigger event 25 (paliad.trigger_events) maps 1:1 to this rule.
|
||||
-- Daily occurrence in HLC infringement work. Anchor: Statement of
|
||||
-- Claim row as proceeding root — actual trigger date supplied via
|
||||
-- 'Datum setzen' when the opp party files, since the confidentiality
|
||||
-- app is not itself modelled as a deadline_rules row.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, trigger_event_id, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.soc'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.confidentiality_response',
|
||||
'Erwiderung auf Vertraulichkeitsantrag',
|
||||
'Response to Confidentiality Application',
|
||||
'both', 14, 'days', 'after', 'RoP.262.2', 'UPC.RoP.262.2',
|
||||
'optional', false, 'published', true, 8,
|
||||
25,
|
||||
'Frist 14 Tage ab Zustellung des Vertraulichkeitsantrags der Gegenseite (R.262.2). Datum bei Eingang des Antrags manuell setzen.',
|
||||
'14-day period from service of the opposing party''s confidentiality application (R.262.2). Set trigger date manually on receipt of the application.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.confidentiality_response'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §3 T1.3 upc.apl.order.grounds_orders — Statement of Grounds on the
|
||||
-- orders-track appeal. 15 days from service of the appealed
|
||||
-- order/decision. UPC RoP R.224.2(b): "A Statement of grounds of
|
||||
-- appeal shall be lodged … within 15 days of service of the
|
||||
-- decision/order in cases referred to in Rule 220.1(c), Rule 220.2
|
||||
-- and Rule 221.3." Existing upc.apl.order tree has the with_leave
|
||||
-- notice but no separate grounds row — adding it.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 20, -- upc.apl.order
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.order'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.apl.order.grounds_orders',
|
||||
'Berufungsbegründung (Orders Track)',
|
||||
'Statement of Grounds (Orders Track)',
|
||||
'both', 15, 'days', 'after', 'RoP.224.2.b', 'UPC.RoP.224.2.b',
|
||||
'mandatory', false, 'published', true, 2,
|
||||
'Frist 15 Tage ab Zustellung der angegriffenen Anordnung/Entscheidung (R.224.2(b)).',
|
||||
'15-day period from service of the appealed order/decision (R.224.2(b)).'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.grounds_orders'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §4 T1.4 upc.apl.order.response_orders — Statement of Response on the
|
||||
-- orders-track appeal. 15 days from service of the grounds. UPC RoP
|
||||
-- R.235.2: "Within 15 days of service of grounds of appeal pursuant
|
||||
-- to Rule 224.2(b), any other party … may lodge a Statement of
|
||||
-- response." Parent: the grounds_orders row inserted in §3, looked
|
||||
-- up by submission_code so this INSERT works either against a fresh
|
||||
-- DB or a partially-applied state.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 20,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.grounds_orders'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.apl.order.response_orders',
|
||||
'Berufungserwiderung (Orders Track)',
|
||||
'Statement of Response (Orders Track)',
|
||||
'both', 15, 'days', 'after', 'RoP.235.2', 'UPC.RoP.235.2',
|
||||
'optional', false, 'published', true, 3,
|
||||
'Frist 15 Tage ab Zustellung der Berufungsbegründung (R.235.2).',
|
||||
'15-day period from service of the Statement of grounds of appeal (R.235.2).'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.response_orders'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §5 T1.5 upc.inf.cfi.cons_orders — Application for orders consequential
|
||||
-- on validity. 2 months from service of the validity decision. UPC
|
||||
-- RoP R.118.4: "The Court may, upon a reasoned request by one of
|
||||
-- the parties, … give a decision granting consequential orders.
|
||||
-- The application … shall be made within two months of service of
|
||||
-- the decision …". Common after central-division revocation in
|
||||
-- bifurcated UPC matters.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.decision'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.cons_orders',
|
||||
'Antrag auf Folgeentscheidungen',
|
||||
'Application for Consequential Orders',
|
||||
'both', 2, 'months', 'after', 'RoP.118.4', 'UPC.RoP.118.4',
|
||||
'optional', false, 'published', true, 60,
|
||||
'Frist 2 Monate ab Zustellung der Validitätsentscheidung (R.118.4). Antrag auf Folgeentscheidungen (z.B. nach Zentralkammer-Nichtigerklärung).',
|
||||
'2-month period from service of the validity decision (R.118.4). Application for orders consequential on validity (e.g. after central-division revocation).'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.cons_orders'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §6 T1.6 upc.inf.cfi.rectification — Application for rectification of a
|
||||
-- decision/order. 1 month from delivery of the decision. UPC RoP
|
||||
-- R.353: "Clerical mistakes, errors arising from any accidental
|
||||
-- slip or omission and obvious errors in a decision or order of
|
||||
-- the Court may be corrected by the Court of its own motion or on
|
||||
-- the application of a party. The application shall be made within
|
||||
-- one month of the decision or order being notified."
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.decision'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.rectification',
|
||||
'Antrag auf Berichtigung',
|
||||
'Application for Rectification',
|
||||
'both', 1, 'months', 'after', 'RoP.353', 'UPC.RoP.353',
|
||||
'optional', false, 'published', true, 70,
|
||||
'Frist 1 Monat ab Zustellung der Entscheidung/Anordnung (R.353). Berichtigung von Schreib-, Rechen- oder ähnlichen Versehen.',
|
||||
'1-month period from notification of the decision/order (R.353). Rectification of clerical mistakes, accidental slips or obvious errors.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.rectification'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §7 T1.7 upc.pi.cfi.deficiency — Cure of PI-application deficiency.
|
||||
-- 14 days from notification of the deficiency. UPC RoP R.207.6(a):
|
||||
-- "The Registry shall as soon as practicable examine the
|
||||
-- Application … and notify any deficiencies to the applicant. The
|
||||
-- applicant shall be invited to correct the deficiencies … within
|
||||
-- 14 days." Failure to cure leads to deemed-withdrawal.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 10, -- upc.pi.cfi
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.app'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.pi.cfi.deficiency',
|
||||
'Mängelbeseitigung Antrag',
|
||||
'Cure of Application Deficiency',
|
||||
'claimant', 14, 'days', 'after', 'RoP.207.6.a', 'UPC.RoP.207.6.a',
|
||||
'mandatory', false, 'published', true, 2,
|
||||
'Frist 14 Tage ab Mängelmitteilung durch die Geschäftsstelle (R.207.6(a)). Bei Nichtbehebung gilt der Antrag als zurückgenommen.',
|
||||
'14-day period from notification of deficiency by the Registry (R.207.6(a)). Failure to cure leads to deemed withdrawal of the application.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.deficiency'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §8 T1.8 upc.pi.cfi.merits_start — Start proceedings on the merits.
|
||||
-- 31 calendar days OR 20 working days, whichever is the longer,
|
||||
-- from grant of the PI. UPC RoP R.213.1 → R.198.1: "the applicant
|
||||
-- shall start proceedings leading to a decision on the merits of
|
||||
-- the case … within a period not exceeding 31 calendar days or
|
||||
-- 20 working days, whichever is the longer." Combine-max wiring
|
||||
-- via Wave 2 Slice A primitives (mig 128: working_days unit +
|
||||
-- combine_op). Failure to commence on time → PI lapses (R.213.2).
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
combine_op, timing, rule_code, legal_source, priority,
|
||||
is_court_set, lifecycle_state, is_active, sequence_order,
|
||||
deadline_notes, deadline_notes_en)
|
||||
SELECT 10,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.order'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.pi.cfi.merits_start',
|
||||
'Klage in der Hauptsache erheben',
|
||||
'Start Proceedings on the Merits',
|
||||
'claimant', 31, 'days',
|
||||
20, 'working_days', 'RoP.198.1',
|
||||
'max', 'after', 'RoP.213', 'UPC.RoP.213',
|
||||
'mandatory', false, 'published', true, 3,
|
||||
'Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung der einstweiligen Maßnahme (R.213 i.V.m. R.198.1). Bei Versäumnis erlischt die einstweilige Maßnahme.',
|
||||
'31 calendar days OR 20 working days, whichever is the longer, from grant of the provisional measure (R.213 referring to R.198.1). Failure to commence within the period causes the provisional measure to lapse.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.merits_start'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §9 T1.9 upc.inf.cfi.translation_request — Request for simultaneous
|
||||
-- translation at the oral hearing. 1 month BEFORE the oral hearing.
|
||||
-- UPC RoP R.109.1: "A party requiring simultaneous interpretation
|
||||
-- of the oral hearing into a language other than the language of
|
||||
-- proceedings shall, no later than one month before the date of
|
||||
-- the oral hearing, lodge a request with the Court." timing='before'
|
||||
-- uses the backward-snap path in deadline_calculator.go (mig 128).
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.oral'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.translation_request',
|
||||
'Antrag auf Simultanübersetzung',
|
||||
'Request for Simultaneous Translation',
|
||||
'both', 1, 'months', 'before', 'RoP.109.1', 'UPC.RoP.109.1',
|
||||
'optional', false, 'published', true, 45,
|
||||
'Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag auf Simultanübersetzung in eine andere Sprache als die Verfahrenssprache.',
|
||||
'1 month BEFORE the oral hearing (R.109.1). Request for simultaneous interpretation into a language other than the language of proceedings.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.translation_request'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §10 T1.10 upc.inf.cfi.interpreter_cost — Notification of interpreter
|
||||
-- cost-bearing. 2 weeks BEFORE the oral hearing. UPC RoP R.109.4:
|
||||
-- "Where … the party which made the request for interpretation is
|
||||
-- not the party who has chosen the language of the proceedings,
|
||||
-- the costs of the interpretation … shall be borne by the
|
||||
-- requesting party, unless the Court orders otherwise. The party
|
||||
-- shall be notified at least two weeks before the oral hearing."
|
||||
-- timing='before' as in §9.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.oral'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.interpreter_cost',
|
||||
'Mitteilung Dolmetscherkosten',
|
||||
'Notification of Interpreter Costs',
|
||||
'court', 2, 'weeks', 'before', 'RoP.109.4', 'UPC.RoP.109.4',
|
||||
'mandatory', false, 'published', true, 46,
|
||||
'Frist 2 Wochen VOR der mündlichen Verhandlung (R.109.4). Mitteilung, dass die antragstellende Partei die Dolmetscherkosten zu tragen hat.',
|
||||
'2 weeks BEFORE the oral hearing (R.109.4). Notification to the requesting party that it shall bear the interpreter costs.'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interpreter_cost'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §11 T1.11 upc.inf.cfi.translations_lodge — Lodging of translations on
|
||||
-- judge-rapporteur order. 2 weeks AFTER the JR's order. UPC RoP
|
||||
-- R.109.5: "If the judge-rapporteur orders, the parties shall lodge
|
||||
-- a translation of any pleading or other document into the language
|
||||
-- of the proceedings within two weeks." trigger_event_id=113 maps
|
||||
-- to the JR translation order. Anchor: Interim Conference row, where
|
||||
-- such JR orders are typically issued.
|
||||
-- =============================================================================
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
primary_party, duration_value, duration_unit, timing, rule_code,
|
||||
legal_source, priority, is_court_set, lifecycle_state, is_active,
|
||||
sequence_order, trigger_event_id, deadline_notes, deadline_notes_en)
|
||||
SELECT 8,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND lifecycle_state = 'published' AND is_active = true
|
||||
LIMIT 1),
|
||||
'upc.inf.cfi.translations_lodge',
|
||||
'Übersetzungen einreichen',
|
||||
'Lodging of Translations',
|
||||
'both', 2, 'weeks', 'after', 'RoP.109.5', 'UPC.RoP.109.5',
|
||||
'mandatory', false, 'published', true, 47,
|
||||
113,
|
||||
'Frist 2 Wochen ab Anordnung des Berichterstatters, Übersetzungen einzureichen (R.109.5).',
|
||||
'2-week period from the judge-rapporteur''s order to lodge translations (R.109.5).'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.translations_lodge'
|
||||
AND lifecycle_state = 'published'
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- §12 T1.12 upc.pi.cfi.response — RE-ANCHOR of the existing PI Response
|
||||
-- row. Currently broken: parent_id=NULL with is_court_set=false and
|
||||
-- duration=0 makes the calculator treat this as a root anchor. UPC
|
||||
-- RoP R.211.2 — judge sets the inter-partes hearing date and the
|
||||
-- deadline for the response. Fix: set is_court_set=true and chain
|
||||
-- parent_id on upc.pi.cfi.app (the proceeding root). Duration
|
||||
-- remains 0 (court-set placeholder); the lawyer fills in the actual
|
||||
-- date via 'Datum setzen'.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = (SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.app'
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
LIMIT 1),
|
||||
is_court_set = true,
|
||||
rule_code = 'RoP.211.2',
|
||||
legal_source = 'UPC.RoP.211.2',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.pi.cfi.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND parent_id IS NULL
|
||||
AND is_court_set = false;
|
||||
|
||||
-- =============================================================================
|
||||
-- §13a Pre-requisite for §13b — drop the deadline_rule_audit.rule_id FK.
|
||||
-- The audit trigger (mig 079) tries to INSERT an audit row on AFTER
|
||||
-- DELETE pointing at OLD.id, but the existing FK constraint
|
||||
-- `deadline_rule_audit_rule_id_fkey` (FOREIGN KEY rule_id REFERENCES
|
||||
-- paliad.deadline_rules(id) ON DELETE CASCADE) makes that INSERT fail
|
||||
-- because by the time the trigger fires the parent row is gone. As a
|
||||
-- result no DELETE on paliad.deadline_rules has ever succeeded in
|
||||
-- production (`SELECT count(*) FROM paliad.deadline_rule_audit
|
||||
-- WHERE action='delete'` returns 0). The trigger's DELETE branch was
|
||||
-- dead code.
|
||||
--
|
||||
-- Standard audit-table design: the audit log is append-only history
|
||||
-- and should NOT FK-constrain on the live entity table — before_json
|
||||
-- captures the full row state at the time of the change, which is
|
||||
-- all the audit trail needs. Dropping the FK fixes the latent bug
|
||||
-- and unblocks legitimate cleanup work (here: §13b, plus any future
|
||||
-- hard-delete migrations against deadline_rules).
|
||||
--
|
||||
-- Idempotent: DROP CONSTRAINT IF EXISTS no-ops on re-run.
|
||||
-- =============================================================================
|
||||
ALTER TABLE paliad.deadline_rule_audit
|
||||
DROP CONSTRAINT IF EXISTS deadline_rule_audit_rule_id_fkey;
|
||||
|
||||
-- =============================================================================
|
||||
-- §13b Q6 cleanup — drop the _archived_litigation.* deadline rules.
|
||||
-- 40 rows at audit §9.7 flagged as obsolete Pipeline-A residue
|
||||
-- (proceeding_type id=32 '_archived_litigation' — kept for FK
|
||||
-- parity but the rules are no longer surfaced anywhere in the
|
||||
-- product). m's Q6 design ack 2026-05-25 locked in their removal.
|
||||
-- Idempotent: prefix + lifecycle_state='archived' match zero rows
|
||||
-- after first run. The proceeding_type row itself is left in place
|
||||
-- (referenced by historical deadline_rule_audit before_json blobs).
|
||||
-- =============================================================================
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code LIKE '_archived_litigation.%' ESCAPE '\'
|
||||
AND lifecycle_state = 'archived';
|
||||
|
||||
-- =============================================================================
|
||||
-- Hard assertions. Each new/changed row must end up in its post-fix
|
||||
-- shape. Re-running the migration is a no-op for the data but the
|
||||
-- assertions still pass because they check the post-fix state.
|
||||
-- =============================================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
v_count integer;
|
||||
BEGIN
|
||||
-- §0 R.105 interim conference backfilled
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interim'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.104'
|
||||
AND legal_source = 'UPC.RoP.104'
|
||||
AND 'RoP.105' = ANY(rule_codes);
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 §0: upc.inf.cfi.interim citation backfill not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §1 T1.1 cmo_review present
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.cmo_review'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.333.2' AND duration_value = 15
|
||||
AND duration_unit = 'days' AND timing = 'after';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.1: upc.inf.cfi.cmo_review missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §2 T1.2 confidentiality_response
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.confidentiality_response'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.262.2' AND duration_value = 14
|
||||
AND duration_unit = 'days' AND trigger_event_id = 25;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.2: upc.inf.cfi.confidentiality_response missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §3 T1.3 grounds_orders
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.order.grounds_orders'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.224.2.b' AND duration_value = 15
|
||||
AND duration_unit = 'days';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.3: upc.apl.order.grounds_orders missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §4 T1.4 response_orders chained on §3
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.deadline_rules p ON p.id = dr.parent_id
|
||||
WHERE dr.submission_code = 'upc.apl.order.response_orders'
|
||||
AND dr.is_active = true AND dr.lifecycle_state = 'published'
|
||||
AND dr.rule_code = 'RoP.235.2' AND dr.duration_value = 15
|
||||
AND p.submission_code = 'upc.apl.order.grounds_orders';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.4: upc.apl.order.response_orders missing or wrong parent chain (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §5 T1.5 cons_orders
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.cons_orders'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.118.4' AND duration_value = 2
|
||||
AND duration_unit = 'months';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.5: upc.inf.cfi.cons_orders missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §6 T1.6 rectification
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.rectification'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.353' AND duration_value = 1
|
||||
AND duration_unit = 'months';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.6: upc.inf.cfi.rectification missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §7 T1.7 pi.deficiency
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.deficiency'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.207.6.a' AND duration_value = 14
|
||||
AND duration_unit = 'days';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.7: upc.pi.cfi.deficiency missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §8 T1.8 pi.merits_start — combine-max wiring
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.merits_start'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.213' AND duration_value = 31
|
||||
AND duration_unit = 'days'
|
||||
AND alt_duration_value = 20 AND alt_duration_unit = 'working_days'
|
||||
AND alt_rule_code = 'RoP.198.1' AND combine_op = 'max';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.8: upc.pi.cfi.merits_start missing or wrong combine-max shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §9 T1.9 translation_request — timing='before'
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.translation_request'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.109.1' AND duration_value = 1
|
||||
AND duration_unit = 'months' AND timing = 'before';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.9: upc.inf.cfi.translation_request missing or wrong timing (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §10 T1.10 interpreter_cost — timing='before'
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.interpreter_cost'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.109.4' AND duration_value = 2
|
||||
AND duration_unit = 'weeks' AND timing = 'before';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.10: upc.inf.cfi.interpreter_cost missing or wrong timing (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §11 T1.11 translations_lodge
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.translations_lodge'
|
||||
AND is_active = true AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.109.5' AND duration_value = 2
|
||||
AND duration_unit = 'weeks' AND trigger_event_id = 113;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.11: upc.inf.cfi.translations_lodge missing or wrong shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §12 T1.12 pi.response re-anchor
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.deadline_rules p ON p.id = dr.parent_id
|
||||
WHERE dr.submission_code = 'upc.pi.cfi.response'
|
||||
AND dr.is_active = true AND dr.lifecycle_state = 'published'
|
||||
AND dr.is_court_set = true
|
||||
AND p.submission_code = 'upc.pi.cfi.app';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 132 T1.12: upc.pi.cfi.response not re-anchored on app (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- §13 Q6 cleanup — no archived _archived_litigation rules left
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code LIKE '_archived_litigation.%' ESCAPE '\'
|
||||
AND lifecycle_state = 'archived';
|
||||
IF v_count <> 0 THEN
|
||||
RAISE EXCEPTION 'mig 132 §13: % archived _archived_litigation.* rules still present after cleanup', v_count;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Reverses mig 133. Removes the 5 new rules:
|
||||
-- * upc.dmgs.cfi.interim
|
||||
-- * upc.dmgs.cfi.oral
|
||||
-- * upc.dmgs.cfi.decision
|
||||
-- * upc.dmgs.cfi.appeal_spawn
|
||||
-- * upc.pi.cfi.appeal_spawn
|
||||
--
|
||||
-- The audit_reason is required by the mig 079 trigger for DELETE;
|
||||
-- set_config at top supplies it.
|
||||
--
|
||||
-- Idempotent — if a rule is already missing the DELETE matches zero
|
||||
-- rows and the audit log records nothing extra.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 133 (down): revert UPC Damages tree-end rows and UPC PI appeal-spawn (t-paliad-285 / m/paliad#117 + t-paliad-286 / m/paliad#118)',
|
||||
true);
|
||||
|
||||
-- Delete the spawn rows first so the parent_id reference goes away
|
||||
-- before the parent decision row is removed.
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code IN (
|
||||
'upc.dmgs.cfi.appeal_spawn',
|
||||
'upc.pi.cfi.appeal_spawn')
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE submission_code IN (
|
||||
'upc.dmgs.cfi.interim',
|
||||
'upc.dmgs.cfi.oral',
|
||||
'upc.dmgs.cfi.decision')
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published';
|
||||
405
internal/db/migrations/133_upc_dmgs_pi_court_followup.up.sql
Normal file
405
internal/db/migrations/133_upc_dmgs_pi_court_followup.up.sql
Normal file
@@ -0,0 +1,405 @@
|
||||
-- t-paliad-285 (m/paliad#117) + t-paliad-286 (m/paliad#118) —
|
||||
-- post-submission court followup for UPC Damages and appeal route
|
||||
-- for UPC Provisional Measures.
|
||||
--
|
||||
-- m's 2026-05-25 report: the upc.dmgs.cfi proceeding stops at the
|
||||
-- last party submission (rejoin) — no interim conference, no oral
|
||||
-- hearing, no decision row, no appeal-spawn. The upc.pi.cfi
|
||||
-- proceeding has its decision row (`pi.order`) but no spawn into
|
||||
-- the appeal tree. Both gaps prevent the Verfahrensablauf timeline
|
||||
-- from rendering the court phase plus any downstream appeal sub-
|
||||
-- tree that atlas's #96 spawn-rendering mechanism is otherwise
|
||||
-- ready to surface.
|
||||
--
|
||||
-- Two sections in one migration (slot 133 — knuth on 132, paliadin
|
||||
-- coordinated):
|
||||
--
|
||||
-- A. UPC Damages tree-end rows (#117)
|
||||
-- A1 upc.dmgs.cfi.interim UPC RoP R.105 court-set hearing
|
||||
-- A2 upc.dmgs.cfi.oral UPC RoP R.118 / R.250 court-set hearing
|
||||
-- A3 upc.dmgs.cfi.decision UPC RoP R.118 / R.144 court-set decision
|
||||
-- A4 upc.dmgs.cfi.appeal_spawn UPC RoP R.220.1(a) / R.224.1(a) 2mo, spawn → upc.apl.merits (id=11)
|
||||
--
|
||||
-- B. UPC Provisional Measures appeal route (#118)
|
||||
-- B1 upc.pi.cfi.appeal_spawn UPC RoP R.220.1(a) / R.224.1(a) 2mo, spawn → upc.apl.merits (id=11)
|
||||
--
|
||||
-- Source citations:
|
||||
-- * docs/research-deadlines-completeness-2026-05-25.md
|
||||
-- — §2.1 (upc.dmgs.cfi has only 4 rules: R.131.2 / R.137.2 / R.139)
|
||||
-- — §D Damages table (R.144 tree-end row missing — listed
|
||||
-- in Tier 4 as "cosmetic", upgraded to Tier-0 by m's
|
||||
-- report once the wider follow-up gap was understood)
|
||||
-- * docs/audit-upc-rop-deadlines-2026-05-08.md §D row R.144,
|
||||
-- §F R.220.1(a) / R.224.1(a) (verified verbatim in youpc DB
|
||||
-- under law_type=UPCRoP).
|
||||
-- * UPC Rules of Procedure (consolidated):
|
||||
-- R.105 — Interim conference (court fixes after written
|
||||
-- procedure closes; same structural shape as the inf
|
||||
-- interim conference, already modelled as `upc.inf.cfi.interim`).
|
||||
-- R.118 — Decision after oral hearing; general rule for
|
||||
-- deciding panels.
|
||||
-- R.250 — Determination of damages decision; damages-
|
||||
-- specific decision rule (chains R.144 indication →
|
||||
-- damages award).
|
||||
-- R.144 — Final decision on damages quantum (tree-end
|
||||
-- anchor for §A3).
|
||||
-- R.220.1(a) — Appeal lies from any final decision /
|
||||
-- decision disposing of the case at first instance.
|
||||
-- A PI order under R.211 disposes of the urgent question
|
||||
-- and is therefore appealable on the main 2-month track
|
||||
-- (not the 15-day order track of R.220.1(c), which covers
|
||||
-- case-management and procedural orders requiring leave).
|
||||
-- Curie's §F table confirms the main-track wiring for
|
||||
-- decisions on merits / disposing orders.
|
||||
-- R.224.1(a) — Statement of Appeal within 2 months of
|
||||
-- service of the final decision; the deadline-notes text
|
||||
-- mirrors mig 095's inf.appeal_spawn / rev.appeal_spawn.
|
||||
-- R.224.2(a) — Statement of grounds within 4 months
|
||||
-- (separate deadline in the spawned upc.apl.merits
|
||||
-- proceeding; already present as upc.apl.merits.grounds).
|
||||
--
|
||||
-- Shape decisions (mirroring mig 012 / mig 095 conventions):
|
||||
-- * Court-set rows (interim / oral / decision) carry
|
||||
-- primary_party='court', event_type='hearing'|'decision',
|
||||
-- duration_value=0, is_court_set=true, parent_id=NULL,
|
||||
-- concept_id reuses the shared concepts already wired for
|
||||
-- upc.inf.cfi (interim-conference / oral-hearing / decision).
|
||||
-- * Spawn rows carry primary_party='both', is_spawn=true,
|
||||
-- spawn_proceeding_type_id=11 (upc.apl.merits), spawn_label
|
||||
-- identical to the merits spawn already in production. The
|
||||
-- spawn row's parent_id is the spawning decision/order row
|
||||
-- (so the audit log carries the trigger link).
|
||||
-- * No condition_expr — m's F2.3 decision recorded in mig 095
|
||||
-- §3: "the appeal deadline should always be triggered by a
|
||||
-- decision … appeal is always a possibility." Visibility
|
||||
-- filtering on the frontend hides appeals on projects where
|
||||
-- no appeal is contemplated.
|
||||
-- * sequence_order numbering follows the inf convention
|
||||
-- (40=interim, 50=oral, 60=decision, 80=appeal_spawn) so the
|
||||
-- Verfahrensablauf timeline orders consistently across
|
||||
-- proceedings. For PI the existing pi.order sits at
|
||||
-- sequence_order=3; the appeal_spawn lands at 10 (clear of
|
||||
-- the writ phase, room for future court-phase rows).
|
||||
--
|
||||
-- Idempotency: every INSERT is gated by `WHERE NOT EXISTS (… same
|
||||
-- submission_code, proceeding_type_id, lifecycle_state)`. Re-apply
|
||||
-- against an already-migrated DB inserts zero rows and the audit
|
||||
-- log carries no duplicate entries.
|
||||
--
|
||||
-- audit_reason set_config required at the top — the mig 079 trigger
|
||||
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
|
||||
-- on INSERT/UPDATE/DELETE without it.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 133: t-paliad-285 / m/paliad#117 + t-paliad-286 / m/paliad#118 — UPC Damages tree-end rows (interim conference R.105, oral hearing R.118/R.250, decision R.118/R.144, appeal-spawn R.220.1(a)) and UPC Provisional Measures appeal-spawn R.220.1(a); see docs/research-deadlines-completeness-2026-05-25.md §D and docs/audit-upc-rop-deadlines-2026-05-08.md §D/§F',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- A. UPC Damages — court-phase tree end (m/paliad#117)
|
||||
-- =============================================================================
|
||||
|
||||
-- A1. upc.dmgs.cfi.interim — Interim conference (UPC RoP R.105).
|
||||
-- Court-set hearing fixed by the judge-rapporteur once the
|
||||
-- written procedure closes. Identical shape to
|
||||
-- upc.inf.cfi.interim; reuses the shared interim-conference
|
||||
-- concept node.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state,
|
||||
concept_id)
|
||||
SELECT
|
||||
17,
|
||||
NULL,
|
||||
'upc.dmgs.cfi.interim',
|
||||
'Zwischenverfahren',
|
||||
'Interim Conference',
|
||||
NULL,
|
||||
'court',
|
||||
'hearing',
|
||||
0,
|
||||
'months',
|
||||
'after',
|
||||
NULL,
|
||||
'Termin vom Gericht bestimmt',
|
||||
'Date set by the court',
|
||||
40,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
NULL,
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
true,
|
||||
'published',
|
||||
'e5071152-d408-4455-b644-9e79d86fd538'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.interim'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- A2. upc.dmgs.cfi.oral — Oral hearing (UPC RoP R.118 / R.250).
|
||||
-- Court-set hearing after the interim conference / close of
|
||||
-- written procedure. Same shape as upc.inf.cfi.oral; reuses
|
||||
-- the shared oral-hearing concept node.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state,
|
||||
concept_id)
|
||||
SELECT
|
||||
17,
|
||||
NULL,
|
||||
'upc.dmgs.cfi.oral',
|
||||
'Mündliche Verhandlung',
|
||||
'Oral Hearing',
|
||||
NULL,
|
||||
'court',
|
||||
'hearing',
|
||||
0,
|
||||
'months',
|
||||
'after',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
50,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
NULL,
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
true,
|
||||
'published',
|
||||
'd6e5b793-dcf1-4d83-81ff-34f42dbb3693'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.oral'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- A3. upc.dmgs.cfi.decision — Damages decision (UPC RoP R.118 /
|
||||
-- R.144 / R.250). Court-set decision delivered after oral
|
||||
-- hearing; closes the §3.1 audit gap (R.144 tree-end). Same
|
||||
-- shape as upc.inf.cfi.decision; reuses the shared decision
|
||||
-- concept node.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state,
|
||||
concept_id)
|
||||
SELECT
|
||||
17,
|
||||
NULL,
|
||||
'upc.dmgs.cfi.decision',
|
||||
'Entscheidung',
|
||||
'Decision',
|
||||
NULL,
|
||||
'court',
|
||||
'decision',
|
||||
0,
|
||||
'months',
|
||||
'after',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
60,
|
||||
false,
|
||||
NULL,
|
||||
NULL,
|
||||
true,
|
||||
NULL,
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
true,
|
||||
'published',
|
||||
'472fc32d-cc4f-4aa4-8ace-e422031812de'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.decision'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- A4. upc.dmgs.cfi.appeal_spawn — Appeal against damages decision
|
||||
-- (UPC RoP R.220.1(a), 2-month main track; grounds R.224.2(a)
|
||||
-- run as a separate deadline in the spawned upc.apl.merits
|
||||
-- proceeding). Parent points at the freshly-inserted
|
||||
-- upc.dmgs.cfi.decision; the SELECT subquery resolves it
|
||||
-- after A3 lands. Same shape as the mig 095 inf.appeal_spawn.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state)
|
||||
SELECT
|
||||
17,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.decision'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true),
|
||||
'upc.dmgs.cfi.appeal_spawn',
|
||||
'Berufung gegen Schadensentscheidung',
|
||||
'Appeal against damages decision',
|
||||
'Berufung gegen die Entscheidung über die Schadensbemessung (R.118 / R.144). Statutarische Frist von 2 Monaten ab Zustellung der Entscheidung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren).',
|
||||
'both',
|
||||
'filing',
|
||||
2,
|
||||
'months',
|
||||
'after',
|
||||
'RoP.220.1.a',
|
||||
'Innerhalb von 2 Monaten ab Zustellung der Schadensentscheidung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
|
||||
'Within 2 months of service of the damages decision lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
|
||||
80,
|
||||
true,
|
||||
11,
|
||||
'Berufungsverfahren öffnen',
|
||||
true,
|
||||
'UPC.RoP.220.1',
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
false,
|
||||
'published'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.dmgs.cfi.appeal_spawn'
|
||||
AND proceeding_type_id = 17
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- =============================================================================
|
||||
-- B. UPC Provisional Measures — appeal route (m/paliad#118)
|
||||
-- =============================================================================
|
||||
|
||||
-- B1. upc.pi.cfi.appeal_spawn — Appeal against PI order (UPC RoP
|
||||
-- R.220.1(a), 2-month main track). PI orders under R.211
|
||||
-- dispose of the urgent question and are appealable on the
|
||||
-- main 2-month track (R.220.1(a)/R.224.1(a)); the 15-day
|
||||
-- order track of R.220.1(c) is for case-management /
|
||||
-- procedural orders requiring leave and does not apply to
|
||||
-- PI dispositions. Parent points at the existing
|
||||
-- upc.pi.cfi.order (sequence_order=3) so the spawn fires
|
||||
-- once the order is anchored on a project's timeline.
|
||||
INSERT INTO paliad.deadline_rules
|
||||
(proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type,
|
||||
duration_value, duration_unit, timing,
|
||||
rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
is_spawn, spawn_proceeding_type_id, spawn_label,
|
||||
is_active, legal_source, is_bilateral,
|
||||
condition_expr, priority, is_court_set, lifecycle_state)
|
||||
SELECT
|
||||
10,
|
||||
(SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.order'
|
||||
AND proceeding_type_id = 10
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true),
|
||||
'upc.pi.cfi.appeal_spawn',
|
||||
'Berufung gegen Anordnung',
|
||||
'Appeal against PI order',
|
||||
'Berufung gegen die einstweilige Anordnung nach R.211. Eine PI-Anordnung erledigt die einstweilige Streitfrage und wird wie eine Endentscheidung im Hauptverfahren behandelt: statutarische Frist von 2 Monaten ab Zustellung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren). Die 15-Tage-Spur nach R.220.1(c) / R.220.2 gilt für Verfahrensanordnungen mit Zulassung und ist hier nicht einschlägig.',
|
||||
'both',
|
||||
'filing',
|
||||
2,
|
||||
'months',
|
||||
'after',
|
||||
'RoP.220.1.a',
|
||||
'Innerhalb von 2 Monaten ab Zustellung der PI-Anordnung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
|
||||
'Within 2 months of service of the PI order lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
|
||||
10,
|
||||
true,
|
||||
11,
|
||||
'Berufungsverfahren öffnen',
|
||||
true,
|
||||
'UPC.RoP.220.1',
|
||||
false,
|
||||
NULL,
|
||||
'optional',
|
||||
false,
|
||||
'published'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.pi.cfi.appeal_spawn'
|
||||
AND proceeding_type_id = 10
|
||||
AND lifecycle_state = 'published');
|
||||
|
||||
-- =============================================================================
|
||||
-- C. Post-insert verification — raise if any expected row is missing
|
||||
-- (matches the mig 095 / 127 convention; protects against a future
|
||||
-- re-shape of the table that silently drops one of the WHERE NOT
|
||||
-- EXISTS predicates).
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_missing text;
|
||||
BEGIN
|
||||
SELECT string_agg(expected, ', ' ORDER BY expected)
|
||||
INTO v_missing
|
||||
FROM (VALUES
|
||||
('upc.dmgs.cfi.interim'),
|
||||
('upc.dmgs.cfi.oral'),
|
||||
('upc.dmgs.cfi.decision'),
|
||||
('upc.dmgs.cfi.appeal_spawn'),
|
||||
('upc.pi.cfi.appeal_spawn')
|
||||
) AS t(expected)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr
|
||||
WHERE dr.submission_code = t.expected
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.is_active = true);
|
||||
|
||||
IF v_missing IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 133: expected published rules missing after insert: %', v_missing;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr
|
||||
WHERE dr.submission_code = 'upc.dmgs.cfi.appeal_spawn'
|
||||
AND dr.proceeding_type_id = 17
|
||||
AND dr.spawn_proceeding_type_id = 11
|
||||
AND dr.is_spawn = true
|
||||
AND dr.parent_id IS NOT NULL
|
||||
AND dr.lifecycle_state = 'published'
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 133: upc.dmgs.cfi.appeal_spawn shape check failed (expected is_spawn=true, spawn_proceeding_type_id=11, parent_id set)';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM paliad.deadline_rules dr
|
||||
WHERE dr.submission_code = 'upc.pi.cfi.appeal_spawn'
|
||||
AND dr.proceeding_type_id = 10
|
||||
AND dr.spawn_proceeding_type_id = 11
|
||||
AND dr.is_spawn = true
|
||||
AND dr.parent_id IS NOT NULL
|
||||
AND dr.lifecycle_state = 'published'
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 133: upc.pi.cfi.appeal_spawn shape check failed (expected is_spawn=true, spawn_proceeding_type_id=11, parent_id set)';
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -63,6 +63,12 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// wins (what-if exploration overrides the saved state).
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
PerCardChoices []services.UpsertEventChoiceInput `json:"perCardChoices,omitempty"`
|
||||
// t-paliad-290 (m/paliad#122): re-surface previously-hidden
|
||||
// optional cards. When true the calculator marks skipped rows
|
||||
// with UIDeadline.IsHidden instead of dropping them; descendants
|
||||
// stay in the result list. Default false preserves the legacy
|
||||
// suppression. HiddenCount on the response is independent.
|
||||
IncludeHidden bool `json:"includeHidden,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -109,6 +115,7 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
PerCardAppellant: addendum.PerCardAppellant,
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||
|
||||
@@ -458,6 +458,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// t-paliad-139 — set unit_role on a member.
|
||||
protected.HandleFunc("PATCH /api/partner-units/{id}/members/{user_id}/role", handleSetUnitMemberRole)
|
||||
|
||||
protected.HandleFunc("GET /api/parties/search", handlePartiesSearch)
|
||||
protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty)
|
||||
|
||||
// Phase F — Appointments (appointments)
|
||||
|
||||
@@ -701,6 +701,31 @@ func handleCreateParty(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
// GET /api/parties/search?q=...
|
||||
//
|
||||
// Cross-project party picker for the submission-draft editor
|
||||
// (t-paliad-287). Returns up to 25 parties from every project the
|
||||
// caller can see, matched by case-insensitive substring on name or
|
||||
// representative. Empty q returns the 20 most-recently-updated rows so
|
||||
// the picker isn't blank on first open. Visibility is enforced in the
|
||||
// service layer via the same predicate every project-scoped read uses.
|
||||
func handlePartiesSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
hits, err := dbSvc.parties.Search(r.Context(), uid, q, 25)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": hits})
|
||||
}
|
||||
|
||||
// DELETE /api/parties/{id}
|
||||
func handleDeleteParty(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
|
||||
@@ -51,13 +51,20 @@ const SpecVersion = 1
|
||||
// can't bury an in-flight approval, per the design doc §3 carve-out).
|
||||
// Set by the bar's `unread_only` axis on /inbox; other surfaces leave
|
||||
// it false and the spec is a no-op.
|
||||
//
|
||||
// Predicates is a flat per-source narrowing record: keys at the top
|
||||
// level are data sources ("deadline", "appointment", …) and values are
|
||||
// the per-source predicate structs directly. The shape on the wire and
|
||||
// the shape the frontend emits agree exactly — see t-paliad-283 for the
|
||||
// latent contract bug (Go used to wrap each entry in another Predicates
|
||||
// struct, so the frontend's overlay clicks parsed back as no-op).
|
||||
type FilterSpec struct {
|
||||
Version int `json:"version"`
|
||||
Sources []DataSource `json:"sources"`
|
||||
Scope ScopeSpec `json:"scope"`
|
||||
Time TimeSpec `json:"time"`
|
||||
Predicates map[DataSource]Predicates `json:"predicates,omitempty"`
|
||||
UnreadOnly bool `json:"unread_only,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Sources []DataSource `json:"sources"`
|
||||
Scope ScopeSpec `json:"scope"`
|
||||
Time TimeSpec `json:"time"`
|
||||
Predicates *Predicates `json:"predicates,omitempty"`
|
||||
UnreadOnly bool `json:"unread_only,omitempty"`
|
||||
}
|
||||
|
||||
// ScopeSpec narrows which projects contribute rows. Resolved at query
|
||||
@@ -147,7 +154,8 @@ const (
|
||||
)
|
||||
|
||||
// Predicates is the per-source narrowing payload. Empty fields mean
|
||||
// "no narrowing" — never "exclude all".
|
||||
// "no narrowing" — never "exclude all". One field per data source;
|
||||
// the wire shape is the same: `{"deadline": {...}, "appointment": {...}}`.
|
||||
type Predicates struct {
|
||||
Deadline *DeadlinePredicates `json:"deadline,omitempty"`
|
||||
Appointment *AppointmentPredicates `json:"appointment,omitempty"`
|
||||
@@ -305,14 +313,25 @@ func (s *FilterSpec) Validate() error {
|
||||
return err
|
||||
}
|
||||
|
||||
for src, preds := range s.Predicates {
|
||||
if !isKnownSource(src) {
|
||||
return fmt.Errorf("%w: predicates set on unknown source %q", ErrInvalidInput, src)
|
||||
if s.Predicates != nil {
|
||||
// Reject predicates set on a source the spec doesn't list — we'd
|
||||
// silently drop the narrowing otherwise. Walk the set fields.
|
||||
type srcCheck struct {
|
||||
src DataSource
|
||||
present bool
|
||||
}
|
||||
if !seen[src] {
|
||||
return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, src)
|
||||
checks := []srcCheck{
|
||||
{SourceDeadline, s.Predicates.Deadline != nil},
|
||||
{SourceAppointment, s.Predicates.Appointment != nil},
|
||||
{SourceProjectEvent, s.Predicates.ProjectEvent != nil},
|
||||
{SourceApprovalRequest, s.Predicates.ApprovalRequest != nil},
|
||||
}
|
||||
if err := preds.validate(); err != nil {
|
||||
for _, c := range checks {
|
||||
if c.present && !seen[c.src] {
|
||||
return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, c.src)
|
||||
}
|
||||
}
|
||||
if err := s.Predicates.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
125
internal/services/filter_spec_predicates_test.go
Normal file
125
internal/services/filter_spec_predicates_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// t-paliad-283 regression: the bar's chip clicks POST a `predicates`
|
||||
// payload shaped as `{<source>: <per-source>}`. The Go side previously
|
||||
// declared `Predicates map[DataSource]Predicates` — a doubled-nested
|
||||
// shape — which silently unmarshalled the bar's payload as no-op
|
||||
// narrowing. This test pins the wire shape so the contract can't drift
|
||||
// again.
|
||||
//
|
||||
// Run with `go test ./internal/services/`.
|
||||
|
||||
func TestFilterSpec_FlatPredicatesWireShape(t *testing.T) {
|
||||
// The shape every chip click in the FilterBar emits: predicates is
|
||||
// keyed by data source, value is the per-source predicate struct
|
||||
// directly. Doubled-nesting would unmarshal as empty Predicates.
|
||||
const wire = `{
|
||||
"version": 1,
|
||||
"sources": ["deadline", "appointment", "project_event", "approval_request"],
|
||||
"scope": {"projects": {"mode": "all_visible"}},
|
||||
"time": {"field": "auto", "horizon": "past_30d"},
|
||||
"predicates": {
|
||||
"deadline": {"status": ["pending"]},
|
||||
"appointment": {"appointment_types": ["hearing"]},
|
||||
"project_event": {"event_types": ["deadline_created"]},
|
||||
"approval_request": {"viewer_role": "any_visible", "status": ["pending"]}
|
||||
}
|
||||
}`
|
||||
|
||||
var spec FilterSpec
|
||||
if err := json.Unmarshal([]byte(wire), &spec); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if err := spec.Validate(); err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
if spec.Predicates == nil {
|
||||
t.Fatal("predicates must be non-nil after unmarshalling the bar's shape")
|
||||
}
|
||||
if spec.Predicates.Deadline == nil || len(spec.Predicates.Deadline.Status) != 1 || spec.Predicates.Deadline.Status[0] != "pending" {
|
||||
t.Errorf("deadline.status must round-trip, got %+v", spec.Predicates.Deadline)
|
||||
}
|
||||
if spec.Predicates.Appointment == nil || len(spec.Predicates.Appointment.AppointmentTypes) != 1 {
|
||||
t.Errorf("appointment.appointment_types must round-trip, got %+v", spec.Predicates.Appointment)
|
||||
}
|
||||
if spec.Predicates.ProjectEvent == nil || len(spec.Predicates.ProjectEvent.EventTypes) != 1 {
|
||||
t.Errorf("project_event.event_types must round-trip, got %+v", spec.Predicates.ProjectEvent)
|
||||
}
|
||||
if spec.Predicates.ApprovalRequest == nil || spec.Predicates.ApprovalRequest.ViewerRole != "any_visible" {
|
||||
t.Errorf("approval_request.viewer_role must round-trip, got %+v", spec.Predicates.ApprovalRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// The shipped FilterSpec must marshal back to exactly the flat shape
|
||||
// the frontend declares in views/types.ts. Otherwise /api/views/system
|
||||
// (which serializes the InboxSystemView's Filter for the bar) returns a
|
||||
// shape the frontend can't consume without translation gymnastics.
|
||||
func TestFilterSpec_MarshalFlatPredicatesShape(t *testing.T) {
|
||||
spec := FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceDeadline},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
|
||||
Predicates: &Predicates{
|
||||
Deadline: &DeadlinePredicates{Status: []string{"pending"}},
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
// Parse back generically so the assertion is on the wire shape, not
|
||||
// on the Go type system that produced it.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
t.Fatalf("re-unmarshal: %v", err)
|
||||
}
|
||||
var preds map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw["predicates"], &preds); err != nil {
|
||||
t.Fatalf("predicates re-unmarshal: %v", err)
|
||||
}
|
||||
dl, ok := preds["deadline"]
|
||||
if !ok {
|
||||
t.Fatal("predicates.deadline missing — wire shape regressed")
|
||||
}
|
||||
var dlBody map[string]json.RawMessage
|
||||
if err := json.Unmarshal(dl, &dlBody); err != nil {
|
||||
t.Fatalf("deadline body unmarshal: %v", err)
|
||||
}
|
||||
if _, ok := dlBody["status"]; !ok {
|
||||
t.Errorf("predicates.deadline.status must be a top-level field; doubled-nesting reappeared. Body: %s", string(dl))
|
||||
}
|
||||
if _, ok := dlBody["deadline"]; ok {
|
||||
t.Errorf("predicates.deadline must NOT wrap a nested deadline key — that's the t-paliad-283 bug. Body: %s", string(dl))
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end pin: the bar's payload after the user clicks
|
||||
// "Frist-Status: Erledigt" (completed) must produce a spec whose
|
||||
// runDeadlines branch narrows to completed deadlines. Without the
|
||||
// t-paliad-283 fix, the unmarshal silently produced an empty Predicates
|
||||
// and the SQL ran without the `status='completed'` clause.
|
||||
func TestFilterSpec_BarChipPayloadNarrowsDeadlineStatus(t *testing.T) {
|
||||
const barPayload = `{
|
||||
"version": 1,
|
||||
"sources": ["deadline"],
|
||||
"scope": {"projects": {"mode": "all_visible"}},
|
||||
"time": {"field": "auto", "horizon": "past_30d"},
|
||||
"predicates": {"deadline": {"status": ["completed"]}}
|
||||
}`
|
||||
var spec FilterSpec
|
||||
if err := json.Unmarshal([]byte(barPayload), &spec); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if spec.Predicates == nil || spec.Predicates.Deadline == nil {
|
||||
t.Fatal("deadline predicate must survive the round-trip")
|
||||
}
|
||||
if len(spec.Predicates.Deadline.Status) != 1 || spec.Predicates.Deadline.Status[0] != "completed" {
|
||||
t.Errorf("deadline.status must be [\"completed\"], got %+v", spec.Predicates.Deadline.Status)
|
||||
}
|
||||
}
|
||||
@@ -180,8 +180,8 @@ func TestFilterSpec_NewSymmetricHorizonsValidate(t *testing.T) {
|
||||
func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceDeadline}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"hearing"}}},
|
||||
s.Predicates = &Predicates{
|
||||
Appointment: &AppointmentPredicates{AppointmentTypes: []string{"hearing"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("predicates on unselected source must reject, got %v", err)
|
||||
@@ -190,8 +190,8 @@ func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
|
||||
|
||||
func TestFilterSpec_DeadlineStatusEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"weird"}}},
|
||||
s.Predicates = &Predicates{
|
||||
Deadline: &DeadlinePredicates{Status: []string{"weird"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown deadline.status must reject, got %v", err)
|
||||
@@ -201,8 +201,8 @@ func TestFilterSpec_DeadlineStatusEnum(t *testing.T) {
|
||||
func TestFilterSpec_AppointmentTypeEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = append(s.Sources, SourceAppointment)
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"bogus"}}},
|
||||
s.Predicates = &Predicates{
|
||||
Appointment: &AppointmentPredicates{AppointmentTypes: []string{"bogus"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown appointment_type must reject, got %v", err)
|
||||
@@ -212,8 +212,8 @@ func TestFilterSpec_AppointmentTypeEnum(t *testing.T) {
|
||||
func TestFilterSpec_ProjectEventKindMustBeKnown(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceProjectEvent}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{EventTypes: []string{"unknown_kind"}}},
|
||||
s.Predicates = &Predicates{
|
||||
ProjectEvent: &ProjectEventPredicates{EventTypes: []string{"unknown_kind"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown project_event kind must reject, got %v", err)
|
||||
@@ -223,8 +223,8 @@ func TestFilterSpec_ProjectEventKindMustBeKnown(t *testing.T) {
|
||||
func TestFilterSpec_ApprovalViewerRoleEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceApprovalRequest}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{ViewerRole: "everyone"}},
|
||||
s.Predicates = &Predicates{
|
||||
ApprovalRequest: &ApprovalRequestPredicates{ViewerRole: "everyone"},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown viewer_role must reject, got %v", err)
|
||||
@@ -234,8 +234,8 @@ func TestFilterSpec_ApprovalViewerRoleEnum(t *testing.T) {
|
||||
func TestFilterSpec_ApprovalRequestStatusEnum(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceApprovalRequest}
|
||||
s.Predicates = map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{Status: []string{"weird"}}},
|
||||
s.Predicates = &Predicates{
|
||||
ApprovalRequest: &ApprovalRequestPredicates{Status: []string{"weird"}},
|
||||
}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown approval_request.status must reject, got %v", err)
|
||||
@@ -251,15 +251,15 @@ func TestFilterSpec_RoundTripJSON(t *testing.T) {
|
||||
PersonalOnly: false,
|
||||
},
|
||||
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceDeadline: {Deadline: &DeadlinePredicates{
|
||||
Predicates: &Predicates{
|
||||
Deadline: &DeadlinePredicates{
|
||||
Status: []string{"pending"},
|
||||
ApprovalStatus: []string{"approved", "pending"},
|
||||
}},
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||
},
|
||||
ApprovalRequest: &ApprovalRequestPredicates{
|
||||
ViewerRole: "approver_eligible",
|
||||
Status: []string{"pending"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, err := MarshalFilterSpec(original)
|
||||
|
||||
@@ -89,6 +89,35 @@ type UIDeadline struct {
|
||||
// is computed off a parent date that the COURT sets, not by the
|
||||
// court itself.
|
||||
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"`
|
||||
// ChoicesOffered surfaces paliad.deadline_rules.choices_offered for
|
||||
// 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
|
||||
// non-empty. (t-paliad-265)
|
||||
AppellantContext string `json:"appellantContext,omitempty"`
|
||||
// IsHidden marks a card the user has previously hidden via a
|
||||
// skip choice. Only ever true when CalcOptions.IncludeHidden is
|
||||
// set — the toggle re-surfaces these rows so the user can either
|
||||
// keep them faded for context or un-hide them via the inline
|
||||
// "Wieder einblenden" chip. (t-paliad-290 / m/paliad#122)
|
||||
IsHidden bool `json:"isHidden,omitempty"`
|
||||
}
|
||||
|
||||
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
|
||||
@@ -137,6 +172,14 @@ type UIResponse struct {
|
||||
// is the appealable first-instance decision (m/paliad#81).
|
||||
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
||||
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
||||
// HiddenCount is the number of rules whose submission_code is in
|
||||
// CalcOptions.SkipRules AND whose condition_expr gate passes —
|
||||
// i.e. how many rows the user has hidden in this projection
|
||||
// regardless of the IncludeHidden toggle state. The frontend uses
|
||||
// this to render the "Ausgeblendete (N)" badge on the toggle even
|
||||
// when the toggle is OFF (so users know there's something to
|
||||
// re-surface). (t-paliad-290 / m/paliad#122)
|
||||
HiddenCount int `json:"hiddenCount"`
|
||||
}
|
||||
|
||||
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
|
||||
@@ -214,6 +257,19 @@ type CalcOptions struct {
|
||||
PerCardAppellant map[string]string
|
||||
SkipRules map[string]struct{}
|
||||
IncludeCCRFor map[string]struct{}
|
||||
|
||||
// IncludeHidden re-surfaces rules whose submission_code is in
|
||||
// SkipRules (t-paliad-290 / m/paliad#122). When true:
|
||||
// - Skipped rules are NOT dropped from the result; they render
|
||||
// with UIDeadline.IsHidden=true so the frontend can fade them.
|
||||
// - Descendant suppression is bypassed (the skipped parent is
|
||||
// present in the result, so children compute their dates off
|
||||
// it as if the user had never hidden it).
|
||||
// Default false preserves the original skip semantic (drop rule +
|
||||
// suppress descendants). HiddenCount on UIResponse is independent
|
||||
// of this flag — it always reflects the number of hide-eligible
|
||||
// rows so the toggle's count badge stays accurate.
|
||||
IncludeHidden bool
|
||||
}
|
||||
|
||||
// Calculate renders the full UI timeline for a proceeding type + trigger date.
|
||||
@@ -372,6 +428,30 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
courtSet := make(map[uuid.UUID]bool, 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
|
||||
}
|
||||
|
||||
// Per-event-card overlays (t-paliad-265). Empty/nil maps are safe
|
||||
// for membership tests; the engine reads them but doesn't mutate.
|
||||
skipRules := opts.SkipRules
|
||||
@@ -381,6 +461,13 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
// child rule's parent has already been classified — so descendant
|
||||
// suppression is a one-pass parent_id lookup.
|
||||
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
|
||||
// hiddenCount counts rows whose submission_code is in skipRules
|
||||
// AND that pass the condition_expr gate — i.e. rows the user has
|
||||
// hidden in this projection. Surfaced on UIResponse.HiddenCount so
|
||||
// the frontend's "Ausgeblendete (N)" badge stays accurate even when
|
||||
// IncludeHidden is off and the rows aren't in the result list.
|
||||
// (t-paliad-290 / m/paliad#122)
|
||||
hiddenCount := 0
|
||||
// appellantContext maps a rule UUID to the appellant value that
|
||||
// applies to its descendants. A rule that has its own PerCardAppellant
|
||||
// pick stamps itself with that value; a rule whose parent has a
|
||||
@@ -403,10 +490,22 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
// this rule (or one of its ancestors) as "don't consider for
|
||||
// this case". Drop the row entirely AND record the rule ID so
|
||||
// descendants suppress too.
|
||||
//
|
||||
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
|
||||
// we re-surface the directly-skipped row (faded via IsHidden)
|
||||
// instead of dropping it. Descendants are NOT cascade-suppressed
|
||||
// in that mode either — the un-suppressed parent computes its
|
||||
// date normally, so children compute off it as usual. Either
|
||||
// way we count the hide for the toggle's badge.
|
||||
var isHidden bool
|
||||
if r.SubmissionCode != nil {
|
||||
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
hiddenCount++
|
||||
if !opts.IncludeHidden {
|
||||
skippedIDs[r.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
isHidden = true
|
||||
}
|
||||
}
|
||||
if r.ParentID != nil {
|
||||
@@ -442,6 +541,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
ConditionExpr: json.RawMessage(r.ConditionExpr),
|
||||
AppellantContext: ctxVal,
|
||||
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
|
||||
IsHidden: isHidden,
|
||||
}
|
||||
if r.SubmissionCode != nil {
|
||||
d.Code = *r.SubmissionCode
|
||||
@@ -464,21 +564,34 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate court-set status from a parent rule whose date the
|
||||
// court determines: if the anchor itself has no real date,
|
||||
// nothing downstream can be computed either — UNLESS the user
|
||||
// has supplied an override date for the parent (which they can
|
||||
// once they know the real decision date).
|
||||
parentOverridden := false
|
||||
if r.ParentID != nil && courtSet[*r.ParentID] {
|
||||
for _, prev := range rules {
|
||||
if prev.ID == *r.ParentID {
|
||||
if prev.SubmissionCode != nil {
|
||||
if _, ok := overrideDates[*prev.SubmissionCode]; ok {
|
||||
parentOverridden = true
|
||||
}
|
||||
}
|
||||
break
|
||||
if r.ParentID != nil && courtSet[*r.ParentID] && parentRule != nil {
|
||||
if parentRule.SubmissionCode != nil {
|
||||
if _, ok := overrideDates[*parentRule.SubmissionCode]; ok {
|
||||
parentOverridden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -530,6 +643,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
// "unbestimmt", not "wird vom Gericht bestimmt".
|
||||
d.IsCourtSet = true
|
||||
d.IsCourtSetIndirect = true
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
@@ -563,6 +677,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
// itself isn't a court action.
|
||||
d.IsCourtSet = true
|
||||
d.IsCourtSetIndirect = true
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
@@ -595,9 +710,19 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
// should say "unbestimmt", not "wird vom Gericht bestimmt":
|
||||
// the date isn't directly determined by the court, it's
|
||||
// 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 {
|
||||
d.IsCourtSet = true
|
||||
d.IsCourtSetIndirect = true
|
||||
d.IsConditional = true
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
courtSet[r.ID] = true
|
||||
@@ -700,6 +825,42 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
d.DueDate = adjusted.Format("2006-01-02")
|
||||
d.WasAdjusted = wasAdj
|
||||
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 {
|
||||
computed[*r.SubmissionCode] = adjusted
|
||||
}
|
||||
@@ -712,6 +873,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
ProceedingNameEN: pickedProceeding.NameEN,
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
HiddenCount: hiddenCount,
|
||||
}
|
||||
// Sub-track routing keeps the user-picked proceeding's identity,
|
||||
// so the trigger-event label rides on `pickedProceeding` (e.g.
|
||||
|
||||
@@ -450,3 +450,130 @@ func TestUIDeadline_WireShape_Slice8(t *testing.T) {
|
||||
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.
|
||||
{"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).
|
||||
{"upc.inf.cfi.cons_orders", true, "upc.inf.cfi.decision"},
|
||||
// Symptom B — optional + both anchored on SoC (trigger anchor).
|
||||
{"upc.inf.cfi.confidentiality_response", true, "upc.inf.cfi.soc"},
|
||||
// Negative control — mandatory rule anchored on SoC must keep
|
||||
// its concrete date (no IsConditional, real DueDate).
|
||||
{"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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// PartySearchHit is one row of the cross-project party search — a real
|
||||
// paliad.parties row enriched with the parent project's title and
|
||||
// reference so the picker can render context the lawyer needs to
|
||||
// disambiguate identically-named parties on different cases
|
||||
// (t-paliad-287).
|
||||
type PartySearchHit struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
||||
ProjectTitle string `db:"project_title" json:"project_title"`
|
||||
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Role *string `db:"role" json:"role,omitempty"`
|
||||
Representative *string `db:"representative" json:"representative,omitempty"`
|
||||
}
|
||||
|
||||
// Search returns parties from every project the caller can see, matched
|
||||
// by case-insensitive substring on name OR representative. Empty query
|
||||
// returns the 20 most recently-updated parties so the picker isn't
|
||||
// blank on first open. Capped at 25 rows; the frontend doesn't paginate
|
||||
// (the typical PA looks for one party they remember by name, not browses).
|
||||
//
|
||||
// Visibility is enforced inline via visibilityPredicatePositional —
|
||||
// invisible projects' parties never surface in the result set.
|
||||
func (s *PartyService) Search(ctx context.Context, userID uuid.UUID, query string, limit int) ([]PartySearchHit, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 25
|
||||
}
|
||||
q := strings.TrimSpace(query)
|
||||
args := []any{userID}
|
||||
conds := []string{visibilityPredicatePositional("p", 1)}
|
||||
if q != "" {
|
||||
args = append(args, "%"+q+"%")
|
||||
conds = append(conds,
|
||||
fmt.Sprintf(`(pa.name ILIKE $%d OR COALESCE(pa.representative,'') ILIKE $%d)`,
|
||||
len(args), len(args)))
|
||||
}
|
||||
args = append(args, limit)
|
||||
sqlStr := `
|
||||
SELECT pa.id, pa.project_id, p.title AS project_title,
|
||||
p.reference AS project_reference,
|
||||
pa.name, pa.role, pa.representative
|
||||
FROM paliad.parties pa
|
||||
JOIN paliad.projects p ON p.id = pa.project_id
|
||||
WHERE ` + strings.Join(conds, " AND ") + `
|
||||
ORDER BY pa.updated_at DESC
|
||||
LIMIT $` + fmt.Sprintf("%d", len(args))
|
||||
hits := []PartySearchHit{}
|
||||
if err := s.db.SelectContext(ctx, &hits, sqlStr, args...); err != nil {
|
||||
return nil, fmt.Errorf("search parties: %w", err)
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
// ListForProject returns all Parties for the Project, visibility-checked.
|
||||
func (s *PartyService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Party, error) {
|
||||
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
||||
|
||||
@@ -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) {
|
||||
hearing := "hearing"
|
||||
decision := "decision"
|
||||
|
||||
@@ -147,6 +147,17 @@ type TimelineEvent struct {
|
||||
// checkbox). At parent-node levels, rows with BubbleUp=true survive
|
||||
// the levelPolicy kind/status filter unconditionally.
|
||||
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.
|
||||
@@ -933,12 +944,13 @@ func (s *ProjectionService) computeProjections(
|
||||
Title: ruleDisplayName(rule, ui, lang(opts.Lang)),
|
||||
RuleCode: ui.Code,
|
||||
DeadlineRuleParty: ui.Party,
|
||||
IsConditional: ui.IsConditional,
|
||||
}
|
||||
idCopy := ruleID
|
||||
ev.DeadlineRuleID = &idCopy
|
||||
|
||||
// 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 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)
|
||||
@@ -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 {
|
||||
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:
|
||||
// Pure court-set rule — date is bound by the court at
|
||||
// hearing/decision time. Surface as undated court_set.
|
||||
|
||||
@@ -66,8 +66,8 @@ func AgendaSystemView() SystemView {
|
||||
Sources: []DataSource{SourceDeadline, SourceAppointment},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"pending"}}},
|
||||
Predicates: &Predicates{
|
||||
Deadline: &DeadlinePredicates{Status: []string{"pending"}},
|
||||
},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
@@ -126,14 +126,14 @@ func InboxSystemView() SystemView {
|
||||
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||
Predicates: &Predicates{
|
||||
ApprovalRequest: &ApprovalRequestPredicates{
|
||||
ViewerRole: "any_visible",
|
||||
Status: []string{"pending"},
|
||||
}},
|
||||
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
|
||||
},
|
||||
ProjectEvent: &ProjectEventPredicates{
|
||||
EventTypes: InboxProjectEventKinds,
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
@@ -159,10 +159,10 @@ func InboxRequesterSystemView() SystemView {
|
||||
Sources: []DataSource{SourceApprovalRequest},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||
Predicates: &Predicates{
|
||||
ApprovalRequest: &ApprovalRequestPredicates{
|
||||
ViewerRole: "self_requested",
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
|
||||
@@ -82,11 +82,10 @@ func TestInboxSystemView_RowActionInbox(t *testing.T) {
|
||||
|
||||
func TestInboxSystemView_CuratedProjectEventKinds(t *testing.T) {
|
||||
sv := InboxSystemView()
|
||||
preds := sv.Filter.Predicates[SourceProjectEvent]
|
||||
if preds.ProjectEvent == nil {
|
||||
if sv.Filter.Predicates == nil || sv.Filter.Predicates.ProjectEvent == nil {
|
||||
t.Fatal("InboxSystemView must narrow project_event predicates")
|
||||
}
|
||||
got := preds.ProjectEvent.EventTypes
|
||||
got := sv.Filter.Predicates.ProjectEvent.EventTypes
|
||||
if len(got) != len(InboxProjectEventKinds) {
|
||||
t.Errorf("expected %d curated kinds, got %d", len(InboxProjectEventKinds), len(got))
|
||||
}
|
||||
|
||||
@@ -234,8 +234,8 @@ func (s *EventService) runDeadlines(ctx context.Context, userID uuid.UUID, spec
|
||||
uid := userID
|
||||
df.CreatedBy = &uid
|
||||
}
|
||||
if preds, ok := spec.Predicates[SourceDeadline]; ok && preds.Deadline != nil {
|
||||
dp := preds.Deadline
|
||||
if spec.Predicates != nil && spec.Predicates.Deadline != nil {
|
||||
dp := spec.Predicates.Deadline
|
||||
// Status: ListFilter has DeadlineStatusFilter (single-value filter).
|
||||
// If the spec asks for both pending+completed → no narrowing; if
|
||||
// only pending → DeadlineFilterPending; only completed → Completed.
|
||||
@@ -317,8 +317,8 @@ func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, sp
|
||||
}
|
||||
af.From = bounds.from
|
||||
af.To = bounds.to
|
||||
if preds, ok := spec.Predicates[SourceAppointment]; ok && preds.Appointment != nil {
|
||||
ap := preds.Appointment
|
||||
if spec.Predicates != nil && spec.Predicates.Appointment != nil {
|
||||
ap := spec.Predicates.Appointment
|
||||
// AppointmentListFilter takes a single Type today; narrow to first
|
||||
// listed value, fall back to all if multiple.
|
||||
if len(ap.AppointmentTypes) == 1 {
|
||||
@@ -482,21 +482,24 @@ func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, s
|
||||
// ApprovalService inbox queries. ViewerRole picks which underlying
|
||||
// query runs.
|
||||
func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService, bounds viewSpecBounds) ([]ViewRow, error) {
|
||||
preds := spec.Predicates[SourceApprovalRequest]
|
||||
var ap *ApprovalRequestPredicates
|
||||
if spec.Predicates != nil {
|
||||
ap = spec.Predicates.ApprovalRequest
|
||||
}
|
||||
role := "approver_eligible"
|
||||
if preds.ApprovalRequest != nil && preds.ApprovalRequest.ViewerRole != "" {
|
||||
role = preds.ApprovalRequest.ViewerRole
|
||||
if ap != nil && ap.ViewerRole != "" {
|
||||
role = ap.ViewerRole
|
||||
}
|
||||
|
||||
filter := InboxFilter{}
|
||||
if preds.ApprovalRequest != nil {
|
||||
if ap != nil {
|
||||
// InboxFilter takes a single status today. If the spec says
|
||||
// only one, narrow; if multiple, leave open.
|
||||
if len(preds.ApprovalRequest.Status) == 1 {
|
||||
filter.Status = preds.ApprovalRequest.Status[0]
|
||||
if len(ap.Status) == 1 {
|
||||
filter.Status = ap.Status[0]
|
||||
}
|
||||
if len(preds.ApprovalRequest.EntityTypes) == 1 {
|
||||
filter.EntityType = preds.ApprovalRequest.EntityTypes[0]
|
||||
if len(ap.EntityTypes) == 1 {
|
||||
filter.EntityType = ap.EntityTypes[0]
|
||||
}
|
||||
}
|
||||
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) == 1 {
|
||||
@@ -665,19 +668,18 @@ func explicitProjectSet(spec FilterSpec) map[uuid.UUID]bool {
|
||||
// approvalStatusMatches checks the entity-side approval_status filter.
|
||||
// Returns true when the row passes (no filter set → always true).
|
||||
func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bool {
|
||||
preds, ok := spec.Predicates[src]
|
||||
if !ok {
|
||||
if spec.Predicates == nil {
|
||||
return true
|
||||
}
|
||||
var allowed []string
|
||||
switch src {
|
||||
case SourceDeadline:
|
||||
if preds.Deadline != nil {
|
||||
allowed = preds.Deadline.ApprovalStatus
|
||||
if spec.Predicates.Deadline != nil {
|
||||
allowed = spec.Predicates.Deadline.ApprovalStatus
|
||||
}
|
||||
case SourceAppointment:
|
||||
if preds.Appointment != nil {
|
||||
allowed = preds.Appointment.ApprovalStatus
|
||||
if spec.Predicates.Appointment != nil {
|
||||
allowed = spec.Predicates.Appointment.ApprovalStatus
|
||||
}
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
@@ -689,15 +691,15 @@ func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bo
|
||||
// allowedAppointmentTypes returns nil when the filter is open, otherwise
|
||||
// a set of legal appointment_type values.
|
||||
func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
|
||||
preds, ok := spec.Predicates[SourceAppointment]
|
||||
if !ok || preds.Appointment == nil {
|
||||
if spec.Predicates == nil || spec.Predicates.Appointment == nil {
|
||||
return nil
|
||||
}
|
||||
if len(preds.Appointment.AppointmentTypes) <= 1 {
|
||||
ap := spec.Predicates.Appointment
|
||||
if len(ap.AppointmentTypes) <= 1 {
|
||||
return nil // single-value already pushed down via AppointmentListFilter.Type
|
||||
}
|
||||
out := make(map[string]bool, len(preds.Appointment.AppointmentTypes))
|
||||
for _, t := range preds.Appointment.AppointmentTypes {
|
||||
out := make(map[string]bool, len(ap.AppointmentTypes))
|
||||
for _, t := range ap.AppointmentTypes {
|
||||
out[t] = true
|
||||
}
|
||||
return out
|
||||
@@ -712,13 +714,16 @@ func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
|
||||
// don't want both rows showing up side-by-side. The drop applies to
|
||||
// both the explicit caller list and the implicit "all kinds" path.
|
||||
func allowedProjectEventKinds(spec FilterSpec) []string {
|
||||
preds, ok := spec.Predicates[SourceProjectEvent]
|
||||
var pe *ProjectEventPredicates
|
||||
if spec.Predicates != nil {
|
||||
pe = spec.Predicates.ProjectEvent
|
||||
}
|
||||
dedupApprovals := slices.Contains(spec.Sources, SourceApprovalRequest)
|
||||
|
||||
var requested []string
|
||||
switch {
|
||||
case ok && preds.ProjectEvent != nil && len(preds.ProjectEvent.EventTypes) > 0:
|
||||
requested = preds.ProjectEvent.EventTypes
|
||||
case pe != nil && len(pe.EventTypes) > 0:
|
||||
requested = pe.EventTypes
|
||||
case dedupApprovals:
|
||||
// No explicit narrowing, but ApprovalRequest is in sources —
|
||||
// rebuild the implicit "all" list so we can subtract approvals.
|
||||
@@ -750,30 +755,30 @@ func isApprovalAuditKind(kind string) bool {
|
||||
// allowedRequestStatuses returns nil for "no narrowing" (or "single value
|
||||
// already pushed into InboxFilter.Status").
|
||||
func allowedRequestStatuses(spec FilterSpec) map[string]bool {
|
||||
preds, ok := spec.Predicates[SourceApprovalRequest]
|
||||
if !ok || preds.ApprovalRequest == nil {
|
||||
if spec.Predicates == nil || spec.Predicates.ApprovalRequest == nil {
|
||||
return nil
|
||||
}
|
||||
if len(preds.ApprovalRequest.Status) <= 1 {
|
||||
ap := spec.Predicates.ApprovalRequest
|
||||
if len(ap.Status) <= 1 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(preds.ApprovalRequest.Status))
|
||||
for _, s := range preds.ApprovalRequest.Status {
|
||||
out := make(map[string]bool, len(ap.Status))
|
||||
for _, s := range ap.Status {
|
||||
out[s] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func allowedRequestEntityTypes(spec FilterSpec) map[string]bool {
|
||||
preds, ok := spec.Predicates[SourceApprovalRequest]
|
||||
if !ok || preds.ApprovalRequest == nil {
|
||||
if spec.Predicates == nil || spec.Predicates.ApprovalRequest == nil {
|
||||
return nil
|
||||
}
|
||||
if len(preds.ApprovalRequest.EntityTypes) <= 1 {
|
||||
ap := spec.Predicates.ApprovalRequest
|
||||
if len(ap.EntityTypes) <= 1 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(preds.ApprovalRequest.EntityTypes))
|
||||
for _, t := range preds.ApprovalRequest.EntityTypes {
|
||||
out := make(map[string]bool, len(ap.EntityTypes))
|
||||
for _, t := range ap.EntityTypes {
|
||||
out[t] = true
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -13,8 +13,8 @@ func TestAllowedProjectEventKinds_DedupsApprovalAudits(t *testing.T) {
|
||||
spec := FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
|
||||
Predicates: &Predicates{
|
||||
ProjectEvent: &ProjectEventPredicates{
|
||||
EventTypes: []string{
|
||||
"deadline_created",
|
||||
"deadline_approval_requested",
|
||||
@@ -22,7 +22,7 @@ func TestAllowedProjectEventKinds_DedupsApprovalAudits(t *testing.T) {
|
||||
"approval_decided",
|
||||
"note_created",
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := allowedProjectEventKinds(spec)
|
||||
@@ -47,13 +47,13 @@ func TestAllowedProjectEventKinds_NoDedupWhenApprovalsAbsent(t *testing.T) {
|
||||
spec := FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{SourceProjectEvent},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
|
||||
Predicates: &Predicates{
|
||||
ProjectEvent: &ProjectEventPredicates{
|
||||
EventTypes: []string{
|
||||
"deadline_created",
|
||||
"deadline_approval_requested",
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := allowedProjectEventKinds(spec)
|
||||
|
||||
@@ -315,11 +315,11 @@ func buildDocumentXML() string {
|
||||
body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})")
|
||||
body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}")
|
||||
|
||||
headerSubsection(&b, "Frist")
|
||||
body0(&b, "Frist-Bezeichnung: {{deadline.title}}")
|
||||
body0(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
body0(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
body0(&b, "Berechnet aus: {{deadline.computed_from}} · Quelle: {{deadline.source}}")
|
||||
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
|
||||
// {{deadline.*}} placeholders stay resolvable in the variable bag
|
||||
// for custom templates that want them, but the default HL skeleton
|
||||
// no longer renders them in the submission body: the deadline is
|
||||
// internal/admin context and has no place in a court-bound document.
|
||||
|
||||
heading(&b, "HLpat-Heading-H2", "I. Sachverhalt")
|
||||
body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]")
|
||||
@@ -349,7 +349,6 @@ func buildDocumentXML() string {
|
||||
body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}")
|
||||
body1(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||
body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}")
|
||||
body1(&b, "Deadline EN long: {{deadline.due_date_long_en}}")
|
||||
body1(&b, "Procedural event name (DE): {{procedural_event.name_de}} · (EN): {{procedural_event.name_en}}")
|
||||
body1(&b, "Rule legacy aliases — name: {{rule.name}}, name_de: {{rule.name_de}}, name_en: {{rule.name_en}}")
|
||||
body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}")
|
||||
|
||||
@@ -137,14 +137,19 @@ const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
</w:styles>`
|
||||
|
||||
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
|
||||
// case caption + parties + submission heading + deadline + a single
|
||||
// neutral body block. Mirrors the variable bag from SubmissionVarsService
|
||||
// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
|
||||
// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
|
||||
// structure. A lawyer customising this template for a UPC SoC, EPO
|
||||
// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
|
||||
// renames the party labels — every placeholder still resolves regardless
|
||||
// of the submission_code chosen.
|
||||
// case caption + parties + submission heading + a single neutral body
|
||||
// block. Mirrors the variable bag from SubmissionVarsService (firm.* /
|
||||
// today.* / user.* / project.* / parties.* / rule.*) without baking in
|
||||
// DE-LG-Klageerwiderung-specific structure. A lawyer customising this
|
||||
// template for a UPC SoC, EPO opposition, or DPMA appeal replaces the
|
||||
// [Schriftsatztext] block and renames the party labels — every
|
||||
// placeholder still resolves regardless of the submission_code chosen.
|
||||
//
|
||||
// The {{deadline.*}} placeholders are deliberately NOT rendered by the
|
||||
// default skeleton (t-paliad-287). The deadline is internal context for
|
||||
// the lawyer, not text that belongs in a court-bound submission. The
|
||||
// keys stay resolvable in the bag so a custom template can still
|
||||
// reference them where it actually wants them.
|
||||
//
|
||||
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
|
||||
// (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, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
|
||||
|
||||
heading2(&b, "Frist")
|
||||
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
|
||||
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
|
||||
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
|
||||
// {{deadline.*}} placeholders stay resolvable in the variable bag
|
||||
// (lawyer can still drop them into a custom paragraph) but the
|
||||
// default skeleton no longer renders them in the submission body:
|
||||
// the deadline is internal/admin context and has no place in a
|
||||
// document going out to court.
|
||||
|
||||
heading2(&b, "Schriftsatztext")
|
||||
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
|
||||
// delete this block; the renderer round-trips it cleanly today.
|
||||
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, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
|
||||
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
|
||||
|
||||
Reference in New Issue
Block a user