Files
paliad/frontend/src/client/admin-email-templates.ts
m 800668a483 feat(t-paliad-078): type i18n key registry + build-time data-i18n scan
F-8 from the t-paliad-074 audit. Replaces silent `?? key` fallback with a
typed key surface so drift caught at compile/build time, not in prod.

- New `frontend/src/i18n-keys.ts` (generated): `I18nKey` literal union of
  all 1288 keys in `i18n.ts`. Regenerated by `frontend/build.ts` on every
  build; written only when content changes (no spurious diffs).
- `t(key: I18nKey)` is now strict — `t("fristn.detail.title")` fails
  `tsc --noEmit`. New `tDyn(key: string)` is the explicit escape hatch
  for runtime-composed keys (`tDyn(\`fristen.status.${x}\`)`); 27 dynamic
  call sites migrated.
- Build-time scan in `build.ts` walks `src/**/*.{ts,tsx}` for literal
  `data-i18n` / `data-i18n-placeholder` / `data-i18n-title` attributes
  and aborts the build on any value not in the key set. Skips `${...}`
  interpolations (can't resolve statically). Applied before bundling so
  no artefact ships when an unknown literal is present.

Surfaced and fixed during migration:

- `data-i18n="fristen.save.modal.project"` (fristenrechner.ts:145) →
  `fristen.save.modal.akte` — F-04-class bug, would render the raw key.
- `t("termine.field.project.none")` (appointments-new.ts:30) →
  `termine.field.akte.none` — same class.
- `t("checklisten.instance.project.open")` (checklists-instance.ts:155)
  → `checklisten.instance.akte.open` — same class.
- 4 duplicate-key entries in `i18n.ts` removed (TS1117): `nav.termine`
  and `akten.detail.tab.termine` each appeared twice in DE and twice in
  EN with identical values.

Out of scope (per brief): the German-vs-English i18n-key namespace split
flagged as F-9, JSX intrinsic typing, and the `akten` → `projects`
half-rename in checklists-detail.ts. Those stay tsc-noisy until separate
tasks land.
2026-04-30 03:56:32 +02:00

151 lines
4.7 KiB
TypeScript

import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
// /admin/email-templates list page. Fetches per-(key, lang) summaries and
// renders them as cards grouped by key. Each card shows the human title plus
// two language buttons that link into the editor.
interface Summary {
key: string;
lang: string;
is_default: boolean;
updated_at?: string | null;
}
interface CardCopy {
title_key: string;
desc_key: string;
fallback_title: string;
fallback_desc: string;
}
const CARDS: Record<string, CardCopy> = {
invitation: {
title_key: "admin.email_templates.card.invitation.title",
desc_key: "admin.email_templates.card.invitation.desc",
fallback_title: "Einladung",
fallback_desc:
"E-Mail an neue Kolleg:innen, ausgelöst über die Sidebar.",
},
deadline_digest: {
title_key: "admin.email_templates.card.deadline_digest.title",
desc_key: "admin.email_templates.card.deadline_digest.desc",
fallback_title: "Fristen-Sammelmail",
fallback_desc:
"Tägliche Morgen- und Abend-Mail mit überfälligen, heute fälligen und kommenden Fristen.",
},
base: {
title_key: "admin.email_templates.card.base.title",
desc_key: "admin.email_templates.card.base.desc",
fallback_title: "Layout-Wrapper",
fallback_desc:
"Geteilter HTML-Rahmen mit Header und Footer, der alle E-Mails umschliesst.",
},
};
const KEY_ORDER = ["invitation", "deadline_digest", "base"];
function fmtDate(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString();
}
function statusLabel(s: Summary): string {
if (s.is_default) {
return t("admin.email_templates.status.default") || "Standard";
}
const date = fmtDate(s.updated_at);
const tpl = t("admin.email_templates.status.last_modified") || "Zuletzt geändert: {date}";
return tpl.replace("{date}", date);
}
function showFeedback(msg: string, isError: boolean) {
const el = document.getElementById("admin-et-feedback");
if (!el) return;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
el.style.display = "block";
}
function render(summaries: Summary[]) {
const container = document.getElementById("admin-et-list");
if (!container) return;
// Group by key in canonical order.
const byKey: Record<string, Summary[]> = {};
for (const s of summaries) {
(byKey[s.key] ||= []).push(s);
}
container.innerHTML = "";
for (const key of KEY_ORDER) {
const meta = CARDS[key];
const rows = byKey[key] || [];
const card = document.createElement("div");
card.className = "card admin-et-card";
const title = tDyn(meta.title_key) || meta.fallback_title;
const desc = tDyn(meta.desc_key) || meta.fallback_desc;
const header = document.createElement("div");
header.className = "admin-et-card-header";
const h2 = document.createElement("h2");
h2.textContent = title;
const code = document.createElement("code");
code.className = "admin-et-card-key";
code.textContent = key;
header.appendChild(h2);
header.appendChild(code);
card.appendChild(header);
const p = document.createElement("p");
p.textContent = desc;
card.appendChild(p);
const langs = document.createElement("div");
langs.className = "admin-et-card-langs";
for (const lang of ["de", "en"]) {
const s = rows.find((r) => r.lang === lang);
const a = document.createElement("a");
a.className = "admin-et-card-lang-btn";
a.href = `/admin/email-templates/${key}?lang=${lang}`;
const flag = document.createElement("span");
flag.className = "admin-et-card-lang-flag";
flag.textContent = lang.toUpperCase();
const status = document.createElement("span");
status.className = "admin-et-card-lang-status";
status.textContent = s ? statusLabel(s) : t("admin.email_templates.status.default") || "Standard";
a.appendChild(flag);
a.appendChild(status);
langs.appendChild(a);
}
card.appendChild(langs);
container.appendChild(card);
}
}
async function loadAndRender() {
const resp = await fetch("/api/admin/email-templates");
if (resp.status === 403) {
showFeedback(t("admin.team.error.forbidden") || "Nur Admins.", true);
return;
}
if (!resp.ok) {
showFeedback(t("admin.email_templates.load_error") || "Fehler beim Laden.", true);
return;
}
const summaries = (await resp.json()) as Summary[];
render(summaries);
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
void loadAndRender();
onLangChange(() => {
void loadAndRender();
});
});