Merge: t-paliad-136 Phase B — card-click → calc panel → add-to-project (v4 complete)
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. */
|
||||
|
||||
@@ -57,6 +57,67 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// POST /api/tools/fristenrechner/calculate-rule — single-rule calc for
|
||||
// the v4 (t-paliad-136 Phase B) result-card click flow.
|
||||
//
|
||||
// Body: { ruleId? } OR { proceedingCode, ruleLocalCode }, plus
|
||||
// triggerDate (YYYY-MM-DD, required) and flags? (string array,
|
||||
// optional condition_flag inputs).
|
||||
//
|
||||
// Returns a RuleCalculation (see services.RuleCalculation) — the rule
|
||||
// metadata + computed dueDate / originalDate / adjustmentReason. Used by
|
||||
// the result-card calc panel; distinct from the full-timeline endpoint
|
||||
// at POST /api/tools/fristenrechner.
|
||||
func handleFristenrechnerCalculateRule(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.fristenrechner == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
RuleID string `json:"ruleId"`
|
||||
ProceedingCode string `json:"proceedingCode"`
|
||||
RuleLocalCode string `json:"ruleLocalCode"`
|
||||
TriggerDate string `json:"triggerDate"`
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
if req.TriggerDate == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "triggerDate ist erforderlich"})
|
||||
return
|
||||
}
|
||||
if req.RuleID == "" && (req.ProceedingCode == "" || req.RuleLocalCode == "") {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "Entweder ruleId oder (proceedingCode + ruleLocalCode) ist erforderlich",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := dbSvc.fristenrechner.CalculateRule(r.Context(), services.CalcRuleParams{
|
||||
RuleID: req.RuleID,
|
||||
ProceedingCode: req.ProceedingCode,
|
||||
RuleLocalCode: req.RuleLocalCode,
|
||||
TriggerDate: req.TriggerDate,
|
||||
Flags: req.Flags,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrUnknownRule):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekannte Regel"})
|
||||
case errors.Is(err, services.ErrUnknownProceedingType):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekannter Verfahrenstyp: " + req.ProceedingCode})
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /api/tools/proceeding-types — metadata list for the wizard buttons.
|
||||
// Returns 503 with an empty array when DATABASE_URL is unset so the page
|
||||
// still renders (buttons are server-rendered from tsx and don't depend on
|
||||
|
||||
@@ -134,6 +134,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/tools/kostenrechner", handleKostenrechnerAPI)
|
||||
protected.HandleFunc("GET /tools/fristenrechner", handleFristenrechnerPage)
|
||||
protected.HandleFunc("POST /api/tools/fristenrechner", handleFristenrechnerAPI)
|
||||
protected.HandleFunc("POST /api/tools/fristenrechner/calculate-rule", handleFristenrechnerCalculateRule)
|
||||
protected.HandleFunc("GET /api/tools/proceeding-types", handleProceedingTypes)
|
||||
protected.HandleFunc("GET /api/tools/trigger-events", handleTriggerEventsList)
|
||||
protected.HandleFunc("POST /api/tools/event-deadlines", handleEventDeadlinesCalculate)
|
||||
|
||||
@@ -416,6 +416,258 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ErrUnknownRule is returned when CalculateRule can't resolve the
|
||||
// (proceedingCode, ruleLocalCode) pair or rule UUID to an active rule.
|
||||
var ErrUnknownRule = errors.New("unknown rule")
|
||||
|
||||
// CalcRuleParams identifies a single rule and the inputs needed to
|
||||
// compute one deadline from it. Caller supplies either RuleID OR the
|
||||
// (ProceedingCode, RuleLocalCode) pair — whichever the frontend has on
|
||||
// hand from the concept-card pill it just received a click on.
|
||||
type CalcRuleParams struct {
|
||||
RuleID string // optional — UUID
|
||||
ProceedingCode string // optional — used with RuleLocalCode
|
||||
RuleLocalCode string // optional — paliad.deadline_rules.code
|
||||
TriggerDate string // required — YYYY-MM-DD
|
||||
Flags []string // optional — condition_flag inputs
|
||||
}
|
||||
|
||||
// RuleCalculation is the v4 (t-paliad-136 Phase B) single-rule calc
|
||||
// response that backs the result-card click → calc-panel flow. Distinct
|
||||
// from UIDeadline (which represents one rendered timeline row inside a
|
||||
// full-proceeding response): RuleCalculation is self-contained — caller
|
||||
// gets the rule metadata + the computed date in one payload, no separate
|
||||
// proceeding-types lookup needed.
|
||||
//
|
||||
// Trigger semantics: TriggerDate is the immediate parent event's
|
||||
// effective date — i.e. when the user clicks "Duplik" in the card and
|
||||
// types "2026-05-05", they mean "I received the Replik on 2026-05-05".
|
||||
// We do NOT walk the parent chain; callers wanting the full timeline
|
||||
// for a proceeding still go through Calculate.
|
||||
type RuleCalculation struct {
|
||||
Rule RuleCalculationRule `json:"rule"`
|
||||
Proceeding RuleCalculationProceeding `json:"proceeding"`
|
||||
TriggerDate string `json:"triggerDate"`
|
||||
OriginalDate string `json:"originalDate"`
|
||||
DueDate string `json:"dueDate"`
|
||||
WasAdjusted bool `json:"wasAdjusted"`
|
||||
AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
|
||||
IsCourtSet bool `json:"isCourtSet"`
|
||||
// FlagsApplied lists the condition_flag values from the rule that
|
||||
// the caller's Flags satisfied. Empty when the rule has no
|
||||
// condition_flag, OR when the caller didn't satisfy the gate. Lets
|
||||
// the frontend show "Mit Nichtigkeitswiderklage angewandt" hints.
|
||||
FlagsApplied []string `json:"flagsApplied,omitempty"`
|
||||
// FlagsRequired is the rule's condition_flag in canonical order so
|
||||
// the frontend can render checkboxes for each flag the rule gates on.
|
||||
FlagsRequired []string `json:"flagsRequired,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculationRule mirrors the small subset of DeadlineRule the
|
||||
// frontend needs to render the calc panel.
|
||||
type RuleCalculationRule struct {
|
||||
ID string `json:"id"`
|
||||
LocalCode string `json:"localCode,omitempty"`
|
||||
NameDE string `json:"nameDE"`
|
||||
NameEN string `json:"nameEN"`
|
||||
RuleRef string `json:"ruleRef,omitempty"`
|
||||
LegalSource string `json:"legalSource,omitempty"`
|
||||
LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"`
|
||||
DurationValue int `json:"durationValue"`
|
||||
DurationUnit string `json:"durationUnit"`
|
||||
Party string `json:"party,omitempty"`
|
||||
IsMandatory bool `json:"isMandatory"`
|
||||
NotesDE string `json:"notesDE,omitempty"`
|
||||
NotesEN string `json:"notesEN,omitempty"`
|
||||
}
|
||||
|
||||
// RuleCalculationProceeding identifies the proceeding context for the
|
||||
// rule. Used by the frontend for display + by the add-to-project flow.
|
||||
type RuleCalculationProceeding struct {
|
||||
Code string `json:"code"`
|
||||
NameDE string `json:"nameDE"`
|
||||
NameEN string `json:"nameEN"`
|
||||
}
|
||||
|
||||
// CalculateRule computes a single deadline from a rule + trigger date.
|
||||
// Used by the v4 result-card click flow. Distinct from Calculate: no
|
||||
// parent-chain walk, no full-timeline rendering — just one date out.
|
||||
//
|
||||
// When the rule is court-determined (primary_party='court' or event_type
|
||||
// ∈ {hearing, decision, order}), DueDate is empty and IsCourtSet=true;
|
||||
// the caller should disable the "Add to project" CTA in that case.
|
||||
//
|
||||
// When the rule has condition_flag and the caller's Flags satisfy every
|
||||
// element AND alt_duration_value is non-NULL, the calc swaps to alt_*
|
||||
// (matches the existing flag-conditional semantics in Calculate).
|
||||
//
|
||||
// When the rule has condition_flag and the caller's Flags do NOT satisfy
|
||||
// every element, the calc still proceeds with the base duration_value
|
||||
// and surfaces FlagsRequired so the frontend can render the gating
|
||||
// checkboxes. The result IS the date the rule would be due if the user
|
||||
// confirmed the flag — letting the user toggle the checkbox and see the
|
||||
// duration change live.
|
||||
func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRuleParams) (*RuleCalculation, error) {
|
||||
triggerDate, err := time.Parse("2006-01-02", params.TriggerDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", params.TriggerDate, err)
|
||||
}
|
||||
|
||||
rule, pt, err := s.resolveRule(ctx, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := &RuleCalculation{
|
||||
Rule: RuleCalculationRule{
|
||||
ID: rule.ID.String(),
|
||||
NameDE: rule.Name,
|
||||
NameEN: rule.NameEN,
|
||||
DurationValue: rule.DurationValue,
|
||||
DurationUnit: rule.DurationUnit,
|
||||
IsMandatory: rule.IsMandatory,
|
||||
},
|
||||
Proceeding: RuleCalculationProceeding{
|
||||
Code: pt.Code,
|
||||
NameDE: pt.Name,
|
||||
NameEN: pt.NameEN,
|
||||
},
|
||||
TriggerDate: params.TriggerDate,
|
||||
}
|
||||
if rule.Code != nil {
|
||||
out.Rule.LocalCode = *rule.Code
|
||||
}
|
||||
if rule.RuleCode != nil {
|
||||
out.Rule.RuleRef = *rule.RuleCode
|
||||
}
|
||||
if rule.LegalSource != nil {
|
||||
out.Rule.LegalSource = *rule.LegalSource
|
||||
out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource)
|
||||
}
|
||||
if rule.PrimaryParty != nil {
|
||||
out.Rule.Party = *rule.PrimaryParty
|
||||
}
|
||||
if rule.DeadlineNotes != nil {
|
||||
out.Rule.NotesDE = *rule.DeadlineNotes
|
||||
}
|
||||
if rule.DeadlineNotesEn != nil {
|
||||
out.Rule.NotesEN = *rule.DeadlineNotesEn
|
||||
}
|
||||
if len(rule.ConditionFlag) > 0 {
|
||||
out.FlagsRequired = []string(rule.ConditionFlag)
|
||||
}
|
||||
|
||||
// Court-determined: no calculable date.
|
||||
if isCourtDeterminedRule(*rule) {
|
||||
out.IsCourtSet = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Resolve flag-conditional duration. Same semantics as Calculate
|
||||
// (services/fristenrechner.go:368): all flags satisfied + alt
|
||||
// values present → swap; otherwise use base values.
|
||||
flagSet := make(map[string]struct{}, len(params.Flags))
|
||||
for _, f := range params.Flags {
|
||||
flagSet[f] = struct{}{}
|
||||
}
|
||||
durationValue := rule.DurationValue
|
||||
durationUnit := rule.DurationUnit
|
||||
if len(rule.ConditionFlag) > 0 && allFlagsSet(rule.ConditionFlag, flagSet) {
|
||||
out.FlagsApplied = []string(rule.ConditionFlag)
|
||||
if rule.AltDurationValue != nil {
|
||||
durationValue = *rule.AltDurationValue
|
||||
}
|
||||
if rule.AltDurationUnit != nil {
|
||||
durationUnit = *rule.AltDurationUnit
|
||||
}
|
||||
if rule.AltRuleCode != nil {
|
||||
out.Rule.RuleRef = *rule.AltRuleCode
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-duration non-court-determined rules are "filed at the same
|
||||
// time as parent" markers (UPC_REV.app_to_amend, UPC_REV.cc_inf):
|
||||
// effectively mean "due on the trigger date itself". The card-click
|
||||
// flow doesn't need to surface those as a calc panel — but if it
|
||||
// does, returning the trigger date is the right answer.
|
||||
if durationValue == 0 {
|
||||
out.OriginalDate = params.TriggerDate
|
||||
out.DueDate = params.TriggerDate
|
||||
return out, nil
|
||||
}
|
||||
|
||||
endDate := addDuration(triggerDate, durationValue, durationUnit)
|
||||
adjusted, _, wasAdj, reason := s.holidays.AdjustForNonWorkingDaysWithReason(endDate)
|
||||
out.OriginalDate = endDate.Format("2006-01-02")
|
||||
out.DueDate = adjusted.Format("2006-01-02")
|
||||
out.WasAdjusted = wasAdj
|
||||
out.AdjustmentReason = reason
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveRule resolves CalcRuleParams to a rule + its proceeding type.
|
||||
// Accepts either RuleID (UUID) or (ProceedingCode, RuleLocalCode). The
|
||||
// frontend uses the latter form (it has the pill context) and the
|
||||
// programmatic / test caller can use the former.
|
||||
func (s *FristenrechnerService) resolveRule(ctx context.Context, params CalcRuleParams) (*models.DeadlineRule, *models.ProceedingType, error) {
|
||||
if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") {
|
||||
return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required")
|
||||
}
|
||||
|
||||
const ptCols = `id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active`
|
||||
|
||||
var rule models.DeadlineRule
|
||||
var pt models.ProceedingType
|
||||
if params.RuleID != "" {
|
||||
err := s.rules.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
WHERE id = $1 AND is_active = true`, params.RuleID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil, ErrUnknownRule
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve rule by id %q: %w", params.RuleID, err)
|
||||
}
|
||||
if rule.ProceedingTypeID == nil {
|
||||
return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID)
|
||||
}
|
||||
err = s.rules.db.GetContext(ctx, &pt,
|
||||
`SELECT `+ptCols+`
|
||||
FROM paliad.proceeding_types
|
||||
WHERE id = $1`, *rule.ProceedingTypeID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err)
|
||||
}
|
||||
return &rule, &pt, nil
|
||||
}
|
||||
|
||||
err := s.rules.db.GetContext(ctx, &pt,
|
||||
`SELECT `+ptCols+`
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = $1 AND is_active = true`, params.ProceedingCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil, ErrUnknownProceedingType
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve proceeding %q: %w", params.ProceedingCode, err)
|
||||
}
|
||||
err = s.rules.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = $1 AND code = $2 AND is_active = true`,
|
||||
pt.ID, params.RuleLocalCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil, ErrUnknownRule
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve rule %q in %q: %w", params.RuleLocalCode, params.ProceedingCode, err)
|
||||
}
|
||||
return &rule, &pt, nil
|
||||
}
|
||||
|
||||
// ListFristenrechnerTypes returns the proceeding types that populate the
|
||||
// Fristenrechner UI (category = 'fristenrechner'), ordered by sort_order.
|
||||
func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]FristenrechnerType, error) {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
@@ -102,3 +108,127 @@ func TestAllFlagsSet(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculateRule covers the v4 (t-paliad-136 Phase B) single-rule
|
||||
// calculator that backs POST /api/tools/fristenrechner/calculate-rule.
|
||||
// Distinct from Calculate: no parent-chain walk; the trigger date is
|
||||
// the immediate parent event's effective date, and the calc applies
|
||||
// the rule's duration + holiday adjustment directly.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring TestDeadlineSearch.
|
||||
func TestCalculateRule(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
holidays := NewHolidayService(pool)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
svc := NewFristenrechnerService(rules, holidays)
|
||||
|
||||
t.Run("plain rule calc — UPC_INF inf.sod, R.23(1), 3 months", func(t *testing.T) {
|
||||
// 2026-01-15 + 3 months = 2026-04-15. No vacation overlap.
|
||||
got, err := svc.CalculateRule(ctx, CalcRuleParams{
|
||||
ProceedingCode: "UPC_INF",
|
||||
RuleLocalCode: "inf.sod",
|
||||
TriggerDate: "2026-01-15",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CalculateRule: %v", err)
|
||||
}
|
||||
if got.IsCourtSet {
|
||||
t.Errorf("inf.sod is not court-set; got IsCourtSet=true")
|
||||
}
|
||||
if got.DueDate != "2026-04-15" {
|
||||
t.Errorf("dueDate = %q, want 2026-04-15", got.DueDate)
|
||||
}
|
||||
if got.Rule.LegalSourceDisplay != "UPC RoP R.23(1)" {
|
||||
t.Errorf("legalSourceDisplay = %q, want UPC RoP R.23(1)", got.Rule.LegalSourceDisplay)
|
||||
}
|
||||
if got.Proceeding.Code != "UPC_INF" {
|
||||
t.Errorf("proceeding code = %q, want UPC_INF", got.Proceeding.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("court-determined rule → IsCourtSet=true, no dueDate", func(t *testing.T) {
|
||||
got, err := svc.CalculateRule(ctx, CalcRuleParams{
|
||||
ProceedingCode: "UPC_INF",
|
||||
RuleLocalCode: "inf.decision",
|
||||
TriggerDate: "2026-01-15",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CalculateRule: %v", err)
|
||||
}
|
||||
if !got.IsCourtSet {
|
||||
t.Errorf("inf.decision should be court-set; got IsCourtSet=false")
|
||||
}
|
||||
if got.DueDate != "" {
|
||||
t.Errorf("court-set dueDate = %q, want empty", got.DueDate)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flag-conditional rule surfaces FlagsRequired even when not satisfied", func(t *testing.T) {
|
||||
// inf.def_to_ccr requires with_ccr. Without the flag, FlagsRequired
|
||||
// is still surfaced so the UI can render the checkbox.
|
||||
got, err := svc.CalculateRule(ctx, CalcRuleParams{
|
||||
ProceedingCode: "UPC_INF",
|
||||
RuleLocalCode: "inf.def_to_ccr",
|
||||
TriggerDate: "2026-01-15",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CalculateRule: %v", err)
|
||||
}
|
||||
if len(got.FlagsRequired) == 0 || got.FlagsRequired[0] != "with_ccr" {
|
||||
t.Errorf("FlagsRequired = %v, want [with_ccr]", got.FlagsRequired)
|
||||
}
|
||||
if len(got.FlagsApplied) != 0 {
|
||||
t.Errorf("FlagsApplied = %v, want empty (flag not supplied)", got.FlagsApplied)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flag-conditional rule with flag → FlagsApplied populated", func(t *testing.T) {
|
||||
got, err := svc.CalculateRule(ctx, CalcRuleParams{
|
||||
ProceedingCode: "UPC_INF",
|
||||
RuleLocalCode: "inf.def_to_ccr",
|
||||
TriggerDate: "2026-01-15",
|
||||
Flags: []string{"with_ccr"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CalculateRule: %v", err)
|
||||
}
|
||||
if len(got.FlagsApplied) == 0 || got.FlagsApplied[0] != "with_ccr" {
|
||||
t.Errorf("FlagsApplied = %v, want [with_ccr]", got.FlagsApplied)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing TriggerDate → error", func(t *testing.T) {
|
||||
_, err := svc.CalculateRule(ctx, CalcRuleParams{
|
||||
ProceedingCode: "UPC_INF",
|
||||
RuleLocalCode: "inf.sod",
|
||||
TriggerDate: "",
|
||||
})
|
||||
if err == nil {
|
||||
t.Errorf("expected error for empty triggerDate")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown rule → ErrUnknownRule", func(t *testing.T) {
|
||||
_, err := svc.CalculateRule(ctx, CalcRuleParams{
|
||||
ProceedingCode: "UPC_INF",
|
||||
RuleLocalCode: "totally.fake",
|
||||
TriggerDate: "2026-01-15",
|
||||
})
|
||||
if err == nil {
|
||||
t.Errorf("expected ErrUnknownRule, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user