Files
paliad/frontend/src/client/checklists-instance.ts
m 341fa6c26f fix(t-paliad-112): i18n leaks — deadline_notes_en, trigger-event DE, Checkliste header
Three i18n bugs from the t-paliad-101 QA sweep, fixed together:

B2 — Fristenrechner deadline notes leaked German into the EN locale.
Migration 032 adds paliad.deadline_rules.deadline_notes_en (TEXT NULL)
and backfills English translations for all 30 rules that carry a
deadline_notes value (UPC RoP / EPC / ZPO terminology). The frontend
prefers _en when locale=EN and falls back to deadline_notes (DE) when
the column is NULL, so future seeds without an EN translation render
in DE rather than empty. UIDeadline DTO gains notesEN. The bulk
"Als Frist(en) speichern" CTA now stores the locale-matched note text
so EN users get an EN note alongside the EN title.

B8 — trigger-event picker labels were English-only when DE locale was
active (102 rows, name_de defaulted to '' in 028, frontend already had
the locale switch but no data). Migration 033 backfills name_de for
all 102 trigger events using standard German UPC RoP terminology
(Klageschrift, Klageerwiderung, Replik, Duplik, Nichtigkeitswiderklage,
Verletzungswiderklage, Berufungsschrift/-begründung, Anschlussberufung,
Schutzschrift, Beweissicherung, etc.).

S3 — frontend/src/client/checklists-instance.ts:154 had a hardcoded
"Project" label in both branches of the locale ternary; the DE branch
now reads "Projekt", matching the surrounding meta-item labels' pattern
(Court / Authority → Gericht / Behörde, Reference → Rechtsgrundlage).
2026-05-04 14:36:50 +02:00

395 lines
13 KiB
TypeScript

