feat(t-paliad-136): Phase B — card-click → calc panel → add to project

The v3 result cards were dead-ends: clicking a Klageerwiderung pill
showed no deadline; users had to switch to Pathway A's wizard, retype
the date, and read the deadline out of the timeline. v4 makes the card
the entry to a single-rule calculator + add-to-project flow per m's
2026-05-05 11:58 feedback.

Backend (single-rule calc, no parent walk):
- New POST /api/tools/fristenrechner/calculate-rule endpoint accepts
  either ruleId OR (proceedingCode + ruleLocalCode), trigger date, and
  optional condition flags. Returns rule metadata + computed dueDate +
  originalDate + adjustment-reason chip data.
- FristenrechnerService.CalculateRule() reuses the existing addDuration
  + HolidayService.AdjustForNonWorkingDaysWithReason pipeline so
  t-paliad-119's adjustment-reason explainer and t-paliad-121's UPC-
  Sommerferien skip both apply automatically. Court-determined rules
  (party='court' or event_type ∈ hearing/decision/order) return
  IsCourtSet=true and an empty due date.
- Flag-conditional rules surface FlagsRequired even when the caller
  hasn't supplied the flag — the UI uses this to render checkboxes;
  toggling recomputes live. With all flags satisfied + alt_duration_*
  present, the calc swaps to alt values (existing semantics).
- Live-DB integration test covers plain calc, court-set, flag handling,
  and error paths (skipped without TEST_DATABASE_URL).

