diff --git a/frontend/build.ts b/frontend/build.ts index 06528e5..14cfeb3 100644 --- a/frontend/build.ts +++ b/frontend/build.ts @@ -23,7 +23,7 @@ import { renderTermine } from "./src/termine"; import { renderTermineNeu } from "./src/termine-neu"; import { renderTermineDetail } from "./src/termine-detail"; import { renderTermineKalender } from "./src/termine-kalender"; -import { renderEinstellungenCalDAV } from "./src/einstellungen-caldav"; +import { renderEinstellungen } from "./src/einstellungen"; import { renderDashboard } from "./src/dashboard"; import { renderOnboarding } from "./src/onboarding"; @@ -60,7 +60,7 @@ async function build() { join(import.meta.dir, "src/client/termine-neu.ts"), join(import.meta.dir, "src/client/termine-detail.ts"), join(import.meta.dir, "src/client/termine-kalender.ts"), - join(import.meta.dir, "src/client/einstellungen-caldav.ts"), + join(import.meta.dir, "src/client/einstellungen.ts"), join(import.meta.dir, "src/client/dashboard.ts"), join(import.meta.dir, "src/client/onboarding.ts"), ], @@ -107,7 +107,7 @@ async function build() { await Bun.write(join(DIST, "termine-neu.html"), renderTermineNeu()); await Bun.write(join(DIST, "termine-detail.html"), renderTermineDetail()); await Bun.write(join(DIST, "termine-kalender.html"), renderTermineKalender()); - await Bun.write(join(DIST, "einstellungen-caldav.html"), renderEinstellungenCalDAV()); + await Bun.write(join(DIST, "einstellungen.html"), renderEinstellungen()); await Bun.write(join(DIST, "dashboard.html"), renderDashboard()); await Bun.write(join(DIST, "onboarding.html"), renderOnboarding()); diff --git a/frontend/src/client/einstellungen-caldav.ts b/frontend/src/client/einstellungen-caldav.ts deleted file mode 100644 index 476ee1a..0000000 --- a/frontend/src/client/einstellungen-caldav.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { initI18n, t, getLang } from "./i18n"; -import { initSidebar } from "./sidebar"; - -interface Config { - configured: boolean; - url?: string; - username?: string; - calendar_path?: string; - enabled?: boolean; - last_sync_at?: string; - last_sync_error?: string; - updated_at?: string; -} - -interface SyncLogEntry { - id: string; - occurred_at: string; - direction: string; - items_pushed: number; - items_pulled: number; - error?: string; - duration_ms?: number; -} - -let config: Config | null = null; - -function esc(s: string): string { - const d = document.createElement("div"); - d.textContent = s; - return d.innerHTML; -} - -function fmtDateTime(iso?: string): string { - if (!iso) return t("caldav.never"); - try { - const d = new Date(iso); - return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }); - } catch { - return iso; - } -} - -async function loadConfig(): Promise { - try { - const resp = await fetch("/api/caldav-config"); - if (resp.status === 501) { - document.getElementById("caldav-disabled")!.style.display = "block"; - document.getElementById("caldav-form")!.style.display = "none"; - return false; - } - if (!resp.ok) return false; - config = await resp.json(); - return true; - } catch { - return false; - } -} - -function fillForm() { - if (!config) return; - if (!config.configured) return; - (document.getElementById("caldav-url") as HTMLInputElement).value = config.url ?? ""; - (document.getElementById("caldav-username") as HTMLInputElement).value = config.username ?? ""; - (document.getElementById("caldav-calendar-path") as HTMLInputElement).value = config.calendar_path ?? ""; - (document.getElementById("caldav-enabled") as HTMLInputElement).checked = config.enabled ?? false; - document.getElementById("caldav-delete-btn")!.style.display = ""; -} - -function renderStatus() { - if (!config || !config.configured) { - document.getElementById("caldav-status-card")!.style.display = "none"; - return; - } - document.getElementById("caldav-status-card")!.style.display = ""; - document.getElementById("caldav-last-sync")!.textContent = fmtDateTime(config.last_sync_at); - const errRow = document.getElementById("caldav-status-error-row")!; - if (config.last_sync_error) { - errRow.style.display = ""; - document.getElementById("caldav-last-error")!.textContent = config.last_sync_error; - } else { - errRow.style.display = "none"; - } -} - -async function loadLog() { - try { - const resp = await fetch("/api/caldav-config/log"); - if (!resp.ok) return; - const rows: SyncLogEntry[] = await resp.json(); - const tbody = document.getElementById("caldav-log-body")!; - const empty = document.getElementById("caldav-log-empty")!; - if (rows.length === 0) { - tbody.innerHTML = ""; - empty.style.display = "block"; - return; - } - empty.style.display = "none"; - tbody.innerHTML = rows - .map((r) => { - const dur = r.duration_ms != null ? `${r.duration_ms} ms` : "\u2014"; - const err = r.error ? `${esc(r.error)}` : "\u2014"; - return ` - ${esc(fmtDateTime(r.occurred_at))} - ${r.items_pushed} - ${r.items_pulled} - ${dur} - ${err} - `; - }) - .join(""); - } catch { - /* non-fatal */ - } -} - -function readForm() { - return { - url: (document.getElementById("caldav-url") as HTMLInputElement).value.trim(), - username: (document.getElementById("caldav-username") as HTMLInputElement).value.trim(), - password: (document.getElementById("caldav-password") as HTMLInputElement).value, - calendar_path: (document.getElementById("caldav-calendar-path") as HTMLInputElement).value.trim(), - enabled: (document.getElementById("caldav-enabled") as HTMLInputElement).checked, - }; -} - -async function saveConfig(ev: Event) { - ev.preventDefault(); - const msg = document.getElementById("caldav-msg")!; - msg.textContent = ""; - - const payload = readForm(); - if (!payload.url || !payload.username) { - msg.textContent = t("caldav.error.required"); - msg.className = "form-msg form-msg-error"; - return; - } - if (!config?.configured && !payload.password) { - msg.textContent = t("caldav.error.password_required"); - msg.className = "form-msg form-msg-error"; - return; - } - - const submitBtn = (ev.target as HTMLFormElement).querySelector("button[type=submit]")!; - submitBtn.disabled = true; - try { - const resp = await fetch("/api/caldav-config", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (resp.ok) { - config = await resp.json(); - // Server returns PublicConfig, not the GET shape — normalise. - config = { configured: true, ...config }; - (document.getElementById("caldav-password") as HTMLInputElement).value = ""; - msg.textContent = t("caldav.saved"); - msg.className = "form-msg form-msg-ok"; - document.getElementById("caldav-delete-btn")!.style.display = ""; - // Re-fetch to get last_sync_at etc. - await loadConfig(); - renderStatus(); - await loadLog(); - } else { - const data = await resp.json().catch(() => ({}) as { error?: string }); - msg.textContent = data.error || t("caldav.error.generic"); - msg.className = "form-msg form-msg-error"; - } - } catch { - msg.textContent = t("caldav.error.generic"); - msg.className = "form-msg form-msg-error"; - } finally { - submitBtn.disabled = false; - } -} - -async function testConnection() { - const msg = document.getElementById("caldav-msg")!; - msg.textContent = ""; - const payload = readForm(); - const btn = document.getElementById("caldav-test-btn") as HTMLButtonElement; - btn.disabled = true; - try { - const resp = await fetch("/api/caldav-config/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const data = await resp.json().catch(() => ({}) as { ok?: boolean; error?: string }); - if (data.ok) { - msg.textContent = t("caldav.test.ok"); - msg.className = "form-msg form-msg-ok"; - } else { - msg.textContent = `${t("caldav.test.fail")}: ${data.error ?? "?"}`; - msg.className = "form-msg form-msg-error"; - } - } catch (e) { - msg.textContent = `${t("caldav.test.fail")}: ${(e as Error).message}`; - msg.className = "form-msg form-msg-error"; - } finally { - btn.disabled = false; - } -} - -async function deleteConfig() { - const msg = document.getElementById("caldav-msg")!; - if (!confirm(t("caldav.delete.confirm"))) return; - msg.textContent = ""; - try { - const resp = await fetch("/api/caldav-config", { method: "DELETE" }); - if (resp.ok || resp.status === 204) { - config = { configured: false }; - (document.getElementById("caldav-form") as HTMLFormElement).reset(); - document.getElementById("caldav-delete-btn")!.style.display = "none"; - renderStatus(); - await loadLog(); - msg.textContent = t("caldav.delete.done"); - msg.className = "form-msg form-msg-ok"; - } else { - const data = await resp.json().catch(() => ({}) as { error?: string }); - msg.textContent = data.error || t("caldav.error.generic"); - msg.className = "form-msg form-msg-error"; - } - } catch { - msg.textContent = t("caldav.error.generic"); - msg.className = "form-msg form-msg-error"; - } -} - -document.addEventListener("DOMContentLoaded", async () => { - initI18n(); - initSidebar(); - document.getElementById("caldav-form")!.addEventListener("submit", saveConfig); - document.getElementById("caldav-test-btn")!.addEventListener("click", testConnection); - document.getElementById("caldav-delete-btn")!.addEventListener("click", deleteConfig); - - const ok = await loadConfig(); - if (!ok) return; - fillForm(); - renderStatus(); - await loadLog(); -}); diff --git a/frontend/src/client/einstellungen.ts b/frontend/src/client/einstellungen.ts new file mode 100644 index 0000000..c31ba25 --- /dev/null +++ b/frontend/src/client/einstellungen.ts @@ -0,0 +1,544 @@ +import { initI18n, onLangChange, getLang, setLang, t, Lang } from "./i18n"; +import { initSidebar } from "./sidebar"; + +// Unified settings page. One init function for the whole page; each tab has +// its own idempotent loader (load*Tab) that runs on first activation and is +// safe to call again on subsequent switches. + +interface Office { + key: string; + label_de: string; + label_en: string; +} + +interface Me { + id: string; + email: string; + display_name: string; + office: string; + role: string; + dezernat?: string; + lang: Lang; + email_preferences: Record; +} + +interface CalDAVConfig { + configured: boolean; + url?: string; + username?: string; + calendar_path?: string; + enabled?: boolean; + last_sync_at?: string; + last_sync_error?: string; + updated_at?: string; +} + +interface SyncLogEntry { + id: string; + occurred_at: string; + direction: string; + items_pushed: number; + items_pulled: number; + error?: string; + duration_ms?: number; +} + +type TabName = "profil" | "benachrichtigungen" | "caldav"; +const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav"]; +const DEFAULT_TAB: TabName = "profil"; + +let me: Me | null = null; +let offices: Office[] = []; +let caldavConfig: CalDAVConfig | null = null; +const loadedTabs = new Set(); + +function esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; +} + +function fmtDateTime(iso?: string): string { + if (!iso) return t("caldav.never"); + try { + const d = new Date(iso); + return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } +} + +// --- Tab switching ---------------------------------------------------------- + +function parseTab(): TabName { + const params = new URLSearchParams(window.location.search); + const raw = params.get("tab"); + return (TABS as string[]).includes(raw ?? "") ? (raw as TabName) : DEFAULT_TAB; +} + +function showTab(tab: TabName, pushHistory: boolean) { + for (const name of TABS) { + const panel = document.getElementById(`tab-${name}`); + if (panel) panel.style.display = name === tab ? "" : "none"; + } + document.querySelectorAll("#einstellungen-tabs .akten-tab").forEach((el) => { + const name = el.getAttribute("data-tab"); + el.classList.toggle("active", name === tab); + }); + + if (pushHistory) { + const url = new URL(window.location.href); + if (tab === DEFAULT_TAB) { + url.searchParams.delete("tab"); + } else { + url.searchParams.set("tab", tab); + } + window.history.replaceState({}, "", url.toString()); + } + + if (!loadedTabs.has(tab)) { + loadedTabs.add(tab); + if (tab === "profil") void loadProfilTab(); + else if (tab === "benachrichtigungen") void loadPrefsTab(); + else if (tab === "caldav") void loadCalDAVTab(); + } +} + +function wireTabLinks() { + document.querySelectorAll("#einstellungen-tabs .akten-tab").forEach((a) => { + a.addEventListener("click", (e) => { + e.preventDefault(); + const tab = a.getAttribute("data-tab") as TabName | null; + if (tab) showTab(tab, true); + }); + }); +} + +// --- Profil tab ------------------------------------------------------------- + +async function loadProfilTab() { + const loading = document.getElementById("profil-loading")!; + const form = document.getElementById("profil-form") as HTMLFormElement; + + await Promise.all([fetchMe(), fetchOffices()]); + renderOfficeOptions(); + fillProfilForm(); + + loading.style.display = "none"; + form.style.display = ""; +} + +async function fetchMe(): Promise { + if (me) return; + const resp = await fetch("/api/me"); + if (resp.status === 404) { + // Shouldn't happen — /einstellungen is behind the onboarding gate — but + // if it does, push the user to onboarding so they don't sit on an empty + // form. + window.location.href = "/onboarding"; + return; + } + if (!resp.ok) return; + me = await resp.json(); +} + +async function fetchOffices(): Promise { + if (offices.length > 0) return; + try { + const resp = await fetch("/api/offices"); + if (!resp.ok) return; + offices = await resp.json(); + } catch { + offices = []; + } +} + +function renderOfficeOptions() { + const select = document.getElementById("profil-office") as HTMLSelectElement | null; + if (!select) return; + const isEN = getLang() === "en"; + const previous = select.value || me?.office || ""; + select.innerHTML = offices + .map((o) => { + const label = isEN ? o.label_en : o.label_de; + return ``; + }) + .join(""); + if (previous && offices.some((o) => o.key === previous)) { + select.value = previous; + } +} + +function fillProfilForm() { + if (!me) return; + (document.getElementById("profil-email") as HTMLInputElement).value = me.email; + (document.getElementById("profil-display-name") as HTMLInputElement).value = me.display_name; + (document.getElementById("profil-office") as HTMLSelectElement).value = me.office; + (document.getElementById("profil-role") as HTMLInputElement).value = me.role; + (document.getElementById("profil-dezernat") as HTMLInputElement).value = me.dezernat ?? ""; + (document.getElementById("profil-lang") as HTMLSelectElement).value = me.lang || "de"; +} + +async function saveProfil(ev: Event) { + ev.preventDefault(); + const msg = document.getElementById("profil-msg")!; + msg.textContent = ""; + msg.className = "form-msg"; + + const displayName = (document.getElementById("profil-display-name") as HTMLInputElement).value.trim(); + const office = (document.getElementById("profil-office") as HTMLSelectElement).value; + const role = (document.getElementById("profil-role") as HTMLInputElement).value.trim(); + const dezernat = (document.getElementById("profil-dezernat") as HTMLInputElement).value.trim(); + const lang = (document.getElementById("profil-lang") as HTMLSelectElement).value as Lang; + + if (!displayName) { + msg.textContent = t("einstellungen.profil.error.display_name"); + msg.className = "form-msg form-msg-error"; + return; + } + if (!office) { + msg.textContent = t("einstellungen.profil.error.office"); + msg.className = "form-msg form-msg-error"; + return; + } + if (!role) { + msg.textContent = t("einstellungen.profil.error.role"); + msg.className = "form-msg form-msg-error"; + return; + } + + const payload = { + display_name: displayName, + office, + role, + dezernat, + lang, + }; + + const submitBtn = (ev.target as HTMLFormElement).querySelector("button[type=submit]")!; + submitBtn.disabled = true; + try { + const resp = await fetch("/api/me", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (resp.ok) { + me = await resp.json(); + msg.textContent = t("einstellungen.saved"); + msg.className = "form-msg form-msg-success"; + // Keep the client-side lang in sync with the newly persisted value so + // the UI (and other tabs) immediately reflect the choice. + if (me && me.lang && me.lang !== getLang()) { + setLang(me.lang); + } + } else { + const data = await resp.json().catch(() => ({}) as { error?: string }); + msg.textContent = data.error || t("einstellungen.error.generic"); + msg.className = "form-msg form-msg-error"; + } + } catch { + msg.textContent = t("einstellungen.error.generic"); + msg.className = "form-msg form-msg-error"; + } finally { + submitBtn.disabled = false; + } +} + +// --- Benachrichtigungen tab ------------------------------------------------- + +function readPrefBool(key: string, fallback: boolean): boolean { + if (!me?.email_preferences) return fallback; + const v = me.email_preferences[key]; + return typeof v === "boolean" ? v : fallback; +} + +async function loadPrefsTab() { + await fetchMe(); + fillPrefsForm(); +} + +function fillPrefsForm() { + const master = document.getElementById("prefs-reminders-master") as HTMLInputElement; + const overdue = document.getElementById("prefs-reminders-overdue") as HTMLInputElement; + const tomorrow = document.getElementById("prefs-reminders-tomorrow") as HTMLInputElement; + const weekly = document.getElementById("prefs-reminders-weekly") as HTMLInputElement; + + master.checked = readPrefBool("deadline_reminders", true); + overdue.checked = readPrefBool("deadline_reminders.overdue", true); + tomorrow.checked = readPrefBool("deadline_reminders.tomorrow", true); + weekly.checked = readPrefBool("deadline_reminders.weekly", true); + + updatePrefsSubGroup(master.checked); + master.addEventListener("change", () => updatePrefsSubGroup(master.checked)); +} + +function updatePrefsSubGroup(masterOn: boolean) { + const sub = document.getElementById("prefs-reminders-sub")!; + sub.querySelectorAll('input[type="checkbox"]').forEach((el) => { + el.disabled = !masterOn; + }); + sub.style.opacity = masterOn ? "1" : "0.5"; +} + +async function savePrefs(ev: Event) { + ev.preventDefault(); + const msg = document.getElementById("prefs-msg")!; + msg.textContent = ""; + msg.className = "form-msg"; + + // Start from the existing prefs so we don't erase keys we don't know about + // (future additions, other subsystems). + const next: Record = { ...(me?.email_preferences ?? {}) }; + next["deadline_reminders"] = (document.getElementById("prefs-reminders-master") as HTMLInputElement).checked; + next["deadline_reminders.overdue"] = (document.getElementById("prefs-reminders-overdue") as HTMLInputElement).checked; + next["deadline_reminders.tomorrow"] = (document.getElementById("prefs-reminders-tomorrow") as HTMLInputElement).checked; + next["deadline_reminders.weekly"] = (document.getElementById("prefs-reminders-weekly") as HTMLInputElement).checked; + + const submitBtn = (ev.target as HTMLFormElement).querySelector("button[type=submit]")!; + submitBtn.disabled = true; + try { + const resp = await fetch("/api/me", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email_preferences: next }), + }); + if (resp.ok) { + me = await resp.json(); + msg.textContent = t("einstellungen.saved"); + msg.className = "form-msg form-msg-success"; + } else { + const data = await resp.json().catch(() => ({}) as { error?: string }); + msg.textContent = data.error || t("einstellungen.error.generic"); + msg.className = "form-msg form-msg-error"; + } + } catch { + msg.textContent = t("einstellungen.error.generic"); + msg.className = "form-msg form-msg-error"; + } finally { + submitBtn.disabled = false; + } +} + +// --- CalDAV tab ------------------------------------------------------------- + +async function loadCalDAVTab() { + const ok = await loadCalDAVConfig(); + if (!ok) return; + fillCalDAVForm(); + renderCalDAVStatus(); + await loadCalDAVLog(); +} + +async function loadCalDAVConfig(): Promise { + try { + const resp = await fetch("/api/caldav-config"); + if (resp.status === 501) { + document.getElementById("caldav-disabled")!.style.display = "block"; + document.getElementById("caldav-form")!.style.display = "none"; + return false; + } + if (!resp.ok) return false; + caldavConfig = await resp.json(); + return true; + } catch { + return false; + } +} + +function fillCalDAVForm() { + if (!caldavConfig?.configured) return; + (document.getElementById("caldav-url") as HTMLInputElement).value = caldavConfig.url ?? ""; + (document.getElementById("caldav-username") as HTMLInputElement).value = caldavConfig.username ?? ""; + (document.getElementById("caldav-calendar-path") as HTMLInputElement).value = caldavConfig.calendar_path ?? ""; + (document.getElementById("caldav-enabled") as HTMLInputElement).checked = caldavConfig.enabled ?? false; + document.getElementById("caldav-delete-btn")!.style.display = ""; +} + +function renderCalDAVStatus() { + const card = document.getElementById("caldav-status-card")!; + if (!caldavConfig?.configured) { + card.style.display = "none"; + return; + } + card.style.display = ""; + document.getElementById("caldav-last-sync")!.textContent = fmtDateTime(caldavConfig.last_sync_at); + const errRow = document.getElementById("caldav-status-error-row")!; + if (caldavConfig.last_sync_error) { + errRow.style.display = ""; + document.getElementById("caldav-last-error")!.textContent = caldavConfig.last_sync_error; + } else { + errRow.style.display = "none"; + } +} + +async function loadCalDAVLog() { + try { + const resp = await fetch("/api/caldav-config/log"); + if (!resp.ok) return; + const rows: SyncLogEntry[] = await resp.json(); + const tbody = document.getElementById("caldav-log-body")!; + const empty = document.getElementById("caldav-log-empty")!; + if (rows.length === 0) { + tbody.innerHTML = ""; + empty.style.display = "block"; + return; + } + empty.style.display = "none"; + tbody.innerHTML = rows + .map((r) => { + const dur = r.duration_ms != null ? `${r.duration_ms} ms` : "\u2014"; + const err = r.error ? `${esc(r.error)}` : "\u2014"; + return ` + ${esc(fmtDateTime(r.occurred_at))} + ${r.items_pushed} + ${r.items_pulled} + ${dur} + ${err} + `; + }) + .join(""); + } catch { + /* non-fatal */ + } +} + +function readCalDAVForm() { + return { + url: (document.getElementById("caldav-url") as HTMLInputElement).value.trim(), + username: (document.getElementById("caldav-username") as HTMLInputElement).value.trim(), + password: (document.getElementById("caldav-password") as HTMLInputElement).value, + calendar_path: (document.getElementById("caldav-calendar-path") as HTMLInputElement).value.trim(), + enabled: (document.getElementById("caldav-enabled") as HTMLInputElement).checked, + }; +} + +async function saveCalDAV(ev: Event) { + ev.preventDefault(); + const msg = document.getElementById("caldav-msg")!; + msg.textContent = ""; + + const payload = readCalDAVForm(); + if (!payload.url || !payload.username) { + msg.textContent = t("caldav.error.required"); + msg.className = "form-msg form-msg-error"; + return; + } + if (!caldavConfig?.configured && !payload.password) { + msg.textContent = t("caldav.error.password_required"); + msg.className = "form-msg form-msg-error"; + return; + } + + const submitBtn = (ev.target as HTMLFormElement).querySelector("button[type=submit]")!; + submitBtn.disabled = true; + try { + const resp = await fetch("/api/caldav-config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (resp.ok) { + caldavConfig = { configured: true, ...(await resp.json()) }; + (document.getElementById("caldav-password") as HTMLInputElement).value = ""; + msg.textContent = t("caldav.saved"); + msg.className = "form-msg form-msg-ok"; + document.getElementById("caldav-delete-btn")!.style.display = ""; + await loadCalDAVConfig(); + renderCalDAVStatus(); + await loadCalDAVLog(); + } else { + const data = await resp.json().catch(() => ({}) as { error?: string }); + msg.textContent = data.error || t("caldav.error.generic"); + msg.className = "form-msg form-msg-error"; + } + } catch { + msg.textContent = t("caldav.error.generic"); + msg.className = "form-msg form-msg-error"; + } finally { + submitBtn.disabled = false; + } +} + +async function testCalDAVConnection() { + const msg = document.getElementById("caldav-msg")!; + msg.textContent = ""; + const payload = readCalDAVForm(); + const btn = document.getElementById("caldav-test-btn") as HTMLButtonElement; + btn.disabled = true; + try { + const resp = await fetch("/api/caldav-config/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await resp.json().catch(() => ({}) as { ok?: boolean; error?: string }); + if (data.ok) { + msg.textContent = t("caldav.test.ok"); + msg.className = "form-msg form-msg-ok"; + } else { + msg.textContent = `${t("caldav.test.fail")}: ${data.error ?? "?"}`; + msg.className = "form-msg form-msg-error"; + } + } catch (e) { + msg.textContent = `${t("caldav.test.fail")}: ${(e as Error).message}`; + msg.className = "form-msg form-msg-error"; + } finally { + btn.disabled = false; + } +} + +async function deleteCalDAVConfig() { + const msg = document.getElementById("caldav-msg")!; + if (!confirm(t("caldav.delete.confirm"))) return; + msg.textContent = ""; + try { + const resp = await fetch("/api/caldav-config", { method: "DELETE" }); + if (resp.ok || resp.status === 204) { + caldavConfig = { configured: false }; + (document.getElementById("caldav-form") as HTMLFormElement).reset(); + document.getElementById("caldav-delete-btn")!.style.display = "none"; + renderCalDAVStatus(); + await loadCalDAVLog(); + msg.textContent = t("caldav.delete.done"); + msg.className = "form-msg form-msg-ok"; + } else { + const data = await resp.json().catch(() => ({}) as { error?: string }); + msg.textContent = data.error || t("caldav.error.generic"); + msg.className = "form-msg form-msg-error"; + } + } catch { + msg.textContent = t("caldav.error.generic"); + msg.className = "form-msg form-msg-error"; + } +} + +// --- Init ------------------------------------------------------------------- + +document.addEventListener("DOMContentLoaded", () => { + initI18n(); + initSidebar(); + + wireTabLinks(); + document.getElementById("profil-form")!.addEventListener("submit", saveProfil); + document.getElementById("prefs-form")!.addEventListener("submit", savePrefs); + document.getElementById("caldav-form")!.addEventListener("submit", saveCalDAV); + document.getElementById("caldav-test-btn")!.addEventListener("click", testCalDAVConnection); + document.getElementById("caldav-delete-btn")!.addEventListener("click", deleteCalDAVConfig); + + onLangChange(() => { + if (loadedTabs.has("profil")) renderOfficeOptions(); + if (loadedTabs.has("caldav")) { + renderCalDAVStatus(); + void loadCalDAVLog(); + } + }); + + showTab(parseTab(), false); +}); diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index f44e1ec..ee3b7e9 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -685,8 +685,44 @@ const translations: Record> = { // Termine + CalDAV (Phase F) "nav.termine": "Termine", "nav.group.einstellungen": "Einstellungen", + "nav.einstellungen": "Einstellungen", "nav.caldav": "CalDAV", + // Settings page (t-paliad-022) + "einstellungen.title": "Einstellungen \u2014 Paliad", + "einstellungen.heading": "Einstellungen", + "einstellungen.subtitle": "Profil, Benachrichtigungen und Kalendersynchronisation.", + "einstellungen.loading": "L\u00e4dt\u2026", + "einstellungen.optional": "(optional)", + "einstellungen.save": "Speichern", + "einstellungen.saved": "Gespeichert.", + "einstellungen.error.generic": "Speichern fehlgeschlagen. Bitte sp\u00e4ter erneut versuchen.", + "einstellungen.tab.profil": "Profil", + "einstellungen.tab.benachrichtigungen": "Benachrichtigungen", + "einstellungen.tab.caldav": "CalDAV", + "einstellungen.profil.email": "E-Mail", + "einstellungen.profil.email.hint": "E-Mail kann nicht ge\u00e4ndert werden.", + "einstellungen.profil.display_name": "Anzeigename", + "einstellungen.profil.display_name.placeholder": "Vor- und Nachname", + "einstellungen.profil.office": "B\u00fcro", + "einstellungen.profil.role": "Rolle", + "einstellungen.profil.role.placeholder": "z.B. Associate, Partner, PA", + "einstellungen.profil.dezernat": "Dezernat / Partner", + "einstellungen.profil.dezernat.placeholder": "z.B. Dr. M\u00fcller, Team Schmidt", + "einstellungen.profil.lang": "Sprache", + "einstellungen.profil.lang.de": "Deutsch", + "einstellungen.profil.lang.en": "English", + "einstellungen.profil.lang.hint": "Wird f\u00fcr Oberfl\u00e4che und Benachrichtigungs-E-Mails verwendet.", + "einstellungen.profil.error.display_name": "Bitte Anzeigename eingeben.", + "einstellungen.profil.error.office": "Bitte B\u00fcro ausw\u00e4hlen.", + "einstellungen.profil.error.role": "Bitte Rolle eingeben.", + "einstellungen.prefs.reminders.heading": "Frist-Erinnerungen", + "einstellungen.prefs.reminders.hint": "Paliad sendet Erinnerungen an Ihre E-Mail, wenn Fristen f\u00e4llig werden.", + "einstellungen.prefs.reminders.master": "Frist-Erinnerungen aktiv", + "einstellungen.prefs.reminders.overdue": "\u00dcberf\u00e4llige Fristen", + "einstellungen.prefs.reminders.tomorrow": "Fristen morgen f\u00e4llig", + "einstellungen.prefs.reminders.weekly": "Wochen\u00fcbersicht (montags)", + // Invitation modal (sidebar) "invite.button": "Kolleg:in einladen", "invite.modal.title": "Kolleg:in zu Paliad einladen", @@ -1494,8 +1530,44 @@ const translations: Record> = { // Termine + CalDAV (Phase F) "nav.termine": "Appointments", "nav.group.einstellungen": "Settings", + "nav.einstellungen": "Settings", "nav.caldav": "CalDAV", + // Settings page (t-paliad-022) + "einstellungen.title": "Settings \u2014 Paliad", + "einstellungen.heading": "Settings", + "einstellungen.subtitle": "Profile, notifications, and calendar sync.", + "einstellungen.loading": "Loading\u2026", + "einstellungen.optional": "(optional)", + "einstellungen.save": "Save", + "einstellungen.saved": "Saved.", + "einstellungen.error.generic": "Save failed. Please try again.", + "einstellungen.tab.profil": "Profile", + "einstellungen.tab.benachrichtigungen": "Notifications", + "einstellungen.tab.caldav": "CalDAV", + "einstellungen.profil.email": "Email", + "einstellungen.profil.email.hint": "Email cannot be changed.", + "einstellungen.profil.display_name": "Display name", + "einstellungen.profil.display_name.placeholder": "First and last name", + "einstellungen.profil.office": "Office", + "einstellungen.profil.role": "Role", + "einstellungen.profil.role.placeholder": "e.g. Associate, Partner, PA", + "einstellungen.profil.dezernat": "Department / Partner", + "einstellungen.profil.dezernat.placeholder": "e.g. Dr. M\u00fcller, Team Schmidt", + "einstellungen.profil.lang": "Language", + "einstellungen.profil.lang.de": "Deutsch", + "einstellungen.profil.lang.en": "English", + "einstellungen.profil.lang.hint": "Used for the UI and notification emails.", + "einstellungen.profil.error.display_name": "Please enter a display name.", + "einstellungen.profil.error.office": "Please select an office.", + "einstellungen.profil.error.role": "Please enter a role.", + "einstellungen.prefs.reminders.heading": "Deadline reminders", + "einstellungen.prefs.reminders.hint": "Paliad emails you when deadlines approach.", + "einstellungen.prefs.reminders.master": "Deadline reminders enabled", + "einstellungen.prefs.reminders.overdue": "Overdue deadlines", + "einstellungen.prefs.reminders.tomorrow": "Due tomorrow", + "einstellungen.prefs.reminders.weekly": "Weekly summary (Monday)", + // Invitation modal (sidebar) "invite.button": "Invite a colleague", "invite.modal.title": "Invite a colleague to Paliad", diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 2f72b1f..de250e7 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -100,7 +100,7 @@ export function Sidebar({ currentPath }: SidebarProps): string { )} {group("nav.group.einstellungen", "Einstellungen", - navItem("/einstellungen/caldav", ICON_GEAR, "nav.caldav", "CalDAV", currentPath), + navItem("/einstellungen", ICON_GEAR, "nav.einstellungen", "Einstellungen", currentPath), )} diff --git a/frontend/src/einstellungen-caldav.tsx b/frontend/src/einstellungen-caldav.tsx deleted file mode 100644 index 1c0811d..0000000 --- a/frontend/src/einstellungen-caldav.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { h } from "./jsx"; -import { Sidebar } from "./components/Sidebar"; -import { Footer } from "./components/Footer"; - -export function renderEinstellungenCalDAV(): string { - return "" + ( - - - - - CalDAV-Synchronisation — Paliad - - - - - -
-
-
-
-

CalDAV-Synchronisation

-

- Synchronisieren Sie Ihre Paliad-Termine mit Ihrem externen Kalender (Nextcloud, iCloud, Outlook, mailcow…). - Das Passwort wird verschlüsselt gespeichert und nie zurückgegeben. -

-
- - - - - -
-
- - -

- Vollständige URL zu Ihrem Kalender (inkl. Pfad). -

-
- -
-
- - -
-
- - -

- Bei vorhandener Konfiguration leer lassen, um das gespeicherte Passwort zu behalten. -

-
-
- -
- - -

- Falls die URL nur den Server zeigt, ist hier der Pfad zum konkreten Kalender einzutragen. -

-
- -
- -
- -

- -

- - - -
-
- -
-

Letzte Synchronisationen

- - - - - - - - - - - -
ZeitpunktGesendetEmpfangenDauerFehler
-

- Noch keine Synchronisationen aufgezeichnet. -

-
-
-
-
- -