Reapply "Merge: t-paliad-133 — Fristenrechner v3 (Pathway A/B fork + B1 decision tree + B2 forum filter + retire legacy tabs)"
This reverts commit 5bd17de732.
This commit is contained in:
@@ -143,6 +143,7 @@ func main() {
|
||||
Fristenrechner: services.NewFristenrechnerService(rules, holidays),
|
||||
EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays),
|
||||
DeadlineSearch: services.NewDeadlineSearchService(pool),
|
||||
EventCategory: nil, // wired below; cross-link order matters
|
||||
EventType: eventTypeSvc,
|
||||
Dashboard: services.NewDashboardService(pool, users),
|
||||
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
|
||||
@@ -155,6 +156,12 @@ func main() {
|
||||
Link: services.NewLinkService(pool),
|
||||
Event: services.NewEventService(pool, deadlineSvc, appointmentSvc),
|
||||
}
|
||||
// v3 (t-paliad-133): wire EventCategoryService and cross-link
|
||||
// it into DeadlineSearchService so ?event_category_slug= can
|
||||
// resolve to a concept-id allow-list during search.
|
||||
eventCategorySvc := services.NewEventCategoryService(pool)
|
||||
svcBundle.EventCategory = eventCategorySvc
|
||||
svcBundle.DeadlineSearch.SetEventCategoryService(eventCategorySvc)
|
||||
log.Println("Phase B services initialised")
|
||||
|
||||
// Spawn background goroutines: CalDAV sync (one per enabled user)
|
||||
|
||||
1048
docs/plans/unified-fristenrechner-v3.md
Normal file
1048
docs/plans/unified-fristenrechner-v3.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1278,7 +1278,14 @@ async function runSearch() {
|
||||
const seq = ++searchSeq;
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`/api/tools/fristenrechner/search?q=${encodeURIComponent(q)}&limit=12`, { credentials: "same-origin" });
|
||||
{
|
||||
const searchURL = new URL("/api/tools/fristenrechner/search", window.location.origin);
|
||||
searchURL.searchParams.set("q", q);
|
||||
searchURL.searchParams.set("limit", "12");
|
||||
const forums = getActiveForumsParam();
|
||||
if (forums) searchURL.searchParams.set("forum", forums);
|
||||
resp = await fetch(searchURL.toString(), { credentials: "same-origin" });
|
||||
}
|
||||
} catch {
|
||||
if (seq !== searchSeq) return;
|
||||
results.classList.remove("is-loading");
|
||||
@@ -1448,9 +1455,14 @@ function drillToProceeding(procCode: string, focusCode: string | null) {
|
||||
}
|
||||
|
||||
function drillToTrigger(triggerId: number) {
|
||||
const eventTab = document.getElementById("mode-event-tab");
|
||||
if (eventTab) eventTab.click();
|
||||
// Defer a tick so the tab activation has run before we touch event-mode state.
|
||||
// v3 (Phase E): legacy tabs are gone. Show the event panel directly.
|
||||
// Triggered from concept-card pill clicks; routes via Pathway A so the
|
||||
// Verfahrensablauf user surface stays consistent.
|
||||
const procedurePanel = document.getElementById("mode-procedure-panel");
|
||||
const eventPanel = document.getElementById("mode-event-panel");
|
||||
if (procedurePanel) procedurePanel.hidden = true;
|
||||
if (eventPanel) eventPanel.hidden = false;
|
||||
// Defer a tick so the panel swap has rendered before we touch state.
|
||||
window.setTimeout(() => {
|
||||
selectTriggerEvent(triggerId);
|
||||
document.getElementById("event-step-2")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
@@ -1554,3 +1566,550 @@ function initSearch() {
|
||||
// Wire on DOM ready (the existing DOMContentLoaded handler is already busy;
|
||||
// add a lightweight follow-up listener to keep the diff small).
|
||||
document.addEventListener("DOMContentLoaded", initSearch);
|
||||
|
||||
// ============================================================================
|
||||
// v3 pathway fork (t-paliad-133)
|
||||
// ============================================================================
|
||||
// Three-state landing surface: fork (default), Pathway A (Verfahrensablauf —
|
||||
// existing wizard), Pathway B (Frist eintragen — search/B1/B2). URL ?path=
|
||||
// drives visibility; localStorage remembers the last-used pathway for soft
|
||||
// re-entry. ?legacy=1 keeps the pre-v3 layout (no fork) for parity testing
|
||||
// during the rollout window.
|
||||
|
||||
type Pathway = "fork" | "a" | "b";
|
||||
type BMode = "tree" | "filter";
|
||||
|
||||
const PATHWAY_STORAGE_KEY = "paliad.fristen.pathway";
|
||||
|
||||
function readPathwayFromURL(): Pathway {
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
const p = sp.get("path");
|
||||
if (p === "a" || p === "b") return p;
|
||||
return "fork";
|
||||
}
|
||||
|
||||
function readBModeFromURL(): BMode {
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
const m = sp.get("mode");
|
||||
if (m === "tree" || m === "filter") return m;
|
||||
// Default: tree mode (B1 cascade is the discovery surface; the
|
||||
// free-text/filter B2 mode is for power users who already know what
|
||||
// they want).
|
||||
return "tree";
|
||||
}
|
||||
|
||||
function setPathwayURL(path: Pathway, mode?: BMode, replace = false) {
|
||||
const url = new URL(window.location.href);
|
||||
if (path === "fork") {
|
||||
url.searchParams.delete("path");
|
||||
url.searchParams.delete("mode");
|
||||
url.searchParams.delete("b1");
|
||||
} else {
|
||||
url.searchParams.set("path", path);
|
||||
if (path === "b" && mode) {
|
||||
url.searchParams.set("mode", mode);
|
||||
} else {
|
||||
url.searchParams.delete("mode");
|
||||
}
|
||||
}
|
||||
if (replace) {
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} else {
|
||||
window.history.pushState({}, "", url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
function showPathway(path: Pathway, mode?: BMode) {
|
||||
const fork = document.getElementById("fristen-pathway-fork");
|
||||
const a = document.getElementById("fristen-pathway-a");
|
||||
const b = document.getElementById("fristen-pathway-b");
|
||||
if (!fork || !a || !b) return;
|
||||
|
||||
fork.hidden = path !== "fork";
|
||||
a.hidden = path !== "a";
|
||||
b.hidden = path !== "b";
|
||||
|
||||
if (path === "b") {
|
||||
showBMode(mode || readBModeFromURL());
|
||||
}
|
||||
}
|
||||
|
||||
function showBMode(mode: BMode) {
|
||||
const tree = document.getElementById("fristen-b1-panel");
|
||||
const filter = document.getElementById("fristen-b2-panel");
|
||||
const treeRadio = document.getElementById("fristen-b-mode-tree") as HTMLInputElement | null;
|
||||
const filterRadio = document.getElementById("fristen-b-mode-filter") as HTMLInputElement | null;
|
||||
if (!tree || !filter) return;
|
||||
tree.hidden = mode !== "tree";
|
||||
filter.hidden = mode !== "filter";
|
||||
if (treeRadio) treeRadio.checked = mode === "tree";
|
||||
if (filterRadio) filterRadio.checked = mode === "filter";
|
||||
|
||||
// Phase B B1 stub — will be replaced by the real cascade in Phase C.
|
||||
if (mode === "tree") {
|
||||
const cascade = document.getElementById("fristen-b1-cascade");
|
||||
if (cascade && cascade.childElementCount === 0) {
|
||||
cascade.innerHTML =
|
||||
`<div class="fristen-b1-stub">${escHtml(t("deadlines.pathway.b.tree.coming_soon"))}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToPathway(path: Pathway, mode?: BMode) {
|
||||
setPathwayURL(path, mode);
|
||||
showPathway(path, mode);
|
||||
if (path !== "fork") {
|
||||
try {
|
||||
localStorage.setItem(PATHWAY_STORAGE_KEY, path);
|
||||
} catch { /* private mode */ }
|
||||
}
|
||||
}
|
||||
|
||||
function initPathwayFork() {
|
||||
// Initial render from URL (or saved preference if URL is bare).
|
||||
const initial = readPathwayFromURL();
|
||||
const initialMode = readBModeFromURL();
|
||||
showPathway(initial, initialMode);
|
||||
|
||||
// Persist initial choice from URL.
|
||||
if (initial !== "fork") {
|
||||
try { localStorage.setItem(PATHWAY_STORAGE_KEY, initial); } catch { /* */ }
|
||||
}
|
||||
|
||||
// Click handlers on the two fork cards.
|
||||
document.getElementById("fristen-pathway-a-cta")?.addEventListener("click", () => {
|
||||
navigateToPathway("a");
|
||||
});
|
||||
document.getElementById("fristen-pathway-b-cta")?.addEventListener("click", () => {
|
||||
// Default to tree mode on first entry to Pathway B.
|
||||
navigateToPathway("b", "tree");
|
||||
});
|
||||
|
||||
// Back-to-fork buttons inside each pathway shell.
|
||||
document.getElementById("fristen-pathway-a-back")?.addEventListener("click", () => {
|
||||
navigateToPathway("fork");
|
||||
});
|
||||
document.getElementById("fristen-pathway-b-back")?.addEventListener("click", () => {
|
||||
navigateToPathway("fork");
|
||||
});
|
||||
|
||||
// B1/B2 mode toggle inside Pathway B.
|
||||
const bModeRadios = document.querySelectorAll<HTMLInputElement>("input[name='fristen-b-mode']");
|
||||
bModeRadios.forEach((r) => {
|
||||
r.addEventListener("change", () => {
|
||||
if (!r.checked) return;
|
||||
const mode: BMode = r.value === "tree" ? "tree" : "filter";
|
||||
setPathwayURL("b", mode);
|
||||
showBMode(mode);
|
||||
});
|
||||
});
|
||||
|
||||
// Quick-pick chips on the fork shortcut row → jump straight to Pathway B + filter mode + prefilled query.
|
||||
document.querySelectorAll<HTMLButtonElement>("#fristen-fork-chips .fristen-search-chip").forEach((chip) => {
|
||||
chip.addEventListener("click", () => {
|
||||
const q = chip.dataset.q || "";
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("path", "b");
|
||||
url.searchParams.set("mode", "filter");
|
||||
if (q) url.searchParams.set("q", q);
|
||||
window.history.pushState({}, "", url.toString());
|
||||
showPathway("b", "filter");
|
||||
// initSearch listens for popstate, but we used pushState; sync the
|
||||
// search input directly.
|
||||
const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
|
||||
if (input && q) {
|
||||
input.value = q;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Browser back/forward should restore pathway state.
|
||||
window.addEventListener("popstate", () => {
|
||||
const path = readPathwayFromURL();
|
||||
const mode = readBModeFromURL();
|
||||
showPathway(path, mode);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initPathwayFork);
|
||||
|
||||
// ============================================================================
|
||||
// v3 B1 decision tree (t-paliad-133 Phase C)
|
||||
// ============================================================================
|
||||
// Data-driven cascade: fetch the event-categories tree from
|
||||
// GET /api/tools/fristenrechner/event-categories, render the current
|
||||
// step's button set, walk down on click, show breadcrumb + reset.
|
||||
// Result cards below come from /api/tools/fristenrechner/search with
|
||||
// ?event_category_slug= narrowing.
|
||||
|
||||
interface EventCategoryNode {
|
||||
id: string;
|
||||
slug: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
description_de?: string;
|
||||
description_en?: string;
|
||||
step_question_de?: string;
|
||||
step_question_en?: string;
|
||||
icon?: string;
|
||||
sort_order: number;
|
||||
is_leaf: boolean;
|
||||
children?: EventCategoryNode[];
|
||||
}
|
||||
|
||||
let eventCategoryTree: EventCategoryNode[] | null = null;
|
||||
let eventCategoryFetchInflight: Promise<EventCategoryNode[]> | null = null;
|
||||
|
||||
async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
|
||||
if (eventCategoryTree) return eventCategoryTree;
|
||||
if (eventCategoryFetchInflight) return eventCategoryFetchInflight;
|
||||
eventCategoryFetchInflight = (async () => {
|
||||
try {
|
||||
const r = await fetch("/api/tools/fristenrechner/event-categories");
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data = await r.json();
|
||||
eventCategoryTree = (data.tree || []) as EventCategoryNode[];
|
||||
return eventCategoryTree;
|
||||
} finally {
|
||||
eventCategoryFetchInflight = null;
|
||||
}
|
||||
})();
|
||||
return eventCategoryFetchInflight;
|
||||
}
|
||||
|
||||
function readB1PathFromURL(): string {
|
||||
return new URLSearchParams(window.location.search).get("b1") || "";
|
||||
}
|
||||
|
||||
function setB1PathInURL(slug: string, replace = false) {
|
||||
const url = new URL(window.location.href);
|
||||
if (slug) {
|
||||
url.searchParams.set("b1", slug);
|
||||
} else {
|
||||
url.searchParams.delete("b1");
|
||||
}
|
||||
if (replace) {
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} else {
|
||||
window.history.pushState({}, "", url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
function findNodeBySlug(roots: EventCategoryNode[], slug: string): EventCategoryNode | null {
|
||||
for (const root of roots) {
|
||||
if (root.slug === slug) return root;
|
||||
if (root.children) {
|
||||
const inner = findNodeBySlug(root.children, slug);
|
||||
if (inner) return inner;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildBreadcrumb(roots: EventCategoryNode[], slug: string): EventCategoryNode[] {
|
||||
// Slug is dot-separated; walk down each segment.
|
||||
if (!slug) return [];
|
||||
const parts = slug.split(".");
|
||||
const trail: EventCategoryNode[] = [];
|
||||
let scope = roots;
|
||||
let cumulative = "";
|
||||
for (const seg of parts) {
|
||||
cumulative = cumulative ? `${cumulative}.${seg}` : seg;
|
||||
const node = scope.find((n) => n.slug === cumulative);
|
||||
if (!node) break;
|
||||
trail.push(node);
|
||||
scope = node.children || [];
|
||||
}
|
||||
return trail;
|
||||
}
|
||||
|
||||
function nodeLabel(n: EventCategoryNode): string {
|
||||
return getLang() === "de" ? n.label_de : n.label_en;
|
||||
}
|
||||
|
||||
function nodeStepQuestion(n: EventCategoryNode): string {
|
||||
return getLang() === "de"
|
||||
? (n.step_question_de || "")
|
||||
: (n.step_question_en || n.step_question_de || "");
|
||||
}
|
||||
|
||||
function renderB1Cascade(currentSlug: string) {
|
||||
const cascade = document.getElementById("fristen-b1-cascade");
|
||||
if (!cascade || !eventCategoryTree) return;
|
||||
|
||||
const trail = buildBreadcrumb(eventCategoryTree, currentSlug);
|
||||
const node = trail.length > 0 ? trail[trail.length - 1] : null;
|
||||
const childScope = node ? (node.children || []) : eventCategoryTree;
|
||||
|
||||
const breadcrumbHtml = trail.length === 0
|
||||
? ""
|
||||
: `<nav class="fristen-b1-breadcrumb" aria-label="Pfad">
|
||||
<button type="button" class="fristen-b1-crumb fristen-b1-crumb--root" data-slug="">
|
||||
${escHtml(t("deadlines.pathway.b.tree.reset"))}
|
||||
</button>
|
||||
${trail.map((c, i) =>
|
||||
`<span class="fristen-b1-crumb-sep" aria-hidden="true">›</span>
|
||||
<button type="button" class="fristen-b1-crumb${i === trail.length - 1 ? " fristen-b1-crumb--current" : ""}" data-slug="${escAttr(c.slug)}">
|
||||
${c.icon ? `<span class="fristen-b1-crumb-icon" aria-hidden="true">${escHtml(c.icon)}</span> ` : ""}${escHtml(nodeLabel(c))}
|
||||
</button>`).join("")}
|
||||
</nav>`;
|
||||
|
||||
const question = node && node.step_question_de
|
||||
? `<p class="fristen-b1-question">${escHtml(nodeStepQuestion(node))}</p>`
|
||||
: trail.length === 0
|
||||
? `<p class="fristen-b1-question">${escHtml(t("deadlines.pathway.b.tree.start_question") || "Was ist passiert?")}</p>`
|
||||
: "";
|
||||
|
||||
let buttonsHtml = "";
|
||||
if (childScope.length > 0) {
|
||||
buttonsHtml = `<div class="fristen-b1-buttons">${
|
||||
childScope.map((c) =>
|
||||
`<button type="button" class="fristen-b1-button${c.is_leaf ? " fristen-b1-button--leaf" : ""}" data-slug="${escAttr(c.slug)}">
|
||||
${c.icon ? `<span class="fristen-b1-button-icon" aria-hidden="true">${escHtml(c.icon)}</span>` : ""}
|
||||
<span class="fristen-b1-button-label">${escHtml(nodeLabel(c))}</span>
|
||||
</button>`).join("")
|
||||
}</div>`;
|
||||
}
|
||||
|
||||
// Skip-step affordance on non-leaf intermediate nodes.
|
||||
let skipHtml = "";
|
||||
if (node && !node.is_leaf && childScope.length > 0) {
|
||||
skipHtml = `<button type="button" class="fristen-b1-skip" data-action="skip">
|
||||
${escHtml(t("deadlines.pathway.b.tree.skip"))}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// Step-back affordance on any non-root state.
|
||||
let backHtml = "";
|
||||
if (trail.length > 0) {
|
||||
const parentSlug = trail.length > 1 ? trail[trail.length - 2].slug : "";
|
||||
backHtml = `<button type="button" class="fristen-b1-step-back" data-slug="${escAttr(parentSlug)}">
|
||||
← ${escHtml(t("deadlines.pathway.b.tree.step.back"))}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
cascade.innerHTML = `${breadcrumbHtml}${question}${buttonsHtml}${skipHtml}${backHtml}`;
|
||||
|
||||
// Wire button clicks.
|
||||
cascade.querySelectorAll<HTMLButtonElement>(".fristen-b1-button, .fristen-b1-crumb, .fristen-b1-step-back").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const slug = btn.dataset.slug || "";
|
||||
navigateB1(slug);
|
||||
});
|
||||
});
|
||||
|
||||
// Skip-step clicks the deepest path with current slug as the anchor —
|
||||
// it just means "search at this node level without deeper narrowing".
|
||||
cascade.querySelectorAll<HTMLButtonElement>(".fristen-b1-skip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
runB1Search(currentSlug);
|
||||
});
|
||||
});
|
||||
|
||||
runB1Search(currentSlug);
|
||||
}
|
||||
|
||||
async function runB1Search(slug: string) {
|
||||
const results = document.getElementById("fristen-search-results");
|
||||
if (!results) return;
|
||||
if (!slug) {
|
||||
// Root state: empty results until user picks a step.
|
||||
results.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.loading"))}</div>`;
|
||||
try {
|
||||
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
|
||||
url.searchParams.set("event_category_slug", slug);
|
||||
url.searchParams.set("limit", "30");
|
||||
const forums = getActiveForumsParam();
|
||||
if (forums) url.searchParams.set("forum", forums);
|
||||
const r = await fetch(url.toString());
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data = await r.json();
|
||||
if (!data.cards || data.cards.length === 0) {
|
||||
results.innerHTML = `<div class="fristen-search-status fristen-search-error">
|
||||
${escHtml(t("deadlines.pathway.b.tree.empty"))}
|
||||
<button type="button" class="fristen-b1-loosen-link" data-action="loosen">
|
||||
${escHtml(t("deadlines.pathway.b.tree.step.back"))}
|
||||
</button>
|
||||
</div>`;
|
||||
results.querySelector<HTMLButtonElement>(".fristen-b1-loosen-link")?.addEventListener("click", () => {
|
||||
const trail = buildBreadcrumb(eventCategoryTree || [], slug);
|
||||
const parent = trail.length > 1 ? trail[trail.length - 2].slug : "";
|
||||
navigateB1(parent);
|
||||
});
|
||||
return;
|
||||
}
|
||||
renderSearchResults(data);
|
||||
} catch (e) {
|
||||
results.innerHTML = `<div class="fristen-search-status fristen-search-error">
|
||||
${escHtml(t("deadlines.search.no_hits"))}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateB1(slug: string) {
|
||||
setB1PathInURL(slug);
|
||||
renderB1Cascade(slug);
|
||||
}
|
||||
|
||||
async function initB1Cascade() {
|
||||
const panel = document.getElementById("fristen-b1-panel");
|
||||
if (!panel) return;
|
||||
// Lazy-load the tree the first time the user arrives in tree mode.
|
||||
const loadAndRender = async () => {
|
||||
try {
|
||||
await loadEventCategoryTree();
|
||||
renderB1Cascade(readB1PathFromURL());
|
||||
} catch (e) {
|
||||
const cascade = document.getElementById("fristen-b1-cascade");
|
||||
if (cascade) {
|
||||
cascade.innerHTML = `<div class="fristen-b1-error">${escHtml(t("deadlines.pathway.b.tree.empty"))}</div>`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for tree mode becoming visible (Phase B's mode toggle).
|
||||
const treeRadio = document.getElementById("fristen-b-mode-tree") as HTMLInputElement | null;
|
||||
if (treeRadio) {
|
||||
treeRadio.addEventListener("change", () => {
|
||||
if (treeRadio.checked) loadAndRender();
|
||||
});
|
||||
}
|
||||
|
||||
// Initial render if the URL already lands in tree mode.
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
if (sp.get("path") === "b" && sp.get("mode") === "tree") {
|
||||
loadAndRender();
|
||||
}
|
||||
|
||||
// popstate restores the cascade depth.
|
||||
window.addEventListener("popstate", () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("path") === "b" && params.get("mode") === "tree" && eventCategoryTree) {
|
||||
renderB1Cascade(params.get("b1") || "");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initB1Cascade);
|
||||
|
||||
// ============================================================================
|
||||
// v3 B2 forum filter (t-paliad-133 Phase D)
|
||||
// ============================================================================
|
||||
// 10 forum buckets per m's spec lock §10 Q8. Multi-select chips,
|
||||
// AND-narrowing: each chip click toggles its membership in the active
|
||||
// set; the active set is sent as ?forum=<comma-separated> on every
|
||||
// search. Empty set = no filter.
|
||||
|
||||
const FORUM_BUCKETS: { slug: string; i18nKey: string }[] = [
|
||||
{ slug: "upc_cfi", i18nKey: "deadlines.filter.forum.upc_cfi" },
|
||||
{ slug: "upc_coa", i18nKey: "deadlines.filter.forum.upc_coa" },
|
||||
{ slug: "de_lg", i18nKey: "deadlines.filter.forum.de_lg" },
|
||||
{ slug: "de_olg", i18nKey: "deadlines.filter.forum.de_olg" },
|
||||
{ slug: "de_bgh", i18nKey: "deadlines.filter.forum.de_bgh" },
|
||||
{ slug: "de_bpatg", i18nKey: "deadlines.filter.forum.de_bpatg" },
|
||||
{ slug: "epa_grant", i18nKey: "deadlines.filter.forum.epa_grant" },
|
||||
{ slug: "epa_opp", i18nKey: "deadlines.filter.forum.epa_opp" },
|
||||
{ slug: "epa_appeal", i18nKey: "deadlines.filter.forum.epa_appeal" },
|
||||
{ slug: "dpma", i18nKey: "deadlines.filter.forum.dpma" },
|
||||
];
|
||||
|
||||
const activeForums = new Set<string>();
|
||||
|
||||
function readForumsFromURL(): string[] {
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
const raw = sp.get("forum");
|
||||
if (!raw) return [];
|
||||
return raw.split(",").map((s) => s.trim()).filter((s) => FORUM_BUCKETS.some((b) => b.slug === s));
|
||||
}
|
||||
|
||||
function writeForumsToURL(replace = false) {
|
||||
const url = new URL(window.location.href);
|
||||
if (activeForums.size === 0) {
|
||||
url.searchParams.delete("forum");
|
||||
} else {
|
||||
url.searchParams.set("forum", Array.from(activeForums).sort().join(","));
|
||||
}
|
||||
if (replace) {
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} else {
|
||||
window.history.pushState({}, "", url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
function renderForumChips() {
|
||||
const container = document.getElementById("fristen-forum-chips");
|
||||
const wrapper = document.getElementById("fristen-forum-filter");
|
||||
if (!container || !wrapper) return;
|
||||
wrapper.hidden = false;
|
||||
container.innerHTML = FORUM_BUCKETS.map((b) => {
|
||||
const active = activeForums.has(b.slug);
|
||||
return `<button type="button" class="fristen-forum-chip${active ? " fristen-forum-chip--active" : ""}"
|
||||
data-forum="${escAttr(b.slug)}"
|
||||
aria-pressed="${active ? "true" : "false"}">
|
||||
${escHtml(t(b.i18nKey))}
|
||||
</button>`;
|
||||
}).join("");
|
||||
container.querySelectorAll<HTMLButtonElement>(".fristen-forum-chip").forEach((chip) => {
|
||||
chip.addEventListener("click", () => {
|
||||
const slug = chip.dataset.forum || "";
|
||||
if (!slug) return;
|
||||
if (activeForums.has(slug)) {
|
||||
activeForums.delete(slug);
|
||||
} else {
|
||||
activeForums.add(slug);
|
||||
}
|
||||
writeForumsToURL();
|
||||
renderForumChips();
|
||||
reissueSearchWithCurrentFilters();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function reissueSearchWithCurrentFilters() {
|
||||
// If we're in B1 mode, refresh the current cascade slug's results.
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
if (sp.get("mode") === "tree") {
|
||||
const slug = sp.get("b1") || "";
|
||||
if (slug) {
|
||||
runB1Search(slug);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Otherwise re-trigger the B2 search input handler.
|
||||
const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
|
||||
if (input && input.value.trim() !== "") {
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveForumsParam(): string {
|
||||
if (activeForums.size === 0) return "";
|
||||
return Array.from(activeForums).sort().join(",");
|
||||
}
|
||||
|
||||
function initForumFilter() {
|
||||
// Hydrate from URL on first load.
|
||||
for (const slug of readForumsFromURL()) {
|
||||
activeForums.add(slug);
|
||||
}
|
||||
renderForumChips();
|
||||
|
||||
// Restore on browser nav.
|
||||
window.addEventListener("popstate", () => {
|
||||
activeForums.clear();
|
||||
for (const slug of readForumsFromURL()) {
|
||||
activeForums.add(slug);
|
||||
}
|
||||
renderForumChips();
|
||||
});
|
||||
|
||||
// Re-render labels on language change.
|
||||
onLangChange(() => renderForumChips());
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initForumFilter);
|
||||
|
||||
|
||||
@@ -284,6 +284,36 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.search.results.count": "{n} Treffer",
|
||||
"deadlines.search.results.count_one": "1 Treffer",
|
||||
"deadlines.search.clear": "Suche leeren",
|
||||
"deadlines.pathway.fork.heading": "Was möchten Sie tun?",
|
||||
"deadlines.pathway.a.title": "Verfahrensablauf informieren",
|
||||
"deadlines.pathway.a.desc": "Verfahrenstyp wählen und alle dazugehörigen Fristen auf einer Zeitleiste sehen.",
|
||||
"deadlines.pathway.b.title": "Frist eintragen aufgrund Ereignis",
|
||||
"deadlines.pathway.b.desc": "Ein Ereignis ist eingetreten — ich brauche die richtige Frist für meine Akte.",
|
||||
"deadlines.pathway.shortcut.label": "oder direkt zu einer Frist springen:",
|
||||
"deadlines.pathway.back": "zurück zur Auswahl",
|
||||
"deadlines.pathway.b.mode.tree": "Schritt-für-Schritt (Entscheidungsbaum)",
|
||||
"deadlines.pathway.b.mode.filter": "Filter / Suche",
|
||||
"deadlines.pathway.b.tree.coming_soon": "Der Entscheidungsbaum ist in Vorbereitung. Wechseln Sie zu „Filter / Suche\" oder kehren Sie zur Auswahl zurück.",
|
||||
"deadlines.pathway.b.tree.step.back": "Schritt zurück",
|
||||
"deadlines.pathway.b.tree.empty": "Keine Treffer für diesen Pfad.",
|
||||
"deadlines.pathway.b.tree.reset": "Neu starten",
|
||||
"deadlines.pathway.b.tree.skip": "Diesen Schritt überspringen",
|
||||
"deadlines.pathway.b.tree.start_question": "Was ist passiert?",
|
||||
"deadlines.filter.forum.label": "Gericht / System:",
|
||||
"deadlines.filter.forum.upc_cfi": "UPC CFI",
|
||||
"deadlines.filter.forum.upc_coa": "UPC CoA",
|
||||
"deadlines.filter.forum.de_lg": "DE LG",
|
||||
"deadlines.filter.forum.de_olg": "DE OLG",
|
||||
"deadlines.filter.forum.de_bgh": "DE BGH",
|
||||
"deadlines.filter.forum.de_bpatg": "DE BPatG",
|
||||
"deadlines.filter.forum.epa_grant": "EPA Erteilung",
|
||||
"deadlines.filter.forum.epa_opp": "EPA Einspruchsabt.",
|
||||
"deadlines.filter.forum.epa_appeal": "EPA Beschwerdek.",
|
||||
"deadlines.filter.forum.dpma": "DPMA",
|
||||
"deadlines.perspective.label": "Ich vertrete:",
|
||||
"deadlines.perspective.claimant": "Klägerseite (Proactive)",
|
||||
"deadlines.perspective.defendant": "Beklagtenseite (Reactive)",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Berufung eingelegt durch:",
|
||||
"deadlines.event.composite.label": "Zusammengesetzt:",
|
||||
"deadlines.event.unit.days.one": "Tag",
|
||||
"deadlines.event.unit.days.many": "Tage",
|
||||
@@ -1838,6 +1868,36 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.search.results.count": "{n} hits",
|
||||
"deadlines.search.results.count_one": "1 hit",
|
||||
"deadlines.search.clear": "Clear search",
|
||||
"deadlines.pathway.fork.heading": "What would you like to do?",
|
||||
"deadlines.pathway.a.title": "Browse a proceeding",
|
||||
"deadlines.pathway.a.desc": "Pick a proceeding type and see all its deadlines on a single timeline.",
|
||||
"deadlines.pathway.b.title": "File a deadline based on an event",
|
||||
"deadlines.pathway.b.desc": "Something happened — find the right deadline for the matter.",
|
||||
"deadlines.pathway.shortcut.label": "or jump straight to a deadline:",
|
||||
"deadlines.pathway.back": "back to selection",
|
||||
"deadlines.pathway.b.mode.tree": "Step-by-step (decision tree)",
|
||||
"deadlines.pathway.b.mode.filter": "Filter / Search",
|
||||
"deadlines.pathway.b.tree.coming_soon": "The decision tree is coming soon. Switch to \"Filter / Search\" or return to selection.",
|
||||
"deadlines.pathway.b.tree.step.back": "step back",
|
||||
"deadlines.pathway.b.tree.empty": "No matches for this path.",
|
||||
"deadlines.pathway.b.tree.reset": "Restart",
|
||||
"deadlines.pathway.b.tree.skip": "Skip this step",
|
||||
"deadlines.pathway.b.tree.start_question": "What happened?",
|
||||
"deadlines.filter.forum.label": "Forum / System:",
|
||||
"deadlines.filter.forum.upc_cfi": "UPC CFI",
|
||||
"deadlines.filter.forum.upc_coa": "UPC CoA",
|
||||
"deadlines.filter.forum.de_lg": "DE LG",
|
||||
"deadlines.filter.forum.de_olg": "DE OLG",
|
||||
"deadlines.filter.forum.de_bgh": "DE BGH",
|
||||
"deadlines.filter.forum.de_bpatg": "DE BPatG",
|
||||
"deadlines.filter.forum.epa_grant": "EPO Examining",
|
||||
"deadlines.filter.forum.epa_opp": "EPO Opposition",
|
||||
"deadlines.filter.forum.epa_appeal": "EPO Board of Appeal",
|
||||
"deadlines.filter.forum.dpma": "DPMA",
|
||||
"deadlines.perspective.label": "I represent:",
|
||||
"deadlines.perspective.claimant": "Claimant side (Proactive)",
|
||||
"deadlines.perspective.defendant": "Defendant side (Reactive)",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Appeal filed by:",
|
||||
"deadlines.event.composite.label": "Composite:",
|
||||
"deadlines.event.unit.days.one": "day",
|
||||
"deadlines.event.unit.days.many": "days",
|
||||
|
||||
@@ -78,46 +78,133 @@ export function renderFristenrechner(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="fristen-search">
|
||||
<label htmlFor="fristen-search-input" className="visually-hidden" data-i18n="deadlines.search.label">Frist suchen</label>
|
||||
<div className="fristen-search-row">
|
||||
<svg className="fristen-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="fristen-search-input"
|
||||
className="fristen-search-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="deadlines.search.placeholder"
|
||||
placeholder="Klageerwiderung, RoP 23, § 82, Wiedereinsetzung…"
|
||||
/>
|
||||
<button type="button" id="fristen-search-clear" className="fristen-search-clear" aria-label="Suche leeren" data-i18n-aria-label="deadlines.search.clear" hidden>
|
||||
×
|
||||
{/* v3 landing fork (t-paliad-133) — visible by default, hidden once
|
||||
the user picks a pathway. URL ?path= drives visibility. */}
|
||||
<div className="fristen-pathway-fork" id="fristen-pathway-fork" role="group" aria-label="Pathway selector">
|
||||
<h2 className="fristen-pathway-fork-heading" data-i18n="deadlines.pathway.fork.heading">Was möchten Sie tun?</h2>
|
||||
<div className="fristen-pathway-fork-cards">
|
||||
<button type="button" className="fristen-pathway-card" data-path="a" id="fristen-pathway-a-cta">
|
||||
<span className="fristen-pathway-card-icon" aria-hidden="true">📖</span>
|
||||
<span className="fristen-pathway-card-title" data-i18n="deadlines.pathway.a.title">Verfahrensablauf informieren</span>
|
||||
<span className="fristen-pathway-card-desc" data-i18n="deadlines.pathway.a.desc">
|
||||
Verfahrenstyp wählen und alle dazugehörigen Fristen auf einer Zeitleiste sehen.
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-pathway-card" data-path="b" id="fristen-pathway-b-cta">
|
||||
<span className="fristen-pathway-card-icon" aria-hidden="true">📅</span>
|
||||
<span className="fristen-pathway-card-title" data-i18n="deadlines.pathway.b.title">Frist eintragen aufgrund Ereignis</span>
|
||||
<span className="fristen-pathway-card-desc" data-i18n="deadlines.pathway.b.desc">
|
||||
Ein Ereignis ist eingetreten — ich brauche die richtige Frist für meine Akte.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="fristen-search-chips" id="fristen-search-chips" role="group" aria-label="Schnellzugriff">
|
||||
<span className="fristen-search-chips-label" data-i18n="deadlines.search.chips.label">Schnellzugriff:</span>
|
||||
<button type="button" className="fristen-search-chip" data-q="Klageerwiderung">Klageerwiderung</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Berufung">Berufung</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Einspruch">Einspruch</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Replik">Replik</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Beschwerde">Beschwerde</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Statement of Defence">Statement of Defence</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Schadensbemessung">Schadensbemessung</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Wiedereinsetzung">Wiedereinsetzung</button>
|
||||
<div className="fristen-pathway-fork-shortcut">
|
||||
<div className="fristen-pathway-fork-shortcut-label" data-i18n="deadlines.pathway.shortcut.label">
|
||||
oder direkt zu einer Frist springen:
|
||||
</div>
|
||||
<div className="fristen-search-chips" id="fristen-fork-chips" role="group" aria-label="Schnellzugriff">
|
||||
<button type="button" className="fristen-search-chip" data-q="Klageerwiderung">Klageerwiderung</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Berufung">Berufung</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Einspruch">Einspruch</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Replik">Replik</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Beschwerde">Beschwerde</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Wiedereinsetzung">Wiedereinsetzung</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="fristen-search-results" className="fristen-search-results" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div className="fristen-mode-tabs" role="tablist">
|
||||
<button type="button" id="mode-procedure-tab" className="mode-tab is-active" role="tab" aria-selected="true" data-mode="procedure" data-i18n="deadlines.mode.procedure">Verfahrensablauf</button>
|
||||
<button type="button" id="mode-event-tab" className="mode-tab" role="tab" aria-selected="false" data-mode="event" data-i18n="deadlines.mode.event">Was kommt nach…</button>
|
||||
{/* Pathway B container — search bar relocates here from the page top.
|
||||
Mode toggle (B1 tree / B2 filter) sits above the panels.
|
||||
Hidden until ?path=b. */}
|
||||
<div className="fristen-pathway-shell" id="fristen-pathway-b" data-path="b" hidden>
|
||||
<button type="button" className="fristen-pathway-back" id="fristen-pathway-b-back">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.back">zurück zur Auswahl</span>
|
||||
</button>
|
||||
<h2 className="fristen-pathway-heading">
|
||||
<span aria-hidden="true">📅</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.b.title">Frist eintragen aufgrund Ereignis</span>
|
||||
</h2>
|
||||
|
||||
<div className="fristen-mode-toggle" role="radiogroup" aria-label="B1/B2 mode">
|
||||
<label className="fristen-mode-toggle-option">
|
||||
<input type="radio" name="fristen-b-mode" value="tree" id="fristen-b-mode-tree" />
|
||||
<span data-i18n="deadlines.pathway.b.mode.tree">Schritt-für-Schritt (Entscheidungsbaum)</span>
|
||||
</label>
|
||||
<label className="fristen-mode-toggle-option">
|
||||
<input type="radio" name="fristen-b-mode" value="filter" id="fristen-b-mode-filter" />
|
||||
<span data-i18n="deadlines.pathway.b.mode.filter">Filter / Suche</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* B1 panel — populated by fristenrechner-tree.ts in Phase C. */}
|
||||
<div className="fristen-b1-panel" id="fristen-b1-panel" data-mode="tree" hidden>
|
||||
<div className="fristen-b1-cascade" id="fristen-b1-cascade"></div>
|
||||
</div>
|
||||
|
||||
{/* B2 panel — search bar + chips + concept-card results.
|
||||
The search input + chips + results host live here so
|
||||
fristenrechner.ts can drive both Phase D (today) and the
|
||||
B1↔B2 state-share in Phase D (forum filter). */}
|
||||
<div className="fristen-b2-panel" id="fristen-b2-panel" data-mode="filter">
|
||||
<div className="fristen-search">
|
||||
<label htmlFor="fristen-search-input" className="visually-hidden" data-i18n="deadlines.search.label">Frist suchen</label>
|
||||
<div className="fristen-search-row">
|
||||
<svg className="fristen-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="fristen-search-input"
|
||||
className="fristen-search-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="deadlines.search.placeholder"
|
||||
placeholder="Klageerwiderung, RoP 23, § 82, Wiedereinsetzung…"
|
||||
/>
|
||||
<button type="button" id="fristen-search-clear" className="fristen-search-clear" aria-label="Suche leeren" data-i18n-aria-label="deadlines.search.clear" hidden>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="fristen-search-chips" id="fristen-search-chips" role="group" aria-label="Schnellzugriff">
|
||||
<span className="fristen-search-chips-label" data-i18n="deadlines.search.chips.label">Schnellzugriff:</span>
|
||||
<button type="button" className="fristen-search-chip" data-q="Klageerwiderung">Klageerwiderung</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Berufung">Berufung</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Einspruch">Einspruch</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Replik">Replik</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Beschwerde">Beschwerde</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Statement of Defence">Statement of Defence</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Schadensbemessung">Schadensbemessung</button>
|
||||
<button type="button" className="fristen-search-chip" data-q="Wiedereinsetzung">Wiedereinsetzung</button>
|
||||
</div>
|
||||
{/* Forum filter row — populated by Phase D. */}
|
||||
<div className="fristen-forum-filter" id="fristen-forum-filter" hidden>
|
||||
<span className="fristen-forum-filter-label" data-i18n="deadlines.filter.forum.label">Gericht / System:</span>
|
||||
<div className="fristen-forum-chips" id="fristen-forum-chips"></div>
|
||||
</div>
|
||||
<div id="fristen-search-results" className="fristen-search-results" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fristen-wizard mode-panel" id="mode-procedure-panel" data-mode="procedure" role="tabpanel" aria-labelledby="mode-procedure-tab">
|
||||
{/* Pathway A container — wraps the existing wizard.
|
||||
Hidden until ?path=a. */}
|
||||
<div className="fristen-pathway-shell" id="fristen-pathway-a" data-path="a" hidden>
|
||||
<button type="button" className="fristen-pathway-back" id="fristen-pathway-a-back">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.back">zurück zur Auswahl</span>
|
||||
</button>
|
||||
<h2 className="fristen-pathway-heading">
|
||||
<span aria-hidden="true">📖</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.a.title">Verfahrensablauf informieren</span>
|
||||
</h2>
|
||||
|
||||
{/* v3: legacy mode tabs retired (m's spec lock §10 Q1, 2026-05-05).
|
||||
Pathway A is Verfahrensablauf-only; trigger-event drill-in
|
||||
surfaces via concept-card pills with ?path=a&trigger=N URL,
|
||||
which resurfaces mode-event-panel programmatically below. */}
|
||||
<div className="fristen-wizard mode-panel" id="mode-procedure-panel" data-mode="procedure" role="tabpanel">
|
||||
<div className="wizard-step" id="step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
@@ -243,7 +330,7 @@ export function renderFristenrechner(): string {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="fristen-wizard mode-panel" id="mode-event-panel" data-mode="event" role="tabpanel" aria-labelledby="mode-event-tab" hidden>
|
||||
<div className="fristen-wizard mode-panel" id="mode-event-panel" data-mode="event" role="tabpanel" hidden>
|
||||
<div className="wizard-step" id="event-step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
@@ -308,6 +395,7 @@ export function renderFristenrechner(): string {
|
||||
← Neu berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>{/* /pathway-a */}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -637,6 +637,17 @@ export type I18nKey =
|
||||
| "deadlines.filter.all"
|
||||
| "deadlines.filter.completed"
|
||||
| "deadlines.filter.event_type"
|
||||
| "deadlines.filter.forum.de_bgh"
|
||||
| "deadlines.filter.forum.de_bpatg"
|
||||
| "deadlines.filter.forum.de_lg"
|
||||
| "deadlines.filter.forum.de_olg"
|
||||
| "deadlines.filter.forum.dpma"
|
||||
| "deadlines.filter.forum.epa_appeal"
|
||||
| "deadlines.filter.forum.epa_grant"
|
||||
| "deadlines.filter.forum.epa_opp"
|
||||
| "deadlines.filter.forum.label"
|
||||
| "deadlines.filter.forum.upc_cfi"
|
||||
| "deadlines.filter.forum.upc_coa"
|
||||
| "deadlines.filter.later"
|
||||
| "deadlines.filter.nextweek"
|
||||
| "deadlines.filter.overdue"
|
||||
@@ -673,6 +684,25 @@ export type I18nKey =
|
||||
| "deadlines.party.claimant"
|
||||
| "deadlines.party.court"
|
||||
| "deadlines.party.defendant"
|
||||
| "deadlines.pathway.a.desc"
|
||||
| "deadlines.pathway.a.title"
|
||||
| "deadlines.pathway.b.desc"
|
||||
| "deadlines.pathway.b.mode.filter"
|
||||
| "deadlines.pathway.b.mode.tree"
|
||||
| "deadlines.pathway.b.title"
|
||||
| "deadlines.pathway.b.tree.coming_soon"
|
||||
| "deadlines.pathway.b.tree.empty"
|
||||
| "deadlines.pathway.b.tree.reset"
|
||||
| "deadlines.pathway.b.tree.skip"
|
||||
| "deadlines.pathway.b.tree.start_question"
|
||||
| "deadlines.pathway.b.tree.step.back"
|
||||
| "deadlines.pathway.back"
|
||||
| "deadlines.pathway.fork.heading"
|
||||
| "deadlines.pathway.shortcut.label"
|
||||
| "deadlines.perspective.appeal_filed_by.label"
|
||||
| "deadlines.perspective.claimant"
|
||||
| "deadlines.perspective.defendant"
|
||||
| "deadlines.perspective.label"
|
||||
| "deadlines.print"
|
||||
| "deadlines.priority.date"
|
||||
| "deadlines.reset"
|
||||
|
||||
@@ -1533,6 +1533,321 @@ input[type="range"]::-moz-range-thumb {
|
||||
|
||||
/* --- Fristenrechner --- */
|
||||
|
||||
/* Fristenrechner v3 (t-paliad-133) — landing fork + pathway shells.
|
||||
The fork is the default landing surface. Each pathway is a peer
|
||||
container that slides in once chosen; back-button returns to fork. */
|
||||
|
||||
.fristen-pathway-fork {
|
||||
margin: 2rem 0 2.5rem;
|
||||
padding: 1.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.fristen-pathway-fork-heading {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.fristen-pathway-fork-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.fristen-pathway-fork-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.fristen-pathway-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 120ms, box-shadow 120ms, transform 60ms;
|
||||
}
|
||||
|
||||
.fristen-pathway-card:hover {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.fristen-pathway-card:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.fristen-pathway-card:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.fristen-pathway-card-icon {
|
||||
font-size: 1.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fristen-pathway-card-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.fristen-pathway-card-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.fristen-pathway-fork-shortcut {
|
||||
border-top: 1px dashed var(--color-border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.fristen-pathway-fork-shortcut-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.fristen-pathway-shell {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.fristen-pathway-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fristen-pathway-back:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.fristen-pathway-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.fristen-mode-toggle {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.fristen-mode-toggle-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fristen-b1-stub {
|
||||
padding: 1.5rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* B1 cascade — Phase C decision-tree UI (t-paliad-133) */
|
||||
|
||||
.fristen-b1-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.fristen-b1-crumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: var(--color-bg-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 120ms;
|
||||
}
|
||||
|
||||
.fristen-b1-crumb:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.fristen-b1-crumb--current {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-text, #000);
|
||||
font-weight: 500;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.fristen-b1-crumb--root {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.fristen-b1-crumb-sep {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.fristen-b1-question {
|
||||
margin: 0.5rem 0 1rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.fristen-b1-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.fristen-b1-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
text-align: left;
|
||||
color: var(--color-text);
|
||||
transition: border-color 120ms, background 120ms;
|
||||
}
|
||||
|
||||
.fristen-b1-button:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.fristen-b1-button:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.fristen-b1-button--leaf {
|
||||
/* Leaf nodes get a subtle marker so users sense they'll see results,
|
||||
not deeper buttons. */
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.fristen-b1-button-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fristen-b1-button-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fristen-b1-skip,
|
||||
.fristen-b1-step-back,
|
||||
.fristen-b1-loosen-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.fristen-b1-skip:hover,
|
||||
.fristen-b1-step-back:hover,
|
||||
.fristen-b1-loosen-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.fristen-b1-error {
|
||||
padding: 1rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.fristen-forum-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
|
||||
.fristen-forum-filter-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.fristen-forum-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.fristen-forum-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
transition: background 120ms, border-color 120ms;
|
||||
}
|
||||
|
||||
.fristen-forum-chip:hover {
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.fristen-forum-chip--active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-text, #000);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Phase D search (t-paliad-131) — augments the proceeding tile grid by
|
||||
letting the user type a phrase and drill into the right calculator
|
||||
from a ranked card list. Sits above the mode tabs. */
|
||||
|
||||
11
internal/db/migrations/048_event_categories.down.sql
Normal file
11
internal/db/migrations/048_event_categories.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- t-paliad-133 Phase A schema rollback.
|
||||
|
||||
-- 3. Drop is_bilateral column.
|
||||
DROP INDEX IF EXISTS paliad.deadline_rules_is_bilateral;
|
||||
ALTER TABLE paliad.deadline_rules DROP COLUMN IF EXISTS is_bilateral;
|
||||
|
||||
-- 2. Drop the junction table.
|
||||
DROP TABLE IF EXISTS paliad.event_category_concepts;
|
||||
|
||||
-- 1. Drop the taxonomy tree (CASCADE handles children).
|
||||
DROP TABLE IF EXISTS paliad.event_categories;
|
||||
137
internal/db/migrations/048_event_categories.up.sql
Normal file
137
internal/db/migrations/048_event_categories.up.sql
Normal file
@@ -0,0 +1,137 @@
|
||||
-- t-paliad-133 Phase A: event taxonomy schema for the Fristenrechner v3
|
||||
-- decision tree (Pathway B / B1) AND a bilateral-rule flag for the
|
||||
-- party-perspective selector.
|
||||
--
|
||||
-- Three artefacts in one migration (additive, no breaking changes):
|
||||
--
|
||||
-- 1. paliad.event_categories — the decision-tree taxonomy. Recursive
|
||||
-- tree (parent_id self-FK) of "what happened" nodes. Internal
|
||||
-- nodes carry a step_question_de/en (the question to ask under
|
||||
-- this node). Leaves are user-actionable events that map to one
|
||||
-- or more concepts via the junction below.
|
||||
--
|
||||
-- 2. paliad.event_category_concepts — many-to-many junction from
|
||||
-- taxonomy leaves to deadline_concepts. Optional
|
||||
-- proceeding_type_code narrows the card pills at this leaf to a
|
||||
-- specific context (e.g. "Hinweisbeschluss" → response-to-
|
||||
-- preliminary-opinion narrowed to DE_NULL only).
|
||||
--
|
||||
-- 3. paliad.deadline_rules.is_bilateral — new bool flag for genuinely-
|
||||
-- bilateral rules (Schriftsatzfristen with Anwaltszwang, joint
|
||||
-- Mängelbeseitigung). When true AND primary_party='both', the rule
|
||||
-- mirrors into both party columns of the columns view. Otherwise
|
||||
-- 'both' → the rule applies to whichever party took the action.
|
||||
--
|
||||
-- Seed data + bilateral backfill ship in migrations 049 / 050.
|
||||
--
|
||||
-- Design ref: docs/plans/unified-fristenrechner-v3.md §4.1 + §10 Q12.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.event_categories — recursive taxonomy tree
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.event_categories (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
parent_id uuid REFERENCES paliad.event_categories(id) ON DELETE CASCADE,
|
||||
slug text NOT NULL UNIQUE,
|
||||
label_de text NOT NULL,
|
||||
label_en text NOT NULL,
|
||||
description_de text,
|
||||
description_en text,
|
||||
step_question_de text,
|
||||
step_question_en text,
|
||||
icon text,
|
||||
sort_order int NOT NULL DEFAULT 100,
|
||||
is_leaf bool NOT NULL DEFAULT false,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.event_categories IS
|
||||
'Decision-tree taxonomy for Fristenrechner v3 Pathway B / B1. Each '
|
||||
'row is a node in a recursive tree of "what happened" categories. '
|
||||
'The tree shape is data-driven — depth is unlimited per m''s '
|
||||
'2026-05-05 spec lock.';
|
||||
|
||||
COMMENT ON COLUMN paliad.event_categories.slug IS
|
||||
'Materialised path-with-dots (e.g. cms-eingang.gericht.hinweisbeschluss). '
|
||||
'Unique across the table. Drives URL bookmarks ?b1=<slug>.';
|
||||
|
||||
COMMENT ON COLUMN paliad.event_categories.step_question_de IS
|
||||
'The question this node''s CHILDREN answer. Rendered above the button '
|
||||
'row for the next step. NULL on leaves (terminal events that map to '
|
||||
'concepts via paliad.event_category_concepts).';
|
||||
|
||||
COMMENT ON COLUMN paliad.event_categories.is_leaf IS
|
||||
'True when this node has no children and produces concept outcomes '
|
||||
'via the junction. False on internal navigation nodes.';
|
||||
|
||||
COMMENT ON COLUMN paliad.event_categories.icon IS
|
||||
'Single emoji or icon slug rendered on the button face. Optional.';
|
||||
|
||||
CREATE INDEX event_categories_parent_id ON paliad.event_categories (parent_id);
|
||||
CREATE INDEX event_categories_slug ON paliad.event_categories (slug);
|
||||
CREATE INDEX event_categories_active ON paliad.event_categories (is_active);
|
||||
|
||||
-- updated_at is managed app-side (consistent with paliad.deadline_concepts
|
||||
-- and other tables in this schema). No DB-level trigger.
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. paliad.event_category_concepts — leaf → concept junction
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.event_category_concepts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_category_id uuid NOT NULL
|
||||
REFERENCES paliad.event_categories(id) ON DELETE CASCADE,
|
||||
concept_id uuid NOT NULL
|
||||
REFERENCES paliad.deadline_concepts(id) ON DELETE CASCADE,
|
||||
proceeding_type_code text,
|
||||
sort_order int NOT NULL DEFAULT 100,
|
||||
-- NULLS NOT DISTINCT: treat (leaf, concept, NULL) as a single tuple
|
||||
-- so we can't accidentally seed the same concept twice with no
|
||||
-- proceeding-narrowing. Requires PostgreSQL 15+ (Supabase ships ≥15).
|
||||
UNIQUE NULLS NOT DISTINCT (event_category_id, concept_id, proceeding_type_code)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.event_category_concepts IS
|
||||
'Many-to-many junction: an event_category leaf produces one or more '
|
||||
'deadline_concepts as its candidate Frist outcomes. Optional '
|
||||
'proceeding_type_code narrows the resulting card pills to that '
|
||||
'context only.';
|
||||
|
||||
COMMENT ON COLUMN paliad.event_category_concepts.proceeding_type_code IS
|
||||
'NULL = all contexts of the concept apply (the result card shows '
|
||||
'every pill). Set = limit the card''s pill set to this proceeding '
|
||||
'(e.g. leaf cms-eingang.gericht.hinweisbeschluss → response-to-'
|
||||
'preliminary-opinion narrowed to DE_NULL only).';
|
||||
|
||||
CREATE INDEX event_category_concepts_category
|
||||
ON paliad.event_category_concepts (event_category_id);
|
||||
CREATE INDEX event_category_concepts_concept
|
||||
ON paliad.event_category_concepts (concept_id);
|
||||
CREATE INDEX event_category_concepts_proceeding
|
||||
ON paliad.event_category_concepts (proceeding_type_code)
|
||||
WHERE proceeding_type_code IS NOT NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. paliad.deadline_rules.is_bilateral — genuinely-bilateral flag
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN is_bilateral bool NOT NULL DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.is_bilateral IS
|
||||
'When true AND primary_party=''both'', the rule mirrors into both '
|
||||
'party columns of the columns view (genuinely-bilateral rules: '
|
||||
'Schriftsatzfristen with Anwaltszwang, joint Mängelbeseitigung, '
|
||||
'mündliche Verhandlung). When false (default) AND primary_party='
|
||||
'''both'', the rule applies only to the side that took the action '
|
||||
'(determined from the parent rule''s party or, if root, from the '
|
||||
'perspective selector). Backfilled in migration 050 for the small '
|
||||
'set of genuinely-bilateral rules in the existing corpus.';
|
||||
|
||||
CREATE INDEX deadline_rules_is_bilateral
|
||||
ON paliad.deadline_rules (is_bilateral)
|
||||
WHERE is_bilateral = true;
|
||||
12
internal/db/migrations/049_event_categories_seed.down.sql
Normal file
12
internal/db/migrations/049_event_categories_seed.down.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- t-paliad-133 Phase A seed rollback. CASCADE on the parent_id FK
|
||||
-- handles descendant rows automatically; we delete by root slug.
|
||||
DELETE FROM paliad.event_categories
|
||||
WHERE parent_id IS NULL
|
||||
AND slug IN (
|
||||
'cms-eingang',
|
||||
'muendl-verhandlung',
|
||||
'beschluss-entscheidung',
|
||||
'frist-verpasst',
|
||||
'ich-moechte-einreichen',
|
||||
'sonstiges'
|
||||
);
|
||||
790
internal/db/migrations/049_event_categories_seed.up.sql
Normal file
790
internal/db/migrations/049_event_categories_seed.up.sql
Normal file
@@ -0,0 +1,790 @@
|
||||
-- t-paliad-133 Phase A seed: event taxonomy for the v3 decision tree.
|
||||
--
|
||||
-- Six root buckets (m's design 2026-05-05):
|
||||
-- 📥 cms-eingang — CMS-Eingang (Schriftstück erhalten)
|
||||
-- 🎤 muendl-verhandlung — Mündliche Verhandlung
|
||||
-- 📊 beschluss-entscheidung — Beschluss / Entscheidung erhalten
|
||||
-- 🚫 frist-verpasst — Frist verpasst
|
||||
-- 📤 ich-moechte-einreichen — Ich möchte etwas einreichen (proactive)
|
||||
-- ❓ sonstiges — Anderes / Sonstiges (B2 fallback)
|
||||
--
|
||||
-- Tree depth is unlimited per design lock §10 Q2; current seed reaches
|
||||
-- depth 4 at deepest (cms-eingang › gericht › endentscheidung › <leaf>).
|
||||
--
|
||||
-- Coverage gate at the end of this migration: every concept with
|
||||
-- category='submission' must be reachable from at least one leaf, except
|
||||
-- the pure-administrative slugs in the exempt list (filing,
|
||||
-- request-for-examination, approval-and-translation).
|
||||
|
||||
-- ============================================================================
|
||||
-- 0. Locals: stable parent-slug references for INSERT-time lookups.
|
||||
-- ============================================================================
|
||||
|
||||
-- We INSERT in tree order (roots first, then children) so each parent_id
|
||||
-- subquery resolves at execution time. Slugs are unique → safe lookup.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Roots
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, description_de, description_en,
|
||||
step_question_de, step_question_en, icon, sort_order, is_leaf)
|
||||
VALUES
|
||||
('cms-eingang', NULL, 'CMS-Eingang', 'CMS receipt',
|
||||
'Ein Schriftstück ist im CMS eingegangen.',
|
||||
'A document arrived in the CMS.',
|
||||
'Von wem ist das Schriftstück?',
|
||||
'Who sent the document?',
|
||||
'📥', 100, false),
|
||||
|
||||
('muendl-verhandlung', NULL, 'Mündliche Verhandlung', 'Oral hearing',
|
||||
'Etwas rund um eine mündliche Verhandlung.',
|
||||
'Something about an oral hearing.',
|
||||
'Was ist mit der Verhandlung passiert?',
|
||||
'What happened with the hearing?',
|
||||
'🎤', 200, false),
|
||||
|
||||
('beschluss-entscheidung', NULL, 'Beschluss / Entscheidung', 'Decision / Order',
|
||||
'Eine Entscheidung oder ein Beschluss wurde erlassen.',
|
||||
'A decision or order has been issued.',
|
||||
'Welche Art von Entscheidung?',
|
||||
'What kind of decision?',
|
||||
'📊', 300, false),
|
||||
|
||||
('frist-verpasst', NULL, 'Frist verpasst', 'Missed deadline',
|
||||
'Eine gesetzliche Frist wurde versäumt — Wiedereinsetzung prüfen.',
|
||||
'A statutory deadline was missed — check for re-establishment of rights.',
|
||||
'In welchem System?',
|
||||
'Which legal system?',
|
||||
'🚫', 400, false),
|
||||
|
||||
('ich-moechte-einreichen', NULL, 'Ich möchte etwas einreichen', 'I want to file something',
|
||||
'Proaktiv: ein Schriftsatz / Klage / Antrag soll eingereicht werden.',
|
||||
'Proactive: a submission / claim / application is to be filed.',
|
||||
'Was möchten Sie einreichen?',
|
||||
'What would you like to file?',
|
||||
'📤', 500, false),
|
||||
|
||||
('sonstiges', NULL, 'Anderes / Sonstiges', 'Other',
|
||||
'Etwas Anderes — wechseln Sie zum Filter / zur Suche.',
|
||||
'Something else — switch to filter / search.',
|
||||
NULL, NULL,
|
||||
'❓', 600, true);
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Level 2 — children of cms-eingang
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, step_question_de, step_question_en,
|
||||
icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, s.step_question_de, s.step_question_en,
|
||||
s.icon, s.sort_order, s.is_leaf
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('cms-eingang.gericht', 'Vom Gericht', 'From the Court',
|
||||
'Welcher Charakter hat das Schriftstück?',
|
||||
'What is the nature of the document?',
|
||||
'⚖', 100, false),
|
||||
('cms-eingang.gegenseite', 'Von der Gegenseite', 'From the opposing party',
|
||||
'In welchem Verfahrenstyp?',
|
||||
'In which proceeding type?',
|
||||
'👥', 200, false)
|
||||
) AS s(slug, label_de, label_en, step_question_de, step_question_en,
|
||||
icon, sort_order, is_leaf)
|
||||
WHERE p.slug = 'cms-eingang';
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. Level 3 — children of cms-eingang.gericht
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, step_question_de, step_question_en,
|
||||
icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, s.step_question_de, s.step_question_en,
|
||||
s.icon, s.sort_order, s.is_leaf
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('cms-eingang.gericht.hinweisbeschluss',
|
||||
'Hinweisbeschluss / vorläufige Würdigung',
|
||||
'Preliminary opinion / court hint',
|
||||
NULL, NULL, '📋', 100, true),
|
||||
|
||||
('cms-eingang.gericht.ladung',
|
||||
'Ladung zur mündlichen Verhandlung',
|
||||
'Summons to oral hearing',
|
||||
NULL, NULL, '📅', 200, true),
|
||||
|
||||
('cms-eingang.gericht.bescheid-mit-frist',
|
||||
'Bescheid mit explizit gesetzter Frist',
|
||||
'Order with court-set deadline',
|
||||
NULL, NULL, '📨', 300, true),
|
||||
|
||||
('cms-eingang.gericht.endentscheidung',
|
||||
'Endentscheidung / Urteil',
|
||||
'Final decision / Judgment',
|
||||
'Welche Instanz / welches Verfahren?',
|
||||
'Which instance / proceeding?',
|
||||
'🏛', 400, false),
|
||||
|
||||
('cms-eingang.gericht.kostenfestsetzung',
|
||||
'Kostenfestsetzungsbeschluss',
|
||||
'Cost-fixing order',
|
||||
NULL, NULL, '💰', 500, true),
|
||||
|
||||
('cms-eingang.gericht.rechtsverlust-epa',
|
||||
'Mitteilung über Rechtsverlust (EPA)',
|
||||
'Loss-of-rights notice (EPO)',
|
||||
NULL, NULL, '🚫', 600, true),
|
||||
|
||||
('cms-eingang.gericht.anordnung',
|
||||
'Anordnung / Order (PI, Beweissicherung)',
|
||||
'Order (PI, evidence preservation)',
|
||||
NULL, NULL, '📌', 700, true)
|
||||
) AS s(slug, label_de, label_en, step_question_de, step_question_en,
|
||||
icon, sort_order, is_leaf)
|
||||
WHERE p.slug = 'cms-eingang.gericht';
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. Level 4 — children of cms-eingang.gericht.endentscheidung
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, step_question_de, step_question_en,
|
||||
icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, NULL, NULL,
|
||||
s.icon, s.sort_order, true
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('cms-eingang.gericht.endentscheidung.urteil-de-inf-lg',
|
||||
'Urteil LG (Verletzung)', 'LG judgment (infringement)', '⚖', 100),
|
||||
('cms-eingang.gericht.endentscheidung.urteil-de-inf-olg',
|
||||
'Urteil OLG (Verletzung)', 'OLG judgment (infringement)', '⚖', 200),
|
||||
('cms-eingang.gericht.endentscheidung.urteil-de-null-bpatg',
|
||||
'Urteil BPatG (Nichtigkeit)', 'BPatG judgment (nullity)', '⚖', 300),
|
||||
('cms-eingang.gericht.endentscheidung.urteil-upc-cfi',
|
||||
'Sachentscheidung UPC (CFI)', 'UPC CFI decision', '⚖', 400),
|
||||
('cms-eingang.gericht.endentscheidung.urteil-upc-coa',
|
||||
'Sachentscheidung UPC (CoA)', 'UPC CoA decision', '⚖', 500),
|
||||
('cms-eingang.gericht.endentscheidung.entscheidung-epa-opp',
|
||||
'Einspruchsentscheidung EPA', 'EPO opposition decision', '⚖', 600),
|
||||
('cms-eingang.gericht.endentscheidung.entscheidung-epa-boa',
|
||||
'Beschwerdeentscheidung EPA', 'EPO Board of Appeal decision', '⚖', 700),
|
||||
('cms-eingang.gericht.endentscheidung.entscheidung-dpma',
|
||||
'DPMA-Einspruchsentscheidung', 'DPMA opposition decision', '⚖', 800),
|
||||
('cms-eingang.gericht.endentscheidung.beschluss-bpatg-beschwerde',
|
||||
'Beschluss BPatG (DPMA-Beschwerde)', 'BPatG order (DPMA appeal)', '⚖', 900),
|
||||
('cms-eingang.gericht.endentscheidung.versaeumnisurteil',
|
||||
'Versäumnisurteil (DE)', 'Default judgment (DE)', '📊', 1000)
|
||||
) AS s(slug, label_de, label_en, icon, sort_order)
|
||||
WHERE p.slug = 'cms-eingang.gericht.endentscheidung';
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. Level 3 — children of cms-eingang.gegenseite
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, step_question_de, step_question_en,
|
||||
icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, s.step_question_de, s.step_question_en,
|
||||
s.icon, s.sort_order, s.is_leaf
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('cms-eingang.gegenseite.upc-inf',
|
||||
'UPC Verletzungsverfahren', 'UPC infringement',
|
||||
'Welcher Schriftsatz wurde eingereicht?',
|
||||
'Which submission was filed?', '⚖', 100, false),
|
||||
|
||||
('cms-eingang.gegenseite.upc-rev',
|
||||
'UPC Nichtigkeitsverfahren', 'UPC revocation',
|
||||
'Welcher Schriftsatz?',
|
||||
'Which submission?', '⚖', 200, false),
|
||||
|
||||
('cms-eingang.gegenseite.de-inf',
|
||||
'DE Verletzungsklage (LG/OLG)', 'DE infringement (LG/OLG)',
|
||||
'Welcher Schriftsatz?',
|
||||
'Which submission?', '🏛', 300, false),
|
||||
|
||||
('cms-eingang.gegenseite.de-null',
|
||||
'DE Nichtigkeitsklage (BPatG)', 'DE nullity (BPatG)',
|
||||
'Welcher Schriftsatz?',
|
||||
'Which submission?', '🏛', 400, false),
|
||||
|
||||
('cms-eingang.gegenseite.epa-opp',
|
||||
'EPA Einspruch', 'EPO opposition',
|
||||
'Welcher Schriftsatz?',
|
||||
'Which submission?', '🏛', 500, false),
|
||||
|
||||
('cms-eingang.gegenseite.epa-app',
|
||||
'EPA Beschwerde', 'EPO appeal',
|
||||
NULL, NULL, '🏛', 600, true),
|
||||
|
||||
('cms-eingang.gegenseite.dpma-opp',
|
||||
'DPMA Einspruchsschrift', 'DPMA opposition',
|
||||
NULL, NULL, '🏛', 700, true)
|
||||
) AS s(slug, label_de, label_en, step_question_de, step_question_en,
|
||||
icon, sort_order, is_leaf)
|
||||
WHERE p.slug = 'cms-eingang.gegenseite';
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. Level 4 — children of cms-eingang.gegenseite.upc-inf
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, s.icon, s.sort_order, true
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('cms-eingang.gegenseite.upc-inf.klageschrift',
|
||||
'Klageschrift', 'Statement of claim', '📜', 100),
|
||||
('cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr',
|
||||
'Klageerwiderung MIT Nichtigkeitswiderklage',
|
||||
'Statement of defence WITH CCR', '🔄', 200),
|
||||
('cms-eingang.gegenseite.upc-inf.klageerwiderung-ohne-ccr',
|
||||
'Klageerwiderung OHNE Nichtigkeitswiderklage',
|
||||
'Statement of defence WITHOUT CCR', '🔄', 300),
|
||||
('cms-eingang.gegenseite.upc-inf.replik',
|
||||
'Replik (Reply to Defence)', 'Reply to Defence', '↩', 400),
|
||||
('cms-eingang.gegenseite.upc-inf.antrag-patentaenderung',
|
||||
'Antrag auf Patentänderung (R.30)',
|
||||
'Application to amend (R.30)', '🔁', 500),
|
||||
('cms-eingang.gegenseite.upc-inf.berufungsschrift',
|
||||
'Berufungsschrift Gegner', 'Opposing party''s notice of appeal', '📈', 600)
|
||||
) AS s(slug, label_de, label_en, icon, sort_order)
|
||||
WHERE p.slug = 'cms-eingang.gegenseite.upc-inf';
|
||||
|
||||
-- ============================================================================
|
||||
-- 7. Level 4 — children of cms-eingang.gegenseite.upc-rev
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, s.icon, s.sort_order, true
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('cms-eingang.gegenseite.upc-rev.nichtigkeitsklage',
|
||||
'Nichtigkeitsklage (Statement for Revocation)',
|
||||
'Application for revocation', '📜', 100),
|
||||
('cms-eingang.gegenseite.upc-rev.defence-to-revocation',
|
||||
'Defence to Revocation (mit/ohne Amend, mit/ohne CCI)',
|
||||
'Defence to revocation', '🔄', 200),
|
||||
('cms-eingang.gegenseite.upc-rev.berufungsschrift',
|
||||
'Berufungsschrift Gegner (UPC_REV)',
|
||||
'Opposing party''s notice of appeal (UPC_REV)', '📈', 300)
|
||||
) AS s(slug, label_de, label_en, icon, sort_order)
|
||||
WHERE p.slug = 'cms-eingang.gegenseite.upc-rev';
|
||||
|
||||
-- ============================================================================
|
||||
-- 8. Level 4 — children of cms-eingang.gegenseite.de-inf
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, s.icon, s.sort_order, true
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('cms-eingang.gegenseite.de-inf.klageschrift',
|
||||
'Klageschrift LG', 'LG statement of claim', '📜', 100),
|
||||
('cms-eingang.gegenseite.de-inf.klageerwiderung',
|
||||
'Klageerwiderung LG', 'LG statement of defence', '🔄', 200),
|
||||
('cms-eingang.gegenseite.de-inf.berufungsschrift-olg',
|
||||
'Berufungsschrift OLG', 'OLG notice of appeal', '📈', 300)
|
||||
) AS s(slug, label_de, label_en, icon, sort_order)
|
||||
WHERE p.slug = 'cms-eingang.gegenseite.de-inf';
|
||||
|
||||
-- ============================================================================
|
||||
-- 9. Level 4 — children of cms-eingang.gegenseite.de-null
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, s.icon, s.sort_order, true
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('cms-eingang.gegenseite.de-null.nichtigkeitsklage',
|
||||
'Nichtigkeitsklage BPatG', 'BPatG application for revocation', '📜', 100),
|
||||
('cms-eingang.gegenseite.de-null.klageerwiderung',
|
||||
'Klageerwiderung BPatG', 'BPatG statement of defence', '🔄', 200),
|
||||
('cms-eingang.gegenseite.de-null.berufungsschrift-bgh',
|
||||
'Berufungsschrift BGH (Nichtigkeit)',
|
||||
'BGH notice of appeal (nullity)', '📈', 300)
|
||||
) AS s(slug, label_de, label_en, icon, sort_order)
|
||||
WHERE p.slug = 'cms-eingang.gegenseite.de-null';
|
||||
|
||||
-- ============================================================================
|
||||
-- 10. Level 4 — children of cms-eingang.gegenseite.epa-opp
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, '📜', s.sort_order, true
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('cms-eingang.gegenseite.epa-opp.einspruchsschrift',
|
||||
'Einspruchsschrift', 'Notice of opposition', 100)
|
||||
) AS s(slug, label_de, label_en, sort_order)
|
||||
WHERE p.slug = 'cms-eingang.gegenseite.epa-opp';
|
||||
|
||||
-- ============================================================================
|
||||
-- 11. Level 2 — children of muendl-verhandlung
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, s.icon, s.sort_order, true
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('muendl-verhandlung.geladen',
|
||||
'Geladen — wann findet sie statt?', 'Summoned — when?', '📅', 100),
|
||||
('muendl-verhandlung.gehalten',
|
||||
'Soeben gehalten / heute', 'Just held / today', '⌛', 200),
|
||||
('muendl-verhandlung.verlegt',
|
||||
'Verlegt', 'Postponed', '🔁', 300),
|
||||
('muendl-verhandlung.zwischenverfahren',
|
||||
'Zwischenverfahren / interim conference', 'Interim conference', '🤝', 400)
|
||||
) AS s(slug, label_de, label_en, icon, sort_order)
|
||||
WHERE p.slug = 'muendl-verhandlung';
|
||||
|
||||
-- ============================================================================
|
||||
-- 12. Level 2 — children of beschluss-entscheidung
|
||||
-- (parallel set to cms-eingang.gericht.endentscheidung — different
|
||||
-- mental classification, same outcomes)
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, s.icon, s.sort_order, true
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('beschluss-entscheidung.urteil-de-inf-lg',
|
||||
'Urteil LG (Verletzung)', 'LG judgment (infringement)', '⚖', 100),
|
||||
('beschluss-entscheidung.urteil-de-inf-olg',
|
||||
'Urteil OLG (Verletzung)', 'OLG judgment (infringement)', '⚖', 200),
|
||||
('beschluss-entscheidung.urteil-de-null-bpatg',
|
||||
'Urteil BPatG (Nichtigkeit)', 'BPatG judgment (nullity)', '⚖', 300),
|
||||
('beschluss-entscheidung.urteil-upc-cfi',
|
||||
'Sachentscheidung UPC (CFI)', 'UPC CFI decision', '⚖', 400),
|
||||
('beschluss-entscheidung.urteil-upc-coa',
|
||||
'Sachentscheidung UPC (CoA)', 'UPC CoA decision', '⚖', 500),
|
||||
('beschluss-entscheidung.entscheidung-epa-opp',
|
||||
'Einspruchsentscheidung EPA', 'EPO opposition decision', '⚖', 600),
|
||||
('beschluss-entscheidung.entscheidung-epa-boa',
|
||||
'Beschwerdeentscheidung EPA', 'EPO Board of Appeal decision', '⚖', 700),
|
||||
('beschluss-entscheidung.entscheidung-dpma',
|
||||
'DPMA-Einspruchsentscheidung', 'DPMA opposition decision', '⚖', 800),
|
||||
('beschluss-entscheidung.beschluss-bpatg-beschwerde',
|
||||
'Beschluss BPatG (DPMA-Beschwerde)', 'BPatG order (DPMA appeal)', '⚖', 900),
|
||||
('beschluss-entscheidung.versaeumnisurteil',
|
||||
'Versäumnisurteil (DE)', 'Default judgment (DE)', '📊', 1000),
|
||||
('beschluss-entscheidung.kostenfestsetzung',
|
||||
'Kostenfestsetzungsbeschluss', 'Cost-fixing order', '💰', 1100)
|
||||
) AS s(slug, label_de, label_en, icon, sort_order)
|
||||
WHERE p.slug = 'beschluss-entscheidung';
|
||||
|
||||
-- ============================================================================
|
||||
-- 13. Level 2 — children of frist-verpasst
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, '🏛', s.sort_order, true
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('frist-verpasst.de-patg',
|
||||
'DE Patentverfahren (PatG §123 — 2 Monate)',
|
||||
'DE patent proceedings (PatG §123)', 100),
|
||||
('frist-verpasst.de-zpo',
|
||||
'DE Zivilverfahren (ZPO §233 — 2 Wochen!)',
|
||||
'DE civil proceedings (ZPO §233 — 2 weeks!)', 200),
|
||||
('frist-verpasst.epa',
|
||||
'EPA (Art. 122 EPÜ — 2 Monate)',
|
||||
'EPO (Art. 122 EPC)', 300),
|
||||
('frist-verpasst.dpma',
|
||||
'DPMA (PatG §123)',
|
||||
'DPMA (PatG §123)', 400)
|
||||
) AS s(slug, label_de, label_en, sort_order)
|
||||
WHERE p.slug = 'frist-verpasst';
|
||||
|
||||
-- ============================================================================
|
||||
-- 14. Level 2 — children of ich-moechte-einreichen
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, step_question_de, step_question_en,
|
||||
icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, s.step_question_de, s.step_question_en,
|
||||
s.icon, s.sort_order, s.is_leaf
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('ich-moechte-einreichen.klage',
|
||||
'Klage / Antrag (1. Instanz)', 'Claim / application (1st instance)',
|
||||
'In welchem Verfahren?', 'Which proceeding?',
|
||||
'📜', 100, false),
|
||||
('ich-moechte-einreichen.berufung',
|
||||
'Berufung / Beschwerde (höhere Instanz)',
|
||||
'Appeal (higher instance)',
|
||||
'Welche Konstellation?', 'Which constellation?',
|
||||
'📈', 200, false),
|
||||
('ich-moechte-einreichen.widerklage',
|
||||
'Widerklage / Counterclaim',
|
||||
'Counterclaim',
|
||||
'Welche Art von Widerklage?', 'Which kind of counterclaim?',
|
||||
'🔁', 300, false),
|
||||
('ich-moechte-einreichen.spaetere-schriftsaetze',
|
||||
'Späterer Schriftsatz im laufenden Verfahren',
|
||||
'Later submission in pending proceedings',
|
||||
'Welcher späterer Schriftsatz?', 'Which later submission?',
|
||||
'↩', 400, false),
|
||||
('ich-moechte-einreichen.einspruch-erteilung',
|
||||
'Einspruchsfrist nach Erteilung',
|
||||
'Opposition deadline after grant',
|
||||
NULL, NULL,
|
||||
'⚖', 500, true)
|
||||
) AS s(slug, label_de, label_en, step_question_de, step_question_en,
|
||||
icon, sort_order, is_leaf)
|
||||
WHERE p.slug = 'ich-moechte-einreichen';
|
||||
|
||||
-- ============================================================================
|
||||
-- 15. Level 3 — children of ich-moechte-einreichen.klage
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, '📜', s.sort_order, true
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('ich-moechte-einreichen.klage.upc-inf',
|
||||
'UPC Verletzungsklage', 'UPC infringement claim', 100),
|
||||
('ich-moechte-einreichen.klage.upc-rev',
|
||||
'UPC Nichtigkeitsklage', 'UPC revocation claim', 200),
|
||||
('ich-moechte-einreichen.klage.upc-pi',
|
||||
'UPC einstw. Maßnahmen', 'UPC provisional measures', 300),
|
||||
('ich-moechte-einreichen.klage.upc-damages',
|
||||
'UPC Schadensbemessung', 'UPC damages determination', 400),
|
||||
('ich-moechte-einreichen.klage.upc-discovery',
|
||||
'UPC Bucheinsicht', 'UPC lay open books', 500),
|
||||
('ich-moechte-einreichen.klage.de-inf',
|
||||
'DE Verletzungsklage LG', 'DE infringement claim (LG)', 600),
|
||||
('ich-moechte-einreichen.klage.de-null',
|
||||
'DE Nichtigkeitsklage BPatG', 'DE revocation (BPatG)', 700),
|
||||
('ich-moechte-einreichen.klage.epa-opp',
|
||||
'EPA Einspruch', 'EPO opposition', 800),
|
||||
('ich-moechte-einreichen.klage.dpma-opp',
|
||||
'DPMA Einspruch', 'DPMA opposition', 900)
|
||||
) AS s(slug, label_de, label_en, sort_order)
|
||||
WHERE p.slug = 'ich-moechte-einreichen.klage';
|
||||
|
||||
-- ============================================================================
|
||||
-- 16. Level 3 — children of ich-moechte-einreichen.berufung
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, '📈', s.sort_order, true
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('ich-moechte-einreichen.berufung.de-olg',
|
||||
'Berufung OLG (Verletzung)', 'OLG appeal (infringement)', 100),
|
||||
('ich-moechte-einreichen.berufung.de-bgh-nzb',
|
||||
'Nichtzulassungsbeschwerde BGH', 'BGH leave-appeal complaint', 200),
|
||||
('ich-moechte-einreichen.berufung.de-bgh-revision',
|
||||
'Revision BGH', 'BGH revision', 300),
|
||||
('ich-moechte-einreichen.berufung.de-bgh-null',
|
||||
'Berufung BGH (Patentnichtigkeit)', 'BGH appeal (nullity)', 400),
|
||||
('ich-moechte-einreichen.berufung.upc-coa',
|
||||
'Berufung UPC CoA', 'UPC CoA appeal', 500),
|
||||
('ich-moechte-einreichen.berufung.upc-coa-orders',
|
||||
'Berufung UPC mit Zulassung (Anordnungen)',
|
||||
'UPC appeal with leave (orders)', 600),
|
||||
('ich-moechte-einreichen.berufung.upc-cost',
|
||||
'Berufung UPC Kostenentscheidung', 'UPC cost-decision appeal', 700),
|
||||
('ich-moechte-einreichen.berufung.epa',
|
||||
'Beschwerde EPA', 'EPO appeal', 800),
|
||||
('ich-moechte-einreichen.berufung.bpatg-beschwerde',
|
||||
'Beschwerde BPatG (gegen DPMA)',
|
||||
'BPatG appeal (vs DPMA)', 900),
|
||||
('ich-moechte-einreichen.berufung.bgh-rb',
|
||||
'Rechtsbeschwerde BGH (DPMA)',
|
||||
'BGH legal-complaint (DPMA)', 1000)
|
||||
) AS s(slug, label_de, label_en, sort_order)
|
||||
WHERE p.slug = 'ich-moechte-einreichen.berufung';
|
||||
|
||||
-- ============================================================================
|
||||
-- 17. Level 3 — children of ich-moechte-einreichen.widerklage
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, '🔁', s.sort_order, true
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('ich-moechte-einreichen.widerklage.nichtigkeit-upc',
|
||||
'Nichtigkeitswiderklage (UPC R.25)',
|
||||
'Counterclaim for revocation (UPC R.25)', 100),
|
||||
('ich-moechte-einreichen.widerklage.verletzung-upc',
|
||||
'Verletzungswiderklage (UPC R.50/49.2.b)',
|
||||
'Counterclaim for infringement (UPC R.50/49.2.b)', 200)
|
||||
) AS s(slug, label_de, label_en, sort_order)
|
||||
WHERE p.slug = 'ich-moechte-einreichen.widerklage';
|
||||
|
||||
-- ============================================================================
|
||||
-- 18. Level 3 — children of ich-moechte-einreichen.spaetere-schriftsaetze
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
|
||||
SELECT
|
||||
s.slug, p.id, s.label_de, s.label_en, '↩', s.sort_order, true
|
||||
FROM paliad.event_categories p
|
||||
CROSS JOIN (VALUES
|
||||
('ich-moechte-einreichen.spaetere-schriftsaetze.replik-ccr-upc',
|
||||
'Replik auf Erwiderung zur Nichtigkeitsw. (UPC R.29.d)',
|
||||
'Reply to Defence to CCR (UPC R.29.d)', 100),
|
||||
('ich-moechte-einreichen.spaetere-schriftsaetze.duplik-ccr-upc',
|
||||
'Duplik auf Replik zur Nichtigkeitsw. (UPC R.29.e)',
|
||||
'Rejoinder on Reply to CCR (UPC R.29.e)', 200),
|
||||
('ich-moechte-einreichen.spaetere-schriftsaetze.duplik-amend-upc',
|
||||
'Duplik auf Replik zum Patentänderungsantrag (UPC R.32.3)',
|
||||
'Rejoinder on Reply to Application to amend (UPC R.32.3)', 300),
|
||||
('ich-moechte-einreichen.spaetere-schriftsaetze.replik-cci-upc',
|
||||
'Replik auf Erwiderung zur Verletzungsw. (UPC R.56.3)',
|
||||
'Reply to Defence to CCI (UPC R.56.3)', 400),
|
||||
('ich-moechte-einreichen.spaetere-schriftsaetze.duplik-cci-upc',
|
||||
'Duplik auf Replik zur Verletzungsw. (UPC R.56.4)',
|
||||
'Rejoinder on Reply to CCI (UPC R.56.4)', 500),
|
||||
('ich-moechte-einreichen.spaetere-schriftsaetze.kostenantrag',
|
||||
'Antrag auf Kostenentscheidung',
|
||||
'Application for cost decision', 600)
|
||||
) AS s(slug, label_de, label_en, sort_order)
|
||||
WHERE p.slug = 'ich-moechte-einreichen.spaetere-schriftsaetze';
|
||||
|
||||
-- ============================================================================
|
||||
-- 19. Junction: leaf → concept (with optional proceeding_type_code)
|
||||
-- Each row is one (leaf, concept, proceeding_code) tuple.
|
||||
-- ============================================================================
|
||||
|
||||
WITH leaf AS (
|
||||
SELECT id, slug FROM paliad.event_categories WHERE is_leaf = true
|
||||
), concept AS (
|
||||
SELECT id, slug FROM paliad.deadline_concepts
|
||||
)
|
||||
INSERT INTO paliad.event_category_concepts
|
||||
(event_category_id, concept_id, proceeding_type_code, sort_order)
|
||||
SELECT l.id, c.id, m.proceeding_type_code, m.sort_order
|
||||
FROM (VALUES
|
||||
-- ── 1. CMS-Eingang vom Gericht ──
|
||||
('cms-eingang.gericht.hinweisbeschluss', 'response-to-preliminary-opinion', 'DE_NULL', 100),
|
||||
('cms-eingang.gericht.hinweisbeschluss', 'response-to-preliminary-opinion', 'DE_INF', 200),
|
||||
('cms-eingang.gericht.ladung', 'r116-final-submissions', 'EPA_OPP', 100),
|
||||
('cms-eingang.gericht.ladung', 'r116-final-submissions', 'EPA_APP', 200),
|
||||
('cms-eingang.gericht.ladung', 'schriftsatznachreichung', NULL, 300),
|
||||
('cms-eingang.gericht.kostenfestsetzung', 'notice-of-appeal', 'UPC_COST_APPEAL', 100),
|
||||
('cms-eingang.gericht.rechtsverlust-epa', 'weiterbehandlung', NULL, 100),
|
||||
('cms-eingang.gericht.rechtsverlust-epa', 'wiedereinsetzung', NULL, 200),
|
||||
('cms-eingang.gericht.anordnung', 'request-for-discretionary-review', NULL, 100),
|
||||
|
||||
-- ── 1.x Endentscheidung (CMS-Eingang variant) ──
|
||||
('cms-eingang.gericht.endentscheidung.urteil-de-inf-lg', 'notice-of-appeal', 'DE_INF_OLG', 100),
|
||||
('cms-eingang.gericht.endentscheidung.urteil-de-inf-lg', 'statement-of-grounds-of-appeal', 'DE_INF_OLG', 200),
|
||||
('cms-eingang.gericht.endentscheidung.urteil-de-inf-olg', 'nichtzulassungsbeschwerde', 'DE_INF_BGH', 100),
|
||||
('cms-eingang.gericht.endentscheidung.urteil-de-inf-olg', 'nichtzulassungsbeschwerde-begruendung', 'DE_INF_BGH', 200),
|
||||
('cms-eingang.gericht.endentscheidung.urteil-de-inf-olg', 'revisionsfrist', 'DE_INF_BGH', 300),
|
||||
('cms-eingang.gericht.endentscheidung.urteil-de-inf-olg', 'revisionsbegruendung', 'DE_INF_BGH', 400),
|
||||
('cms-eingang.gericht.endentscheidung.urteil-de-null-bpatg', 'notice-of-appeal', 'DE_NULL_BGH', 100),
|
||||
('cms-eingang.gericht.endentscheidung.urteil-de-null-bpatg', 'statement-of-grounds-of-appeal', 'DE_NULL_BGH', 200),
|
||||
('cms-eingang.gericht.endentscheidung.urteil-upc-cfi', 'notice-of-appeal', 'UPC_APP', 100),
|
||||
('cms-eingang.gericht.endentscheidung.urteil-upc-cfi', 'statement-of-grounds-of-appeal', 'UPC_APP', 200),
|
||||
('cms-eingang.gericht.endentscheidung.urteil-upc-coa', 'petition-for-review', NULL, 100),
|
||||
('cms-eingang.gericht.endentscheidung.entscheidung-epa-opp', 'notice-of-appeal', 'EPA_APP', 100),
|
||||
('cms-eingang.gericht.endentscheidung.entscheidung-epa-opp', 'statement-of-grounds-of-appeal', 'EPA_APP', 200),
|
||||
('cms-eingang.gericht.endentscheidung.entscheidung-epa-boa', 'petition-for-review', 'EPA_APP', 100),
|
||||
('cms-eingang.gericht.endentscheidung.entscheidung-dpma', 'notice-of-appeal', 'DPMA_BPATG_BESCHWERDE', 100),
|
||||
('cms-eingang.gericht.endentscheidung.entscheidung-dpma', 'statement-of-grounds-of-appeal', 'DPMA_BPATG_BESCHWERDE', 200),
|
||||
('cms-eingang.gericht.endentscheidung.beschluss-bpatg-beschwerde', 'rechtsbeschwerde', 'DPMA_BGH_RB', 100),
|
||||
('cms-eingang.gericht.endentscheidung.beschluss-bpatg-beschwerde', 'rechtsbeschwerde-begruendung', 'DPMA_BGH_RB', 200),
|
||||
('cms-eingang.gericht.endentscheidung.versaeumnisurteil', 'versaeumnisurteil-einspruch', NULL, 100),
|
||||
|
||||
-- ── 1.x Gegenseite UPC_INF ──
|
||||
('cms-eingang.gegenseite.upc-inf.klageschrift', 'notice-of-defence-intention', 'UPC_INF', 100),
|
||||
('cms-eingang.gegenseite.upc-inf.klageschrift', 'statement-of-defence', 'UPC_INF', 200),
|
||||
('cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr', 'defence-to-counterclaim-for-revocation', 'UPC_INF', 100),
|
||||
('cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr', 'application-to-amend', 'UPC_INF', 200),
|
||||
('cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr', 'reply-to-defence', 'UPC_INF', 300),
|
||||
('cms-eingang.gegenseite.upc-inf.klageerwiderung-ohne-ccr', 'reply-to-defence', 'UPC_INF', 100),
|
||||
('cms-eingang.gegenseite.upc-inf.replik', 'rejoinder', 'UPC_INF', 100),
|
||||
('cms-eingang.gegenseite.upc-inf.antrag-patentaenderung', 'defence-to-application-to-amend', 'UPC_INF', 100),
|
||||
('cms-eingang.gegenseite.upc-inf.antrag-patentaenderung', 'reply-to-defence-to-application-to-amend', 'UPC_INF', 200),
|
||||
('cms-eingang.gegenseite.upc-inf.berufungsschrift', 'response-to-appeal', 'UPC_APP', 100),
|
||||
('cms-eingang.gegenseite.upc-inf.berufungsschrift', 'cross-appeal', 'UPC_APP', 200),
|
||||
|
||||
-- ── 1.x Gegenseite UPC_REV ──
|
||||
('cms-eingang.gegenseite.upc-rev.nichtigkeitsklage', 'statement-of-defence', 'UPC_REV', 100),
|
||||
('cms-eingang.gegenseite.upc-rev.defence-to-revocation', 'reply-to-defence', 'UPC_REV', 100),
|
||||
('cms-eingang.gegenseite.upc-rev.defence-to-revocation', 'defence-to-application-to-amend', 'UPC_REV', 200),
|
||||
('cms-eingang.gegenseite.upc-rev.defence-to-revocation', 'defence-to-counterclaim-for-infringement', 'UPC_REV', 300),
|
||||
('cms-eingang.gegenseite.upc-rev.berufungsschrift', 'response-to-appeal', 'UPC_APP', 100),
|
||||
('cms-eingang.gegenseite.upc-rev.berufungsschrift', 'cross-appeal', 'UPC_APP', 200),
|
||||
|
||||
-- ── 1.x Gegenseite DE_INF ──
|
||||
('cms-eingang.gegenseite.de-inf.klageschrift', 'notice-of-defence-intention', 'DE_INF', 100),
|
||||
('cms-eingang.gegenseite.de-inf.klageschrift', 'statement-of-defence', 'DE_INF', 200),
|
||||
('cms-eingang.gegenseite.de-inf.klageerwiderung', 'reply-to-defence', 'DE_INF', 100),
|
||||
('cms-eingang.gegenseite.de-inf.berufungsschrift-olg', 'response-to-appeal', 'DE_INF_OLG', 100),
|
||||
('cms-eingang.gegenseite.de-inf.berufungsschrift-olg', 'cross-appeal', 'DE_INF_OLG', 200),
|
||||
|
||||
-- ── 1.x Gegenseite DE_NULL ──
|
||||
('cms-eingang.gegenseite.de-null.nichtigkeitsklage', 'statement-of-defence', 'DE_NULL', 100),
|
||||
('cms-eingang.gegenseite.de-null.klageerwiderung', 'reply-to-defence', 'DE_NULL', 100),
|
||||
('cms-eingang.gegenseite.de-null.berufungsschrift-bgh', 'response-to-appeal', 'DE_NULL_BGH', 100),
|
||||
|
||||
-- ── 1.x Gegenseite EPA OPP / APP / DPMA ──
|
||||
('cms-eingang.gegenseite.epa-opp.einspruchsschrift', 'statement-of-defence', 'EPA_OPP', 100),
|
||||
('cms-eingang.gegenseite.epa-opp.einspruchsschrift', 'r79-further-stellungnahme', 'EPA_OPP', 200),
|
||||
('cms-eingang.gegenseite.epa-app', 'response-to-appeal', 'EPA_APP', 100),
|
||||
('cms-eingang.gegenseite.dpma-opp', 'statement-of-defence', 'DPMA_OPP', 100),
|
||||
|
||||
-- ── 2. Mündliche Verhandlung ──
|
||||
('muendl-verhandlung.geladen', 'r116-final-submissions', 'EPA_OPP', 100),
|
||||
('muendl-verhandlung.geladen', 'r116-final-submissions', 'EPA_APP', 200),
|
||||
('muendl-verhandlung.geladen', 'schriftsatznachreichung', NULL, 300),
|
||||
('muendl-verhandlung.gehalten', 'schriftsatznachreichung', NULL, 100),
|
||||
('muendl-verhandlung.zwischenverfahren', 'interim-conference', 'UPC_INF', 100),
|
||||
('muendl-verhandlung.zwischenverfahren', 'interim-conference', 'UPC_REV', 200),
|
||||
-- (verlegt has no concept outcome — informational card only)
|
||||
|
||||
-- ── 3. Beschluss / Entscheidung (parallel set, same outcomes) ──
|
||||
('beschluss-entscheidung.urteil-de-inf-lg', 'notice-of-appeal', 'DE_INF_OLG', 100),
|
||||
('beschluss-entscheidung.urteil-de-inf-lg', 'statement-of-grounds-of-appeal', 'DE_INF_OLG', 200),
|
||||
('beschluss-entscheidung.urteil-de-inf-olg', 'nichtzulassungsbeschwerde', 'DE_INF_BGH', 100),
|
||||
('beschluss-entscheidung.urteil-de-inf-olg', 'nichtzulassungsbeschwerde-begruendung', 'DE_INF_BGH', 200),
|
||||
('beschluss-entscheidung.urteil-de-inf-olg', 'revisionsfrist', 'DE_INF_BGH', 300),
|
||||
('beschluss-entscheidung.urteil-de-inf-olg', 'revisionsbegruendung', 'DE_INF_BGH', 400),
|
||||
('beschluss-entscheidung.urteil-de-null-bpatg', 'notice-of-appeal', 'DE_NULL_BGH', 100),
|
||||
('beschluss-entscheidung.urteil-de-null-bpatg', 'statement-of-grounds-of-appeal', 'DE_NULL_BGH', 200),
|
||||
('beschluss-entscheidung.urteil-upc-cfi', 'notice-of-appeal', 'UPC_APP', 100),
|
||||
('beschluss-entscheidung.urteil-upc-cfi', 'statement-of-grounds-of-appeal', 'UPC_APP', 200),
|
||||
('beschluss-entscheidung.urteil-upc-coa', 'petition-for-review', NULL, 100),
|
||||
('beschluss-entscheidung.entscheidung-epa-opp', 'notice-of-appeal', 'EPA_APP', 100),
|
||||
('beschluss-entscheidung.entscheidung-epa-opp', 'statement-of-grounds-of-appeal', 'EPA_APP', 200),
|
||||
('beschluss-entscheidung.entscheidung-epa-boa', 'petition-for-review', 'EPA_APP', 100),
|
||||
('beschluss-entscheidung.entscheidung-dpma', 'notice-of-appeal', 'DPMA_BPATG_BESCHWERDE', 100),
|
||||
('beschluss-entscheidung.entscheidung-dpma', 'statement-of-grounds-of-appeal', 'DPMA_BPATG_BESCHWERDE', 200),
|
||||
('beschluss-entscheidung.beschluss-bpatg-beschwerde', 'rechtsbeschwerde', 'DPMA_BGH_RB', 100),
|
||||
('beschluss-entscheidung.beschluss-bpatg-beschwerde', 'rechtsbeschwerde-begruendung', 'DPMA_BGH_RB', 200),
|
||||
('beschluss-entscheidung.versaeumnisurteil', 'versaeumnisurteil-einspruch', NULL, 100),
|
||||
('beschluss-entscheidung.kostenfestsetzung', 'notice-of-appeal', 'UPC_COST_APPEAL', 100),
|
||||
|
||||
-- ── 4. Frist verpasst ──
|
||||
('frist-verpasst.de-patg', 'wiedereinsetzung', NULL, 100),
|
||||
('frist-verpasst.de-zpo', 'wiedereinsetzung', NULL, 100),
|
||||
('frist-verpasst.epa', 'wiedereinsetzung', NULL, 100),
|
||||
('frist-verpasst.epa', 'weiterbehandlung', NULL, 200),
|
||||
('frist-verpasst.dpma', 'wiedereinsetzung', NULL, 100),
|
||||
|
||||
-- ── 5. Ich möchte einreichen — Klage ──
|
||||
('ich-moechte-einreichen.klage.upc-inf', 'statement-of-claim', 'UPC_INF', 100),
|
||||
('ich-moechte-einreichen.klage.upc-rev', 'application-for-revocation', 'UPC_REV', 100),
|
||||
('ich-moechte-einreichen.klage.upc-pi', 'application-for-provisional-measures', 'UPC_PI', 100),
|
||||
('ich-moechte-einreichen.klage.upc-damages', 'application-for-determination-of-damages', 'UPC_DAMAGES', 100),
|
||||
('ich-moechte-einreichen.klage.upc-discovery', 'request-to-lay-open-books', 'UPC_DISCOVERY', 100),
|
||||
('ich-moechte-einreichen.klage.de-inf', 'statement-of-claim', 'DE_INF', 100),
|
||||
('ich-moechte-einreichen.klage.de-null', 'application-for-revocation', 'DE_NULL', 100),
|
||||
('ich-moechte-einreichen.klage.epa-opp', 'opposition', 'EPA_OPP', 100),
|
||||
('ich-moechte-einreichen.klage.dpma-opp', 'opposition', 'DPMA_OPP', 100),
|
||||
|
||||
-- ── 6. Ich möchte einreichen — Berufung ──
|
||||
('ich-moechte-einreichen.berufung.de-olg', 'notice-of-appeal', 'DE_INF_OLG', 100),
|
||||
('ich-moechte-einreichen.berufung.de-olg', 'statement-of-grounds-of-appeal', 'DE_INF_OLG', 200),
|
||||
('ich-moechte-einreichen.berufung.de-bgh-nzb', 'nichtzulassungsbeschwerde', 'DE_INF_BGH', 100),
|
||||
('ich-moechte-einreichen.berufung.de-bgh-nzb', 'nichtzulassungsbeschwerde-begruendung', 'DE_INF_BGH', 200),
|
||||
('ich-moechte-einreichen.berufung.de-bgh-revision', 'revisionsfrist', 'DE_INF_BGH', 100),
|
||||
('ich-moechte-einreichen.berufung.de-bgh-revision', 'revisionsbegruendung', 'DE_INF_BGH', 200),
|
||||
('ich-moechte-einreichen.berufung.de-bgh-null', 'notice-of-appeal', 'DE_NULL_BGH', 100),
|
||||
('ich-moechte-einreichen.berufung.de-bgh-null', 'statement-of-grounds-of-appeal', 'DE_NULL_BGH', 200),
|
||||
('ich-moechte-einreichen.berufung.upc-coa', 'notice-of-appeal', 'UPC_APP', 100),
|
||||
('ich-moechte-einreichen.berufung.upc-coa', 'statement-of-grounds-of-appeal', 'UPC_APP', 200),
|
||||
('ich-moechte-einreichen.berufung.upc-coa-orders', 'appeal-with-leave', 'UPC_APP_ORDERS', 100),
|
||||
('ich-moechte-einreichen.berufung.upc-coa-orders', 'application-for-leave-to-appeal', 'UPC_APP_ORDERS', 200),
|
||||
('ich-moechte-einreichen.berufung.upc-cost', 'notice-of-appeal', 'UPC_COST_APPEAL', 100),
|
||||
('ich-moechte-einreichen.berufung.epa', 'notice-of-appeal', 'EPA_APP', 100),
|
||||
('ich-moechte-einreichen.berufung.epa', 'statement-of-grounds-of-appeal', 'EPA_APP', 200),
|
||||
('ich-moechte-einreichen.berufung.bpatg-beschwerde', 'notice-of-appeal', 'DPMA_BPATG_BESCHWERDE', 100),
|
||||
('ich-moechte-einreichen.berufung.bpatg-beschwerde', 'statement-of-grounds-of-appeal', 'DPMA_BPATG_BESCHWERDE', 200),
|
||||
('ich-moechte-einreichen.berufung.bgh-rb', 'rechtsbeschwerde', 'DPMA_BGH_RB', 100),
|
||||
('ich-moechte-einreichen.berufung.bgh-rb', 'rechtsbeschwerde-begruendung', 'DPMA_BGH_RB', 200),
|
||||
|
||||
-- ── 7. Ich möchte einreichen — Widerklage ──
|
||||
('ich-moechte-einreichen.widerklage.nichtigkeit-upc', 'counterclaim-for-revocation', 'UPC_INF', 100),
|
||||
('ich-moechte-einreichen.widerklage.nichtigkeit-upc', 'application-to-amend', 'UPC_INF', 200),
|
||||
('ich-moechte-einreichen.widerklage.verletzung-upc', 'counterclaim-for-infringement', 'UPC_REV', 100),
|
||||
|
||||
-- ── 8. Ich möchte einreichen — Spätere Schriftsätze ──
|
||||
('ich-moechte-einreichen.spaetere-schriftsaetze.replik-ccr-upc', 'reply-to-defence-to-counterclaim-for-revocation', 'UPC_INF', 100),
|
||||
('ich-moechte-einreichen.spaetere-schriftsaetze.duplik-ccr-upc', 'rejoinder-on-reply-to-defence-to-ccr', 'UPC_INF', 100),
|
||||
('ich-moechte-einreichen.spaetere-schriftsaetze.duplik-amend-upc','rejoinder-on-reply-to-amend', 'UPC_INF', 100),
|
||||
('ich-moechte-einreichen.spaetere-schriftsaetze.duplik-amend-upc','rejoinder-on-reply-to-amend', 'UPC_REV', 200),
|
||||
('ich-moechte-einreichen.spaetere-schriftsaetze.replik-cci-upc', 'reply-to-defence-to-counterclaim-for-infringement', 'UPC_REV', 100),
|
||||
('ich-moechte-einreichen.spaetere-schriftsaetze.duplik-cci-upc', 'rejoinder-on-counterclaim-for-infringement', 'UPC_REV', 100),
|
||||
('ich-moechte-einreichen.spaetere-schriftsaetze.kostenantrag', 'application-for-cost-decision', 'UPC_INF', 100),
|
||||
|
||||
-- ── 9. Einspruch nach Erteilung ──
|
||||
('ich-moechte-einreichen.einspruch-erteilung', 'opposition', 'EPA_OPP', 100),
|
||||
('ich-moechte-einreichen.einspruch-erteilung', 'opposition', 'DPMA_OPP', 200)
|
||||
|
||||
) AS m(leaf_slug, concept_slug, proceeding_type_code, sort_order)
|
||||
JOIN leaf l ON l.slug = m.leaf_slug
|
||||
JOIN concept c ON c.slug = m.concept_slug;
|
||||
|
||||
-- ============================================================================
|
||||
-- 20. Coverage gate: every category='submission' concept must be reachable
|
||||
-- from at least one leaf, except a small exempt list of pure-administrative
|
||||
-- concepts that live on Pathway A (browse-by-proceeding) only.
|
||||
-- ============================================================================
|
||||
|
||||
DO $coverage$
|
||||
DECLARE
|
||||
unreachable_count int;
|
||||
unreachable_slugs text;
|
||||
BEGIN
|
||||
SELECT count(*),
|
||||
string_agg(dc.slug, ', ' ORDER BY dc.slug)
|
||||
INTO unreachable_count, unreachable_slugs
|
||||
FROM paliad.deadline_concepts dc
|
||||
WHERE dc.is_active
|
||||
AND dc.category = 'submission'
|
||||
AND dc.slug NOT IN (
|
||||
-- Pure-administrative concepts: filed during prosecution, not
|
||||
-- typically discovered via "what happened" decision tree.
|
||||
'filing',
|
||||
'request-for-examination',
|
||||
'approval-and-translation'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.event_category_concepts ecc
|
||||
WHERE ecc.concept_id = dc.id
|
||||
);
|
||||
|
||||
IF unreachable_count > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'Phase A seed: % submission concept(s) unreachable from any leaf: %',
|
||||
unreachable_count, unreachable_slugs;
|
||||
END IF;
|
||||
END $coverage$;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- t-paliad-133 Phase A bilateral-tag rollback.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_bilateral = false
|
||||
WHERE code IN (
|
||||
'de_null.stellungnahme',
|
||||
'epa_opp.r79_further',
|
||||
'epa_opp.r116',
|
||||
'epa_app.r116'
|
||||
);
|
||||
51
internal/db/migrations/050_bilateral_rules_backfill.up.sql
Normal file
51
internal/db/migrations/050_bilateral_rules_backfill.up.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- t-paliad-133 Phase A backfill: tag genuinely-bilateral deadline rules.
|
||||
--
|
||||
-- Most rules with primary_party='both' are role-swap appeals — either
|
||||
-- party can file depending on who lost / acted at the lower instance.
|
||||
-- Those resolve at render time via the new perspective selector
|
||||
-- (?my_side= + ?appeal_filed_by=). The renderer assigns them to ONE
|
||||
-- column based on perspective.
|
||||
--
|
||||
-- The exceptions are GENUINELY BILATERAL rules — both parties can or
|
||||
-- must file independently of who acted before. These mirror into BOTH
|
||||
-- party columns of the v3 column-timeline view.
|
||||
--
|
||||
-- Set is_bilateral=true ONLY for:
|
||||
-- • Stellungnahme zum Hinweisbeschluss (DE_NULL §83(2)) — both parties
|
||||
-- comment on the court's preliminary opinion.
|
||||
-- • R.79 Stellungnahme weiterer Beteiligter (EPA_OPP) — multi-party
|
||||
-- opposition; all parties may submit.
|
||||
-- • R.116 Eingaben vor mündl. Verhandlung (EPA_OPP, EPA_APP) — every
|
||||
-- party prepares submissions before the oral hearing.
|
||||
--
|
||||
-- The cross-cutting schriftsatznachreichung lives in event_deadlines
|
||||
-- (not deadline_rules), so its bilateral nature is handled by the
|
||||
-- frontend renderer separately — no DB change needed.
|
||||
--
|
||||
-- Spot-checkable list of 4 rules; m or HLC colleague reviews on this
|
||||
-- commit.
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_bilateral = true
|
||||
WHERE code IN (
|
||||
'de_null.stellungnahme',
|
||||
'epa_opp.r79_further',
|
||||
'epa_opp.r116',
|
||||
'epa_app.r116'
|
||||
)
|
||||
AND is_active = true;
|
||||
|
||||
-- Sanity check: exactly 4 rules tagged.
|
||||
DO $check$
|
||||
DECLARE
|
||||
tagged int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO tagged
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_bilateral = true;
|
||||
IF tagged <> 4 THEN
|
||||
RAISE EXCEPTION
|
||||
'Phase A bilateral backfill: expected 4 rules tagged, got %',
|
||||
tagged;
|
||||
END IF;
|
||||
END $check$;
|
||||
31
internal/handlers/fristenrechner_event_categories.go
Normal file
31
internal/handlers/fristenrechner_event_categories.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// GET /api/tools/fristenrechner/event-categories — returns the full
|
||||
// decision-tree taxonomy for the v3 Pathway B / B1 cascade UI
|
||||
// (t-paliad-133). Tree is small (~100 nodes) and mostly static; the
|
||||
// frontend ETag-caches it via localStorage.
|
||||
//
|
||||
// Returns 503 if the DB-backed services aren't wired (DATABASE_URL
|
||||
// unset).
|
||||
func handleFristenrechnerEventCategories(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.eventCategory == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Decision-tree-Taxonomie vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
})
|
||||
return
|
||||
}
|
||||
tree, err := dbSvc.eventCategory.Tree(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "Decision-tree fehlgeschlagen: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"tree": tree,
|
||||
})
|
||||
}
|
||||
@@ -3,15 +3,30 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/tools/fristenrechner/search — unified search across the
|
||||
// Fristenrechner concept layer (t-paliad-131 Phase C). Returns at most
|
||||
// `limit` concept cards, each with its proceeding pills. Supports
|
||||
// optional facet filters: party, proc (proceeding code), source
|
||||
// (legal_source prefix).
|
||||
// Fristenrechner concept layer (t-paliad-131 Phase C, t-paliad-133 v3
|
||||
// extension). Returns at most `limit` concept cards, each with its
|
||||
// proceeding pills.
|
||||
//
|
||||
// Query params:
|
||||
// q - free-text search (trigram + alias)
|
||||
// party - filter by effective_party
|
||||
// proc - filter by proceeding_type code
|
||||
// source - filter by legal_source prefix
|
||||
// event_category_slug - v3 B1 narrowing; only concepts reachable
|
||||
// from this taxonomy node and its descendants
|
||||
// appear. Empty q is allowed when this is set
|
||||
// (browse mode).
|
||||
// forum - comma-separated v3 forum-bucket slugs
|
||||
// (upc_cfi, upc_coa, de_lg, de_olg, de_bgh,
|
||||
// de_bpatg, epa_grant, epa_opp, epa_appeal,
|
||||
// dpma). Trigger pills bypass this filter.
|
||||
// limit - max cards (default 12, max 30)
|
||||
//
|
||||
// Returns an empty cards array (not 400) when q is empty — that lets
|
||||
// the frontend boot the search input without a server round-trip.
|
||||
@@ -24,10 +39,12 @@ func handleFristenrechnerSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
opts := services.SearchOptions{
|
||||
Party: r.URL.Query().Get("party"),
|
||||
Proc: r.URL.Query().Get("proc"),
|
||||
Source: r.URL.Query().Get("source"),
|
||||
Limit: parseLimit(r.URL.Query().Get("limit")),
|
||||
Party: r.URL.Query().Get("party"),
|
||||
Proc: r.URL.Query().Get("proc"),
|
||||
Source: r.URL.Query().Get("source"),
|
||||
EventCategorySlug: r.URL.Query().Get("event_category_slug"),
|
||||
Forums: parseCSV(r.URL.Query().Get("forum")),
|
||||
Limit: parseLimit(r.URL.Query().Get("limit")),
|
||||
}
|
||||
resp, err := dbSvc.deadlineSearch.Search(r.Context(), q, opts)
|
||||
if err != nil {
|
||||
@@ -37,6 +54,26 @@ func handleFristenrechnerSearch(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// parseCSV splits a comma-separated query-string value into a slice of
|
||||
// trimmed non-empty entries. Empty input → nil.
|
||||
func parseCSV(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseLimit(raw string) int {
|
||||
if raw == "" {
|
||||
return 0
|
||||
|
||||
@@ -49,6 +49,7 @@ type Services struct {
|
||||
Fristenrechner *services.FristenrechnerService
|
||||
EventDeadline *services.EventDeadlineService
|
||||
DeadlineSearch *services.DeadlineSearchService
|
||||
EventCategory *services.EventCategoryService
|
||||
EventType *services.EventTypeService
|
||||
Dashboard *services.DashboardService
|
||||
Note *services.NoteService
|
||||
@@ -81,6 +82,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
fristenrechner: svc.Fristenrechner,
|
||||
eventDeadline: svc.EventDeadline,
|
||||
deadlineSearch: svc.DeadlineSearch,
|
||||
eventCategory: svc.EventCategory,
|
||||
eventType: svc.EventType,
|
||||
dashboard: svc.Dashboard,
|
||||
note: svc.Note,
|
||||
@@ -136,6 +138,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/tools/trigger-events", handleTriggerEventsList)
|
||||
protected.HandleFunc("POST /api/tools/event-deadlines", handleEventDeadlinesCalculate)
|
||||
protected.HandleFunc("GET /api/tools/fristenrechner/search", handleFristenrechnerSearch)
|
||||
protected.HandleFunc("GET /api/tools/fristenrechner/event-categories", handleFristenrechnerEventCategories)
|
||||
protected.HandleFunc("GET /downloads", handleDownloadsPage)
|
||||
protected.HandleFunc("GET /glossary", handleGlossaryPage)
|
||||
protected.HandleFunc("GET /api/glossary", handleGlossaryAPI)
|
||||
|
||||
@@ -29,6 +29,7 @@ type dbServices struct {
|
||||
fristenrechner *services.FristenrechnerService
|
||||
eventDeadline *services.EventDeadlineService
|
||||
deadlineSearch *services.DeadlineSearchService
|
||||
eventCategory *services.EventCategoryService
|
||||
eventType *services.EventTypeService
|
||||
dashboard *services.DashboardService
|
||||
note *services.NoteService
|
||||
|
||||
@@ -25,22 +25,64 @@ import (
|
||||
// 2. Fetch all matview rows for those concept_ids and assemble the
|
||||
// per-pill payload.
|
||||
//
|
||||
// See docs/plans/unified-fristenrechner.md §4.6 + §6.
|
||||
// v3 (t-paliad-133) extends the service to accept:
|
||||
// - EventCategorySlug: drives the B1 decision-tree narrowing. When
|
||||
// set, only concepts reachable from that taxonomy node (via the
|
||||
// paliad.event_category_concepts junction) appear in results.
|
||||
// An empty `q` is permitted when EventCategorySlug is set — the
|
||||
// tree alone is enough to produce a candidate concept set.
|
||||
// - Forums: a list of forum slugs from the v3 bucket map. Translated
|
||||
// to proceeding_type_codes by the search service; trigger-event
|
||||
// pills bypass the forum filter (cross-cutting by design).
|
||||
//
|
||||
// See docs/plans/unified-fristenrechner.md §4.6 + §6 (v2) and
|
||||
// docs/plans/unified-fristenrechner-v3.md §3.5 + §5.2 (v3).
|
||||
type DeadlineSearchService struct {
|
||||
db *sqlx.DB
|
||||
db *sqlx.DB
|
||||
eventCategory *EventCategoryService
|
||||
}
|
||||
|
||||
// NewDeadlineSearchService wires the service to its DB pool.
|
||||
// NewDeadlineSearchService wires the service to its DB pool. The
|
||||
// EventCategoryService dependency is optional — pass nil if the v3
|
||||
// taxonomy isn't needed (legacy callers).
|
||||
func NewDeadlineSearchService(db *sqlx.DB) *DeadlineSearchService {
|
||||
return &DeadlineSearchService{db: db}
|
||||
}
|
||||
|
||||
// SetEventCategoryService injects the optional v3 event-category
|
||||
// resolver. Wired by main.go after both services exist.
|
||||
func (s *DeadlineSearchService) SetEventCategoryService(ec *EventCategoryService) {
|
||||
s.eventCategory = ec
|
||||
}
|
||||
|
||||
// ForumToProceedingCodes maps the v3 forum buckets to proceeding_type
|
||||
// codes. Lives here (rather than in the DB) because the bucket choice
|
||||
// is presentation, not data — m can rebucket via code change without
|
||||
// migration. m's spec lock §10 Q8 (2026-05-05): 10 buckets.
|
||||
//
|
||||
// Empty bucket slug = no narrowing.
|
||||
var ForumToProceedingCodes = map[string][]string{
|
||||
"upc_cfi": {"UPC_INF", "UPC_REV", "UPC_PI", "UPC_DAMAGES", "UPC_DISCOVERY", "UPC_APP_ORDERS"},
|
||||
"upc_coa": {"UPC_APP", "UPC_COST_APPEAL"},
|
||||
"de_lg": {"DE_INF"},
|
||||
"de_olg": {"DE_INF_OLG"},
|
||||
"de_bgh": {"DE_INF_BGH", "DE_NULL_BGH", "DPMA_BGH_RB"},
|
||||
"de_bpatg": {"DE_NULL", "DPMA_BPATG_BESCHWERDE"},
|
||||
"epa_grant": {"EP_GRANT"},
|
||||
"epa_opp": {"EPA_OPP"},
|
||||
"epa_appeal": {"EPA_APP"},
|
||||
"dpma": {"DPMA_OPP"},
|
||||
}
|
||||
|
||||
// SearchOptions carries the optional facet filters from the URL query
|
||||
// string. Empty strings mean "no filter on this facet".
|
||||
// string. Empty strings / empty slices mean "no filter on this facet".
|
||||
type SearchOptions struct {
|
||||
Party string
|
||||
Proc string
|
||||
Source string
|
||||
// v3 (t-paliad-133):
|
||||
EventCategorySlug string // drives B1 decision-tree narrowing
|
||||
Forums []string // multi-select forum buckets (UNION within)
|
||||
Limit int
|
||||
MaxLimit int
|
||||
}
|
||||
@@ -153,9 +195,10 @@ type pillRow struct {
|
||||
|
||||
// Search runs the two-query pipeline and assembles the cards.
|
||||
//
|
||||
// q is the raw user input. Empty q returns an empty result set (no
|
||||
// filtering across the entire matview — that's a "browse" surface
|
||||
// the design doc reserves for Phase D).
|
||||
// q is the raw user input. Empty q returns an empty result set UNLESS
|
||||
// opts.EventCategorySlug is set — that triggers v3 browse-mode where the
|
||||
// taxonomy alone produces a candidate concept list (used by the B1
|
||||
// decision-tree cascade in Pathway B).
|
||||
func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts SearchOptions) (*SearchResponse, error) {
|
||||
limit := opts.Limit
|
||||
if limit <= 0 {
|
||||
@@ -176,18 +219,45 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
|
||||
}
|
||||
|
||||
qNorm := normalizeQuery(q)
|
||||
if qNorm == "" {
|
||||
browseMode := qNorm == "" && opts.EventCategorySlug != ""
|
||||
|
||||
// v3: resolve the event-category slug to a concept_id allow-list.
|
||||
var allowConceptIDs []string
|
||||
if opts.EventCategorySlug != "" && s.eventCategory != nil {
|
||||
ids, err := s.eventCategory.ConceptIDsForSlug(ctx, opts.EventCategorySlug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
// Slug resolves to no concepts; return empty without hitting
|
||||
// the matview.
|
||||
return resp, nil
|
||||
}
|
||||
allowConceptIDs = ids
|
||||
}
|
||||
|
||||
// v3: translate forum slugs to proceeding_code allow-list.
|
||||
forumCodes := translateForums(opts.Forums)
|
||||
|
||||
if !browseMode && qNorm == "" {
|
||||
return resp, nil
|
||||
}
|
||||
qLow := strings.ToLower(qNorm)
|
||||
|
||||
party := nullable(opts.Party)
|
||||
proc := nullable(opts.Proc)
|
||||
source := nullable(opts.Source)
|
||||
|
||||
ranks, err := s.rankConcepts(ctx, qNorm, qLow, party, proc, source, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var ranks []rankRow
|
||||
if browseMode {
|
||||
// Browse mode: synthesize ranks from the allow-list directly.
|
||||
ranks = s.browseRanks(ctx, allowConceptIDs, party, proc, source, forumCodes, limit)
|
||||
} else {
|
||||
qLow := strings.ToLower(qNorm)
|
||||
var err error
|
||||
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, allowConceptIDs, forumCodes, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(ranks) == 0 {
|
||||
return resp, nil
|
||||
@@ -197,7 +267,7 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
|
||||
for i, r := range ranks {
|
||||
conceptIDs[i] = r.ConceptID
|
||||
}
|
||||
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source)
|
||||
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, forumCodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -209,12 +279,99 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// translateForums maps a list of forum slugs to the union of their
|
||||
// proceeding_type_codes via ForumToProceedingCodes. Unknown slugs are
|
||||
// silently dropped.
|
||||
func translateForums(slugs []string) []string {
|
||||
if len(slugs) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, slug := range slugs {
|
||||
codes, ok := ForumToProceedingCodes[slug]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, c := range codes {
|
||||
if seen[c] {
|
||||
continue
|
||||
}
|
||||
seen[c] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// browseRanks synthesizes a rank list from a concept-id allow-list
|
||||
// (v3 B1 browse mode). No trigram scoring — order is by concept
|
||||
// sort_order then name. Forum filter applies post-hoc to keep concepts
|
||||
// that have at least one matching pill.
|
||||
func (s *DeadlineSearchService) browseRanks(
|
||||
ctx context.Context,
|
||||
conceptIDs []string,
|
||||
party, proc, source *string,
|
||||
forumCodes []string,
|
||||
limit int,
|
||||
) []rankRow {
|
||||
const sqlText = `
|
||||
SELECT DISTINCT
|
||||
s.concept_id,
|
||||
false AS alias_hit,
|
||||
1.0 AS score,
|
||||
s.concept_sort_order,
|
||||
s.concept_name_de,
|
||||
ARRAY[]::text[] AS matched_aliases
|
||||
FROM paliad.deadline_search s
|
||||
WHERE s.concept_id = ANY($1::uuid[])
|
||||
AND ($2::text IS NULL OR s.effective_party = $2)
|
||||
AND ($3::text IS NULL OR s.proceeding_code = $3)
|
||||
AND ($4::text IS NULL OR s.legal_source LIKE $4 || '%')
|
||||
AND (
|
||||
$5::text[] IS NULL
|
||||
OR cardinality($5::text[]) = 0
|
||||
OR s.kind = 'trigger'
|
||||
OR s.proceeding_code = ANY($5::text[])
|
||||
)
|
||||
ORDER BY s.concept_sort_order ASC, s.concept_name_de ASC
|
||||
LIMIT $6
|
||||
`
|
||||
var rows []rankRow
|
||||
if err := s.db.SelectContext(ctx, &rows, sqlText,
|
||||
pq.Array(conceptIDs),
|
||||
party, proc, source,
|
||||
nullableArray(forumCodes),
|
||||
limit,
|
||||
); err != nil {
|
||||
// Browse mode failures degrade to empty (taxonomy-driven UX
|
||||
// shouldn't crash on a malformed slug); log via the caller.
|
||||
return nil
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// nullableArray returns nil for empty input so the SQL `IS NULL OR
|
||||
// cardinality = 0` short-circuit applies cleanly. pq.Array on a nil
|
||||
// slice still produces a non-NULL empty array, which doesn't match
|
||||
// the IS NULL test — hence the explicit nil sentinel.
|
||||
func nullableArray(s []string) any {
|
||||
if len(s) == 0 {
|
||||
return nil
|
||||
}
|
||||
return pq.Array(s)
|
||||
}
|
||||
|
||||
func (s *DeadlineSearchService) rankConcepts(
|
||||
ctx context.Context,
|
||||
q, qLow string,
|
||||
party, proc, source *string,
|
||||
allowConceptIDs []string,
|
||||
forumCodes []string,
|
||||
limit int,
|
||||
) ([]rankRow, error) {
|
||||
// $1 q · $2 qLow · $3 party · $4 proc · $5 source ·
|
||||
// $6 concept_allow uuid[]? · $7 forum_codes text[]? · $8 limit
|
||||
const sqlText = `
|
||||
WITH matched AS (
|
||||
SELECT
|
||||
@@ -253,6 +410,13 @@ WITH matched AS (
|
||||
AND ($3::text IS NULL OR s.effective_party = $3)
|
||||
AND ($4::text IS NULL OR s.proceeding_code = $4)
|
||||
AND ($5::text IS NULL OR s.legal_source LIKE $5 || '%')
|
||||
AND ($6::uuid[] IS NULL OR s.concept_id = ANY($6::uuid[]))
|
||||
AND (
|
||||
$7::text[] IS NULL
|
||||
OR cardinality($7::text[]) = 0
|
||||
OR s.kind = 'trigger'
|
||||
OR s.proceeding_code = ANY($7::text[])
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
m.concept_id,
|
||||
@@ -261,16 +425,20 @@ SELECT
|
||||
THEN 0.2 ELSE 0 END AS score,
|
||||
min(m.concept_sort_order) AS concept_sort_order,
|
||||
min(m.concept_name_de) AS concept_name_de,
|
||||
-- All rows in a concept share the same aliases; min() over identical
|
||||
-- text[] values is well-defined and returns one of them verbatim.
|
||||
COALESCE(min(m.row_matched_aliases), ARRAY[]::text[]) AS matched_aliases
|
||||
FROM matched m
|
||||
GROUP BY m.concept_id
|
||||
ORDER BY score DESC, concept_sort_order ASC, concept_name_de ASC
|
||||
LIMIT $6
|
||||
LIMIT $8
|
||||
`
|
||||
var rows []rankRow
|
||||
if err := s.db.SelectContext(ctx, &rows, sqlText, q, qLow, party, proc, source, limit); err != nil {
|
||||
if err := s.db.SelectContext(ctx, &rows, sqlText,
|
||||
q, qLow,
|
||||
party, proc, source,
|
||||
nullableArray(allowConceptIDs),
|
||||
nullableArray(forumCodes),
|
||||
limit,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("rank concepts: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
@@ -280,6 +448,7 @@ func (s *DeadlineSearchService) loadPills(
|
||||
ctx context.Context,
|
||||
conceptIDs []string,
|
||||
party, proc, source *string,
|
||||
forumCodes []string,
|
||||
) ([]pillRow, error) {
|
||||
const sqlText = `
|
||||
SELECT
|
||||
@@ -311,10 +480,18 @@ SELECT
|
||||
AND ($2::text IS NULL OR s.effective_party = $2)
|
||||
AND ($3::text IS NULL OR s.proceeding_code = $3)
|
||||
AND ($4::text IS NULL OR s.legal_source LIKE $4 || '%')
|
||||
AND (
|
||||
$5::text[] IS NULL
|
||||
OR cardinality($5::text[]) = 0
|
||||
OR s.kind = 'trigger'
|
||||
OR s.proceeding_code = ANY($5::text[])
|
||||
)
|
||||
ORDER BY s.concept_id, s.kind, s.proceeding_code NULLS LAST, s.rule_local_code
|
||||
`
|
||||
var rows []pillRow
|
||||
if err := s.db.SelectContext(ctx, &rows, sqlText, pq.Array(conceptIDs), party, proc, source); err != nil {
|
||||
if err := s.db.SelectContext(ctx, &rows, sqlText,
|
||||
pq.Array(conceptIDs), party, proc, source, nullableArray(forumCodes),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("load pills: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
|
||||
253
internal/services/event_category_service.go
Normal file
253
internal/services/event_category_service.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// EventCategoryService backs the Fristenrechner v3 decision tree
|
||||
// (Pathway B / B1, t-paliad-133). The taxonomy is a recursive tree of
|
||||
// "what happened" event categories; leaves map to deadline_concepts via
|
||||
// the paliad.event_category_concepts junction.
|
||||
//
|
||||
// Two main operations:
|
||||
// 1. Tree: hand the entire taxonomy to the frontend as a nested JSON
|
||||
// structure for the cascade UI (small dataset, ETag-cached).
|
||||
// 2. ConceptsForSlug: given a slug like "cms-eingang.gericht.hinweisbeschluss",
|
||||
// return the (concept_id, proceeding_type_code) tuples reachable from
|
||||
// that node OR any of its descendants. Drives B1's narrowing of the
|
||||
// shared concept-card list.
|
||||
type EventCategoryService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewEventCategoryService wires the service to its DB pool.
|
||||
func NewEventCategoryService(db *sqlx.DB) *EventCategoryService {
|
||||
return &EventCategoryService{db: db}
|
||||
}
|
||||
|
||||
// EventCategoryNode is one row in the taxonomy with its children attached.
|
||||
// JSON shape is what the frontend consumes from
|
||||
// GET /api/tools/fristenrechner/event-categories.
|
||||
type EventCategoryNode struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
DescriptionDE *string `json:"description_de,omitempty"`
|
||||
DescriptionEN *string `json:"description_en,omitempty"`
|
||||
StepQuestionDE *string `json:"step_question_de,omitempty"`
|
||||
StepQuestionEN *string `json:"step_question_en,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsLeaf bool `json:"is_leaf"`
|
||||
Children []EventCategoryNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// ConceptOutcome maps a leaf to one (concept, optional proceeding) pair.
|
||||
// Used to narrow the deadline_search matview by event_category.
|
||||
type ConceptOutcome struct {
|
||||
ConceptID string `db:"concept_id" json:"concept_id"`
|
||||
ProceedingTypeCode *string `db:"proceeding_type_code" json:"proceeding_type_code,omitempty"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
}
|
||||
|
||||
// categoryRow is the flat row shape from the DB.
|
||||
type categoryRow struct {
|
||||
ID string `db:"id"`
|
||||
ParentID sql.NullString `db:"parent_id"`
|
||||
Slug string `db:"slug"`
|
||||
LabelDE string `db:"label_de"`
|
||||
LabelEN string `db:"label_en"`
|
||||
DescriptionDE sql.NullString `db:"description_de"`
|
||||
DescriptionEN sql.NullString `db:"description_en"`
|
||||
StepQuestionDE sql.NullString `db:"step_question_de"`
|
||||
StepQuestionEN sql.NullString `db:"step_question_en"`
|
||||
Icon sql.NullString `db:"icon"`
|
||||
SortOrder int `db:"sort_order"`
|
||||
IsLeaf bool `db:"is_leaf"`
|
||||
}
|
||||
|
||||
// Tree returns the full taxonomy as a list of root nodes with children
|
||||
// nested in. Inactive rows are excluded.
|
||||
//
|
||||
// Result is small (≤ ~100 nodes today) and stable across requests, so the
|
||||
// handler ETag-caches it.
|
||||
func (s *EventCategoryService) Tree(ctx context.Context) ([]EventCategoryNode, error) {
|
||||
const sqlText = `
|
||||
SELECT id, parent_id, slug, label_de, label_en,
|
||||
description_de, description_en,
|
||||
step_question_de, step_question_en,
|
||||
icon, sort_order, is_leaf
|
||||
FROM paliad.event_categories
|
||||
WHERE is_active = true
|
||||
ORDER BY sort_order ASC, slug ASC
|
||||
`
|
||||
var rows []categoryRow
|
||||
if err := s.db.SelectContext(ctx, &rows, sqlText); err != nil {
|
||||
return nil, fmt.Errorf("event_categories list: %w", err)
|
||||
}
|
||||
|
||||
// Build node map and stitch children to parents in one pass.
|
||||
nodes := make(map[string]*EventCategoryNode, len(rows))
|
||||
for _, r := range rows {
|
||||
n := EventCategoryNode{
|
||||
ID: r.ID,
|
||||
Slug: r.Slug,
|
||||
LabelDE: r.LabelDE,
|
||||
LabelEN: r.LabelEN,
|
||||
SortOrder: r.SortOrder,
|
||||
IsLeaf: r.IsLeaf,
|
||||
}
|
||||
if r.DescriptionDE.Valid {
|
||||
n.DescriptionDE = &r.DescriptionDE.String
|
||||
}
|
||||
if r.DescriptionEN.Valid {
|
||||
n.DescriptionEN = &r.DescriptionEN.String
|
||||
}
|
||||
if r.StepQuestionDE.Valid {
|
||||
n.StepQuestionDE = &r.StepQuestionDE.String
|
||||
}
|
||||
if r.StepQuestionEN.Valid {
|
||||
n.StepQuestionEN = &r.StepQuestionEN.String
|
||||
}
|
||||
if r.Icon.Valid {
|
||||
n.Icon = &r.Icon.String
|
||||
}
|
||||
nodes[r.ID] = &n
|
||||
}
|
||||
|
||||
var roots []EventCategoryNode
|
||||
for _, r := range rows {
|
||||
node := nodes[r.ID]
|
||||
if !r.ParentID.Valid {
|
||||
roots = append(roots, *node)
|
||||
continue
|
||||
}
|
||||
parent, ok := nodes[r.ParentID.String]
|
||||
if !ok {
|
||||
// Orphan (parent inactive or deleted) — surface as root so the
|
||||
// taxonomy doesn't disappear. Defensive; shouldn't happen in
|
||||
// practice given is_active=true filter applies to both sides.
|
||||
roots = append(roots, *node)
|
||||
continue
|
||||
}
|
||||
parent.Children = append(parent.Children, *node)
|
||||
}
|
||||
|
||||
// Re-collect children from the map into the root copies — the
|
||||
// `roots = append(roots, *node)` above stored a snapshot, so we need
|
||||
// to walk back through and replace each with the live pointer's data.
|
||||
final := make([]EventCategoryNode, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
live := nodes[root.ID]
|
||||
final = append(final, *live)
|
||||
}
|
||||
return final, nil
|
||||
}
|
||||
|
||||
// ConceptsForSlug returns the (concept, optional proceeding_code) tuples
|
||||
// reachable from the named slug OR any of its descendants. Empty slug
|
||||
// returns nothing (caller must validate). Unknown slug also returns
|
||||
// empty without error so the caller can render an empty result UI.
|
||||
func (s *EventCategoryService) ConceptsForSlug(ctx context.Context, slug string) ([]ConceptOutcome, error) {
|
||||
if slug == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Single recursive CTE walks the descendants of `slug`, then joins
|
||||
// the junction. text-cast on concept_id keeps the slice serialisable
|
||||
// to a stable JSON shape.
|
||||
const sqlText = `
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id FROM paliad.event_categories
|
||||
WHERE slug = $1 AND is_active = true
|
||||
UNION ALL
|
||||
SELECT c.id
|
||||
FROM paliad.event_categories c
|
||||
JOIN descendants d ON c.parent_id = d.id
|
||||
WHERE c.is_active = true
|
||||
)
|
||||
SELECT DISTINCT
|
||||
ecc.concept_id::text AS concept_id,
|
||||
ecc.proceeding_type_code AS proceeding_type_code,
|
||||
min(ecc.sort_order) AS sort_order
|
||||
FROM paliad.event_category_concepts ecc
|
||||
JOIN descendants d ON d.id = ecc.event_category_id
|
||||
GROUP BY ecc.concept_id, ecc.proceeding_type_code
|
||||
ORDER BY sort_order ASC, concept_id ASC
|
||||
`
|
||||
var rows []ConceptOutcome
|
||||
if err := s.db.SelectContext(ctx, &rows, sqlText, slug); err != nil {
|
||||
return nil, fmt.Errorf("event_category concepts for %q: %w", slug, err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ConceptIDsForSlug is the convenience reduction of ConceptsForSlug to a
|
||||
// flat slice of concept_ids for use in `WHERE concept_id = ANY($1::uuid[])`
|
||||
// queries against paliad.deadline_search.
|
||||
func (s *EventCategoryService) ConceptIDsForSlug(ctx context.Context, slug string) ([]string, error) {
|
||||
rows, err := s.ConceptsForSlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
seen := make(map[string]bool, len(rows))
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if seen[r.ConceptID] {
|
||||
continue
|
||||
}
|
||||
seen[r.ConceptID] = true
|
||||
out = append(out, r.ConceptID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ProceedingCodesForSlug returns the distinct proceeding_type_code
|
||||
// values associated with the slug's reachable concept set. Used by the
|
||||
// search service to AND the user's forum filter against the leaf's
|
||||
// per-concept narrowing.
|
||||
//
|
||||
// NULL proceeding_type_code (concept applies to all contexts) is
|
||||
// reported as the empty string in the result; callers treat empty as
|
||||
// "no narrowing at this leaf".
|
||||
func (s *EventCategoryService) ProceedingCodesForSlug(ctx context.Context, slug string) ([]string, error) {
|
||||
if slug == "" {
|
||||
return nil, nil
|
||||
}
|
||||
const sqlText = `
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id FROM paliad.event_categories WHERE slug = $1 AND is_active = true
|
||||
UNION ALL
|
||||
SELECT c.id FROM paliad.event_categories c
|
||||
JOIN descendants d ON c.parent_id = d.id
|
||||
WHERE c.is_active = true
|
||||
)
|
||||
SELECT DISTINCT COALESCE(ecc.proceeding_type_code, '') AS code
|
||||
FROM paliad.event_category_concepts ecc
|
||||
JOIN descendants d ON d.id = ecc.event_category_id
|
||||
ORDER BY code
|
||||
`
|
||||
var codes pq.StringArray
|
||||
rows, err := s.db.QueryContext(ctx, sqlText, slug)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("proceeding codes for %q: %w", slug, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
return nil, fmt.Errorf("scan proceeding code: %w", err)
|
||||
}
|
||||
codes = append(codes, c)
|
||||
}
|
||||
return []string(codes), rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user