Files
paliad/frontend/src/client/checklists.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

426 lines
14 KiB
TypeScript

import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface ChecklistSummary {
slug: string;
titleDE: string;
titleEN: string;
descriptionDE: string;
descriptionEN: string;
regime: string;
courtDE: string;
courtEN: string;
itemCount: number;
origin?: "static" | "authored";
visibility?: string;
owner_email?: string;
owner_display_name?: string;
}
interface MyChecklist {
id: string;
slug: string;
owner_id: string;
title: string;
description: string;
regime: string;
court: string;
reference: string;
deadline: string;
lang: string;
visibility: string;
created_at: string;
updated_at: string;
}
interface ChecklistInstance {
id: string;
template_slug: string;
name: string;
project_id?: string | null;
state: Record<string, boolean>;
created_by: string;
created_at: string;
updated_at: string;
project_reference?: string | null;
project_title?: string | null;
}
type TabId = "templates" | "mine" | "gallery" | "instances";
const VALID_TABS: TabId[] = ["templates", "mine", "gallery", "instances"];
let allChecklists: ChecklistSummary[] = [];
let activeRegime = "all";
let galleryRegime = "all";
let allInstances: ChecklistInstance[] = [];
let templatesBySlug: Record<string, ChecklistSummary> = {};
let instancesLoaded = false;
let myTemplates: MyChecklist[] = [];
let myTemplatesLoaded = false;
let galleryLoaded = false;
let me: { id: string; email: string } | null = null;
let activeTab: TabId = "templates";
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function parseTab(): TabId {
const params = new URLSearchParams(window.location.search);
const candidate = params.get("tab");
if (candidate && (VALID_TABS as string[]).includes(candidate)) {
return candidate as TabId;
}
return "templates";
}
async function loadTemplates() {
const resp = await fetch("/api/checklists");
if (!resp.ok) return;
allChecklists = await resp.json();
templatesBySlug = {};
for (const tpl of allChecklists) templatesBySlug[tpl.slug] = tpl;
renderTemplates();
}
function renderTemplates() {
const grid = document.getElementById("checklist-grid")!;
const isEN = getLang() === "en";
const filtered = activeRegime === "all"
? allChecklists
: allChecklists.filter((c) => c.regime === activeRegime);
if (filtered.length === 0) {
grid.innerHTML = `<p class="checklist-empty" data-i18n="checklisten.empty">${esc(t("checklisten.empty"))}</p>`;
return;
}
grid.innerHTML = filtered.map((c) => {
const title = isEN ? c.titleEN : c.titleDE;
const desc = isEN ? c.descriptionEN : c.descriptionDE;
const court = isEN ? c.courtEN : c.courtDE;
const itemsLabel = isEN ? "items" : "Punkte";
return `<a href="/checklists/${esc(c.slug)}" class="checklist-card">
<div class="checklist-card-top">
<span class="checklist-regime checklist-regime-${esc(c.regime)}">${esc(c.regime)}</span>
<span class="checklist-card-count">${c.itemCount} ${itemsLabel}</span>
</div>
<h2 class="checklist-card-title">${esc(title)}</h2>
<p class="checklist-card-desc">${esc(desc)}</p>
<p class="checklist-card-court">${esc(court)}</p>
</a>`;
}).join("");
}
function initFilters() {
const container = document.getElementById("checklist-filters")!;
container.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill");
if (!btn) return;
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
activeRegime = btn.dataset.regime ?? "all";
renderTemplates();
});
}
async function loadInstances() {
if (instancesLoaded) return;
instancesLoaded = true;
// Templates may not be loaded yet if the user lands directly on
// ?tab=instances — fetch in parallel so the join below has names.
const [instResp, tplResp] = await Promise.all([
fetch("/api/checklist-instances"),
allChecklists.length === 0 ? fetch("/api/checklists") : Promise.resolve(null),
]);
if (instResp.ok) {
allInstances = (await instResp.json()) ?? [];
} else {
allInstances = [];
}
if (tplResp && tplResp.ok) {
allChecklists = (await tplResp.json()) ?? [];
templatesBySlug = {};
for (const tpl of allChecklists) templatesBySlug[tpl.slug] = tpl;
}
renderInstances();
}
function renderInstances() {
const loading = document.getElementById("checklists-instances-loading")!;
const empty = document.getElementById("checklists-instances-empty")!;
const wrap = document.getElementById("checklists-instances-tablewrap")!;
const body = document.getElementById("checklists-instances-body")!;
loading.style.display = "none";
if (allInstances.length === 0) {
empty.style.display = "";
wrap.style.display = "none";
return;
}
empty.style.display = "none";
wrap.style.display = "";
const isEN = getLang() === "en";
const fmtDate = (iso: string) => {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
return d.toLocaleDateString(isEN ? "en-GB" : "de-DE", {
year: "numeric", month: "2-digit", day: "2-digit",
});
};
body.innerHTML = allInstances.map((inst) => {
const tpl = templatesBySlug[inst.template_slug];
const tplName = tpl
? (isEN ? tpl.titleEN : tpl.titleDE)
: inst.template_slug;
const total = tpl ? tpl.itemCount : 0;
const done = Object.values(inst.state || {}).filter(Boolean).length;
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
let projectCell: string;
if (inst.project_id && inst.project_title) {
const ref = inst.project_reference ? esc(inst.project_reference) : "";
const title = esc(inst.project_title);
const refPart = ref ? `<span class="entity-ref">${ref}</span> ` : "";
projectCell = `<a href="/projects/${esc(inst.project_id)}" class="checklist-instance-project">${refPart}${title}</a>`;
} else {
projectCell = `<span class="form-hint" data-i18n="checklisten.instances.all.personal">Pers&ouml;nlich</span>`;
}
return `<tr class="checklist-instance-row" data-id="${esc(inst.id)}">
<td>${esc(tplName)}</td>
<td><a href="/checklists/instances/${esc(inst.id)}" class="checklist-instance-name">${esc(inst.name)}</a></td>
<td>${projectCell}</td>
<td>
<div class="checklist-progress-inline">
<div class="checklist-progress-bar">
<div class="checklist-progress-fill" style="width:${pct}%"></div>
</div>
<span class="checklist-progress-label">${done} / ${total}</span>
</div>
</td>
<td>${esc(fmtDate(inst.created_at))}</td>
</tr>`;
}).join("");
body.querySelectorAll<HTMLTableRowElement>(".checklist-instance-row").forEach((row) => {
const id = row.dataset.id!;
row.addEventListener("click", (e) => {
// Let inner links (project, instance name) handle their own navigation.
if ((e.target as HTMLElement).closest("a")) return;
window.location.href = `/checklists/instances/${id}`;
});
});
}
function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
activeTab = tab;
document.querySelectorAll<HTMLElement>("#checklists-tabs .entity-tab").forEach((el) => {
el.classList.toggle("active", el.dataset.tab === tab);
});
document.querySelectorAll<HTMLElement>(".entity-tab-panel").forEach((el) => {
el.style.display = el.id === `tab-${tab}` ? "" : "none";
});
if (opts.pushHistory ?? true) {
let newURL = "/checklists";
if (tab === "instances") newURL = "/checklists?tab=instances";
if (tab === "mine") newURL = "/checklists?tab=mine";
if (tab === "gallery") newURL = "/checklists?tab=gallery";
if (window.location.pathname + window.location.search !== newURL) {
window.history.replaceState({}, "", newURL);
}
}
if (tab === "instances") {
void loadInstances();
}
if (tab === "mine") {
void loadMyTemplates();
}
if (tab === "gallery") {
void loadGallery();
}
}
async function loadGallery(force = false) {
if (galleryLoaded && !force) return;
galleryLoaded = true;
// /api/checklists already returns the merged catalog; the gallery
// filter just narrows to non-static + non-owned + non-private.
if (allChecklists.length === 0) {
await loadTemplates();
}
renderGallery();
}
function renderGallery() {
const loading = document.getElementById("checklists-gallery-loading")!;
const empty = document.getElementById("checklists-gallery-empty")!;
const grid = document.getElementById("checklists-gallery-grid") as HTMLElement;
loading.style.display = "none";
const visible = allChecklists.filter((c) => {
if (c.origin !== "authored") return false;
if (me && c.owner_email && me.email.toLowerCase() === c.owner_email.toLowerCase()) return false;
if (galleryRegime !== "all" && c.regime !== galleryRegime) return false;
return true;
});
if (visible.length === 0) {
empty.style.display = "";
grid.style.display = "none";
return;
}
empty.style.display = "none";
grid.style.display = "";
const isEN = getLang() === "en";
grid.innerHTML = visible.map((c) => {
const title = isEN ? c.titleEN : c.titleDE;
const desc = isEN ? c.descriptionEN : c.descriptionDE;
const court = isEN ? c.courtEN : c.courtDE;
const itemsLabel = isEN ? "items" : "Punkte";
const visKey = `checklisten.mine.visibility.${c.visibility || ""}`;
const visLabel = c.visibility ? esc(t(visKey as never) || c.visibility) : "";
const authorLine = c.owner_display_name
? `<p class="checklist-card-author">${esc(t("checklisten.detail.authored.by").replace("{author}", c.owner_display_name))}</p>`
: "";
return `<a href="/checklists/${esc(c.slug)}" class="checklist-card">
<div class="checklist-card-top">
<span class="checklist-regime checklist-regime-${esc(c.regime)}">${esc(c.regime)}</span>
<span class="checklist-card-count">${c.itemCount} ${itemsLabel}</span>
</div>
<h2 class="checklist-card-title">${esc(title)}</h2>
<p class="checklist-card-desc">${esc(desc)}</p>
<p class="checklist-card-court">${esc(court)}</p>
${authorLine}
${visLabel ? `<span class="visibility-chip visibility-chip-${esc(c.visibility || "")}">${visLabel}</span>` : ""}
</a>`;
}).join("");
}
function initGalleryFilters() {
const container = document.getElementById("checklist-gallery-filters");
if (!container) return;
container.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill");
if (!btn) return;
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
galleryRegime = btn.dataset.regime ?? "all";
renderGallery();
});
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch { /* leave me=null */ }
}
async function loadMyTemplates(force = false) {
if (myTemplatesLoaded && !force) return;
myTemplatesLoaded = true;
const resp = await fetch("/api/checklists/templates/mine");
if (!resp.ok) {
myTemplates = [];
} else {
myTemplates = (await resp.json()) ?? [];
}
renderMyTemplates();
}
function renderMyTemplates() {
const loading = document.getElementById("checklists-mine-loading")!;
const empty = document.getElementById("checklists-mine-empty")!;
const grid = document.getElementById("checklists-mine-grid") as HTMLElement;
loading.style.display = "none";
if (myTemplates.length === 0) {
empty.style.display = "";
grid.style.display = "none";
return;
}
empty.style.display = "none";
grid.style.display = "";
grid.innerHTML = myTemplates.map((tpl) => {
const visKey = `checklisten.mine.visibility.${tpl.visibility}`;
const visLabel = esc(t(visKey as never) || tpl.visibility);
const titleSafe = esc(tpl.title);
return `<article class="checklist-card checklist-card-mine" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}">
<div class="checklist-card-top">
<span class="checklist-regime checklist-regime-${esc(tpl.regime)}">${esc(tpl.regime)}</span>
<span class="checklist-card-count visibility-chip visibility-chip-${esc(tpl.visibility)}">${visLabel}</span>
</div>
<h2 class="checklist-card-title">
<a href="/checklists/${esc(tpl.slug)}">${titleSafe}</a>
</h2>
<p class="checklist-card-desc">${esc(tpl.description || "")}</p>
<p class="checklist-card-court">${esc(tpl.court || "")}</p>
<div class="checklist-card-actions">
<a class="btn btn-small" href="/checklists/templates/${esc(tpl.slug)}/edit" data-i18n="checklisten.mine.edit">Bearbeiten</a>
<button class="btn btn-small btn-danger" data-action="delete" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}" data-i18n="checklisten.mine.delete">L&ouml;schen</button>
</div>
</article>`;
}).join("");
grid.querySelectorAll<HTMLButtonElement>("button[data-action=delete]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
const slug = btn.dataset.slug!;
const title = btn.dataset.title || slug;
const msg = t("checklisten.mine.delete.confirm").replace("{title}", title);
if (!window.confirm(msg)) return;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, { method: "DELETE" });
if (!resp.ok) {
window.alert(t("checklisten.mine.delete.error"));
return;
}
await loadMyTemplates(true);
});
});
}
function initTabs() {
document.querySelectorAll<HTMLAnchorElement>("#checklists-tabs .entity-tab").forEach((tab) => {
tab.addEventListener("click", (e) => {
// Let middle-click / cmd-click open in new tab via the real href.
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
const id = tab.dataset.tab as TabId;
if (VALID_TABS.includes(id)) showTab(id);
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initFilters();
initGalleryFilters();
initTabs();
onLangChange(() => {
renderTemplates();
if (instancesLoaded) renderInstances();
if (myTemplatesLoaded) renderMyTemplates();
if (galleryLoaded) renderGallery();
});
void loadMe();
void loadTemplates();
showTab(parseTab(), { pushHistory: false });
});