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:
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user