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.
519 lines
18 KiB
TypeScript
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();
|
|
});
|