Phase 2 S1 + S1a (pre-ratified from t-paliad-327, folded into the
Phase 2 train).
S1 — Cross-party display:
- FristenrechnerService.LookupFollowUps stops filtering by party
server-side; queryFollowUpRows drops the perspective WHERE clause
and returns every published+active child.
- Server now computes is_cross_party per row (true only when
perspective ∈ {claimant,defendant} AND primary_party is the
opposite side; NULL/both/court is never cross-party).
- FollowUpRule wire shape gains the boolean.
- Frontend renderRule adds a "Gegenseitig" badge + is-cross-party
row class (muted styling, disabled checkbox affordance).
- defaultChecked returns false for cross-party rows.
- countSelected + submitWriteBack skip cross-party rows
unconditionally — even if a user manually checks the box, they
describe opposing-side filings and don't belong in our Akte set
(design §2.4 write-back exclusion).
- i18n: deadlines.overhaul.crossparty.badge / .tooltip (DE+EN).
- CSS: .fristen-overhaul-rule-crossparty + .is-cross-party row
modifier.
S1a — Spawn-only picker filter:
- SearchEvents WHERE now adds `sr.is_spawn = false` so spawn rules
(e.g. appeal_spawn, the inf.cfi → upc.apl.merits hop) no longer
surface as picker hits. Spawn rules are consequences, not
triggers — a lawyer searching "Berufung" wants the appeal-tree
root, not the inf.cfi spawn link.
- Terminal leaves (Duplik etc.) stay pickable per design §2.2's
carve-out: their own anchor is non-spawn, so they surface and
render an honest empty follow-up list.
Honest UX: hiding cross-party follow-ups lied about what the
workflow does next (cf. RoP.029.d falling off when perspective=
claimant on def_to_ccr — the workflow continues, just on the
defendant's docket). The fix makes the data legible without
contaminating the write-back path.
Verified: go vet clean, bun build clean, bun test 256/256,
go test ./internal/services/... -run LookupFollowUps... clean.
Design: docs/design-deadline-system-revision-2026-05-27.md §2.4
(cross-party) + §2.2 (spawn-only picker). t-paliad-331.
694 lines
27 KiB
TypeScript
694 lines
27 KiB
TypeScript
// Fristenrechner overhaul — shared result view (design §4).
|
|
//
|
|
// Given a locked trigger event + a trigger date, this module renders
|
|
// the result surface: a sticky trigger card on top, then four priority
|
|
// groups (mandatory / recommended / optional / conditional) of follow-up
|
|
// rules with computed dates, then a write-back footer that calls the
|
|
// existing POST /api/projects/{id}/deadlines/bulk.
|
|
//
|
|
// The two future entry paths (Mode A "Direkt suchen" in S3, Mode B
|
|
// wizard in S4) both land here once they've identified a trigger
|
|
// procedural_event. S2 mounts the surface under `?overhaul=1` and is
|
|
// deep-linkable on its own via `?overhaul=1&event=<code>&trigger_date=…`.
|
|
|
|
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
|
|
import { getLang, t, tDyn } from "./i18n";
|
|
|
|
// Wire shape from GET /api/tools/fristenrechner/follow-ups. Mirrors
|
|
// services.FollowUpsResponse server-side.
|
|
export interface FollowUpRule {
|
|
rule_id: string;
|
|
event_code: string;
|
|
title_de: string;
|
|
title_en: string;
|
|
priority: string;
|
|
primary_party?: string;
|
|
// m/paliad#149 Phase 2 S1 (design §2.4) — true when the rule's
|
|
// primary_party is the side opposite the perspective. Drives the
|
|
// Gegenseitig badge + muted style + unchecked default.
|
|
is_cross_party: boolean;
|
|
duration_value?: number;
|
|
duration_unit?: string;
|
|
timing?: string;
|
|
due_date?: string;
|
|
original_due_date?: string;
|
|
was_adjusted?: boolean;
|
|
is_court_set: boolean;
|
|
is_spawn: boolean;
|
|
is_bilateral: boolean;
|
|
has_condition: boolean;
|
|
rule_code?: string;
|
|
legal_source?: string;
|
|
legal_source_display?: string;
|
|
legal_source_url?: string;
|
|
notes_de?: string;
|
|
notes_en?: string;
|
|
spawn_label?: string;
|
|
spawn_proceeding_code?: string;
|
|
concept_id?: string;
|
|
}
|
|
|
|
export interface FollowUpsResponse {
|
|
trigger: {
|
|
id: string;
|
|
code: string;
|
|
name_de: string;
|
|
name_en: string;
|
|
event_kind?: string;
|
|
proceeding_type: {
|
|
id: number;
|
|
code: string;
|
|
name_de: string;
|
|
name_en: string;
|
|
jurisdiction?: string;
|
|
};
|
|
anchor_rule_id: string;
|
|
};
|
|
trigger_date: string;
|
|
party?: string;
|
|
follow_ups: FollowUpRule[];
|
|
}
|
|
|
|
// Per-rule UI state — checkbox, optional date override.
|
|
interface RuleSelection {
|
|
checked: boolean;
|
|
override?: string;
|
|
}
|
|
|
|
// Module-local state. Single result view at a time; the surface
|
|
// re-renders in place when the user changes the trigger date or
|
|
// re-locks a different event.
|
|
let currentResponse: FollowUpsResponse | null = null;
|
|
const selections = new Map<string, RuleSelection>();
|
|
let currentProjectId: string | null = null;
|
|
|
|
// Public API ----------------------------------------------------------
|
|
|
|
// isOverhaulMode reports whether the page is in overhaul mode.
|
|
// After Slice S5 (t-paliad-323), overhaul is the default; the legacy
|
|
// wizard / row-stack / cascade is only reachable via `?legacy=1` for
|
|
// a two-week deprecation window. The `?overhaul=1` deep links from
|
|
// S2-S4 still work — they're now redundant with the default but kept
|
|
// alive so bookmarks don't 302 / lose state.
|
|
export function isOverhaulMode(): boolean {
|
|
return new URLSearchParams(window.location.search).get("legacy") !== "1";
|
|
}
|
|
|
|
// resolveProjectId reads the active Akte from the URL query string.
|
|
// Returns null when in kontextfrei mode (no project picked).
|
|
function resolveProjectId(): string | null {
|
|
const p = new URLSearchParams(window.location.search).get("project");
|
|
return p && p.length > 0 ? p : null;
|
|
}
|
|
|
|
// MODE_TAB_KEYS — the two entry-mode tabs landed by S3 + S4. S2's deep
|
|
// link path bypasses these (jumps straight to the result view via
|
|
// ?event=); the tabs appear when no event is locked yet.
|
|
export type ModeTab = "search" | "wizard";
|
|
|
|
// mountModeShell renders the mode-tab pair under the page header and
|
|
// hosts whichever mode panel is currently active. Called from the boot
|
|
// path when no `?event=` is present. S3 wires Mode A; S4 will add
|
|
// Mode B and the actual tab switching.
|
|
export async function mountModeShell(activeTab: ModeTab): Promise<void> {
|
|
const root = document.getElementById("fristen-overhaul-root");
|
|
if (!root) return;
|
|
root.hidden = false;
|
|
// Defer to the per-mode module to render into the root. The tab
|
|
// strip itself is a small header above the mode panel — for S3 we
|
|
// render the shell + Mode A in one shot.
|
|
// S4 will replace this with a real tab switcher.
|
|
const tabs = `
|
|
<nav class="fristen-mode-tabs" role="tablist" aria-label="${escAttr(t("deadlines.overhaul.modes.label"))}">
|
|
<button type="button" class="fristen-mode-tab${activeTab === "search" ? " is-active" : ""}" role="tab"
|
|
aria-selected="${activeTab === "search"}" data-tab="search">
|
|
<span class="fristen-mode-tab-icon" aria-hidden="true">⚡</span>
|
|
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.search"))}</span>
|
|
</button>
|
|
<button type="button" class="fristen-mode-tab${activeTab === "wizard" ? " is-active" : ""}" role="tab"
|
|
aria-selected="${activeTab === "wizard"}" data-tab="wizard">
|
|
<span class="fristen-mode-tab-icon" aria-hidden="true">🧭</span>
|
|
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.wizard"))}</span>
|
|
</button>
|
|
</nav>
|
|
<div id="fristen-overhaul-mode-host"></div>
|
|
`;
|
|
root.innerHTML = tabs;
|
|
|
|
// Wire tab switching. S3 only has Mode A wired; Mode B is a
|
|
// placeholder until S4.
|
|
root.querySelectorAll<HTMLButtonElement>(".fristen-mode-tab").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const tab = (btn.dataset.tab || "search") as ModeTab;
|
|
void mountModeShell(tab);
|
|
});
|
|
});
|
|
|
|
// Mount the active mode panel into the host. S3 only routes "search";
|
|
// "wizard" renders a placeholder until S4 lands.
|
|
const host = document.getElementById("fristen-overhaul-mode-host");
|
|
if (!host) return;
|
|
if (activeTab === "search") {
|
|
// Lazy import to keep the bundle layered and avoid a circular ref
|
|
// between fristenrechner-result.ts ↔ fristenrechner-mode-a.ts.
|
|
const mod = await import("./fristenrechner-mode-a");
|
|
await mod.mountModeA();
|
|
} else {
|
|
const mod = await import("./fristenrechner-wizard");
|
|
await mod.mountWizard();
|
|
}
|
|
}
|
|
|
|
// MountOptions configures the surface entry. Both entry-mode paths
|
|
// (Mode A in S3, Mode B in S4) call mount() with the event reference
|
|
// that the user committed.
|
|
export interface MountOptions {
|
|
// eventRef is the procedural_event code OR its uuid OR the anchor
|
|
// sequencing_rule id. Resolved server-side; the wire returns the
|
|
// canonical code so the URL bookmark is stable.
|
|
eventRef: string;
|
|
// triggerDate is YYYY-MM-DD. Defaults to today when omitted.
|
|
triggerDate?: string;
|
|
// party is "claimant" | "defendant"; mode A may pass "both" or
|
|
// "court". When omitted, follow-ups are returned without party
|
|
// narrowing.
|
|
party?: string;
|
|
// courtId selects the holiday calendar for the per-rule date
|
|
// adjustment. Optional.
|
|
courtId?: string;
|
|
}
|
|
|
|
// mountResultView fetches /follow-ups and renders the result surface
|
|
// into the host container. Re-callable: replaces previous state.
|
|
export async function mountResultView(opts: MountOptions): Promise<void> {
|
|
const root = document.getElementById("fristen-overhaul-root");
|
|
if (!root) return;
|
|
root.hidden = false;
|
|
|
|
const triggerDate = opts.triggerDate || todayIso();
|
|
currentProjectId = resolveProjectId();
|
|
|
|
// Show a quick "loading…" placeholder so the user sees something
|
|
// immediately, even on a cold fetch.
|
|
root.innerHTML = `<div class="fristen-overhaul-loading">${escHtml(t("deadlines.overhaul.loading"))}</div>`;
|
|
|
|
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
|
|
url.searchParams.set("event", opts.eventRef);
|
|
url.searchParams.set("trigger_date", triggerDate);
|
|
if (opts.party) url.searchParams.set("party", opts.party);
|
|
if (opts.courtId) url.searchParams.set("court_id", opts.courtId);
|
|
|
|
let data: FollowUpsResponse;
|
|
try {
|
|
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
|
if (!resp.ok) {
|
|
const body = await resp.json().catch(() => ({}) as { error?: string });
|
|
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(body.error || t("deadlines.overhaul.load_error"))}</div>`;
|
|
return;
|
|
}
|
|
data = (await resp.json()) as FollowUpsResponse;
|
|
} catch (err) {
|
|
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(t("deadlines.overhaul.load_error"))}</div>`;
|
|
return;
|
|
}
|
|
|
|
currentResponse = data;
|
|
selections.clear();
|
|
for (const r of data.follow_ups) {
|
|
selections.set(r.rule_id, { checked: defaultChecked(r) });
|
|
}
|
|
|
|
renderSurface();
|
|
// Reflect the canonical event code + trigger date in the URL so the
|
|
// deep-link survives a reload.
|
|
syncUrlState(data.trigger.code, data.trigger_date);
|
|
}
|
|
|
|
// Render --------------------------------------------------------------
|
|
|
|
function renderSurface(): void {
|
|
const root = document.getElementById("fristen-overhaul-root");
|
|
if (!root || !currentResponse) return;
|
|
|
|
const lang = getLang();
|
|
const trig = currentResponse.trigger;
|
|
const triggerName = lang === "en" ? trig.name_en || trig.name_de : trig.name_de;
|
|
const ptName = lang === "en" ? trig.proceeding_type.name_en || trig.proceeding_type.name_de : trig.proceeding_type.name_de;
|
|
const juris = trig.proceeding_type.jurisdiction || "";
|
|
const kindIcon = eventKindIcon(trig.event_kind);
|
|
|
|
const triggerCard = `
|
|
<section class="fristen-overhaul-trigger" aria-label="${escAttr(t("deadlines.overhaul.trigger.label"))}">
|
|
<header class="fristen-overhaul-trigger-header">
|
|
<span class="fristen-overhaul-kind-icon" aria-hidden="true">${kindIcon}</span>
|
|
<h2 class="fristen-overhaul-trigger-title">${escHtml(triggerName)}</h2>
|
|
</header>
|
|
<div class="fristen-overhaul-trigger-meta">
|
|
<span class="fristen-overhaul-trigger-code">${escHtml(trig.code)}</span>
|
|
<span class="fristen-overhaul-trigger-pt">${escHtml(ptName)}</span>
|
|
${juris ? `<span class="fristen-overhaul-trigger-juris">${escHtml(juris)}</span>` : ""}
|
|
</div>
|
|
<div class="fristen-overhaul-trigger-date">
|
|
<label for="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-label">
|
|
${escHtml(t("deadlines.overhaul.trigger.date"))}
|
|
</label>
|
|
<input type="date" id="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-input"
|
|
value="${escAttr(currentResponse.trigger_date)}" />
|
|
</div>
|
|
</section>
|
|
`;
|
|
|
|
const groups = groupFollowUps(currentResponse.follow_ups);
|
|
const groupHtml = renderGroups(groups, lang);
|
|
|
|
const nudge = currentProjectId
|
|
? ""
|
|
: `<div class="fristen-overhaul-nudge">${escHtml(t("deadlines.overhaul.nudge.no_project"))}</div>`;
|
|
|
|
const footer = currentProjectId
|
|
? renderFooter()
|
|
: "";
|
|
|
|
root.innerHTML = `
|
|
${triggerCard}
|
|
${nudge}
|
|
<section class="fristen-overhaul-groups" aria-label="${escAttr(t("deadlines.overhaul.followups.label"))}">
|
|
${groupHtml}
|
|
</section>
|
|
${footer}
|
|
<div class="fristen-overhaul-msg" id="fristen-overhaul-msg" role="status" aria-live="polite"></div>
|
|
`;
|
|
|
|
wireSurfaceEvents();
|
|
}
|
|
|
|
export interface GroupedFollowUps {
|
|
mandatory: FollowUpRule[];
|
|
recommended: FollowUpRule[];
|
|
optional: FollowUpRule[];
|
|
conditional: FollowUpRule[];
|
|
}
|
|
|
|
// groupFollowUps splits the wire list into the four visible groups per
|
|
// design §4.2. Conditional (sr.condition_expr IS NOT NULL) takes
|
|
// precedence over the priority bucket so a "nur wenn CCR" mandatory
|
|
// rule renders under Conditional with the gating language visible.
|
|
export function groupFollowUps(rows: FollowUpRule[]): GroupedFollowUps {
|
|
const out: GroupedFollowUps = { mandatory: [], recommended: [], optional: [], conditional: [] };
|
|
for (const r of rows) {
|
|
if (r.has_condition) {
|
|
out.conditional.push(r);
|
|
continue;
|
|
}
|
|
switch (r.priority) {
|
|
case "mandatory":
|
|
out.mandatory.push(r);
|
|
break;
|
|
case "recommended":
|
|
out.recommended.push(r);
|
|
break;
|
|
case "optional":
|
|
out.optional.push(r);
|
|
break;
|
|
default:
|
|
// unknown / informational — fold into optional so the row is at
|
|
// least visible. Future Phase 2 'informational' tier gets a
|
|
// dedicated bucket once seeded.
|
|
out.optional.push(r);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function renderGroups(groups: GroupedFollowUps, lang: "de" | "en"): string {
|
|
const blocks: string[] = [];
|
|
if (groups.mandatory.length > 0) {
|
|
blocks.push(renderGroup("mandatory", t("deadlines.overhaul.group.mandatory"), groups.mandatory, lang));
|
|
}
|
|
if (groups.recommended.length > 0) {
|
|
blocks.push(renderGroup("recommended", t("deadlines.overhaul.group.recommended"), groups.recommended, lang));
|
|
}
|
|
if (groups.optional.length > 0) {
|
|
blocks.push(renderGroup("optional", t("deadlines.overhaul.group.optional"), groups.optional, lang));
|
|
}
|
|
if (groups.conditional.length > 0) {
|
|
blocks.push(renderGroup("conditional", t("deadlines.overhaul.group.conditional"), groups.conditional, lang));
|
|
}
|
|
if (blocks.length === 0) {
|
|
return `<div class="fristen-overhaul-empty">${escHtml(t("deadlines.overhaul.empty"))}</div>`;
|
|
}
|
|
return blocks.join("");
|
|
}
|
|
|
|
function renderGroup(slug: string, label: string, rows: FollowUpRule[], lang: "de" | "en"): string {
|
|
const items = rows.map((r) => renderRule(r, lang)).join("");
|
|
return `
|
|
<div class="fristen-overhaul-group fristen-overhaul-group--${escAttr(slug)}">
|
|
<h3 class="fristen-overhaul-group-title">${escHtml(label)}</h3>
|
|
<ul class="fristen-overhaul-rule-list">
|
|
${items}
|
|
</ul>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderRule(r: FollowUpRule, lang: "de" | "en"): string {
|
|
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
|
|
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
|
|
const sel = selections.get(r.rule_id);
|
|
const checked = sel ? sel.checked : defaultChecked(r);
|
|
const dateOverride = sel?.override;
|
|
const computedDate = r.due_date || "";
|
|
const effectiveDate = dateOverride || computedDate;
|
|
const disabled = r.is_court_set || (r.is_spawn && !r.due_date);
|
|
|
|
// Duration phrase: "3 Monate" / "14 Tage" — language-aware.
|
|
const durationPhrase = formatDurationPhrase(r, lang);
|
|
const dateCell = r.is_court_set
|
|
? `<span class="fristen-overhaul-rule-court-set">${escHtml(t("deadlines.court.set"))}</span>`
|
|
: effectiveDate
|
|
? `<span class="fristen-overhaul-rule-date" data-rule-id="${escAttr(r.rule_id)}">${escHtml(formatDateForLang(effectiveDate, lang))}</span>`
|
|
: `<span class="fristen-overhaul-rule-date fristen-overhaul-rule-date--unknown">—</span>`;
|
|
|
|
const partyBadge = r.primary_party
|
|
? `<span class="fristen-overhaul-rule-party fristen-overhaul-rule-party--${escAttr(r.primary_party)}">${escHtml(t(`deadlines.party.${r.primary_party}` as never))}</span>`
|
|
: "";
|
|
|
|
const sourceBadge = r.legal_source_display
|
|
? r.legal_source_url
|
|
? `<a class="fristen-overhaul-rule-source" href="${escAttr(r.legal_source_url)}" target="_blank" rel="noreferrer">${escHtml(r.legal_source_display)}</a>`
|
|
: `<span class="fristen-overhaul-rule-source">${escHtml(r.legal_source_display)}</span>`
|
|
: r.rule_code
|
|
? `<span class="fristen-overhaul-rule-source">${escHtml(r.rule_code)}</span>`
|
|
: "";
|
|
|
|
const spawnBadge = r.is_spawn
|
|
? `<span class="fristen-overhaul-rule-spawn" title="${escAttr(t("deadlines.overhaul.spawn.tooltip"))}">${escHtml(t("deadlines.overhaul.spawn.badge"))}${r.spawn_proceeding_code ? ` · ${escHtml(r.spawn_proceeding_code)}` : ""}</span>`
|
|
: "";
|
|
|
|
const condBadge = r.has_condition
|
|
? `<span class="fristen-overhaul-rule-cond">${escHtml(t("deadlines.overhaul.condition.badge"))}</span>`
|
|
: "";
|
|
|
|
const crossPartyBadge = r.is_cross_party
|
|
? `<span class="fristen-overhaul-rule-crossparty" title="${escAttr(t("deadlines.overhaul.crossparty.tooltip"))}">${escHtml(t("deadlines.overhaul.crossparty.badge"))}</span>`
|
|
: "";
|
|
|
|
const notesHtml = notes
|
|
? `<details class="fristen-overhaul-rule-notes"><summary>${escHtml(t("deadlines.overhaul.notes.summary"))}</summary><p>${escHtml(notes)}</p></details>`
|
|
: "";
|
|
|
|
const editBtn = r.is_court_set || r.is_spawn || !computedDate
|
|
? ""
|
|
: `<button type="button" class="fristen-overhaul-rule-edit-date" data-rule-id="${escAttr(r.rule_id)}" title="${escAttr(t("deadlines.overhaul.edit_date.title"))}" aria-label="${escAttr(t("deadlines.overhaul.edit_date.title"))}">${escHtml(t("deadlines.overhaul.edit_date.label"))}</button>`;
|
|
|
|
return `
|
|
<li class="fristen-overhaul-rule${disabled ? " is-disabled" : ""}${r.is_cross_party ? " is-cross-party" : ""}" data-rule-id="${escAttr(r.rule_id)}">
|
|
<label class="fristen-overhaul-rule-check">
|
|
<input type="checkbox" data-rule-id="${escAttr(r.rule_id)}"
|
|
${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
|
|
<span class="visually-hidden">${escHtml(t("deadlines.overhaul.select_rule"))}</span>
|
|
</label>
|
|
<div class="fristen-overhaul-rule-body">
|
|
<div class="fristen-overhaul-rule-title-row">
|
|
<span class="fristen-overhaul-rule-title">${escHtml(title)}</span>
|
|
${spawnBadge}
|
|
${condBadge}
|
|
${crossPartyBadge}
|
|
</div>
|
|
<div class="fristen-overhaul-rule-meta-row">
|
|
${durationPhrase ? `<span class="fristen-overhaul-rule-duration">${escHtml(durationPhrase)}</span>` : ""}
|
|
${partyBadge}
|
|
${sourceBadge}
|
|
</div>
|
|
${notesHtml}
|
|
</div>
|
|
<div class="fristen-overhaul-rule-date-cell">
|
|
${dateCell}
|
|
${editBtn}
|
|
</div>
|
|
</li>
|
|
`;
|
|
}
|
|
|
|
function renderFooter(): string {
|
|
const selectedCount = countSelected();
|
|
return `
|
|
<footer class="fristen-overhaul-footer" id="fristen-overhaul-footer">
|
|
<span class="fristen-overhaul-footer-count" id="fristen-overhaul-footer-count">
|
|
${escHtml(tDyn("deadlines.overhaul.footer.count").replace("{n}", String(selectedCount)))}
|
|
</span>
|
|
<button type="button" class="fristen-overhaul-footer-cta btn-primary btn-cta-lime"
|
|
id="fristen-overhaul-write-back"
|
|
${selectedCount === 0 ? "disabled" : ""}>
|
|
${escHtml(t("deadlines.overhaul.footer.cta"))}
|
|
</button>
|
|
</footer>
|
|
`;
|
|
}
|
|
|
|
// Event wiring --------------------------------------------------------
|
|
|
|
function wireSurfaceEvents(): void {
|
|
// Trigger-date change → re-fetch with new date.
|
|
const dateInput = document.getElementById("fristen-overhaul-trigger-date") as HTMLInputElement | null;
|
|
if (dateInput && currentResponse) {
|
|
dateInput.addEventListener("change", () => {
|
|
if (!currentResponse) return;
|
|
const newDate = dateInput.value;
|
|
if (!newDate) return;
|
|
void mountResultView({
|
|
eventRef: currentResponse.trigger.code,
|
|
triggerDate: newDate,
|
|
party: currentResponse.party,
|
|
});
|
|
});
|
|
}
|
|
|
|
// Checkbox toggles → update selections + footer count.
|
|
const root = document.getElementById("fristen-overhaul-root");
|
|
if (root) {
|
|
root.querySelectorAll<HTMLInputElement>(".fristen-overhaul-rule-check input[type=checkbox]").forEach((cb) => {
|
|
cb.addEventListener("change", () => {
|
|
const id = cb.dataset.ruleId || "";
|
|
const sel = selections.get(id) ?? { checked: cb.checked };
|
|
sel.checked = cb.checked;
|
|
selections.set(id, sel);
|
|
refreshFooterCount();
|
|
});
|
|
});
|
|
|
|
// Per-rule date override.
|
|
root.querySelectorAll<HTMLButtonElement>(".fristen-overhaul-rule-edit-date").forEach((btn) => {
|
|
btn.addEventListener("click", () => editRuleDate(btn));
|
|
});
|
|
}
|
|
|
|
// Write-back CTA.
|
|
const cta = document.getElementById("fristen-overhaul-write-back");
|
|
if (cta) cta.addEventListener("click", () => void submitWriteBack());
|
|
}
|
|
|
|
function editRuleDate(btn: HTMLButtonElement): void {
|
|
const ruleId = btn.dataset.ruleId || "";
|
|
const rule = currentResponse?.follow_ups.find((r) => r.rule_id === ruleId);
|
|
if (!rule) return;
|
|
const sel = selections.get(ruleId) ?? { checked: defaultChecked(rule) };
|
|
const current = sel.override || rule.due_date || todayIso();
|
|
|
|
const dateCell = btn.parentElement;
|
|
if (!dateCell) return;
|
|
const dateSpan = dateCell.querySelector<HTMLSpanElement>(".fristen-overhaul-rule-date");
|
|
if (!dateSpan) return;
|
|
|
|
const input = document.createElement("input");
|
|
input.type = "date";
|
|
input.value = current;
|
|
input.className = "fristen-overhaul-rule-date-input";
|
|
dateSpan.replaceWith(input);
|
|
btn.disabled = true;
|
|
input.focus();
|
|
|
|
const commit = () => {
|
|
const newDate = input.value;
|
|
if (newDate && newDate !== current) {
|
|
sel.override = newDate;
|
|
selections.set(ruleId, sel);
|
|
}
|
|
renderSurface();
|
|
};
|
|
input.addEventListener("blur", commit, { once: true });
|
|
input.addEventListener("keydown", (e) => {
|
|
if ((e as KeyboardEvent).key === "Enter") {
|
|
e.preventDefault();
|
|
input.blur();
|
|
} else if ((e as KeyboardEvent).key === "Escape") {
|
|
renderSurface();
|
|
}
|
|
});
|
|
}
|
|
|
|
function refreshFooterCount(): void {
|
|
const countEl = document.getElementById("fristen-overhaul-footer-count");
|
|
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
|
|
const n = countSelected();
|
|
if (countEl) {
|
|
countEl.textContent = tDyn("deadlines.overhaul.footer.count").replace("{n}", String(n));
|
|
}
|
|
if (cta) cta.disabled = n === 0;
|
|
}
|
|
|
|
function countSelected(): number {
|
|
let n = 0;
|
|
if (!currentResponse) return 0;
|
|
for (const r of currentResponse.follow_ups) {
|
|
if (r.is_court_set) continue;
|
|
// Cross-party rows are unconditionally excluded from write-back
|
|
// (design §2.4). Even if the user manually checks the box, they
|
|
// describe what the opponent files — not Akte work for our side.
|
|
if (r.is_cross_party) continue;
|
|
const sel = selections.get(r.rule_id);
|
|
if (sel?.checked) n++;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
// Write-back ----------------------------------------------------------
|
|
|
|
async function submitWriteBack(): Promise<void> {
|
|
if (!currentResponse) return;
|
|
if (!currentProjectId) return;
|
|
const msg = document.getElementById("fristen-overhaul-msg");
|
|
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
|
|
const lang = getLang();
|
|
|
|
const deadlines: Array<Record<string, unknown>> = [];
|
|
for (const r of currentResponse.follow_ups) {
|
|
const sel = selections.get(r.rule_id);
|
|
if (!sel?.checked) continue;
|
|
if (r.is_court_set) continue;
|
|
// Skip cross-party rows even if checked — they describe opposing-
|
|
// side filings and don't belong in our side's Akte deadline set
|
|
// (design §2.4, write-back exclusion).
|
|
if (r.is_cross_party) continue;
|
|
const dueDate = sel.override || r.due_date;
|
|
if (!dueDate) continue;
|
|
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
|
|
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
|
|
deadlines.push({
|
|
title,
|
|
rule_code: r.rule_code || undefined,
|
|
due_date: dueDate,
|
|
original_due_date: r.original_due_date || r.due_date || undefined,
|
|
source: "fristenrechner",
|
|
rule_id: r.rule_id,
|
|
notes: notes || undefined,
|
|
audit_reason: auditReason(),
|
|
});
|
|
}
|
|
|
|
if (deadlines.length === 0 || !msg || !cta) return;
|
|
cta.disabled = true;
|
|
msg.textContent = "";
|
|
msg.className = "fristen-overhaul-msg";
|
|
|
|
try {
|
|
const resp = await fetch(`/api/projects/${encodeURIComponent(currentProjectId)}/deadlines/bulk`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ deadlines }),
|
|
});
|
|
if (!resp.ok) {
|
|
const body = await resp.json().catch(() => ({}) as { error?: string });
|
|
msg.textContent = body.error || t("deadlines.save.error");
|
|
msg.className = "fristen-overhaul-msg form-msg-error";
|
|
cta.disabled = false;
|
|
return;
|
|
}
|
|
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(currentProjectId)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
|
|
msg.className = "fristen-overhaul-msg form-msg-ok";
|
|
setTimeout(() => {
|
|
if (cta) cta.disabled = false;
|
|
}, 1500);
|
|
} catch {
|
|
msg.textContent = t("deadlines.save.error");
|
|
msg.className = "fristen-overhaul-msg form-msg-error";
|
|
cta.disabled = false;
|
|
}
|
|
}
|
|
|
|
// audit reason per design §11.Q12: "Aus Fristenrechner — Trigger: {name} ({date})".
|
|
function auditReason(): string {
|
|
if (!currentResponse) return "";
|
|
const name = currentResponse.trigger.name_de;
|
|
const date = currentResponse.trigger_date;
|
|
return `Aus Fristenrechner — Trigger: ${name} (${date})`;
|
|
}
|
|
|
|
// Helpers -------------------------------------------------------------
|
|
|
|
export function defaultChecked(r: FollowUpRule): boolean {
|
|
// Cross-party rows are unchecked by default — they describe what the
|
|
// OTHER side files. They render to honestly show the workflow, but
|
|
// the Akte write-back excludes them unconditionally (design §2.4).
|
|
if (r.is_cross_party) return false;
|
|
if (r.is_court_set) return false;
|
|
if (r.is_spawn) return r.priority === "mandatory";
|
|
if (r.has_condition) return false;
|
|
return r.priority === "mandatory" || r.priority === "recommended";
|
|
}
|
|
|
|
function formatDurationPhrase(r: FollowUpRule, lang: "de" | "en"): string {
|
|
if (!r.duration_value || !r.duration_unit) return "";
|
|
const unitDE: Record<string, string> = {
|
|
days: "Tage",
|
|
months: "Monate",
|
|
weeks: "Wochen",
|
|
years: "Jahre",
|
|
};
|
|
const unitEN: Record<string, string> = {
|
|
days: "days",
|
|
months: "months",
|
|
weeks: "weeks",
|
|
years: "years",
|
|
};
|
|
const u = (lang === "en" ? unitEN : unitDE)[r.duration_unit] || r.duration_unit;
|
|
return `${r.duration_value} ${u}`;
|
|
}
|
|
|
|
function formatDateForLang(iso: string, lang: "de" | "en"): string {
|
|
// YYYY-MM-DD → DE: DD.MM.YYYY / EN: DD MMM YYYY (short).
|
|
if (!iso || iso.length < 10) return iso;
|
|
const [y, m, d] = iso.split("-");
|
|
if (!y || !m || !d) return iso;
|
|
if (lang === "en") {
|
|
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
|
const idx = parseInt(m, 10) - 1;
|
|
const mn = idx >= 0 && idx < months.length ? months[idx] : m;
|
|
return `${parseInt(d, 10)} ${mn} ${y}`;
|
|
}
|
|
return `${d}.${m}.${y}`;
|
|
}
|
|
|
|
function eventKindIcon(kind?: string): string {
|
|
switch (kind) {
|
|
case "filing": return "📥"; // inbox/letter
|
|
case "hearing": return "🏛️"; // courthouse
|
|
case "decision": return "⚖️"; // scales
|
|
case "order": return "📜"; // page
|
|
default: return "📅"; // calendar
|
|
}
|
|
}
|
|
|
|
function todayIso(): string {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
function syncUrlState(eventCode: string, triggerDate: string): void {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set("overhaul", "1");
|
|
url.searchParams.set("event", eventCode);
|
|
url.searchParams.set("trigger_date", triggerDate);
|
|
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
|
}
|