Merge: t-paliad-179 Slice 1 — Tools surface split (route + shell + code-lift)

This commit is contained in:
mAi
2026-05-13 00:20:44 +02:00
13 changed files with 1023 additions and 488 deletions

View File

@@ -4,6 +4,7 @@ import { renderIndex } from "./src/index";
import { renderLogin } from "./src/login";
import { renderKostenrechner } from "./src/kostenrechner";
import { renderFristenrechner } from "./src/fristenrechner";
import { renderVerfahrensablauf } from "./src/verfahrensablauf";
import { renderDownloads } from "./src/downloads";
import { renderLinks } from "./src/links";
import { renderGlossary } from "./src/glossary";
@@ -235,6 +236,7 @@ async function build() {
join(import.meta.dir, "src/client/login.ts"),
join(import.meta.dir, "src/client/kostenrechner.ts"),
join(import.meta.dir, "src/client/fristenrechner.ts"),
join(import.meta.dir, "src/client/verfahrensablauf.ts"),
join(import.meta.dir, "src/client/downloads.ts"),
join(import.meta.dir, "src/client/links.ts"),
join(import.meta.dir, "src/client/glossary.ts"),
@@ -356,6 +358,7 @@ async function build() {
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
await Bun.write(join(DIST, "verfahrensablauf.html"), renderVerfahrensablauf());
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
await Bun.write(join(DIST, "links.html"), renderLinks());
await Bun.write(join(DIST, "glossary.html"), renderGlossary());

View File

@@ -1,67 +1,27 @@
// Fristenrechner client-side logic
// 3-step wizard: select proceeding -> enter date -> view timeline
//
// Rendering primitives (renderTimelineBody / renderColumnsBody /
// deadlineCardHtml / formatDate / partyBadge / court picker) live in
// `./views/verfahrensablauf-core` and are shared with the
// /tools/verfahrensablauf page (t-paliad-179 Slice 1). This module owns
// the Step1/2/3a wizard, Pathway A/B, Akte save flow, anchor-override
// click-to-edit — none of which Verfahrensablauf wants.
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
interface AdjustmentHoliday {
Date: string;
Name: string;
IsVacation: boolean;
IsClosure: boolean;
}
interface AdjustmentReason {
kind: "weekend" | "public_holiday" | "vacation";
holidays?: AdjustmentHoliday[];
vacation_name?: string;
vacation_start?: string;
vacation_end?: string;
original_weekday?: string;
}
interface CalculatedDeadline {
code: string;
name: string;
nameEN: string;
party: string;
isMandatory: boolean;
ruleRef: string;
legalSource?: string;
notes?: string;
notesEN?: string;
dueDate: string;
originalDate: string;
wasAdjusted: boolean;
adjustmentReason?: AdjustmentReason;
isRootEvent: boolean;
isCourtSet: boolean;
// True when isCourtSet is "unbestimmt" — the rule chains off a
// court-determined parent (e.g. RoP.151 = 1 Monat ab
// Hauptentscheidung) rather than being itself court-set. The UI
// renders "unbestimmt" instead of "wird vom Gericht bestimmt".
isCourtSetIndirect?: boolean;
// True when the deadline is conditional on a user act (filing a
// cost-decision request, choosing to appeal, etc.). Pre-unchecked
// in the save modal so the user must opt in.
isOptional?: boolean;
isOverridden?: boolean;
}
interface DeadlineResponse {
proceedingType: string;
proceedingName: string;
triggerDate: string;
deadlines: CalculatedDeadline[];
}
const PARTY_CLASS: Record<string, string> = {
claimant: "party-claimant",
defendant: "party-defendant",
court: "party-court",
both: "party-both",
};
import {
type CalculatedDeadline,
type DeadlineResponse,
calculateDeadlines,
escAttr,
escHtml,
formatDate,
populateCourtPicker as populateCourtPickerCore,
renderColumnsBody,
renderTimelineBody,
} from "./views/verfahrensablauf-core";
let lastResponse: DeadlineResponse | null = null;
@@ -106,92 +66,29 @@ onLangChange(() => {
}
});
function formatDate(dateStr: string): string {
if (!dateStr) return "\u2014";
const d = new Date(dateStr + "T00:00:00");
if (getLang() === "en") {
// ISO date (YYYY-MM-DD) \u2014 unambiguous for both US and intl readers, since
// en-GB renders dd/mm/yyyy which US users misread as mm/dd/yyyy.
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${weekday}, ${yyyy}-${mm}-${dd}`;
}
return d.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
// formatDate / partyBadge / formatDateSpan / localizeVacationName /
// localizeWeekday / renderAdjustmentReason / formatAdjustedNote moved to
// ./views/verfahrensablauf-core so /tools/verfahrensablauf can share them.
// (t-paliad-179 Slice 1)
function partyBadge(party: string): string {
const cls = PARTY_CLASS[party] || "party-both";
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
}
// Short date span like "27.7.28.8." (DE) or "27 Jul 28 Aug" (EN). Used in
// the vacation adjustment label, where the explicit weekday + year would
// just be noise — the surrounding sentence carries the full year via the
// dueDate / originalDate that the note brackets.
function formatDateSpan(startISO: string, endISO: string): string {
const start = new Date(startISO + "T00:00:00");
const end = new Date(endISO + "T00:00:00");
if (getLang() === "en") {
const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
return `${fmt(start)} ${fmt(end)}`;
}
const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
return `${fmt(start)}${fmt(end)}`;
}
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
// vacation"). The Fristenrechner doesn't translate them: they're proper
// names of court-set closures, not generic strings, and rotating them via
// i18n.ts duplicates state that should live in the DB. Rename in the seed
// if the wording needs to change.
function localizeVacationName(name: string): string {
return name;
}
function localizeWeekday(en: string): string {
if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
return en;
}
// Backend-shaped reason → human-readable phrase ("UPC-Gerichtsferien
// (27.7.28.8.)" / "Karfreitag holiday" / "Wochenende"). See t-paliad-119.
function renderAdjustmentReason(r: AdjustmentReason): string {
if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
const span = formatDateSpan(r.vacation_start, r.vacation_end);
return tDyn("deadlines.adjusted.vacation")
.replace("{name}", localizeVacationName(r.vacation_name))
.replace("{span}", span);
}
if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
}
if (r.kind === "weekend" && r.original_weekday) {
return localizeWeekday(r.original_weekday);
}
return t("deadlines.adjusted.weekend");
}
// "Verschoben wegen X: A → B" (DE) / "Shifted (X): A → B" (EN). Falls back
// to the legacy "Wochenende/Feiertag" string when the backend hasn't sent a
// structured reason — keeps older API responses readable.
function formatAdjustedNote(dl: CalculatedDeadline): string {
const arrow = `${formatDate(dl.originalDate)}${formatDate(dl.dueDate)}`;
const reason = dl.adjustmentReason
? renderAdjustmentReason(dl.adjustmentReason)
: t("deadlines.adjusted.reason");
if (getLang() === "en") {
return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
}
return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
}
let selectedType = "";
@@ -247,35 +144,19 @@ async function calculate() {
? courtPicker.value
: "";
try {
const resp = await fetch("/api/tools/fristenrechner", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
proceedingType: selectedType,
triggerDate,
priorityDate: priorityDate || undefined,
flags: flags.length > 0 ? flags : undefined,
anchorOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
courtId: courtId || undefined,
}),
});
if (seq !== procCalcSeq) return;
if (!resp.ok) {
const err = await resp.json();
console.error("API error:", err);
return;
}
const data: DeadlineResponse = await resp.json();
if (seq !== procCalcSeq) return;
lastResponse = data;
renderProcedureResults(data);
showStep(3);
} catch (e) {
console.error("Fetch error:", e);
}
const data = await calculateDeadlines({
proceedingType: selectedType,
triggerDate,
priorityDate,
flags,
anchorOverrides: overrides,
courtId,
});
if (seq !== procCalcSeq) return;
if (!data) return;
lastResponse = data;
renderProcedureResults(data);
showStep(3);
}
interface ProjectOption {
@@ -296,16 +177,6 @@ interface ProjectOption {
our_side?: "claimant" | "defendant" | "court" | "both" | null;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function escHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
async function fetchProjects(): Promise<ProjectOption[]> {
try {
const resp = await fetch("/api/projects");
@@ -500,8 +371,8 @@ function renderProcedureResults(data: DeadlineResponse) {
</div>`;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data)
: renderTimelineBody(data);
? renderColumnsBody(data, { editable: true })
: renderTimelineBody(data, { showParty: true, editable: true });
container.innerHTML = headerHtml + bodyHtml;
printBtn.style.display = "block";
@@ -572,186 +443,8 @@ function openInlineDateEditor(span: HTMLElement) {
if (editor.value) editor.select();
}
function deadlineCardHtml(dl: CalculatedDeadline, opts: { showParty: boolean }): string {
// Click-to-edit on dated rows + court-set placeholders: lets the user
// override the calculated date (e.g. court extended the deadline) or
// fill in a court-set decision date once known. Downstream rules
// re-anchor on the override via anchorOverrides → /api/tools/fristenrechner.
// Root-event rows (the trigger anchor itself) are NOT editable — the
// trigger date input is the canonical place to change that.
const editable = !dl.isRootEvent && dl.code !== "";
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
const editAttrs = editable
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
: "";
// "wird vom Gericht bestimmt" only fits direct court-set rules
// (Urteil / Beschluss / Anordnung). Indirect rules (chained off a
// court-set parent, e.g. RoP.151) render "unbestimmt" instead — the
// date isn't directly determined by the court, it's derived from
// the parent's date that the court will set. m's 2026-05-08 call.
const courtLabelKey = dl.isCourtSetIndirect
? "deadlines.court.indirect"
: "deadlines.court.set";
const dateStr = dl.isCourtSet
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
const mandatoryBadge = dl.isMandatory
? ""
: '<span class="optional-badge">optional</span>';
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
const adjustedNote = dl.wasAdjusted
? `<div class="timeline-adjusted">\u26a0 ${formatAdjustedNote(dl)}</div>`
: "";
const ruleRef = dl.ruleRef
? `<span class="timeline-rule">${dl.ruleRef}</span>`
: "";
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
const notes = noteText
? `<div class="timeline-notes">${noteText}</div>`
: "";
const meta = (opts.showParty || ruleRef)
? `<div class="timeline-meta">
${opts.showParty ? partyBadge(dl.party) : ""}
${ruleRef}
</div>`
: "";
return `<div class="timeline-item-header">
<span class="timeline-name">
${dlName}
${mandatoryBadge}
</span>
${dateStr}
</div>
${meta}
${adjustedNote}
${notes}`;
}
function renderTimelineBody(data: DeadlineResponse): string {
let html = '<div class="timeline">';
for (const dl of data.deadlines) {
html += `
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
<div class="timeline-dot-col">
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
<div class="timeline-line"></div>
</div>
<div class="timeline-content">
${deadlineCardHtml(dl, { showParty: true })}
</div>
</div>
`;
}
html += "</div>";
return html;
}
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
// (defendant). Each grid row corresponds to a distinct dueDate, so events on
// the same day line up across columns. Deadlines with party=both render in
// BOTH the Proactive and Reactive cells of their row with a "beide Seiten"
// caption so the duplication is legible as intentional. Undated events
// (Urteil, Beschluss, court-set placeholders) trail the dated rows; each
// gets its own row in the backend's sequence_order so e.g. Urteil precedes
// Berufungseinlegung visually instead of stacking in one bucket.
function renderColumnsBody(data: DeadlineResponse): string {
type Cell = CalculatedDeadline[];
type Row = { proactive: Cell; court: Cell; reactive: Cell };
const UNSCHEDULED_PREFIX = "__unscheduled__";
const rowsMap = new Map<string, Row>();
const ensureRow = (key: string): Row => {
let r = rowsMap.get(key);
if (!r) {
r = { proactive: [], court: [], reactive: [] };
rowsMap.set(key, r);
}
return r;
};
data.deadlines.forEach((dl, idx) => {
// Dated rows share a row by date; undated rows each get their own row,
// keyed by index so the backend's sequence_order is preserved in the
// dateless tail.
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
const row = ensureRow(key);
switch (dl.party) {
case "claimant":
row.proactive.push(dl);
break;
case "defendant":
row.reactive.push(dl);
break;
case "court":
row.court.push(dl);
break;
case "both":
// Mirrored: same card lands in Proactive AND Reactive at this date.
row.proactive.push(dl);
row.reactive.push(dl);
break;
default:
// Unknown party: keep visible by parking in the Court column.
row.court.push(dl);
}
});
// Dated keys (YYYY-MM-DD) sort chronologically by lexicographic compare.
// Unscheduled keys carry the sequence-order index in their padded suffix
// so they likewise sort by source order. Concatenate so the dateless tail
// sits below the dated rows.
const datedKeys: string[] = [];
const unscheduledKeys: string[] = [];
for (const k of rowsMap.keys()) {
if (k.startsWith(UNSCHEDULED_PREFIX)) unscheduledKeys.push(k);
else datedKeys.push(k);
}
datedKeys.sort();
unscheduledKeys.sort();
const keys = [...datedKeys, ...unscheduledKeys];
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
}
const cards = items
.map((dl) => {
const mirrorTag = dl.party === "both"
? `<div class="fr-col-mirror">\u2194 ${escHtml(t("deadlines.party.both.label"))}</div>`
: "";
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
${deadlineCardHtml(dl, { showParty: false })}
${mirrorTag}
</div>`;
})
.join("");
return `<div class="fr-col-cell">${cards}</div>`;
};
const headerCell = (label: string, cls: string) =>
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
let html = '<div class="fr-columns-view">';
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
html += headerCell(t("deadlines.col.court"), "fr-col-court");
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
for (const key of keys) {
const row = rowsMap.get(key)!;
html += renderCell(row.proactive);
html += renderCell(row.court);
html += renderCell(row.reactive);
}
html += "</div>";
return html;
}
// deadlineCardHtml / renderTimelineBody / renderColumnsBody moved to
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
function reset() {
selectedType = "";
@@ -812,7 +505,7 @@ function selectProceeding(btn: HTMLButtonElement) {
if (revCciRow) revCciRow.style.display = selectedType === "UPC_REV" ? "" : "none";
syncInfAmendEnabled();
populateCourtPicker(selectedType);
populateCourtPickerCore("court-picker-row", "court-picker", selectedType);
// Hide the four group blocks; show the compact summary in their place.
setProceedingPickerCollapsed(true, name);
@@ -821,99 +514,9 @@ function selectProceeding(btn: HTMLButtonElement) {
scheduleProcCalc(0);
}
// Court picker — t-paliad-122. Visible only for proceeding types that can
// land in multiple courts with different holiday calendars (today: every
// UPC-flavoured proceeding type, since UPC LDs span DE/FR/IT/NL/BE/FI/PT/
// AT/SI/DK + Stockholm RD + 3 CD seats). For DE-only proceedings (DE_NULL,
// DE_NULL_BGH, DE_INF_BGH, DPMA_*, EPA_*, EP_GRANT) the court is fixed by
// the proceeding type — no picker, server resolves the default.
//
// The picker calls /api/tools/courts?courtType=UPC-LD on first need and
// caches the response per-type. Defaulting to upc-ld-muenchen matches HLC's
// most common venue and keeps current behaviour for users who don't choose.
interface CourtRow {
id: string;
code: string;
nameDE: string;
nameEN: string;
country: string;
regime?: string;
courtType: string;
}
const courtCache = new Map<string, CourtRow[]>();
function courtTypesFor(proceedingType: string): string[] {
// Map proceeding code to compatible court types. UPC proceedings → UPC-LD
// (most common); appeals → UPC-CoA; central-division revocations → UPC-CD.
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
return ["UPC-CoA"];
}
if (proceedingType === "UPC_REV") {
return ["UPC-CD", "UPC-LD"]; // CD is the default revocation forum, LD when joined with infringement
}
if (proceedingType.startsWith("UPC_")) {
return ["UPC-LD"];
}
return [];
}
function defaultCourtFor(proceedingType: string): string {
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
return "upc-coa-luxembourg";
}
if (proceedingType === "UPC_REV") {
return "upc-cd-paris";
}
return "upc-ld-muenchen";
}
async function fetchCourts(courtType: string): Promise<CourtRow[]> {
if (courtCache.has(courtType)) return courtCache.get(courtType)!;
try {
const resp = await fetch(`/api/tools/courts?courtType=${encodeURIComponent(courtType)}`);
if (!resp.ok) return [];
const rows = (await resp.json()) as CourtRow[];
courtCache.set(courtType, rows);
return rows;
} catch {
return [];
}
}
async function populateCourtPicker(proceedingType: string): Promise<void> {
const row = document.getElementById("court-picker-row");
const select = document.getElementById("court-picker") as HTMLSelectElement | null;
if (!row || !select) return;
const types = courtTypesFor(proceedingType);
if (types.length === 0) {
row.style.display = "none";
select.innerHTML = "";
return;
}
// Load all compatible court types and concatenate (CD before LD for REV).
const lists = await Promise.all(types.map(t => fetchCourts(t)));
const courts = lists.flat();
if (courts.length <= 1) {
// Single compatible court — no point asking the user. Server's
// jurisdiction default lands the same place.
row.style.display = "none";
select.innerHTML = "";
return;
}
const lang = getLang();
const defaultID = defaultCourtFor(proceedingType);
select.innerHTML = courts.map(c => {
const name = lang === "en" ? c.nameEN : c.nameDE;
return `<option value="${escAttr(c.id)}"${c.id === defaultID ? " selected" : ""}>${escHtml(name)}</option>`;
}).join("");
row.style.display = "";
}
// inf-amend-flag is only meaningful when ccr-flag is on (R.30 application
// Court-picker primitives (CourtRow / courtCache / courtTypesFor /
// defaultCourtFor / fetchCourts / populateCourtPicker) moved to
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
// is filed within the Defence to CCR). When ccr-flag flips off, also
// untick inf-amend-flag so the calc payload stays coherent.
function syncInfAmendEnabled() {
@@ -2709,12 +2312,9 @@ function initPathwayFork() {
document.getElementById("fristen-step2-happened")?.addEventListener("click", () => {
navigateToPathway("b", "tree");
});
// t-paliad-168 — Verfahrensablauf einsehen (browse / learn). Drops
// straight into Pathway A's proceeding-tile picker. The save CTA
// disables itself in this mode (see isBrowseOrAdhocMode below).
document.getElementById("fristen-step2-browse")?.addEventListener("click", () => {
navigateToPathway("a");
});
// t-paliad-179 Slice 1: the "Verfahrensablauf einsehen" Step 2 card
// has been retired — the abstract-browse intent lives on its own
// route at /tools/verfahrensablauf now. No third-card handler here.
// Step 3a cards — File / Draft / Enter. File drops into the existing
// Pathway A wizard; Enter routes to the manual-create form;

View File

@@ -198,6 +198,12 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.title": "Fristenrechner \u2014 Paliad",
"deadlines.heading": "Fristenrechner",
"deadlines.subtitle": "Berechnung von Verfahrensfristen f\u00fcr UPC-, deutsche und EPA-Verfahren.",
// Verfahrensablauf (t-paliad-179 Slice 1)
"tools.verfahrensablauf.title": "Verfahrensablauf \u2014 Paliad",
"tools.verfahrensablauf.heading": "Verfahrensablauf",
"tools.verfahrensablauf.subtitle": "Typischen Verfahrensablauf einsehen \u2014 Verfahrensart w\u00e4hlen, Datum optional setzen.",
"deadlines.step1": "Verfahrensart w\u00e4hlen",
"deadlines.step2": "Ausgangsdatum eingeben",
"deadlines.step3": "Ergebnis",
@@ -2529,6 +2535,12 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.title": "Deadline Calculator \u2014 Paliad",
"deadlines.heading": "Patent Deadline Calculator",
"deadlines.subtitle": "Calculate procedural deadlines for UPC, German, and EPA proceedings.",
// Verfahrensablauf (t-paliad-179 Slice 1)
"tools.verfahrensablauf.title": "Procedure Roadmap \u2014 Paliad",
"tools.verfahrensablauf.heading": "Procedure Roadmap",
"tools.verfahrensablauf.subtitle": "Browse the typical proceeding shape \u2014 pick a proceeding type, optionally set a trigger date.",
"deadlines.step1": "Select Proceeding Type",
"deadlines.step2": "Enter Trigger Date",
"deadlines.step3": "Result",

View File

@@ -75,7 +75,6 @@ export function initSidebar() {
initPaliadinLinks();
initUserViewsGroup();
initThemeToggle();
fixVerfahrensablaufActive();
const sidebar = document.querySelector<HTMLElement>(".sidebar");
if (!sidebar) return;
initSidebarResize(sidebar);
@@ -444,29 +443,10 @@ function initUserViewsGroup(): void {
});
}
// fixVerfahrensablaufActive disambiguates the two /tools/fristenrechner
// sidebar entries (t-paliad-168). The SSR navItem helper compares
// hrefs against pathname only, which can't tell ?path=a apart from
// the no-query Fristenrechner — both would render as Fristenrechner=
// active. At the client we know the search params; flip the active
// class so the sidebar lights up the entry the user actually opened.
function fixVerfahrensablaufActive(): void {
if (window.location.pathname !== "/tools/fristenrechner") return;
const path = new URLSearchParams(window.location.search).get("path");
const fristenrechner = document.querySelector<HTMLAnchorElement>(
'a.sidebar-item[href="/tools/fristenrechner"]',
);
const verfahrensablauf = document.querySelector<HTMLAnchorElement>(
'a.sidebar-item[href="/tools/fristenrechner?path=a"]',
);
if (path === "a") {
fristenrechner?.classList.remove("active");
verfahrensablauf?.classList.add("active");
} else {
verfahrensablauf?.classList.remove("active");
fristenrechner?.classList.add("active");
}
}
// fixVerfahrensablaufActive removed (t-paliad-179 Slice 1). The two
// sidebar entries now map 1:1 to distinct URLs (/tools/fristenrechner
// vs /tools/verfahrensablauf), so the SSR navItem helper picks the
// correct active class by pathname alone.
function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement {
const a = document.createElement("a");

View File

@@ -0,0 +1,190 @@
// /tools/verfahrensablauf client (t-paliad-179 Slice 1)
//
// Abstract-browse surface: pick a proceeding, pick a trigger date,
// see the typical timeline. No Akte, no save-to-project, no anchor
// override editing, no Pathway B cascade. Variant chips + lane view
// (Slice 3) and compare (Slice 4) layer on top of this in later
// slices. Court picker + view toggle + calc fetch + renderers all
// come from ./views/verfahrensablauf-core, which fristenrechner.ts
// shares.
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import {
type DeadlineResponse,
calculateDeadlines,
formatDate,
populateCourtPicker,
renderColumnsBody,
renderTimelineBody,
} from "./views/verfahrensablauf-core";
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns";
// Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner
// so rapid input changes never let a stale response overwrite a fresh
// one.
let calcSeq = 0;
let calcTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleCalc(delayMs = 200) {
if (calcTimer !== null) clearTimeout(calcTimer);
calcTimer = setTimeout(() => {
calcTimer = null;
void doCalc();
}, delayMs);
}
function showStep(n: number) {
for (let i = 1; i <= 3; i++) {
const el = document.getElementById(`step-${i}`);
if (el) el.style.display = i <= n ? "block" : "none";
}
}
async function doCalc() {
const seq = ++calcSeq;
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
const triggerDate = dateInput?.value || "";
if (!triggerDate || !selectedType) return;
const courtPickerRow = document.getElementById("court-picker-row");
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
const courtId = courtPickerRow && courtPickerRow.style.display !== "none" && courtPicker?.value
? courtPicker.value
: "";
const data = await calculateDeadlines({
proceedingType: selectedType,
triggerDate,
courtId,
});
if (seq !== calcSeq) return;
if (!data) return;
lastResponse = data;
renderResults(data);
showStep(3);
}
function renderResults(data: DeadlineResponse) {
const container = document.getElementById("timeline-container");
if (!container) return;
const printBtn = document.getElementById("fristen-print-btn");
const toggle = document.getElementById("fristen-view-toggle");
const procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
const headerHtml = `<div class="timeline-header">
<strong>${procName}</strong>
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
</div>`;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data)
: renderTimelineBody(data);
container.innerHTML = headerHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
if (toggle) toggle.style.display = "";
}
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
const groups = document.querySelectorAll<HTMLElement>(".proceeding-group");
const summary = document.getElementById("proceeding-summary") as HTMLElement | null;
const summaryName = document.getElementById("proceeding-summary-name");
groups.forEach((g) => { g.style.display = collapsed ? "none" : ""; });
if (summary) summary.style.display = collapsed ? "" : "none";
if (summaryName && displayName) summaryName.textContent = displayName;
}
function selectProceeding(btn: HTMLButtonElement) {
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
selectedType = btn.dataset.code || "";
const name = btn.querySelector("strong")?.textContent || "";
const triggerEventEl = document.getElementById("trigger-event");
if (triggerEventEl) triggerEventEl.textContent = name;
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
setProceedingPickerCollapsed(true, name);
showStep(2);
scheduleCalc(0);
}
function initViewToggle() {
const toggle = document.getElementById("fristen-view-toggle");
if (!toggle) return;
const initial = new URLSearchParams(window.location.search).get("view");
if (initial === "timeline") procedureView = "timeline";
toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
input.checked = input.value === procedureView;
input.addEventListener("change", () => {
if (!input.checked) return;
procedureView = input.value === "columns" ? "columns" : "timeline";
const url = new URL(window.location.href);
if (procedureView === "columns") {
url.searchParams.delete("view");
} else {
url.searchParams.set("view", procedureView);
}
history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
if (lastResponse) renderResults(lastResponse);
});
});
toggle.style.display = "none";
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
btn.addEventListener("click", () => selectProceeding(btn));
});
document.getElementById("proceeding-summary-reselect")?.addEventListener("click", () => {
setProceedingPickerCollapsed(false);
});
document.getElementById("calculate-btn")?.addEventListener("click", () => scheduleCalc(0));
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
if (dateInput) {
dateInput.addEventListener("change", () => scheduleCalc());
dateInput.addEventListener("input", () => scheduleCalc());
dateInput.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") scheduleCalc(0);
});
}
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
initViewToggle();
onLangChange(() => {
if (lastResponse) renderResults(lastResponse);
const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
if (activeBtn) {
const name = activeBtn.querySelector("strong")?.textContent || "";
const triggerEventEl = document.getElementById("trigger-event");
if (triggerEventEl) triggerEventEl.textContent = name;
}
});
// Pre-select the first proceeding tile so users see a timeline
// immediately on landing — matches /tools/fristenrechner behaviour.
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
if (firstBtn) selectProceeding(firstBtn);
});

View File

@@ -0,0 +1,447 @@
// Shared core for Fristenrechner-style proceeding-timeline rendering.
//
// Both /tools/fristenrechner (deadline determination) and
// /tools/verfahrensablauf (abstract browse — t-paliad-179 Slice 1) call
// POST /api/tools/fristenrechner and paint the result with the same
// renderers. The module is pure-functional: no shared mutable state, all
// language / overrides / editability flow in through args so the two
// pages can wire their own per-page concerns (Akte save, anchor edits,
// Pathway B etc. on fristenrechner; variant chips, compare etc. coming
// to verfahrensablauf in later slices) without leaking into each other.
import { t, tDyn, getLang } from "../i18n";
export interface AdjustmentHoliday {
Date: string;
Name: string;
IsVacation: boolean;
IsClosure: boolean;
}
export interface AdjustmentReason {
kind: "weekend" | "public_holiday" | "vacation";
holidays?: AdjustmentHoliday[];
vacation_name?: string;
vacation_start?: string;
vacation_end?: string;
original_weekday?: string;
}
export interface CalculatedDeadline {
code: string;
name: string;
nameEN: string;
party: string;
isMandatory: boolean;
ruleRef: string;
legalSource?: string;
notes?: string;
notesEN?: string;
dueDate: string;
originalDate: string;
wasAdjusted: boolean;
adjustmentReason?: AdjustmentReason;
isRootEvent: boolean;
isCourtSet: boolean;
isCourtSetIndirect?: boolean;
isOptional?: boolean;
isOverridden?: boolean;
}
export interface DeadlineResponse {
proceedingType: string;
proceedingName: string;
triggerDate: string;
deadlines: CalculatedDeadline[];
}
export interface CourtRow {
id: string;
code: string;
nameDE: string;
nameEN: string;
country: string;
regime?: string;
courtType: string;
}
export interface CalcParams {
proceedingType: string;
triggerDate: string;
priorityDate?: string;
flags?: string[];
anchorOverrides?: Record<string, string>;
courtId?: string;
}
const PARTY_CLASS: Record<string, string> = {
claimant: "party-claimant",
defendant: "party-defendant",
court: "party-court",
both: "party-both",
};
// ─── small helpers ─────────────────────────────────────────────────────────
export function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
export function escHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
export function formatDate(dateStr: string): string {
if (!dateStr) return "—";
const d = new Date(dateStr + "T00:00:00");
if (getLang() === "en") {
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${weekday}, ${yyyy}-${mm}-${dd}`;
}
return d.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function formatDateSpan(startISO: string, endISO: string): string {
const start = new Date(startISO + "T00:00:00");
const end = new Date(endISO + "T00:00:00");
if (getLang() === "en") {
const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
return `${fmt(start)} ${fmt(end)}`;
}
const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
return `${fmt(start)}${fmt(end)}`;
}
function localizeWeekday(en: string): string {
if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
return en;
}
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
// vacation"). Not translated — they're proper names of court-set closures.
function localizeVacationName(name: string): string {
return name;
}
function renderAdjustmentReason(r: AdjustmentReason): string {
if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
const span = formatDateSpan(r.vacation_start, r.vacation_end);
return tDyn("deadlines.adjusted.vacation")
.replace("{name}", localizeVacationName(r.vacation_name))
.replace("{span}", span);
}
if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
}
if (r.kind === "weekend" && r.original_weekday) {
return localizeWeekday(r.original_weekday);
}
return t("deadlines.adjusted.weekend");
}
function formatAdjustedNote(dl: CalculatedDeadline): string {
const arrow = `${formatDate(dl.originalDate)}${formatDate(dl.dueDate)}`;
const reason = dl.adjustmentReason
? renderAdjustmentReason(dl.adjustmentReason)
: t("deadlines.adjusted.reason");
if (getLang() === "en") {
return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
}
return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
}
export function partyBadge(party: string): string {
const cls = PARTY_CLASS[party] || "party-both";
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
}
// ─── card + body renderers ────────────────────────────────────────────────
export interface CardOpts {
showParty: boolean;
// editable=true wires the click-to-edit affordance: data-rule-code,
// role=button, tabindex, hover hint. Fristenrechner enables it; the
// verfahrensablauf abstract-browse surface keeps editable=false because
// there's no anchor-override state on that page in Slice 1.
editable?: boolean;
}
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
const wantsEditable = !!opts.editable;
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
const editAttrs = editable
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
: "";
const courtLabelKey = dl.isCourtSetIndirect
? "deadlines.court.indirect"
: "deadlines.court.set";
const dateStr = dl.isCourtSet
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
const mandatoryBadge = dl.isMandatory
? ""
: '<span class="optional-badge">optional</span>';
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
const adjustedNote = dl.wasAdjusted
? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>`
: "";
const ruleRef = dl.ruleRef
? `<span class="timeline-rule">${dl.ruleRef}</span>`
: "";
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
const notes = noteText
? `<div class="timeline-notes">${noteText}</div>`
: "";
const meta = (opts.showParty || ruleRef)
? `<div class="timeline-meta">
${opts.showParty ? partyBadge(dl.party) : ""}
${ruleRef}
</div>`
: "";
return `<div class="timeline-item-header">
<span class="timeline-name">
${dlName}
${mandatoryBadge}
</span>
${dateStr}
</div>
${meta}
${adjustedNote}
${notes}`;
}
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
let html = '<div class="timeline">';
for (const dl of data.deadlines) {
html += `
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
<div class="timeline-dot-col">
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
<div class="timeline-line"></div>
</div>
<div class="timeline-content">
${deadlineCardHtml(dl, opts)}
</div>
</div>
`;
}
html += "</div>";
return html;
}
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
// (defendant). Each grid row shares a dueDate so same-day events line up
// across columns; party=both renders in BOTH the Proactive and Reactive
// cells of the row. Undated rows (Urteil etc.) trail the dated tail, each
// keyed by sequence-order so e.g. Urteil precedes Berufungseinlegung.
export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "showParty"> = {}): string {
type Cell = CalculatedDeadline[];
type Row = { proactive: Cell; court: Cell; reactive: Cell };
const UNSCHEDULED_PREFIX = "__unscheduled__";
const rowsMap = new Map<string, Row>();
const ensureRow = (key: string): Row => {
let r = rowsMap.get(key);
if (!r) {
r = { proactive: [], court: [], reactive: [] };
rowsMap.set(key, r);
}
return r;
};
data.deadlines.forEach((dl, idx) => {
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
const row = ensureRow(key);
switch (dl.party) {
case "claimant":
row.proactive.push(dl);
break;
case "defendant":
row.reactive.push(dl);
break;
case "court":
row.court.push(dl);
break;
case "both":
row.proactive.push(dl);
row.reactive.push(dl);
break;
default:
row.court.push(dl);
}
});
const datedKeys: string[] = [];
const unscheduledKeys: string[] = [];
for (const k of rowsMap.keys()) {
if (k.startsWith(UNSCHEDULED_PREFIX)) unscheduledKeys.push(k);
else datedKeys.push(k);
}
datedKeys.sort();
unscheduledKeys.sort();
const keys = [...datedKeys, ...unscheduledKeys];
const cardOpts: CardOpts = { showParty: false, editable: opts.editable };
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
}
const cards = items
.map((dl) => {
const mirrorTag = dl.party === "both"
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
: "";
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
${deadlineCardHtml(dl, cardOpts)}
${mirrorTag}
</div>`;
})
.join("");
return `<div class="fr-col-cell">${cards}</div>`;
};
const headerCell = (label: string, cls: string) =>
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
let html = '<div class="fr-columns-view">';
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
html += headerCell(t("deadlines.col.court"), "fr-col-court");
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
for (const key of keys) {
const row = rowsMap.get(key)!;
html += renderCell(row.proactive);
html += renderCell(row.court);
html += renderCell(row.reactive);
}
html += "</div>";
return html;
}
// ─── calculate fetch wrapper ──────────────────────────────────────────────
export async function calculateDeadlines(params: CalcParams): Promise<DeadlineResponse | null> {
if (!params.proceedingType || !params.triggerDate) return null;
try {
const resp = await fetch("/api/tools/fristenrechner", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
proceedingType: params.proceedingType,
triggerDate: params.triggerDate,
priorityDate: params.priorityDate || undefined,
flags: params.flags && params.flags.length > 0 ? params.flags : undefined,
anchorOverrides: params.anchorOverrides && Object.keys(params.anchorOverrides).length > 0
? params.anchorOverrides
: undefined,
courtId: params.courtId || undefined,
}),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
console.error("API error:", err);
return null;
}
return (await resp.json()) as DeadlineResponse;
} catch (e) {
console.error("Fetch error:", e);
return null;
}
}
// ─── court picker ─────────────────────────────────────────────────────────
const courtCache = new Map<string, CourtRow[]>();
export function courtTypesFor(proceedingType: string): string[] {
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
return ["UPC-CoA"];
}
if (proceedingType === "UPC_REV") {
return ["UPC-CD", "UPC-LD"];
}
if (proceedingType.startsWith("UPC_")) {
return ["UPC-LD"];
}
return [];
}
export function defaultCourtFor(proceedingType: string): string {
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
return "upc-coa-luxembourg";
}
if (proceedingType === "UPC_REV") {
return "upc-cd-paris";
}
return "upc-ld-muenchen";
}
export async function fetchCourts(courtType: string): Promise<CourtRow[]> {
if (courtCache.has(courtType)) return courtCache.get(courtType)!;
try {
const resp = await fetch(`/api/tools/courts?courtType=${encodeURIComponent(courtType)}`);
if (!resp.ok) return [];
const rows = (await resp.json()) as CourtRow[];
courtCache.set(courtType, rows);
return rows;
} catch {
return [];
}
}
// populateCourtPicker fills the <select> for the proceeding's compatible
// court types. The row + select IDs are passed in so each page can own
// its own DOM scope. Visible only when the proceeding has ≥2 compatible
// courts; otherwise hidden (server resolves the jurisdiction default).
export async function populateCourtPicker(
rowId: string,
selectId: string,
proceedingType: string,
): Promise<void> {
const row = document.getElementById(rowId);
const select = document.getElementById(selectId) as HTMLSelectElement | null;
if (!row || !select) return;
const types = courtTypesFor(proceedingType);
if (types.length === 0) {
row.style.display = "none";
select.innerHTML = "";
return;
}
const lists = await Promise.all(types.map((c) => fetchCourts(c)));
const courts = lists.flat();
if (courts.length <= 1) {
row.style.display = "none";
select.innerHTML = "";
return;
}
const lang = getLang();
const defaultID = defaultCourtFor(proceedingType);
select.innerHTML = courts.map((c) => {
const name = lang === "en" ? c.nameEN : c.nameDE;
return `<option value="${escAttr(c.id)}"${c.id === defaultID ? " selected" : ""}>${escHtml(name)}</option>`;
}).join("");
row.style.display = "";
}

