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; created_by: string; created_at: string; updated_at: string; // Slice C — snapshot of the template body + its version at create time. template_snapshot?: { groups: ChecklistGroup[] } | null; template_version?: number | null; } // Slice C — augmented Checklist with origin + version, returned by // /api/checklists/{slug}. interface ChecklistWithMeta extends Checklist { origin?: "static" | "authored"; version?: number; } 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 { 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 { 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(`
${regimeLabel}
${esc(template.regime)}
`); parts.push(`
${courtLabel}
${esc(court)}
`); if (deadline) { parts.push(`
${deadlineLabel}
${esc(deadline)}
`); } if (reference) { parts.push(`
${refLabel}
${esc(reference)}
`); } if (instance.project_id) { const akteLabel = isEN ? "Project" : "Projekt"; parts.push(``); } document.getElementById("instance-meta")!.innerHTML = parts.join(""); renderOutdatedBadge(); } // Slice C — show an "outdated" badge when the live template has a // version > the instance's snapshot version. Both values must be // non-null for the comparison to be meaningful (pre-Slice-C instances // have NULL template_version; static templates always have version=1 // and never bump). function renderOutdatedBadge() { const slot = document.getElementById("instance-outdated-slot"); if (!slot || !instance || !template) return; const tplMeta = template as ChecklistWithMeta; const instVersion = instance.template_version; const tplVersion = tplMeta.version; if ( instVersion == null || tplVersion == null || tplMeta.origin !== "authored" || tplVersion <= instVersion ) { slot.innerHTML = ""; return; } const badge = esc(t("checklisten.instance.outdated.badge")); const note = esc( t("checklisten.instance.outdated.note") .replace("{from}", String(instVersion)) .replace("{to}", String(tplVersion)), ); const action = esc(t("checklisten.instance.outdated.diff")); slot.innerHTML = `
${badge} ${note}
`; document.getElementById("btn-show-diff")!.addEventListener("click", openDiffModal); } // Shallow diff between two checklist bodies. Compares item label/note/ // rule pairs grouped by section title. Items with the same group title // + same label are matched; differences in note/rule are flagged // 'changed'. Items present only in snapshot are 'removed'; items only // in current are 'added'. function diffBodies(snapshot: { groups: ChecklistGroup[] } | null | undefined, current: ChecklistGroup[]): { added: string[]; removed: string[]; changed: string[] } { const added: string[] = []; const removed: string[] = []; const changed: string[] = []; const oldGroups = snapshot?.groups ?? []; const oldMap: Record = {}; for (const g of oldGroups) { for (const it of g.items) { const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`; oldMap[key] = it; } } const newMap: Record = {}; for (const g of current) { for (const it of g.items) { const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`; newMap[key] = it; if (!(key in oldMap)) { added.push(it.labelDE || it.labelEN); } else { const o = oldMap[key]; if ((o.noteDE || o.noteEN || "") !== (it.noteDE || it.noteEN || "") || (o.rule || "") !== (it.rule || "")) { changed.push(it.labelDE || it.labelEN); } } } } for (const key in oldMap) { if (!(key in newMap)) { const labelParts = key.split("::"); removed.push(labelParts[1] || key); } } return { added, removed, changed }; } function openDiffModal() { if (!template || !instance) return; const modal = document.getElementById("instance-diff-modal")!; const body = document.getElementById("instance-diff-body")!; const diff = diffBodies(instance.template_snapshot, template.groups); const empty = diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0; if (empty) { body.innerHTML = `

${esc(t("checklisten.instance.diff.empty"))}

`; } else { const section = (label: string, klass: string, items: string[]) => { if (items.length === 0) return ""; return `

${esc(label)}

    ${items.map((s) => `
  • ${esc(s)}
  • `).join("")}
`; }; body.innerHTML = [ section(t("checklisten.instance.diff.added"), "instance-diff-added", diff.added), section(t("checklisten.instance.diff.removed"), "instance-diff-removed", diff.removed), section(t("checklisten.instance.diff.changed"), "instance-diff-changed", diff.changed), ].join(""); } modal.style.display = "flex"; } function initDiffModal() { const modal = document.getElementById("instance-diff-modal"); if (!modal) return; const close = () => { modal.style.display = "none"; }; document.getElementById("instance-diff-close")?.addEventListener("click", close); document.getElementById("instance-diff-close-bottom")?.addEventListener("click", close); modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); }); } 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 ? `

${esc(note)}

` : ""; const ruleHTML = rule ? `${esc(rule)}` : ""; return `
  • `; }).join(""); return `

    ${esc(groupTitle)}

      ${items}
    `; }).join(""); container.querySelectorAll(".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) { 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(); initDiffModal(); onLangChange(renderAll); void bootstrap(); });