Files
paliad/frontend/src/client/checklists-instance.ts
mAi e56cb3b210 feat(checklists): t-paliad-225 Slice C frontend — Geteilte Vorlagen tab + outdated-template badge
m/paliad#61 Slice C frontend pass.

Discovery (Geteilte Vorlagen):
- New 4th tab on /checklists between "Meine Vorlagen" and "Vorhandene
  Instanzen". Filters the merged catalog response to authored entries
  not owned by the caller (firm-visible OR globally-promoted OR
  share-recipient). Tab state round-trips via ?tab=gallery.
- Regime filter pills (UPC / DE / EPA / OTHER) operate independently
  from the main Vorlagen tab.
- Cards show regime badge, item count, author line, visibility chip.
- Self-filter relies on /api/me email match — loadMe() fires once on
  page boot and is idempotent.

Versioning UI on /checklists/instances/{id}:
- "Vorlage aktualisiert" badge appears when the instance's
  template_version is known AND lags the live template version (only
  for authored templates; static templates never bump). Shows "v{from}
  → v{to}" delta.
- "Änderungen anzeigen" button opens a diff modal that compares the
  instance's template_snapshot against the live template body.
  Item-level grouping by (section title, item label). Surfaces added /
  removed / changed items with localised section labels. Empty state
  when only metadata changed.

i18n: 13 new keys per language (DE + EN) under
checklisten.tab.gallery, checklisten.gallery.*, checklisten.filter.other,
and checklisten.instance.{outdated,diff}.*. Total 2666 keys.

Build hygiene: bun run build clean; i18n scan clean. Go build/vet/test
+ TestBootSmoke ./cmd/server/ all green.
2026-05-20 15:50:38 +02:00

519 lines
18 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;
// 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<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("");
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 = `<div class="instance-outdated-banner">
<span class="instance-outdated-badge">${badge}</span>
<span class="instance-outdated-note">${note}</span>
<button type="button" class="btn-small" id="btn-show-diff">${action}</button>
</div>`;
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<string, ChecklistItem> = {};
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<string, ChecklistItem> = {};
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 = `<p class="entity-events-empty">${esc(t("checklisten.instance.diff.empty"))}</p>`;
} else {
const section = (label: string, klass: string, items: string[]) => {
if (items.length === 0) return "";
return `<section class="instance-diff-section ${klass}">
<h3>${esc(label)}</h3>
<ul>${items.map((s) => `<li>${esc(s)}</li>`).join("")}</ul>
</section>`;
};
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 ? `<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();
initDiffModal();
onLangChange(renderAll);
void bootstrap();
});