View File

@@ -7,9 +7,10 @@ const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const ICON_LINK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>';
// Open-book icon for the /tools/fristenrechner?path=a "Verfahrensablauf"
// nav entry (t-paliad-168). Distinct from ICON_BOOK (Glossar, closed)
// so the two affordances read as different at a glance.
// Open-book icon for the /tools/verfahrensablauf "Verfahrensablauf"
// nav entry (t-paliad-168 → t-paliad-179 Slice 1 split). Distinct from
// ICON_BOOK (Glossar, closed) so the two affordances read as different
// at a glance.
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
@@ -161,7 +162,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
Gerichte / Glossar), then content (Links / Downloads). */}
{group("nav.group.werkzeuge", "Werkzeuge",
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
navItem("/tools/fristenrechner?path=a", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +

View File

@@ -207,20 +207,9 @@ export function renderFristenrechner(): string {
Incoming &mdash; ein Ereignis hat eine Frist ausgel&ouml;st.
</span>
</button>
{/* t-paliad-168 — third card: discoverable browse-/learn-mode
entry. Drops directly into Pathway A (Verfahrensablauf
wizard) with no save flow — mirrors the existing ad-hoc
explore behaviour: timeline renders, save CTA stays
disabled because there's no save intent. */}
<button type="button" className="fristen-step2-card" data-action="browse" id="fristen-step2-browse">
<span className="fristen-step2-card-icon" aria-hidden="true">&#128214;</span>
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.browse.title">
Verfahrensablauf einsehen
</span>
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.browse.desc">
Browse / Learn &mdash; sehen, was wann passiert. Keine Frist eintragen.
</span>
</button>
{/* t-paliad-179 Slice 1: the third "Verfahrensablauf
einsehen" card retired — abstract-browse intent now
owns its own route at /tools/verfahrensablauf. */}
</div>
<div className="fristen-step2-shortcut">
<div className="fristen-pathway-fork-shortcut-label" data-i18n="deadlines.pathway.shortcut.label">

View File

@@ -2041,6 +2041,9 @@ export type I18nKey =
| "theme.toggle.cycle.light"
| "theme.toggle.dark"
| "theme.toggle.light"
| "tools.verfahrensablauf.heading"
| "tools.verfahrensablauf.subtitle"
| "tools.verfahrensablauf.title"
| "unit_role.attorney"
| "unit_role.lead"
| "unit_role.pa"

View File

@@ -0,0 +1,207 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Slice 1 (t-paliad-179) — the dedicated abstract-browse surface for
// procedural shape. Same backend (POST /api/tools/fristenrechner) +
// same renderer module (./client/views/verfahrensablauf-core) as
// /tools/fristenrechner; this page strips the Step 1 Akte picker /
// Step 2 cards / Pathway A wizard / Pathway B cascade / save modal,
// leaving just: proceeding-type tile picker + trigger date + court
// picker + result panel. Variant chips, lane view and compare arrive in
// Slices 2-4.
interface ProceedingDef {
code: string;
i18nKey: string;
name: string;
}
function proceedingBtn(p: ProceedingDef): string {
return (
<button type="button" className="proceeding-btn" data-code={p.code}>
<strong data-i18n={p.i18nKey}>{p.name}</strong>
</button>
);
}
const UPC_TYPES: ProceedingDef[] = [
{ code: "UPC_INF", i18nKey: "deadlines.upc_inf", name: "Verletzungsverfahren" },
{ code: "UPC_REV", i18nKey: "deadlines.upc_rev", name: "Nichtigkeitsklage" },
{ code: "UPC_PI", i18nKey: "deadlines.upc_pi", name: "Einstw. Maßnahmen" },
{ code: "UPC_APP", i18nKey: "deadlines.upc_app", name: "Berufung" },
{ code: "UPC_DAMAGES", i18nKey: "deadlines.upc_damages", name: "Schadensbemessung" },
{ code: "UPC_DISCOVERY", i18nKey: "deadlines.upc_discovery", name: "Bucheinsicht" },
{ code: "UPC_COST_APPEAL", i18nKey: "deadlines.upc_cost_appeal", name: "Berufung Kosten" },
{ code: "UPC_APP_ORDERS", i18nKey: "deadlines.upc_app_orders", name: "Berufung Anordnungen" },
];
const DE_TYPES: ProceedingDef[] = [
{ code: "DE_INF", i18nKey: "deadlines.de_inf", name: "Verletzungsklage (LG)" },
{ code: "DE_INF_OLG", i18nKey: "deadlines.de_inf_olg", name: "Berufung OLG" },
{ code: "DE_INF_BGH", i18nKey: "deadlines.de_inf_bgh", name: "Revision/NZB BGH" },
{ code: "DE_NULL", i18nKey: "deadlines.de_null", name: "Nichtigkeitsverfahren" },
{ code: "DE_NULL_BGH", i18nKey: "deadlines.de_null_bgh", name: "Berufung BGH (Nichtigk.)" },
];
const EPA_TYPES: ProceedingDef[] = [
{ code: "EPA_OPP", i18nKey: "deadlines.epa_opp", name: "Einspruchsverfahren" },
{ code: "EPA_APP", i18nKey: "deadlines.epa_app", name: "Beschwerdeverfahren" },
{ code: "EP_GRANT", i18nKey: "deadlines.ep_grant", name: "EP-Erteilungsverfahren" },
];
const DPMA_TYPES: ProceedingDef[] = [
{ code: "DPMA_OPP", i18nKey: "deadlines.dpma_opp", name: "Einspruch DPMA" },
{ code: "DPMA_BPATG_BESCHWERDE", i18nKey: "deadlines.dpma_bpatg_beschwerde", name: "Beschwerde BPatG (DPMA)" },
{ code: "DPMA_BGH_RB", i18nKey: "deadlines.dpma_bgh_rb", name: "Rechtsbeschwerde BGH" },
];
export function renderVerfahrensablauf(): string {
const today = new Date().toISOString().split("T")[0];
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="tools.verfahrensablauf.title">Verfahrensablauf &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/tools/verfahrensablauf" />
<BottomNav currentPath="/tools/verfahrensablauf" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="tools.verfahrensablauf.heading">Verfahrensablauf</h1>
<p className="tool-subtitle" data-i18n="tools.verfahrensablauf.subtitle">
Typischen Verfahrensablauf einsehen &mdash; Verfahrensart w&auml;hlen, Datum optional setzen.
</p>
</div>
{/* Verfahrensart picker (single-tile mode — same DOM ids as
/tools/fristenrechner so the shared renderer module and
court-picker primitives bind without parameterisation). */}
<div className="fristen-wizard" id="verfahrensablauf-wizard" data-mode="procedure">
<div className="wizard-step" id="step-1">
<h3 className="wizard-step-label">
<span className="step-number">1</span>
<span data-i18n="deadlines.step1">Verfahrensart w&auml;hlen</span>
</h3>
<div className="proceeding-group" data-forum="upc">
<h4 data-i18n="deadlines.upc">UPC</h4>
<div className="proceeding-btns">
{UPC_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-btns">
{DE_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group" data-forum="epa">
<h4 data-i18n="deadlines.epa">EPA</h4>
<div className="proceeding-btns">
{EPA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group" data-forum="dpma">
<h4 data-i18n="deadlines.dpma">DPMA</h4>
<div className="proceeding-btns">
{DPMA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
<strong className="proceeding-summary-name" id="proceeding-summary-name">&mdash;</strong>
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
data-i18n="deadlines.proceeding.reselect">
Anderes Verfahren w&auml;hlen
</button>
</div>
</div>
<div className="wizard-step" id="step-2" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">2</span>
<span data-i18n="deadlines.step2">Ausgangsdatum eingeben</span>
</h3>
<div className="date-input-group">
<div className="date-field-row">
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</label>
<span id="trigger-event" className="trigger-event-name">&mdash;</span>
</div>
<div className="date-field-row">
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
<input type="date" id="trigger-date" className="date-input" value={today} />
</div>
<div className="date-field-row" id="court-picker-row" style="display:none">
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
<select id="court-picker" className="date-input"></select>
</div>
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
Fristen berechnen
</button>
</div>
</div>
<div className="wizard-step" id="step-3" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">3</span>
<span data-i18n="deadlines.step3">Ergebnis</span>
</h3>
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="columns" checked />
<span data-i18n="deadlines.view.columns">Spalten</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="timeline" />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
</label>
</div>
<div id="timeline-container">
</div>
<div className="fristen-result-actions">
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 6 2 18 2 18 9"></polyline>
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
<rect x="6" y="14" width="12" height="8"></rect>
</svg>
<span data-i18n="deadlines.print">Drucken</span>
</button>
</div>
</div>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/verfahrensablauf.js"></script>
</body>
</html>
);
}