Files
paliad/frontend/src/client/checklists.ts
mAi 7a1fd81d23 feat(checklists): t-paliad-225 Slice A frontend — Meine Vorlagen + authoring wizard
m/paliad#61 Slice A frontend pass.

Pages:
- /checklists gets a third tab "Meine Vorlagen" between Vorlagen and
  Vorhandene Instanzen — lists owned authored templates with regime
  badge, visibility chip, Bearbeiten / Löschen actions, "Neue Vorlage"
  CTA. Tab state round-trips via ?tab=mine.
- /checklists/new and /checklists/{slug}/edit serve a shared bundle
  (checklists-author.html). Client reads location.pathname to decide
  create vs edit mode; edit mode prefills from /api/checklists/templates/mine.

Wizard:
- Metadata form (title, description, regime UPC/DE/EPA/OTHER, court,
  reference, deadline, language de/en, visibility private/firm).
- Repeating section + item editor — add/remove sections, add/remove
  items per section, label + optional note + optional rule per item.
- Single-language authoring (lang column on paliad.checklists). The
  catalog read layer mirrors the title/description onto both DE and EN
  sides so the existing bilingual frontend renders without a special
  case for authored entries.
- Save POSTs (create) or PATCHes (edit) the template; visibility flip
  on edit goes through its own endpoint so the audit row captures the
  transition.

Merged catalog:
- /api/checklists now returns the merged list (static + DB visible);
  the Summary shape gained origin / visibility / owner_email /
  owner_display_name fields.

i18n: 55 new keys per language (110 total) under
checklisten.tab.mine.*, checklisten.mine.*, checklisten.author.*,
checklisten.detail.* (Bearbeiten/Löschen labels for Slice B). i18n
codegen total: 2621 keys.

Build hygiene: bun run build clean, go build clean, go vet clean,
go test ./internal/... + ./cmd/server/ all green.
2026-05-20 15:24:07 +02:00

338 lines
11 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" | "instances";
const VALID_TABS: TabId[] = ["templates", "mine", "instances"];
let allChecklists: ChecklistSummary[] = [];
let activeRegime = "all";
let allInstances: ChecklistInstance[] = [];
let templatesBySlug: Record<string, ChecklistSummary> = {};
let instancesLoaded = false;
let myTemplates: MyChecklist[] = [];
let myTemplatesLoaded = false;
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 (window.location.pathname + window.location.search !== newURL) {
window.history.replaceState({}, "", newURL);
}
}
if (tab === "instances") {
void loadInstances();
}
if (tab === "mine") {
void loadMyTemplates();
}
}
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/${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();
initTabs();
onLangChange(() => {
renderTemplates();
if (instancesLoaded) renderInstances();
if (myTemplatesLoaded) renderMyTemplates();
});
void loadTemplates();
showTab(parseTab(), { pushHistory: false });
});