Frontend (inline calc panel):
- Click any card body or rule pill → expand inline panel inside the
  card (only one open at a time). Pill picker (radio chips) appears
  when the card has 2+ rule pills; first preselected. Trigger date
  defaults to today (m's Q3). Flag checkboxes auto-render from the
  rule's condition_flag.
- Result row shows due date, "(N units from triggerDate)", and a
  shift chip when wasAdjusted ("⚠ Verschoben vom … wegen UPC-
  Sommerferien (27.7.–28.8.)").
- "Zu Akte hinzufügen" CTA → inline project picker → POST to existing
  /api/projects/{id}/deadlines/bulk with a single-element array using
  source='fristenrechner' (m's Q2: existing tag, no new audit category).
- Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve the legacy
  drill-to-Pathway-A semantics via <a href> anchors. Trigger pills
  (Wiedereinsetzung, etc.) keep the trigger-event drill — they don't
  have a single rule to compute.
- Escape collapses the open card.

CSS: lime accent border on hover/expanded; dashed top border for the
calc panel; mobile-friendly grid for the pill picker.

UPC R.221 cost-appeal sequence (m's Q5) is wired in Phase C's seed
already; Phase B's pill picker renders both pills (leave-to-appeal +
notice-of-appeal) when the user hits one of those leaves.
This commit is contained in:
m
2026-05-05 14:04:54 +02:00
parent 6c3a6efc34
commit b54e938bdf
8 changed files with 1239 additions and 12 deletions

View File

@@ -1333,30 +1333,501 @@ function renderSearchResultsInto(containerId: string, data: SearchResponse) {
<div class="fristen-search-cards">${cardsHtml}</div>`;
}
// wirePillClicks attaches the rule/trigger drill-in click handler to a
// results container. Idempotent across re-renders because the listener
// lives on the container, not on individual pill anchors.
// wirePillClicks attaches the v4 card-click → inline calc-panel handler
// to a results container. Idempotent across re-renders because the
// listener lives on the container, not on individual pill anchors.
//
// v4 (t-paliad-136 Phase B): the primary click action on ANY card —
// header, pill, or body — expands the card with an inline calc panel
// (trigger-date input + flag checkboxes + computed deadline + add-to-
// project CTA). Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve
// the legacy drill-to-Pathway-A semantics via the `<a href="…">`
// fallback (browser default new-tab behaviour). Trigger pills still
// drill to the youpc-style trigger-event picker on plain click — those
// concepts (Wiedereinsetzung, Weiterbehandlung) don't have a single
// rule to compute against, so the inline calc panel doesn't apply.
function wirePillClicks(container: HTMLElement) {
if (container.dataset.pillClicksWired === "1") return;
container.dataset.pillClicksWired = "1";
container.addEventListener("click", (e) => {
// Clicks inside an open calc panel are handled by their own
// listeners — do not re-trigger the expand/collapse logic.
if ((e.target as HTMLElement).closest(".fristen-card-calc")) return;
const pill = (e.target as HTMLElement).closest<HTMLAnchorElement>(".fristen-pill");
if (!pill) return;
const card = (e.target as HTMLElement).closest<HTMLElement>(".fristen-card");
if (!card) return;
const me = e as MouseEvent;
// Modifier-key fallback: let `<a href>` open in a new tab / new
// window for users who want to deep-link into Pathway A. Don't
// interfere with normal text selection (no expand on shift-drag).
if (me.metaKey || me.ctrlKey || me.shiftKey || me.button === 1) return;
e.preventDefault();
const kind = pill.dataset.kind;
if (kind === "rule") {
const proc = pill.dataset.proc;
if (!proc) return;
drillToProceeding(proc, pill.dataset.focus || null);
} else if (kind === "trigger") {
// Trigger pills drill to the trigger-event picker (legacy youpc
// pathway). The inline calc panel only applies to rule pills.
if (pill && pill.dataset.kind === "trigger") {
e.preventDefault();
const id = Number(pill.dataset.triggerId);
if (Number.isFinite(id)) drillToTrigger(id);
return;
}
e.preventDefault();
expandCardCalc(card, pill);
});
}
// ============================================================================
// v4 card-click → inline calc panel (t-paliad-136 Phase B)
// ============================================================================
// Click a result card → expand inline → user enters trigger date + flags →
// server computes one deadline → user can add it to a project.
//
// Only one card may be expanded at a time (multiple panels would confuse
// "which trigger date am I looking at?"). Collapsing happens automatically
// when another card is clicked, when × is pressed, or when the user clicks
// outside the panel.
interface RuleCalcResponse {
rule: {
id: string;
localCode?: string;
nameDE: string;
nameEN: string;
ruleRef?: string;
legalSource?: string;
legalSourceDisplay?: string;
durationValue: number;
durationUnit: string;
party?: string;
isMandatory: boolean;
notesDE?: string;
notesEN?: string;
};
proceeding: { code: string; nameDE: string; nameEN: string };
triggerDate: string;
originalDate: string;
dueDate: string;
wasAdjusted: boolean;
adjustmentReason?: {
holidays?: Array<{ name: string; date: string }>;
upcVacation?: boolean;
moveToNextWorkday?: boolean;
};
isCourtSet: boolean;
flagsApplied?: string[];
flagsRequired?: string[];
}
let lastCalcByCard: WeakMap<HTMLElement, RuleCalcResponse> = new WeakMap();
let calcDebounce: number | undefined;
let calcSeq = 0;
function collapseAnyExpandedCard() {
document.querySelectorAll<HTMLElement>(".fristen-card.is-expanded").forEach((c) => {
c.classList.remove("is-expanded");
c.setAttribute("aria-expanded", "false");
const panel = c.querySelector<HTMLElement>(".fristen-card-calc");
if (panel) panel.remove();
});
}
function expandCardCalc(card: HTMLElement, autoSelectPill: HTMLElement | null) {
// Click a different card → collapse the current one first.
if (!card.classList.contains("is-expanded")) {
collapseAnyExpandedCard();
} else {
// Already expanded; if the user clicked a different pill, switch
// selection. If they clicked the body again, do nothing.
if (autoSelectPill) selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus);
return;
}
const payload = card.dataset.cardPayload;
if (!payload) return;
let cardData: SearchCard;
try {
cardData = JSON.parse(payload) as SearchCard;
} catch {
return;
}
// Only rule pills are computable. Drop trigger pills from the picker.
const rulePills = cardData.pills.filter((p) => p.kind === "rule");
if (rulePills.length === 0) return;
card.classList.add("is-expanded");
card.setAttribute("aria-expanded", "true");
const panel = buildCalcPanel(cardData, rulePills);
card.appendChild(panel);
// Auto-select the clicked pill if it's a rule pill; otherwise the
// first pill is preselected by buildCalcPanel.
if (autoSelectPill && autoSelectPill.dataset.kind === "rule") {
selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus);
}
scheduleCardCalc(card);
}
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLElement {
const panel = document.createElement("div");
panel.className = "fristen-card-calc";
// stopPropagation so clicks inside the panel don't bubble to the
// card-level expand handler.
panel.addEventListener("click", (e) => e.stopPropagation());
panel.addEventListener("keydown", (e) => e.stopPropagation());
const lang = getLang();
const today = new Date().toISOString().split("T")[0];
// Pill picker (only when >1 rule pill).
const pickerHtml = rulePills.length <= 1
? `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`
: `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
${rulePills.map((p, i) => {
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
const ruleName = lang === "en" && p.rule_name_en ? p.rule_name_en : p.rule_name_de;
const src = p.legal_source_display || p.legal_source || "";
return `<label class="fristen-card-calc-pill-option">
<input type="radio" name="fristen-card-calc-pill" value="${i}" ${i === 0 ? "checked" : ""} data-proc="${escAttr(p.proceeding?.code || "")}" data-focus="${escAttr(p.rule_local_code || "")}" />
<span class="fristen-card-calc-pill-option-proc">${escHtml(procName)}</span>
<span class="fristen-card-calc-pill-option-rule">${escHtml(ruleName)}</span>
${src ? `<span class="fristen-card-calc-pill-option-source">${escHtml(src)}</span>` : ""}
</label>`;
}).join("")}
</fieldset>`;
panel.innerHTML = `
<button type="button" class="fristen-card-calc-close" aria-label="${escAttr(t("deadlines.card.calc.close"))}">×</button>
${pickerHtml}
<div class="fristen-card-calc-inputs">
<label class="fristen-card-calc-trigger">
<span class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.trigger.label"))}</span>
<input type="date" class="fristen-card-calc-trigger-input" value="${escAttr(today)}" />
</label>
<div class="fristen-card-calc-flags" hidden></div>
</div>
<div class="fristen-card-calc-result" aria-live="polite">
<div class="fristen-card-calc-result-status">${escHtml(t("deadlines.card.calc.result.calculating"))}</div>
</div>
<div class="fristen-card-calc-actions">
<button type="button" class="btn-primary btn-cta-lime fristen-card-calc-add" disabled>${escHtml(t("deadlines.card.calc.add_to_project"))}</button>
</div>
<div class="fristen-card-calc-msg" aria-live="polite"></div>
`;
// Wire interactions.
const close = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-close")!;
close.addEventListener("click", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
if (card) {
card.classList.remove("is-expanded");
card.setAttribute("aria-expanded", "false");
panel.remove();
}
});
const dateInput = panel.querySelector<HTMLInputElement>(".fristen-card-calc-trigger-input")!;
dateInput.addEventListener("input", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
if (card) scheduleCardCalc(card);
});
panel.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]').forEach((r) => {
r.addEventListener("change", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
if (card) scheduleCardCalc(card, 0);
});
});
panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!.addEventListener("click", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
if (!card) return;
const last = lastCalcByCard.get(card);
if (!last) return;
void addCalcToProject(card, last);
});
return panel;
}
function selectCalcPill(card: HTMLElement, proc?: string | null, focus?: string | null) {
if (!proc) return;
const radios = card.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]');
radios.forEach((r) => {
if (r.dataset.proc === proc && (!focus || r.dataset.focus === focus)) {
r.checked = true;
r.dispatchEvent(new Event("change", { bubbles: true }));
}
});
}
function scheduleCardCalc(card: HTMLElement, delayMs = 200) {
if (calcDebounce !== undefined) clearTimeout(calcDebounce);
calcDebounce = window.setTimeout(() => void runCardCalc(card), delayMs);
}
async function runCardCalc(card: HTMLElement) {
const panel = card.querySelector<HTMLElement>(".fristen-card-calc");
if (!panel) return;
const result = panel.querySelector<HTMLElement>(".fristen-card-calc-result")!;
const addBtn = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!;
const msgEl = panel.querySelector<HTMLElement>(".fristen-card-calc-msg")!;
const dateInput = panel.querySelector<HTMLInputElement>(".fristen-card-calc-trigger-input")!;
const triggerDate = dateInput.value;
if (!triggerDate) return;
// Resolve currently-selected pill (proc + ruleLocalCode).
let proc = "";
let focus = "";
const checked = panel.querySelector<HTMLInputElement>('input[name="fristen-card-calc-pill"]:checked');
if (checked) {
proc = checked.dataset.proc || "";
focus = checked.dataset.focus || "";
} else {
const hidden = panel.querySelector<HTMLInputElement>(".fristen-card-calc-pill-picker");
if (hidden) {
proc = hidden.dataset.proc || "";
focus = hidden.dataset.focus || "";
}
}
if (!proc || !focus) return;
// Read flag checkboxes.
const flags: string[] = [];
panel.querySelectorAll<HTMLInputElement>('.fristen-card-calc-flags input[type="checkbox"]:checked').forEach((cb) => {
if (cb.value) flags.push(cb.value);
});
result.innerHTML = `<div class="fristen-card-calc-result-status">${escHtml(t("deadlines.card.calc.result.calculating"))}</div>`;
msgEl.textContent = "";
addBtn.disabled = true;
const seq = ++calcSeq;
let resp: Response;
try {
resp = await fetch("/api/tools/fristenrechner/calculate-rule", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
proceedingCode: proc,
ruleLocalCode: focus,
triggerDate,
flags,
}),
});
} catch {
if (seq !== calcSeq) return;
result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-error">${escHtml(t("deadlines.card.calc.result.error"))}</div>`;
return;
}
if (seq !== calcSeq) return;
if (!resp.ok) {
const data = (await resp.json().catch(() => ({} as { error?: string }))) as { error?: string };
result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-error">${escHtml(data.error || t("deadlines.card.calc.result.error"))}</div>`;
return;
}
const calc = (await resp.json()) as RuleCalcResponse;
if (seq !== calcSeq) return;
lastCalcByCard.set(card, calc);
renderCalcResult(card, calc);
syncFlagCheckboxes(card, calc);
addBtn.disabled = calc.isCourtSet || !calc.dueDate;
if (calc.isCourtSet) {
addBtn.textContent = t("deadlines.card.calc.add_to_project.disabled");
} else {
addBtn.textContent = t("deadlines.card.calc.add_to_project");
}
}
function syncFlagCheckboxes(card: HTMLElement, calc: RuleCalcResponse) {
const flagsEl = card.querySelector<HTMLElement>(".fristen-card-calc-flags");
if (!flagsEl) return;
const required = calc.flagsRequired || [];
if (required.length === 0) {
flagsEl.hidden = true;
flagsEl.innerHTML = "";
return;
}
flagsEl.hidden = false;
// Preserve current state when re-rendering: check current DOM for
// existing checkbox values so user input survives a recalc.
const existing = new Map<string, boolean>();
flagsEl.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((cb) => {
existing.set(cb.value, cb.checked);
});
const labelKey = (flag: string) => {
const k = `deadlines.card.calc.flag.${flag}`;
const localised = tDyn(k);
return localised === k ? flag : localised;
};
flagsEl.innerHTML = `
<span class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.flags.label"))}</span>
${required.map((f) => {
const checked = existing.get(f) ?? (calc.flagsApplied || []).includes(f);
return `<label class="fristen-card-calc-flag">
<input type="checkbox" value="${escAttr(f)}" ${checked ? "checked" : ""} />
<span>${escHtml(labelKey(f))}</span>
</label>`;
}).join("")}
`;
flagsEl.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((cb) => {
cb.addEventListener("change", () => scheduleCardCalc(card, 0));
});
}
function renderCalcResult(card: HTMLElement, calc: RuleCalcResponse) {
const result = card.querySelector<HTMLElement>(".fristen-card-calc-result");
if (!result) return;
if (calc.isCourtSet) {
result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-court">${escHtml(t("deadlines.card.calc.result.court_set"))}</div>`;
return;
}
const lang = getLang();
const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
const durLabel = `${calc.rule.durationValue} ${formatDurationUnit(calc.rule.durationUnit, lang)}`;
const dueLabel = formatDate(calc.dueDate);
const fromLabel = formatDate(calc.triggerDate);
const adjustmentChip = calc.wasAdjusted
? renderAdjustmentChip(calc, lang)
: "";
result.innerHTML = `
<div class="fristen-card-calc-result-row">
<span class="fristen-card-calc-result-arrow" aria-hidden="true">►</span>
<span class="fristen-card-calc-result-due"><strong>${escHtml(dueLabel)}</strong></span>
<span class="fristen-card-calc-result-detail">(${escHtml(durLabel)} ${escHtml(t("deadlines.card.calc.result.from_trigger"))} ${escHtml(fromLabel)})</span>
</div>
${adjustmentChip}
<div class="fristen-card-calc-result-rule">${escHtml(ruleName)}</div>
`;
}
function renderAdjustmentChip(calc: RuleCalcResponse, _lang: "de" | "en"): string {
const reason = calc.adjustmentReason;
let why = "";
if (reason && reason.upcVacation) {
why = "UPC-Sommerferien (27.7.28.8.)";
} else if (reason && reason.holidays && reason.holidays.length > 0) {
why = reason.holidays.map((h) => h.name).join(", ");
} else {
why = "Wochenende / Feiertag";
}
return `<div class="fristen-card-calc-result-shift">
${escHtml(t("deadlines.card.calc.result.shifted_from"))} <strong>${escHtml(formatDate(calc.originalDate))}</strong>
${escHtml(t("deadlines.card.calc.result.shifted_because"))} ${escHtml(why)}.
</div>`;
}
function formatDurationUnit(unit: string, lang: "de" | "en"): string {
const map: Record<string, { de: string; en: string }> = {
days: { de: "Tage", en: "days" },
working_days: { de: "Arbeitstage", en: "working days" },
weeks: { de: "Wochen", en: "weeks" },
months: { de: "Monate", en: "months" },
years: { de: "Jahre", en: "years" },
};
return map[unit] ? map[unit][lang] : unit;
}
async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
const panel = card.querySelector<HTMLElement>(".fristen-card-calc");
if (!panel) return;
const msgEl = panel.querySelector<HTMLElement>(".fristen-card-calc-msg")!;
const addBtn = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!;
msgEl.textContent = "";
addBtn.disabled = true;
const projects = await fetchProjects();
if (projects.length === 0) {
addBtn.disabled = false;
msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(t("deadlines.save.modal.no_akten"))}</span> <a href="/projects/new">${escHtml(t("deadlines.save.modal.no_akten.link"))}</a>`;
return;
}
// Inline picker — render a compact <select> + Confirm button under
// the result. Keeps the user inside the card; no full modal needed.
const lang = getLang();
const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
const dueLabel = formatDate(calc.dueDate);
msgEl.innerHTML = `
<div class="fristen-card-calc-add-picker">
<label class="fristen-card-calc-label">${escHtml(t("deadlines.save.modal.akte"))}
<select class="fristen-card-calc-add-select">
${projects.map((p) => {
const ref = (p.reference || "").trim();
const indent = projectIndent(p.path);
const label = ref ? `${indent}${ref}${p.title}` : `${indent}${p.title}`;
return `<option value="${escAttr(p.id)}">${escHtml(label)}</option>`;
}).join("")}
</select>
</label>
<button type="button" class="btn-primary btn-cta-lime fristen-card-calc-add-confirm">${escHtml(t("deadlines.save.modal.submit"))}</button>
<button type="button" class="btn-cancel fristen-card-calc-add-cancel">${escHtml(t("deadlines.save.modal.cancel"))}</button>
</div>
`;
const sel = msgEl.querySelector<HTMLSelectElement>(".fristen-card-calc-add-select")!;
msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-cancel")!.addEventListener("click", () => {
msgEl.innerHTML = "";
addBtn.disabled = false;
});
msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-confirm")!.addEventListener("click", async () => {
const projectID = sel.value;
if (!projectID) return;
const dlNotes = lang === "en"
? (calc.rule.notesEN || calc.rule.notesDE)
: calc.rule.notesDE;
const payload = {
deadlines: [{
title: ruleName,
rule_code: calc.rule.ruleRef || undefined,
due_date: calc.dueDate,
original_due_date: calc.originalDate || undefined,
// m's Q2 (2026-05-05): use 'fristenrechner' (existing tag), not
// 'fristenrechner_card'. Audit-log differentiation is not needed.
source: "fristenrechner",
notes: dlNotes || undefined,
}],
};
const confirm = msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-confirm")!;
confirm.disabled = true;
try {
const r = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) {
const data = (await r.json().catch(() => ({}))) as { error?: string };
msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(data.error || t("deadlines.save.error"))}</span>`;
addBtn.disabled = false;
return;
}
msgEl.innerHTML = `<span class="form-msg form-msg-ok">${escHtml(t("deadlines.save.success"))} (${escHtml(dueLabel)}) <a href="/deadlines?project_id=${encodeURIComponent(projectID)}">${escHtml(t("deadlines.save.success.link"))}</a></span>`;
addBtn.disabled = false;
} catch {
msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(t("deadlines.save.error"))}</span>`;
addBtn.disabled = false;
}
});
}
// Collapse the open card on Escape key for quick keyboard exit.
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
const open = document.querySelector<HTMLElement>(".fristen-card.is-expanded");
if (open) {
open.classList.remove("is-expanded");
open.setAttribute("aria-expanded", "false");
open.querySelector<HTMLElement>(".fristen-card-calc")?.remove();
}
});
function renderConceptCard(card: SearchCard, lang: "de" | "en"): string {
const name = lang === "en" ? card.concept.name_en : card.concept.name_de;
const altName = lang === "en" ? card.concept.name_de : card.concept.name_en;
@@ -1380,8 +1851,13 @@ function renderConceptCard(card: SearchCard, lang: "de" | "en"): string {
<div class="fristen-card-pills">${triggerPills.map((p) => renderPill(p, lang)).join("")}</div>
</div>`;
// v4 (t-paliad-136 Phase B): stash the card payload on the article so
// expandCardCalc() can read pills + concept name without re-querying.
// JSON.stringify → escAttr survives htmlentity round-trip.
const cardPayload = escAttr(JSON.stringify(card));
return `
<article class="fristen-card" data-concept-slug="${escAttr(card.concept.slug)}">
<article class="fristen-card" data-concept-slug="${escAttr(card.concept.slug)}" data-card-payload="${cardPayload}" tabindex="0" role="button" aria-expanded="false" title="${escAttr(t("deadlines.card.calc.expand_hint"))}">
<header class="fristen-card-header">
<h3 class="fristen-card-title">${escHtml(name)}</h3>
<span class="fristen-card-altname">${escHtml(altName)}</span>

View File

@@ -284,6 +284,25 @@ 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",
// Fristenrechner — card-click → calc panel → add-to-project (t-paliad-136 Phase B)
"deadlines.card.calc.expand_hint": "Frist berechnen oder zu Akte hinzufügen",
"deadlines.card.calc.close": "schließen",
"deadlines.card.calc.pill_picker.label": "Welcher Kontext?",
"deadlines.card.calc.trigger.label": "Datum des auslösenden Ereignisses",
"deadlines.card.calc.flags.label": "Bedingungen:",
"deadlines.card.calc.flag.with_ccr": "Mit Nichtigkeitswiderklage",
"deadlines.card.calc.flag.with_amend": "Mit Antrag auf Patentänderung",
"deadlines.card.calc.flag.with_cci": "Mit Verletzungswiderklage",
"deadlines.card.calc.result.due": "Frist:",
"deadlines.card.calc.result.original_from": "ab",
"deadlines.card.calc.result.from_trigger": "ab",
"deadlines.card.calc.result.shifted_from": "Verschoben vom",
"deadlines.card.calc.result.shifted_because": "wegen",
"deadlines.card.calc.result.court_set": "Gericht-bestimmt — kein berechenbares Datum.",
"deadlines.card.calc.result.calculating": "Berechne…",
"deadlines.card.calc.result.error": "Berechnung fehlgeschlagen.",
"deadlines.card.calc.add_to_project": "Zu Akte hinzufügen",
"deadlines.card.calc.add_to_project.disabled": "Gerichtsbestimmt — manuell anlegen",
"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.",
@@ -1867,6 +1886,25 @@ 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",
// Fristenrechner — card-click → calc panel → add-to-project (t-paliad-136 Phase B)
"deadlines.card.calc.expand_hint": "Calculate deadline or add to project",
"deadlines.card.calc.close": "close",
"deadlines.card.calc.pill_picker.label": "Which context?",
"deadlines.card.calc.trigger.label": "Date of triggering event",
"deadlines.card.calc.flags.label": "Conditions:",
"deadlines.card.calc.flag.with_ccr": "With counterclaim for revocation",
"deadlines.card.calc.flag.with_amend": "With application to amend",
"deadlines.card.calc.flag.with_cci": "With counterclaim for infringement",
"deadlines.card.calc.result.due": "Deadline:",
"deadlines.card.calc.result.original_from": "from",
"deadlines.card.calc.result.from_trigger": "from",
"deadlines.card.calc.result.shifted_from": "Shifted from",
"deadlines.card.calc.result.shifted_because": "due to",
"deadlines.card.calc.result.court_set": "Court-determined — no calculable date.",
"deadlines.card.calc.result.calculating": "Calculating…",
"deadlines.card.calc.result.error": "Calculation failed.",
"deadlines.card.calc.add_to_project": "Add to project",
"deadlines.card.calc.add_to_project.disabled": "Court-determined — add manually",
"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.",

View File

@@ -540,6 +540,24 @@ export type I18nKey =
| "deadlines.adjusted.weekend.saturday"
| "deadlines.adjusted.weekend.sunday"
| "deadlines.calculate"
| "deadlines.card.calc.add_to_project"
| "deadlines.card.calc.add_to_project.disabled"
| "deadlines.card.calc.close"
| "deadlines.card.calc.expand_hint"
| "deadlines.card.calc.flag.with_amend"
| "deadlines.card.calc.flag.with_cci"
| "deadlines.card.calc.flag.with_ccr"
| "deadlines.card.calc.flags.label"
| "deadlines.card.calc.pill_picker.label"
| "deadlines.card.calc.result.calculating"
| "deadlines.card.calc.result.court_set"
| "deadlines.card.calc.result.due"
| "deadlines.card.calc.result.error"
| "deadlines.card.calc.result.from_trigger"
| "deadlines.card.calc.result.original_from"
| "deadlines.card.calc.result.shifted_because"
| "deadlines.card.calc.result.shifted_from"
| "deadlines.card.calc.trigger.label"
| "deadlines.col.akte"
| "deadlines.col.both"
| "deadlines.col.court"

View File

@@ -2160,6 +2160,257 @@ input[type="range"]::-moz-range-thumb {
.fristen-pill-party { grid-column: 2; }
}
/* v4 (t-paliad-136 Phase B): card-click → inline calc panel.
The card is now interactive — click anywhere in the body (or on a
pill) to expand the panel that holds the trigger-date input, flag
checkboxes, computed deadline, and "Add to project" CTA. Only one
card may be expanded at a time; opening another collapses the
previous. */
.fristen-card[role="button"] {
cursor: pointer;
transition: border-color 120ms, box-shadow 120ms;
}
.fristen-card[role="button"]:hover:not(.is-expanded) {
border-color: var(--brand-lime, #c6f41c);
box-shadow: 0 0 0 2px rgba(198, 244, 28, 0.18);
}
.fristen-card.is-expanded {
border-color: var(--brand-lime, #c6f41c);
box-shadow: 0 0 0 2px rgba(198, 244, 28, 0.25);
}
.fristen-card-calc {
margin-top: 1rem;
padding: 0.9rem 1rem 0.8rem;
border-top: 1px dashed var(--color-border, #e2e2e2);
background: rgba(198, 244, 28, 0.04);
border-radius: 0 0 8px 8px;
display: flex;
flex-direction: column;
gap: 0.7rem;
position: relative;
cursor: default;
}
.fristen-card-calc-close {
position: absolute;
top: 0.4rem;
right: 0.5rem;
background: transparent;
border: 0;
font-size: 1.4rem;
line-height: 1;
color: var(--color-muted, #888);
cursor: pointer;
padding: 0.1rem 0.4rem;
}
.fristen-card-calc-close:hover {
color: var(--color-text, #222);
}
.fristen-card-calc-label {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-muted, #777);
font-weight: 600;
display: block;
margin-bottom: 0.3rem;
}
.fristen-card-calc-pill-picker {
border: 0;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.fristen-card-calc-pill-picker legend {
padding: 0;
margin-bottom: 0.3rem;
}
.fristen-card-calc-pill-option {
display: grid;
grid-template-columns: auto minmax(0, 1.2fr) minmax(0, 1.5fr) minmax(0, 1fr);
gap: 0.5rem;
align-items: center;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border-subtle, #ececec);
border-radius: 5px;
background: var(--color-bg, #fff);
cursor: pointer;
font-size: 0.88rem;
}
.fristen-card-calc-pill-option:has(input:checked) {
border-color: var(--brand-lime, #c6f41c);
background: rgba(198, 244, 28, 0.08);
}
.fristen-card-calc-pill-option-proc {
font-weight: 600;
color: var(--color-text, #222);
}
.fristen-card-calc-pill-option-rule {
color: var(--color-text, #222);
}
.fristen-card-calc-pill-option-source {
font-size: 0.8rem;
color: var(--color-muted, #888);
font-family: ui-monospace, monospace;
}
.fristen-card-calc-inputs {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
}
.fristen-card-calc-trigger {
display: flex;
flex-direction: column;
min-width: 200px;
}
.fristen-card-calc-trigger-input {
padding: 0.45rem 0.6rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 5px;
font-size: 0.95rem;
background: var(--color-bg, #fff);
}
.fristen-card-calc-flags {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
}
.fristen-card-calc-flag {
display: inline-flex;
gap: 0.35rem;
align-items: center;
font-size: 0.88rem;
padding: 0.3rem 0.55rem;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border-subtle, #ececec);
border-radius: 4px;
cursor: pointer;
}
.fristen-card-calc-flag:has(input:checked) {
border-color: var(--brand-lime, #c6f41c);
background: rgba(198, 244, 28, 0.08);
}
.fristen-card-calc-result {
padding: 0.6rem 0.8rem;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border-subtle, #ececec);
border-radius: 6px;
min-height: 2.6rem;
}
.fristen-card-calc-result-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.fristen-card-calc-result-arrow {
color: var(--brand-lime-strong, #88a800);
font-weight: 700;
}
.fristen-card-calc-result-due strong {
font-size: 1.1rem;
color: var(--color-text, #222);
}
.fristen-card-calc-result-detail {
color: var(--color-muted, #777);
font-size: 0.88rem;
}
.fristen-card-calc-result-rule {
color: var(--color-muted, #888);
font-size: 0.83rem;
margin-top: 0.3rem;
font-style: italic;
}
.fristen-card-calc-result-shift {
margin-top: 0.4rem;
padding: 0.4rem 0.55rem;
background: rgba(255, 184, 0, 0.1);
border-left: 3px solid #f5a800;
border-radius: 3px;
font-size: 0.85rem;
color: #6c4a00;
}
.fristen-card-calc-result-status {
color: var(--color-muted, #888);
font-size: 0.9rem;
font-style: italic;
}
.fristen-card-calc-result-error {
color: var(--color-danger, #c44);
font-style: normal;
}
.fristen-card-calc-result-court {
color: #8a5d00;
font-style: normal;
}
.fristen-card-calc-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.fristen-card-calc-add[disabled] {
opacity: 0.55;
cursor: not-allowed;
}
.fristen-card-calc-msg {
font-size: 0.88rem;
min-height: 0;
}
.fristen-card-calc-msg .form-msg {
display: inline-block;
padding: 0.3rem 0.55rem;
border-radius: 4px;
}
.fristen-card-calc-msg .form-msg-ok {
background: rgba(140, 200, 80, 0.15);
color: #2e6a16;
}
.fristen-card-calc-msg .form-msg-error {
background: rgba(200, 60, 60, 0.1);
color: #a31919;
}
.fristen-card-calc-add-picker {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: flex-end;
padding: 0.5rem;
border: 1px solid var(--color-border-subtle, #ececec);
border-radius: 5px;
background: var(--color-bg, #fff);
}
.fristen-card-calc-add-select {
padding: 0.4rem 0.55rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 4px;
min-width: 240px;
font-size: 0.95rem;
}
@media (max-width: 720px) {
.fristen-card-calc-pill-option {
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
}
.fristen-card-calc-pill-option-rule,
.fristen-card-calc-pill-option-source { grid-column: 2; }
}
/* Drill-in highlight — applied to the focused timeline / column row for
~2.4 s after a pill click pre-selects the proceeding and computes the
timeline. Fades the row to draw attention without staying loud. */