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