The Verfahrensablauf and "Was kommt nach" tabs now render results immediately, without requiring a click on "Fristen berechnen". The button stays as a manual force-recalc affordance. - Pre-select the first proceeding type on load so step 3 has data out of the box. - Pre-select the first trigger event on first event-tab activation (or right after the list loads if the tab was already active). - Auto-recalc on date / proceeding-type / condition-flag change. - Debounce input events to 200ms so spam-edits coalesce into one request, with a per-mode sequence counter so a stale fetch result can never overwrite a fresher one.
886 lines
32 KiB
TypeScript
886 lines
32 KiB
TypeScript
// Fristenrechner client-side logic
|
||
// 3-step wizard: select proceeding -> enter date -> view timeline
|
||
|
||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||
import { initSidebar } from "./sidebar";
|
||
import { projectIndent } from "./project-indent";
|
||
|
||
interface AdjustmentHoliday {
|
||
Date: string;
|
||
Name: string;
|
||
IsVacation: boolean;
|
||
IsClosure: boolean;
|
||
}
|
||
|
||
interface AdjustmentReason {
|
||
kind: "weekend" | "public_holiday" | "vacation";
|
||
holidays?: AdjustmentHoliday[];
|
||
vacation_name?: string;
|
||
vacation_start?: string;
|
||
vacation_end?: string;
|
||
original_weekday?: string;
|
||
}
|
||
|
||
interface CalculatedDeadline {
|
||
code: string;
|
||
name: string;
|
||
nameEN: string;
|
||
party: string;
|
||
isMandatory: boolean;
|
||
ruleRef: string;
|
||
notes?: string;
|
||
notesEN?: string;
|
||
dueDate: string;
|
||
originalDate: string;
|
||
wasAdjusted: boolean;
|
||
adjustmentReason?: AdjustmentReason;
|
||
isRootEvent: boolean;
|
||
isCourtSet: boolean;
|
||
}
|
||
|
||
interface DeadlineResponse {
|
||
proceedingType: string;
|
||
proceedingName: string;
|
||
triggerDate: string;
|
||
deadlines: CalculatedDeadline[];
|
||
}
|
||
|
||
const PARTY_CLASS: Record<string, string> = {
|
||
claimant: "party-claimant",
|
||
defendant: "party-defendant",
|
||
court: "party-court",
|
||
both: "party-both",
|
||
};
|
||
|
||
let lastResponse: DeadlineResponse | null = null;
|
||
|
||
// Auto-calc plumbing: a sequence counter prevents stale fetches from clobbering
|
||
// fresher results, and a single timer debounces rapid input changes.
|
||
let procCalcSeq = 0;
|
||
let procCalcTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
function scheduleProcCalc(delayMs = 200) {
|
||
if (procCalcTimer !== null) clearTimeout(procCalcTimer);
|
||
procCalcTimer = setTimeout(() => {
|
||
procCalcTimer = null;
|
||
void calculate();
|
||
}, delayMs);
|
||
}
|
||
|
||
onLangChange(() => {
|
||
if (lastResponse) renderTimeline(lastResponse);
|
||
// Update trigger event name if a proceeding is selected
|
||
const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
|
||
if (activeBtn) {
|
||
const name = activeBtn.querySelector("strong")?.textContent || "";
|
||
document.getElementById("trigger-event")!.textContent = name;
|
||
}
|
||
});
|
||
|
||
function formatDate(dateStr: string): string {
|
||
if (!dateStr) return "\u2014";
|
||
const d = new Date(dateStr + "T00:00:00");
|
||
if (getLang() === "en") {
|
||
// ISO date (YYYY-MM-DD) \u2014 unambiguous for both US and intl readers, since
|
||
// en-GB renders dd/mm/yyyy which US users misread as mm/dd/yyyy.
|
||
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
|
||
const yyyy = d.getFullYear();
|
||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||
const dd = String(d.getDate()).padStart(2, "0");
|
||
return `${weekday}, ${yyyy}-${mm}-${dd}`;
|
||
}
|
||
return d.toLocaleDateString("de-DE", {
|
||
weekday: "short",
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
});
|
||
}
|
||
|
||
function partyBadge(party: string): string {
|
||
const cls = PARTY_CLASS[party] || "party-both";
|
||
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
|
||
}
|
||
|
||
// Short date span like "27.7.–28.8." (DE) or "27 Jul – 28 Aug" (EN). Used in
|
||
// the vacation adjustment label, where the explicit weekday + year would
|
||
// just be noise — the surrounding sentence carries the full year via the
|
||
// dueDate / originalDate that the note brackets.
|
||
function formatDateSpan(startISO: string, endISO: string): string {
|
||
const start = new Date(startISO + "T00:00:00");
|
||
const end = new Date(endISO + "T00:00:00");
|
||
if (getLang() === "en") {
|
||
const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
|
||
return `${fmt(start)} – ${fmt(end)}`;
|
||
}
|
||
const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
|
||
return `${fmt(start)}–${fmt(end)}`;
|
||
}
|
||
|
||
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
|
||
// vacation"). The Fristenrechner doesn't translate them: they're proper
|
||
// names of court-set closures, not generic strings, and rotating them via
|
||
// i18n.ts duplicates state that should live in the DB. Rename in the seed
|
||
// if the wording needs to change.
|
||
function localizeVacationName(name: string): string {
|
||
return name;
|
||
}
|
||
|
||
function localizeWeekday(en: string): string {
|
||
if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
|
||
if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
|
||
return en;
|
||
}
|
||
|
||
// Backend-shaped reason → human-readable phrase ("UPC-Gerichtsferien
|
||
// (27.7.–28.8.)" / "Karfreitag holiday" / "Wochenende"). See t-paliad-119.
|
||
function renderAdjustmentReason(r: AdjustmentReason): string {
|
||
if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
|
||
const span = formatDateSpan(r.vacation_start, r.vacation_end);
|
||
return tDyn("deadlines.adjusted.vacation")
|
||
.replace("{name}", localizeVacationName(r.vacation_name))
|
||
.replace("{span}", span);
|
||
}
|
||
if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
|
||
return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
|
||
}
|
||
if (r.kind === "weekend" && r.original_weekday) {
|
||
return localizeWeekday(r.original_weekday);
|
||
}
|
||
return t("deadlines.adjusted.weekend");
|
||
}
|
||
|
||
// "Verschoben wegen X: A → B" (DE) / "Shifted (X): A → B" (EN). Falls back
|
||
// to the legacy "Wochenende/Feiertag" string when the backend hasn't sent a
|
||
// structured reason — keeps older API responses readable.
|
||
function formatAdjustedNote(dl: CalculatedDeadline): string {
|
||
const arrow = `${formatDate(dl.originalDate)} → ${formatDate(dl.dueDate)}`;
|
||
const reason = dl.adjustmentReason
|
||
? renderAdjustmentReason(dl.adjustmentReason)
|
||
: t("deadlines.adjusted.reason");
|
||
if (getLang() === "en") {
|
||
return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
|
||
}
|
||
return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
|
||
}
|
||
|
||
let selectedType = "";
|
||
|
||
function showStep(n: number) {
|
||
for (let i = 1; i <= 3; i++) {
|
||
const el = document.getElementById(`step-${i}`);
|
||
if (el) el.style.display = i <= n ? "block" : "none";
|
||
}
|
||
const resetBtn = document.getElementById("reset-btn")!;
|
||
resetBtn.style.display = n > 1 ? "block" : "none";
|
||
}
|
||
|
||
async function calculate() {
|
||
const seq = ++procCalcSeq;
|
||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement;
|
||
const triggerDate = dateInput.value;
|
||
if (!triggerDate || !selectedType) return;
|
||
|
||
// Priority date — only meaningful for EP_GRANT (Art. 93 EPÜ publish-anchor).
|
||
const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
|
||
const priorityDate = selectedType === "EP_GRANT" && priorityInput?.value ? priorityInput.value : "";
|
||
|
||
// Flags — UPC_INF surfaces "Mit Widerklage auf Nichtigkeit" toggle.
|
||
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||
const flags: string[] = [];
|
||
if (selectedType === "UPC_INF" && ccrFlag?.checked) flags.push("with_ccr");
|
||
|
||
try {
|
||
const resp = await fetch("/api/tools/fristenrechner", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
proceedingType: selectedType,
|
||
triggerDate,
|
||
priorityDate: priorityDate || undefined,
|
||
flags: flags.length > 0 ? flags : undefined,
|
||
}),
|
||
});
|
||
|
||
if (seq !== procCalcSeq) return;
|
||
if (!resp.ok) {
|
||
const err = await resp.json();
|
||
console.error("API error:", err);
|
||
return;
|
||
}
|
||
|
||
const data: DeadlineResponse = await resp.json();
|
||
if (seq !== procCalcSeq) return;
|
||
lastResponse = data;
|
||
renderTimeline(data);
|
||
showStep(3);
|
||
} catch (e) {
|
||
console.error("Fetch error:", e);
|
||
}
|
||
}
|
||
|
||
interface ProjectOption {
|
||
id: string;
|
||
reference?: string | null;
|
||
title: string;
|
||
path: string;
|
||
}
|
||
|
||
function escAttr(s: string): string {
|
||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||
}
|
||
|
||
function escHtml(s: string): string {
|
||
const d = document.createElement("div");
|
||
d.textContent = s;
|
||
return d.innerHTML;
|
||
}
|
||
|
||
async function fetchProjects(): Promise<ProjectOption[]> {
|
||
try {
|
||
const resp = await fetch("/api/projects");
|
||
if (!resp.ok) return [];
|
||
return (await resp.json()) as ProjectOption[];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function ensureSaveModal() {
|
||
if (document.getElementById("frist-save-modal")) return;
|
||
const modal = document.createElement("div");
|
||
modal.id = "frist-save-modal";
|
||
modal.className = "modal-overlay";
|
||
modal.style.display = "none";
|
||
modal.innerHTML = `
|
||
<div class="modal-card">
|
||
<div class="modal-header">
|
||
<h2 data-i18n="deadlines.save.modal.title">${escHtml(t("deadlines.save.modal.title"))}</h2>
|
||
<button class="modal-close" id="frist-save-modal-close" type="button">×</button>
|
||
</div>
|
||
<div class="form-field">
|
||
<label for="frist-save-project" data-i18n="deadlines.save.modal.akte">${escHtml(t("deadlines.save.modal.akte"))}</label>
|
||
<select id="frist-save-project"></select>
|
||
<p class="form-hint" id="frist-save-no-akten" style="display:none">
|
||
<span data-i18n="deadlines.save.modal.no_akten">${escHtml(t("deadlines.save.modal.no_akten"))}</span>
|
||
<a href="/projects/new" data-i18n="deadlines.save.modal.no_akten.link">${escHtml(t("deadlines.save.modal.no_akten.link"))}</a>
|
||
</p>
|
||
</div>
|
||
<div class="form-field">
|
||
<p data-i18n="deadlines.save.modal.choose">${escHtml(t("deadlines.save.modal.choose"))}</p>
|
||
<ul class="frist-save-list" id="frist-save-list"></ul>
|
||
<p class="form-hint" data-i18n="deadlines.save.skip_court_set">${escHtml(t("deadlines.save.skip_court_set"))}</p>
|
||
</div>
|
||
<p class="form-msg" id="frist-save-msg"></p>
|
||
<div class="form-actions">
|
||
<button type="button" class="btn-cancel" id="frist-save-cancel" data-i18n="deadlines.save.modal.cancel">${escHtml(t("deadlines.save.modal.cancel"))}</button>
|
||
<button type="button" class="btn-primary btn-cta-lime" id="frist-save-submit" data-i18n="deadlines.save.modal.submit">${escHtml(t("deadlines.save.modal.submit"))}</button>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(modal);
|
||
|
||
modal.addEventListener("click", (e) => {
|
||
if (e.target === e.currentTarget) closeSaveModal();
|
||
});
|
||
document.getElementById("frist-save-modal-close")!.addEventListener("click", closeSaveModal);
|
||
document.getElementById("frist-save-cancel")!.addEventListener("click", closeSaveModal);
|
||
document.getElementById("frist-save-submit")!.addEventListener("click", submitSave);
|
||
}
|
||
|
||
function closeSaveModal() {
|
||
const modal = document.getElementById("frist-save-modal");
|
||
if (modal) modal.style.display = "none";
|
||
}
|
||
|
||
async function openSaveModal() {
|
||
if (!lastResponse) return;
|
||
ensureSaveModal();
|
||
const projects = await fetchProjects();
|
||
const sel = document.getElementById("frist-save-project") as HTMLSelectElement;
|
||
const noProjects = document.getElementById("frist-save-no-akten")!;
|
||
const submit = document.getElementById("frist-save-submit") as HTMLButtonElement;
|
||
|
||
if (projects.length === 0) {
|
||
sel.style.display = "none";
|
||
noProjects.style.display = "";
|
||
submit.disabled = true;
|
||
} else {
|
||
sel.style.display = "";
|
||
noProjects.style.display = "none";
|
||
submit.disabled = false;
|
||
sel.innerHTML = projects
|
||
.map((p) => {
|
||
const ref = (p.reference || "").trim();
|
||
const indent = projectIndent(p.path);
|
||
const label = ref
|
||
? `${indent}${escHtml(ref)} \u2014 ${escHtml(p.title)}`
|
||
: `${indent}${escHtml(p.title)}`;
|
||
return `<option value="${escAttr(p.id)}">${label}</option>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
const list = document.getElementById("frist-save-list")!;
|
||
list.innerHTML = lastResponse.deadlines
|
||
.map((dl, idx) => {
|
||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||
// Court-determined entries (interim conference, oral hearing, decision,
|
||
// any party=court row) have no calculable date — disable + pre-uncheck
|
||
// so users don't save the trigger-date placeholder as a real deadline.
|
||
const isCourtDetermined = dl.isCourtSet || dl.party === "court";
|
||
const disabled = isCourtDetermined || !dl.dueDate;
|
||
const checked = !disabled;
|
||
const meta = isCourtDetermined
|
||
? `<span class="frist-save-meta">${escHtml(t("deadlines.court.set"))}</span>`
|
||
: `<span class="frist-save-meta">${escHtml(formatDate(dl.dueDate))}</span>`;
|
||
return `<li class="frist-save-row">
|
||
<label>
|
||
<input type="checkbox" data-idx="${idx}" ${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
|
||
<span class="frist-save-title">${escHtml(dlName)}</span>
|
||
${meta}
|
||
</label>
|
||
</li>`;
|
||
})
|
||
.join("");
|
||
|
||
document.getElementById("frist-save-msg")!.textContent = "";
|
||
document.getElementById("frist-save-modal")!.style.display = "flex";
|
||
}
|
||
|
||
async function submitSave() {
|
||
if (!lastResponse) return;
|
||
const sel = document.getElementById("frist-save-project") as HTMLSelectElement;
|
||
const projectID = sel.value;
|
||
const submit = document.getElementById("frist-save-submit") as HTMLButtonElement;
|
||
const msg = document.getElementById("frist-save-msg")!;
|
||
if (!projectID) return;
|
||
|
||
const checks = document.querySelectorAll<HTMLInputElement>("#frist-save-list input[type=checkbox]");
|
||
const deadlinesPayload: Array<Record<string, unknown>> = [];
|
||
checks.forEach((cb) => {
|
||
if (!cb.checked || cb.disabled) return;
|
||
const idx = Number(cb.dataset.idx);
|
||
const dl = lastResponse!.deadlines[idx];
|
||
if (!dl || !dl.dueDate) return;
|
||
const isEN = getLang() === "en";
|
||
const dlName = isEN ? dl.nameEN : dl.name;
|
||
const dlNotes = isEN ? (dl.notesEN || dl.notes) : dl.notes;
|
||
deadlinesPayload.push({
|
||
title: dlName,
|
||
rule_code: dl.ruleRef || undefined,
|
||
due_date: dl.dueDate,
|
||
original_due_date: dl.originalDate || undefined,
|
||
source: "fristenrechner",
|
||
notes: dlNotes || undefined,
|
||
});
|
||
});
|
||
if (deadlinesPayload.length === 0) return;
|
||
|
||
submit.disabled = true;
|
||
try {
|
||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines/bulk`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ deadlines: deadlinesPayload }),
|
||
});
|
||
if (!resp.ok) {
|
||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||
msg.textContent = data.error || t("deadlines.save.error");
|
||
msg.className = "form-msg form-msg-error";
|
||
submit.disabled = false;
|
||
return;
|
||
}
|
||
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(projectID)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
|
||
msg.className = "form-msg form-msg-ok";
|
||
// Re-enable after a short delay so user can read it; modal stays open with the link.
|
||
setTimeout(() => {
|
||
submit.disabled = false;
|
||
}, 1500);
|
||
} catch {
|
||
msg.textContent = t("deadlines.save.error");
|
||
msg.className = "form-msg form-msg-error";
|
||
submit.disabled = false;
|
||
}
|
||
}
|
||
|
||
function renderTimeline(data: DeadlineResponse) {
|
||
const container = document.getElementById("timeline-container")!;
|
||
const printBtn = document.getElementById("fristen-print-btn")!;
|
||
const saveBtn = document.getElementById("fristen-save-cta") as HTMLButtonElement | null;
|
||
|
||
const procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
|
||
|
||
let html = `<div class="timeline-header">
|
||
<strong>${procName}</strong>
|
||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||
</div>`;
|
||
|
||
html += '<div class="timeline">';
|
||
|
||
for (const dl of data.deadlines) {
|
||
const dateStr = dl.isCourtSet
|
||
? `<span class="timeline-court-set">${t("deadlines.court.set")}</span>`
|
||
: `<span class="timeline-date">${formatDate(dl.dueDate)}</span>`;
|
||
|
||
const mandatoryBadge = dl.isMandatory
|
||
? ""
|
||
: '<span class="optional-badge">optional</span>';
|
||
|
||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||
|
||
const adjustedNote = dl.wasAdjusted
|
||
? `<div class="timeline-adjusted">\u26a0 ${formatAdjustedNote(dl)}</div>`
|
||
: "";
|
||
|
||
const ruleRef = dl.ruleRef
|
||
? `<span class="timeline-rule">${dl.ruleRef}</span>`
|
||
: "";
|
||
|
||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||
const notes = noteText
|
||
? `<div class="timeline-notes">${noteText}</div>`
|
||
: "";
|
||
|
||
html += `
|
||
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
|
||
<div class="timeline-dot-col">
|
||
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
||
<div class="timeline-line"></div>
|
||
</div>
|
||
<div class="timeline-content">
|
||
<div class="timeline-item-header">
|
||
<span class="timeline-name">
|
||
${dlName}
|
||
${mandatoryBadge}
|
||
</span>
|
||
${dateStr}
|
||
</div>
|
||
<div class="timeline-meta">
|
||
${partyBadge(dl.party)}
|
||
${ruleRef}
|
||
</div>
|
||
${adjustedNote}
|
||
${notes}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
html += "</div>";
|
||
container.innerHTML = html;
|
||
printBtn.style.display = "block";
|
||
if (saveBtn) saveBtn.style.display = "block";
|
||
}
|
||
|
||
function reset() {
|
||
selectedType = "";
|
||
lastResponse = null;
|
||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||
document.getElementById("timeline-container")!.innerHTML = "";
|
||
document.getElementById("fristen-print-btn")!.style.display = "none";
|
||
const saveBtn = document.getElementById("fristen-save-cta");
|
||
if (saveBtn) saveBtn.style.display = "none";
|
||
showStep(1);
|
||
}
|
||
|
||
function selectProceeding(btn: HTMLButtonElement) {
|
||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
selectedType = btn.dataset.code!;
|
||
|
||
// Update trigger event name
|
||
const name = btn.querySelector("strong")?.textContent || "";
|
||
document.getElementById("trigger-event")!.textContent = name;
|
||
|
||
// Conditional inputs: priority date for EP_GRANT, CCR toggle for UPC_INF.
|
||
const priorityRow = document.getElementById("priority-date-row");
|
||
if (priorityRow) priorityRow.style.display = selectedType === "EP_GRANT" ? "" : "none";
|
||
const ccrRow = document.getElementById("ccr-flag-row");
|
||
if (ccrRow) ccrRow.style.display = selectedType === "UPC_INF" ? "" : "none";
|
||
|
||
showStep(2);
|
||
scheduleProcCalc(0);
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
initI18n();
|
||
initSidebar();
|
||
|
||
// Proceeding type selection
|
||
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
|
||
btn.addEventListener("click", () => selectProceeding(btn));
|
||
});
|
||
|
||
// Calculate button — manual force-recalc affordance.
|
||
document.getElementById("calculate-btn")!.addEventListener("click", () => scheduleProcCalc(0));
|
||
|
||
// Auto-recalc on input changes. Date `change` covers picker and blur;
|
||
// `input` covers manual typing. The Enter key on the date field bypasses
|
||
// debounce for keyboard-savvy users.
|
||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement;
|
||
dateInput.addEventListener("change", () => scheduleProcCalc());
|
||
dateInput.addEventListener("input", () => scheduleProcCalc());
|
||
dateInput.addEventListener("keydown", (e) => {
|
||
if ((e as KeyboardEvent).key === "Enter") scheduleProcCalc(0);
|
||
});
|
||
|
||
const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
|
||
if (priorityInput) {
|
||
priorityInput.addEventListener("change", () => scheduleProcCalc());
|
||
priorityInput.addEventListener("input", () => scheduleProcCalc());
|
||
}
|
||
|
||
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||
if (ccrFlag) ccrFlag.addEventListener("change", () => scheduleProcCalc(0));
|
||
|
||
// Reset button
|
||
document.getElementById("reset-btn")!.addEventListener("click", reset);
|
||
|
||
// Print button
|
||
document.getElementById("fristen-print-btn")!.addEventListener("click", () => window.print());
|
||
|
||
// Save-to-Project CTA (Phase E)
|
||
const saveBtn = document.getElementById("fristen-save-cta");
|
||
if (saveBtn) saveBtn.addEventListener("click", openSaveModal);
|
||
|
||
// Tab switching between "Verfahrensablauf" and "Was kommt nach…" modes.
|
||
initModeTabs();
|
||
|
||
// Event-mode wiring (PR-2: youpc-parity trigger-event lookup)
|
||
initEventMode();
|
||
|
||
// Pre-select the first proceeding button so the deadline list renders
|
||
// immediately on page load — no click on "Fristen berechnen" required.
|
||
// This also fires the first auto-calc via scheduleProcCalc().
|
||
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
|
||
if (firstBtn) selectProceeding(firstBtn);
|
||
});
|
||
|
||
// ============================================================================
|
||
// Mode tabs (procedure vs event)
|
||
// ============================================================================
|
||
|
||
function initModeTabs() {
|
||
const tabs = document.querySelectorAll<HTMLButtonElement>(".mode-tab");
|
||
if (tabs.length === 0) return;
|
||
|
||
tabs.forEach((tab) => {
|
||
tab.addEventListener("click", () => {
|
||
const mode = tab.dataset.mode;
|
||
if (!mode) return;
|
||
tabs.forEach((t) => {
|
||
const isActive = t === tab;
|
||
t.classList.toggle("is-active", isActive);
|
||
t.setAttribute("aria-selected", String(isActive));
|
||
});
|
||
document.querySelectorAll<HTMLElement>(".mode-panel").forEach((panel) => {
|
||
panel.hidden = panel.dataset.mode !== mode;
|
||
});
|
||
// Auto-calc on tab activation. Procedure mode self-bootstraps via the
|
||
// pre-selected proceeding button on init, so the only special case is
|
||
// event mode: pick a default trigger event the first time the tab is
|
||
// shown so step 3 isn't empty.
|
||
if (mode === "event") ensureDefaultTriggerEvent();
|
||
});
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Event mode: pick a trigger event, see all deadlines that flow from it.
|
||
// Mirrors youpc.org's deadline calculator. Uses /api/tools/trigger-events
|
||
// (list) and /api/tools/event-deadlines (compute).
|
||
// ============================================================================
|
||
|
||
interface TriggerEventSummary {
|
||
id: number;
|
||
code: string;
|
||
name: string;
|
||
name_de: string;
|
||
}
|
||
|
||
interface EventDeadlineResult {
|
||
id: number;
|
||
title: string;
|
||
titleDE: string;
|
||
durationValue: number;
|
||
durationUnit: string;
|
||
timing: string;
|
||
notes?: string;
|
||
notesEN?: string;
|
||
ruleCodes: string[];
|
||
dueDate: string;
|
||
originalDueDate: string;
|
||
wasAdjusted: boolean;
|
||
isComposite?: boolean;
|
||
compositeNote?: string;
|
||
}
|
||
|
||
interface EventCalculateResponse {
|
||
triggerEvent: TriggerEventSummary;
|
||
triggerDate: string;
|
||
deadlines: EventDeadlineResult[];
|
||
}
|
||
|
||
let triggerEvents: TriggerEventSummary[] = [];
|
||
let selectedTrigger: TriggerEventSummary | null = null;
|
||
let lastEventResponse: EventCalculateResponse | null = null;
|
||
|
||
// Auto-calc plumbing for event mode — same shape as procedure mode.
|
||
let eventCalcSeq = 0;
|
||
let eventCalcTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
function scheduleEventCalc(delayMs = 200) {
|
||
if (eventCalcTimer !== null) clearTimeout(eventCalcTimer);
|
||
eventCalcTimer = setTimeout(() => {
|
||
eventCalcTimer = null;
|
||
void calculateEvent();
|
||
}, delayMs);
|
||
}
|
||
|
||
function showEventStep(n: number) {
|
||
for (let i = 1; i <= 3; i++) {
|
||
const el = document.getElementById(`event-step-${i}`);
|
||
if (el) el.style.display = i <= n ? "block" : "none";
|
||
}
|
||
const resetBtn = document.getElementById("event-reset-btn");
|
||
if (resetBtn) resetBtn.style.display = n > 1 ? "block" : "none";
|
||
}
|
||
|
||
function eventName(ev: TriggerEventSummary): string {
|
||
// Fall back to English when name_de is empty (default seed has empty DE).
|
||
return getLang() === "de" && ev.name_de ? ev.name_de : ev.name;
|
||
}
|
||
|
||
function deadlineTitle(d: EventDeadlineResult): string {
|
||
return getLang() === "de" && d.titleDE ? d.titleDE : d.title;
|
||
}
|
||
|
||
function unitLabel(unit: string, value: number): string {
|
||
const key = `deadlines.event.unit.${unit}` + (value === 1 ? ".one" : ".many");
|
||
return tDyn(key);
|
||
}
|
||
|
||
function timingLabel(timing: string): string {
|
||
return tDyn(`deadlines.event.timing.${timing}`);
|
||
}
|
||
|
||
function renderEventList(query: string) {
|
||
const list = document.getElementById("event-list");
|
||
if (!list) return;
|
||
const q = query.trim().toLowerCase();
|
||
const matches = q
|
||
? triggerEvents.filter(
|
||
(ev) =>
|
||
ev.name.toLowerCase().includes(q) ||
|
||
(ev.name_de && ev.name_de.toLowerCase().includes(q)) ||
|
||
ev.code.toLowerCase().includes(q),
|
||
)
|
||
: triggerEvents;
|
||
|
||
if (matches.length === 0) {
|
||
list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.empty"))}</li>`;
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = matches
|
||
.map(
|
||
(ev) =>
|
||
`<li class="event-list-item" role="option" data-id="${ev.id}" tabindex="0">${escHtml(eventName(ev))}</li>`,
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
async function loadTriggerEvents() {
|
||
const list = document.getElementById("event-list");
|
||
if (!list) return;
|
||
list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.loading"))}</li>`;
|
||
try {
|
||
const resp = await fetch("/api/tools/trigger-events");
|
||
if (!resp.ok) {
|
||
list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.error"))}</li>`;
|
||
return;
|
||
}
|
||
triggerEvents = (await resp.json()) as TriggerEventSummary[];
|
||
renderEventList("");
|
||
// If the user already switched to the event tab while the list was
|
||
// loading, pre-select now so they don't see an empty step 1.
|
||
const eventTab = document.getElementById("mode-event-tab");
|
||
if (eventTab?.classList.contains("is-active")) ensureDefaultTriggerEvent();
|
||
} catch {
|
||
list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.error"))}</li>`;
|
||
}
|
||
}
|
||
|
||
function selectTriggerEvent(id: number) {
|
||
const ev = triggerEvents.find((e) => e.id === id);
|
||
if (!ev) return;
|
||
selectedTrigger = ev;
|
||
const nameEl = document.getElementById("event-selected-name");
|
||
if (nameEl) nameEl.textContent = eventName(ev);
|
||
showEventStep(2);
|
||
scheduleEventCalc(0);
|
||
}
|
||
|
||
async function calculateEvent() {
|
||
const seq = ++eventCalcSeq;
|
||
if (!selectedTrigger) return;
|
||
const dateInput = document.getElementById("event-date") as HTMLInputElement | null;
|
||
const triggerDate = dateInput?.value;
|
||
if (!triggerDate) return;
|
||
|
||
try {
|
||
const resp = await fetch("/api/tools/event-deadlines", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ triggerEventId: selectedTrigger.id, triggerDate }),
|
||
});
|
||
if (seq !== eventCalcSeq) return;
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
console.error("event API error:", err);
|
||
return;
|
||
}
|
||
const data = (await resp.json()) as EventCalculateResponse;
|
||
if (seq !== eventCalcSeq) return;
|
||
lastEventResponse = data;
|
||
renderEventResults(data);
|
||
showEventStep(3);
|
||
} catch (e) {
|
||
console.error("event fetch error:", e);
|
||
}
|
||
}
|
||
|
||
function renderEventResults(data: EventCalculateResponse) {
|
||
const container = document.getElementById("event-results-container");
|
||
const printBtn = document.getElementById("event-print-btn");
|
||
if (!container) return;
|
||
|
||
if (data.deadlines.length === 0) {
|
||
container.innerHTML = `<p class="event-results-empty">${escHtml(t("deadlines.event.noresults"))}</p>`;
|
||
if (printBtn) printBtn.style.display = "none";
|
||
return;
|
||
}
|
||
|
||
const triggerName = eventName(data.triggerEvent);
|
||
const triggerDate = formatDate(data.triggerDate);
|
||
|
||
const rowsHtml = data.deadlines
|
||
.map((d) => {
|
||
const codes = d.ruleCodes
|
||
.map((c) => `<span class="event-rule-code">${escHtml(c)}</span>`)
|
||
.join(" ");
|
||
const original = d.wasAdjusted
|
||
? `<div class="event-result-adjusted">${escHtml(
|
||
tDyn("deadlines.event.adjusted") + " " + formatDate(d.originalDueDate),
|
||
)}</div>`
|
||
: "";
|
||
const composite = d.isComposite && d.compositeNote
|
||
? `<div class="event-result-composite" title="${escAttr(d.compositeNote)}">${escHtml(t("deadlines.event.composite.label"))} ${escHtml(d.compositeNote)}</div>`
|
||
: "";
|
||
const noteText = getLang() === "en" ? (d.notesEN || d.notes) : d.notes;
|
||
const notes = noteText
|
||
? `<div class="event-result-notes">${escHtml(noteText)}</div>`
|
||
: "";
|
||
return `<li class="event-result-row">
|
||
<div class="event-result-header">
|
||
<span class="event-result-title">${escHtml(deadlineTitle(d))}</span>
|
||
<span class="event-result-date">${escHtml(formatDate(d.dueDate))}</span>
|
||
</div>
|
||
<div class="event-result-meta">
|
||
<span class="event-result-duration">${d.durationValue} ${escHtml(unitLabel(d.durationUnit, d.durationValue))} ${escHtml(timingLabel(d.timing))}</span>
|
||
${codes}
|
||
</div>
|
||
${composite}
|
||
${original}
|
||
${notes}
|
||
</li>`;
|
||
})
|
||
.join("");
|
||
|
||
container.innerHTML = `
|
||
<div class="event-results-header">
|
||
<div><strong>${escHtml(t("deadlines.event.results.trigger"))}</strong> ${escHtml(triggerName)}</div>
|
||
<div><strong>${escHtml(t("deadlines.event.results.date"))}</strong> ${escHtml(triggerDate)}</div>
|
||
</div>
|
||
<ul class="event-result-list">${rowsHtml}</ul>`;
|
||
|
||
if (printBtn) printBtn.style.display = "block";
|
||
}
|
||
|
||
function resetEventMode() {
|
||
selectedTrigger = null;
|
||
lastEventResponse = null;
|
||
const search = document.getElementById("event-search") as HTMLInputElement | null;
|
||
if (search) search.value = "";
|
||
renderEventList("");
|
||
showEventStep(1);
|
||
}
|
||
|
||
function initEventMode() {
|
||
const search = document.getElementById("event-search") as HTMLInputElement | null;
|
||
if (!search) return; // page didn't render the event mode (older bundle)
|
||
|
||
loadTriggerEvents();
|
||
|
||
search.addEventListener("input", () => renderEventList(search.value));
|
||
|
||
const list = document.getElementById("event-list");
|
||
if (list) {
|
||
list.addEventListener("click", (e) => {
|
||
const target = (e.target as HTMLElement).closest<HTMLLIElement>(".event-list-item");
|
||
if (!target) return;
|
||
const id = Number(target.dataset.id);
|
||
if (!Number.isFinite(id)) return;
|
||
selectTriggerEvent(id);
|
||
});
|
||
list.addEventListener("keydown", (e) => {
|
||
const ke = e as KeyboardEvent;
|
||
if (ke.key !== "Enter" && ke.key !== " ") return;
|
||
const target = (ke.target as HTMLElement).closest<HTMLLIElement>(".event-list-item");
|
||
if (!target) return;
|
||
ke.preventDefault();
|
||
const id = Number(target.dataset.id);
|
||
if (Number.isFinite(id)) selectTriggerEvent(id);
|
||
});
|
||
}
|
||
|
||
// Event-tab calculate button — manual force-recalc affordance.
|
||
document.getElementById("event-calculate-btn")?.addEventListener("click", () => scheduleEventCalc(0));
|
||
|
||
// Auto-recalc when the user changes the event date.
|
||
const eventDate = document.getElementById("event-date") as HTMLInputElement | null;
|
||
if (eventDate) {
|
||
eventDate.addEventListener("change", () => scheduleEventCalc());
|
||
eventDate.addEventListener("input", () => scheduleEventCalc());
|
||
eventDate.addEventListener("keydown", (e) => {
|
||
if ((e as KeyboardEvent).key === "Enter") scheduleEventCalc(0);
|
||
});
|
||
}
|
||
|
||
document.getElementById("event-reset-btn")?.addEventListener("click", resetEventMode);
|
||
document.getElementById("event-print-btn")?.addEventListener("click", () => window.print());
|
||
}
|
||
|
||
// Pre-select the first trigger event so the "Was kommt nach" tab renders
|
||
// immediately with default selection + today's date. Idempotent — only fires
|
||
// when there's no existing selection. Called both after the event list loads
|
||
// and when the user first activates the event tab, since either one can be
|
||
// the trigger depending on which finishes first.
|
||
function ensureDefaultTriggerEvent() {
|
||
if (selectedTrigger || triggerEvents.length === 0) return;
|
||
selectTriggerEvent(triggerEvents[0].id);
|
||
}
|
||
|
||
// Re-render event results when language flips (titles/notes are bilingual).
|
||
onLangChange(() => {
|
||
if (lastEventResponse) renderEventResults(lastEventResponse);
|
||
if (selectedTrigger) {
|
||
const nameEl = document.getElementById("event-selected-name");
|
||
if (nameEl) nameEl.textContent = eventName(selectedTrigger);
|
||
}
|
||
if (triggerEvents.length > 0) {
|
||
const search = document.getElementById("event-search") as HTMLInputElement | null;
|
||
renderEventList(search?.value || "");
|
||
}
|
||
});
|