import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface ChecklistItem {
labelDE: string;
labelEN: string;
noteDE?: string;
noteEN?: string;
rule?: string;
}
interface ChecklistGroup {
titleDE: string;
titleEN: string;
items: ChecklistItem[];
}
interface Checklist {
slug: string;
titleDE: string;
titleEN: string;
descriptionDE: string;
descriptionEN: string;
regime: string;
courtDE: string;
courtEN: string;
deadlineDE?: string;
deadlineEN?: string;
referenceDE?: string;
referenceEN?: string;
groups: ChecklistGroup[];
}
interface Instance {
id: string;
template_slug: string;
name: string;
project_id?: string | null;
state: Record<string, boolean>;
created_by: string;
created_at: string;
updated_at: string;
}
let template: Checklist | null = null;
let instance: Instance | null = null;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function itemKey(groupIdx: number, itemIdx: number): string {
return `g${groupIdx}-i${itemIdx}`;
}
function instanceID(): string {
// /checklisten/instances/{id}
const parts = window.location.pathname.split("/").filter(Boolean);
return parts[2] ?? "";
}
function totalItems(): number {
if (!template) return 0;
return template.groups.reduce((n, g) => n + g.items.length, 0);
}
function doneItems(): number {
if (!instance) return 0;
return Object.values(instance.state || {}).filter(Boolean).length;
}
async function loadInstance(): Promise<boolean> {
const id = instanceID();
if (!id) return false;
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(id)}`);
if (!resp.ok) return false;
instance = await resp.json();
if (instance && typeof instance.state !== "object") instance.state = {};
return true;
}
async function loadTemplate(slug: string): Promise<boolean> {
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}`);
if (!resp.ok) return false;
template = await resp.json();
return true;
}
async function bootstrap() {
const loading = document.getElementById("instance-loading")!;
const notfound = document.getElementById("instance-notfound")!;
const body = document.getElementById("instance-body")!;
const okInst = await loadInstance();
if (!okInst || !instance) {
loading.style.display = "none";
notfound.style.display = "";
document.title = t("checklisten.instance.notfound");
return;
}
const okTpl = await loadTemplate(instance.template_slug);
if (!okTpl || !template) {
loading.style.display = "none";
notfound.style.display = "";
return;
}
loading.style.display = "none";
body.style.display = "";
// Back link goes to the template page.
const back = document.getElementById("instance-back") as HTMLAnchorElement;
back.href = `/checklists/${encodeURIComponent(instance.template_slug)}`;
renderAll();
}
function renderAll() {
if (!template || !instance) return;
renderHeader();
renderGroups();
updateProgress();
}
function renderHeader() {
if (!template || !instance) return;
const isEN = getLang() === "en";
const tplTitle = isEN ? template.titleEN : template.titleDE;
const court = isEN ? template.courtEN : template.courtDE;
const deadline = isEN ? template.deadlineEN : template.deadlineDE;
const reference = isEN ? template.referenceEN : template.referenceDE;
document.title = `${instance.name} — Paliad`;
(document.getElementById("instance-name-display") as HTMLElement).textContent = instance.name;
(document.getElementById("instance-name-edit") as HTMLInputElement).value = instance.name;
(document.getElementById("instance-template-title") as HTMLElement).textContent = tplTitle;
const courtLabel = isEN ? "Court / Authority" : "Gericht / Behörde";
const deadlineLabel = isEN ? "Deadline" : "Deadline";
const refLabel = isEN ? "Reference" : "Rechtsgrundlage";
const regimeLabel = isEN ? "Regime" : "Bereich";
const parts: string[] = [];
parts.push(`<div class="checklist-meta-item"><dt>${regimeLabel}</dt><dd><span class="checklist-regime checklist-regime-${esc(template.regime)}">${esc(template.regime)}</span></dd></div>`);
parts.push(`<div class="checklist-meta-item"><dt>${courtLabel}</dt><dd>${esc(court)}</dd></div>`);
if (deadline) {
parts.push(`<div class="checklist-meta-item"><dt>${deadlineLabel}</dt><dd>${esc(deadline)}</dd></div>`);
}
if (reference) {
parts.push(`<div class="checklist-meta-item"><dt>${refLabel}</dt><dd>${esc(reference)}</dd></div>`);
}
if (instance.project_id) {
const akteLabel = isEN ? "Project" : "Projekt";
parts.push(`<div class="checklist-meta-item"><dt>${akteLabel}</dt><dd><a href="/projects/${esc(instance.project_id)}">${t("checklisten.instance.akte.open") || "Öffnen"}</a></dd></div>`);
}
document.getElementById("instance-meta")!.innerHTML = parts.join("");
}
function renderGroups() {
if (!template || !instance) return;
const isEN = getLang() === "en";
const container = document.getElementById("checklist-groups")!;
const state = instance.state || {};
container.innerHTML = template.groups.map((g, gi) => {
const groupTitle = isEN ? g.titleEN : g.titleDE;
const items = g.items.map((item, ii) => {
const key = itemKey(gi, ii);
const checked = !!state[key];
const label = isEN ? item.labelEN : item.labelDE;
const note = isEN ? item.noteEN : item.noteDE;
const rule = item.rule;
const noteHTML = note ? `<p class="checklist-item-note">${esc(note)}</p>` : "";
const ruleHTML = rule ? `<span class="checklist-item-rule">${esc(rule)}</span>` : "";
return `<li class="checklist-item${checked ? " checked" : ""}" data-key="${key}">
<label class="checklist-item-label">
<input type="checkbox" class="checklist-checkbox" data-key="${key}"${checked ? " checked" : ""} />
<span class="checklist-item-body">
<span class="checklist-item-row">
<span class="checklist-item-text">${esc(label)}</span>
${ruleHTML}
</span>
${noteHTML}
</span>
</label>
</li>`;
}).join("");
return `<section class="checklist-group">
<h2 class="checklist-group-title">${esc(groupTitle)}</h2>
<ol class="checklist-list">${items}</ol>
</section>`;
}).join("");
container.querySelectorAll<HTMLInputElement>(".checklist-checkbox").forEach((cb) => {
cb.addEventListener("change", () => {
if (!instance) return;
const key = cb.dataset.key!;
instance.state[key] = cb.checked;
const li = cb.closest(".checklist-item");
if (li) li.classList.toggle("checked", cb.checked);
updateProgress();
void patchState({ [key]: cb.checked });
});
});
}
function updateProgress() {
const total = totalItems();
const done = doneItems();
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
const fill = document.getElementById("progress-fill");
if (fill) (fill as HTMLElement).style.width = `${pct}%`;
const label = document.getElementById("progress-label");
if (label) {
const doneLabel = getLang() === "en" ? "done" : "erledigt";
label.textContent = `${done} / ${total} ${doneLabel}`;
}
}
async function patchState(patch: Record<string, boolean>) {
if (!instance) return;
try {
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(instance.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: patch }),
});
if (!resp.ok) {
console.warn("patchState failed", resp.status);
// Revert local state on server failure.
for (const k of Object.keys(patch)) instance.state[k] = !patch[k];
renderGroups();
updateProgress();
}
} catch (e) {
console.warn("patchState error", e);
}
}
function initReset() {
const btn = document.getElementById("btn-reset");
if (!btn) return;
btn.addEventListener("click", async () => {
if (!instance) return;
const ok = confirm(t("checklisten.reset.confirm"));
if (!ok) return;
try {
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(instance.id)}/reset`, {
method: "POST",
});
if (!resp.ok) {
alert(t("checklisten.reset.error"));
return;
}
const updated = await resp.json() as Instance;
instance = updated;
if (typeof instance.state !== "object" || instance.state === null) instance.state = {};
renderGroups();
updateProgress();
} catch {
alert(t("checklisten.reset.error"));
}
});
}
function initPrint() {
const btn = document.getElementById("btn-print");
if (!btn) return;
btn.addEventListener("click", () => window.print());
}
function initRename() {
const display = document.getElementById("instance-name-display") as HTMLElement;
const editInput = document.getElementById("instance-name-edit") as HTMLInputElement;
const editBtn = document.getElementById("instance-rename-btn") as HTMLButtonElement;
const saveBtn = document.getElementById("instance-name-save") as HTMLButtonElement;
if (!display || !editInput || !editBtn || !saveBtn) return;
const enterEdit = () => {
if (!instance) return;
editInput.value = instance.name;
display.style.display = "none";
editBtn.style.display = "none";
editInput.style.display = "";
saveBtn.style.display = "";
editInput.focus();
editInput.select();
};
const exitEdit = () => {
display.style.display = "";
editBtn.style.display = "";
editInput.style.display = "none";
saveBtn.style.display = "none";
};
editBtn.addEventListener("click", enterEdit);
saveBtn.addEventListener("click", async () => {
if (!instance) return;
const newName = editInput.value.trim();
if (!newName || newName === instance.name) {
exitEdit();
return;
}
try {
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(instance.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName }),
});
if (!resp.ok) {
alert(t("checklisten.instance.rename.error"));
return;
}
instance = await resp.json();
renderHeader();
exitEdit();
} catch {
alert(t("checklisten.instance.rename.error"));
}
});
editInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); }
if (e.key === "Escape") { e.preventDefault(); exitEdit(); }
});
}
function initFeedback() {
const modal = document.getElementById("feedback-modal")!;
const form = document.getElementById("feedback-form")!;
const msg = document.getElementById("feedback-msg")!;
document.getElementById("btn-feedback")!.addEventListener("click", () => {
msg.textContent = "";
msg.className = "form-msg";
modal.style.display = "flex";
});
document.getElementById("modal-close")!.addEventListener("click", () => { modal.style.display = "none"; });
document.getElementById("modal-cancel")!.addEventListener("click", () => { modal.style.display = "none"; });
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) modal.style.display = "none"; });
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!template) return;
const submitBtn = form.querySelector(".btn-submit") as HTMLButtonElement;
const payload = {
feedback_type: (document.getElementById("feedback-type") as HTMLSelectElement).value,
checklist: template.slug,
message: (document.getElementById("feedback-message") as HTMLTextAreaElement).value.trim(),
};
if (!payload.message) {
msg.textContent = t("checklisten.feedback.error.required");
msg.className = "form-msg form-msg-error";
return;
}
submitBtn.disabled = true;
try {
const resp = await fetch("/api/checklists/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
msg.textContent = t("checklisten.feedback.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
msg.textContent = t("checklisten.feedback.success");
msg.className = "form-msg form-msg-success";
(document.getElementById("feedback-message") as HTMLTextAreaElement).value = "";
setTimeout(() => { modal.style.display = "none"; }, 1500);
} catch {
msg.textContent = t("checklisten.feedback.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initReset();
initPrint();
initRename();
initFeedback();
onLangChange(renderAll);
void bootstrap();
});