Completes the nomen train (S1–S5). Adds the FIRM tier of the name-composition
precedence chain — per-document → user → FIRM → system (PRD §3.1/§3.2) —
mirroring firm_dashboard_default exactly.
Storage + service:
- Migration 162: paliad.firm_name_compositions singleton (id=1, CHECK id=1,
RLS read-all + service-role writes) — same shape as firm_dashboard_default
(mig 117), holding a validated { artifact_id: Composition } jsonb map.
- FirmNameCompositionService (Get/Set/Clear) + getFirmNameCompositions /
setFirmNameCompositions / clearFirmNameCompositions singleton helpers in
name_composition_spec.go.
Resolution:
- resolveComposition is now variadic over ordered specs (user, firm); first
valid wins, else system default. Existing single-spec callers unchanged.
- Render path threads the firm tier: renderSubmissionDraftTitle /
RenderSubmissionFilenameFor gain a firm param; newDraftName +
submissionDownloadFilename load it (nil-safe). A firm default thus changes
the effective name for every user without a personal override.
Admin surface (mirrors firm_dashboard_default):
- GET/PUT/DELETE /api/admin/name-compositions{/artifact_id} (adminGate) read
back / set / clear the firm default per artifact.
- /settings Namensschemata cards gain an admin-only "Firmenstandard" block
(set from the current template field / clear) revealed via is_admin, plus a
"Firmenstandard" badge for non-admin users whose effective name comes from
the firm tier. SettingsNameArtifact now resolves user→firm→system and
exposes firm_is_set/firm_template.
Tests: pure precedence (user>firm>system) + firm-tier view + live firm
round-trip/Validate-rejection (via db.ApplyMigrations). go vet, go test ./...,
bun build all clean; gated live tests green against TEST_DATABASE_URL.
NOTE (merge ordering): golang-migrate is forward-only. Migration 162 must not
reach a DB before bohr's 161 (Rubrum Composer seed) exists, or 161 will be
skipped (current>161 → never applied). Merge 161 before/with 162.
Browser Playwright of the admin firm controls deferred to post-deploy
mai-tester — shared Supabase login wall blocks pre-merge browser login (same
ceiling as t-paliad-354).
1570 lines
59 KiB
TypeScript
1570 lines
59 KiB
TypeScript
import { initI18n, onLangChange, getLang, setLang, t, tDyn, 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;
|
|
job_title: string | null;
|
|
global_role: string;
|
|
lang: Lang;
|
|
email_preferences: Record<string, unknown>;
|
|
reminder_morning_time: string;
|
|
reminder_evening_time: string;
|
|
reminder_timezone: string;
|
|
reminder_warning_offset_days: number;
|
|
// Optional override of the DRINGEND/overdue escalation channel — when
|
|
// set, replaces the global_admins fallback for this user's deadlines.
|
|
// Server returns either a UUID string or omits the key (omitempty).
|
|
escalation_contact_id?: string;
|
|
}
|
|
|
|
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" | "names" | "export";
|
|
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "names", "export"];
|
|
const DEFAULT_TAB: TabName = "profil";
|
|
|
|
let me: Me | null = null;
|
|
let offices: Office[] = [];
|
|
let caldavConfig: CalDAVConfig | null = null;
|
|
const loadedTabs = new Set<TabName>();
|
|
|
|
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<HTMLElement>("#einstellungen-tabs .entity-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();
|
|
else if (tab === "names") void loadNamesTab();
|
|
else if (tab === "export") void loadExportTab();
|
|
}
|
|
}
|
|
|
|
function wireTabLinks() {
|
|
document.querySelectorAll<HTMLAnchorElement>("#einstellungen-tabs .entity-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();
|
|
void renderMyPartnerUnits();
|
|
|
|
loading.style.display = "none";
|
|
form.style.display = "";
|
|
}
|
|
|
|
async function fetchMe(): Promise<void> {
|
|
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<void> {
|
|
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 `<option value="${esc(o.key)}">${esc(label)}</option>`;
|
|
})
|
|
.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.job_title ?? "";
|
|
(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 jobTitle = (document.getElementById("profil-role") 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 (!jobTitle) {
|
|
msg.textContent = t("einstellungen.profil.error.job_title");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
display_name: displayName,
|
|
office,
|
|
job_title: jobTitle,
|
|
lang,
|
|
};
|
|
|
|
const submitBtn = (ev.target as HTMLFormElement).querySelector<HTMLButtonElement>("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 Promise.all([fetchMe(), loadUserOptions()]);
|
|
fillPrefsForm();
|
|
fillEscalationContactOptions();
|
|
}
|
|
|
|
function fillEscalationContactOptions() {
|
|
const select = document.getElementById("prefs-escalation-contact") as HTMLSelectElement | null;
|
|
if (!select || !me) return;
|
|
// Filter out self — pointing your escalation at yourself is silly and
|
|
// the server enforces it via a CHECK constraint. Sort by display_name
|
|
// for a stable order (then email as tiebreaker for users without a
|
|
// display name).
|
|
const candidates = userOptions
|
|
.filter((u) => u.id !== me!.id)
|
|
.slice()
|
|
.sort((a, b) => {
|
|
const an = (a.display_name || a.email).toLowerCase();
|
|
const bn = (b.display_name || b.email).toLowerCase();
|
|
return an.localeCompare(bn);
|
|
});
|
|
const defaultLabel = t("einstellungen.prefs.escalation.default_option");
|
|
const opts: string[] = [`<option value="">${esc(defaultLabel)}</option>`];
|
|
for (const u of candidates) {
|
|
const label = u.display_name ? `${u.display_name} (${u.email})` : u.email;
|
|
opts.push(`<option value="${esc(u.id)}">${esc(label)}</option>`);
|
|
}
|
|
select.innerHTML = opts.join("");
|
|
select.value = me.escalation_contact_id ?? "";
|
|
}
|
|
|
|
function fillPrefsForm() {
|
|
const master = document.getElementById("prefs-reminders-master") as HTMLInputElement;
|
|
const overdue = document.getElementById("prefs-reminders-overdue") as HTMLInputElement;
|
|
const dueToday = document.getElementById("prefs-reminders-due-today") as HTMLInputElement;
|
|
const dueWarning = document.getElementById("prefs-reminders-due-warning") as HTMLInputElement;
|
|
|
|
master.checked = readPrefBool("deadline_reminders", true);
|
|
overdue.checked = readPrefBool("deadline_reminders.overdue", true);
|
|
dueToday.checked = readPrefBool("deadline_reminders.due_today", true);
|
|
dueWarning.checked = readPrefBool("deadline_reminders.due_warning", true);
|
|
|
|
// The model returns "HH:MM:SS" but <input type="time"> wants "HH:MM".
|
|
(document.getElementById("prefs-reminder-morning") as HTMLInputElement).value =
|
|
(me?.reminder_morning_time ?? "09:00:00").slice(0, 5);
|
|
(document.getElementById("prefs-reminder-evening") as HTMLInputElement).value =
|
|
(me?.reminder_evening_time ?? "16:00:00").slice(0, 5);
|
|
(document.getElementById("prefs-reminder-timezone") as HTMLInputElement).value =
|
|
me?.reminder_timezone ?? "Europe/Berlin";
|
|
(document.getElementById("prefs-warning-offset") as HTMLInputElement).value =
|
|
String(me?.reminder_warning_offset_days ?? 7);
|
|
|
|
updatePrefsSubGroup(master.checked);
|
|
master.addEventListener("change", () => updatePrefsSubGroup(master.checked));
|
|
}
|
|
|
|
function updatePrefsSubGroup(masterOn: boolean) {
|
|
const sub = document.getElementById("prefs-reminders-sub")!;
|
|
sub.querySelectorAll<HTMLInputElement>('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<string, unknown> = { ...(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.due_today"] = (document.getElementById("prefs-reminders-due-today") as HTMLInputElement).checked;
|
|
next["deadline_reminders.due_warning"] = (document.getElementById("prefs-reminders-due-warning") as HTMLInputElement).checked;
|
|
// Retire legacy keys so the next save's PATCH body doesn't keep them on
|
|
// the row indefinitely.
|
|
delete next["deadline_reminders.tomorrow"];
|
|
delete next["deadline_reminders.due_today_evening"];
|
|
delete next["deadline_reminders.weekly"];
|
|
|
|
const morning = (document.getElementById("prefs-reminder-morning") as HTMLInputElement).value;
|
|
const evening = (document.getElementById("prefs-reminder-evening") as HTMLInputElement).value;
|
|
const timezone = (document.getElementById("prefs-reminder-timezone") as HTMLInputElement).value.trim();
|
|
const warningOffset = parseInt(
|
|
(document.getElementById("prefs-warning-offset") as HTMLInputElement).value,
|
|
10,
|
|
);
|
|
// "" = clear back to the global_admins fallback; UUID = explicit
|
|
// escalation contact. The server's UpdateProfileInput uses *string with
|
|
// empty-string-as-clear semantics for nullable references.
|
|
const escalationContact = (document.getElementById("prefs-escalation-contact") as HTMLSelectElement).value;
|
|
|
|
if (!morning || !evening) {
|
|
msg.textContent = t("einstellungen.prefs.times.error.required");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
if (!Number.isFinite(warningOffset) || warningOffset < 1 || warningOffset > 30) {
|
|
msg.textContent = t("einstellungen.prefs.warning_offset.error");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
|
|
const submitBtn = (ev.target as HTMLFormElement).querySelector<HTMLButtonElement>("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,
|
|
reminder_morning_time: morning,
|
|
reminder_evening_time: evening,
|
|
reminder_timezone: timezone || "Europe/Berlin",
|
|
reminder_warning_offset_days: warningOffset,
|
|
escalation_contact_id: escalationContact,
|
|
}),
|
|
});
|
|
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();
|
|
// Slice 2b — multi-calendar bindings. loadBindingProjects feeds the
|
|
// project picker for scope=project; runs in parallel with the binding
|
|
// list fetch.
|
|
void loadBindingProjects();
|
|
await loadBindings();
|
|
}
|
|
|
|
async function loadCalDAVConfig(): Promise<boolean> {
|
|
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 ? `<span class="caldav-status-error">${esc(r.error)}</span>` : "\u2014";
|
|
return `<tr>
|
|
<td>${esc(fmtDateTime(r.occurred_at))}</td>
|
|
<td>${r.items_pushed}</td>
|
|
<td>${r.items_pulled}</td>
|
|
<td>${dur}</td>
|
|
<td>${err}</td>
|
|
</tr>`;
|
|
})
|
|
.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<HTMLButtonElement>("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";
|
|
}
|
|
}
|
|
|
|
// --- CalDAV bindings (Slice 2b multi-calendar picker) ---------------------
|
|
|
|
interface UserCalendarBinding {
|
|
id: string;
|
|
user_id: string;
|
|
calendar_path: string;
|
|
display_name: string;
|
|
scope_kind: "all_visible" | "personal_only" | "project" | "client" | "litigation" | "patent" | "case";
|
|
scope_id?: string | null;
|
|
include_personal: boolean;
|
|
enabled: boolean;
|
|
last_sync_at?: string | null;
|
|
last_sync_error?: string | null;
|
|
}
|
|
|
|
interface DiscoveredCalendar {
|
|
href: string;
|
|
display_name: string;
|
|
supported_components?: string[];
|
|
}
|
|
|
|
interface ProjectListItem {
|
|
id: string;
|
|
reference?: string;
|
|
title?: string;
|
|
type?: string;
|
|
}
|
|
|
|
let bindings: UserCalendarBinding[] = [];
|
|
let discoveredCalendars: DiscoveredCalendar[] = [];
|
|
let bindingProjects: ProjectListItem[] = [];
|
|
let editingBindingID: string | null = null;
|
|
// Slice 2c — capability cached from /api/caldav-discover. null = unprobed,
|
|
// true = MKCALENDAR supported (show "Create new calendar" radio),
|
|
// false = degrade UX (hide radio, surface bilingual notice).
|
|
let supportsMKCalendar: boolean | null = null;
|
|
|
|
async function loadBindings(): Promise<void> {
|
|
const section = document.getElementById("caldav-bindings-section");
|
|
if (!section) return;
|
|
try {
|
|
const resp = await fetch("/api/caldav-bindings");
|
|
if (resp.status === 501) return; // CalDAV unavailable; leave hidden
|
|
if (!resp.ok) return;
|
|
bindings = (await resp.json()) as UserCalendarBinding[];
|
|
section.style.display = "";
|
|
renderBindingsList();
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
|
|
function renderBindingsList(): void {
|
|
const list = document.getElementById("caldav-bindings-list")!;
|
|
const empty = document.getElementById("caldav-bindings-empty")!;
|
|
if (!bindings.length) {
|
|
list.innerHTML = "";
|
|
empty.style.display = "block";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
list.innerHTML = bindings.map(renderBindingCard).join("");
|
|
// Wire per-card buttons.
|
|
for (const b of bindings) {
|
|
const card = document.getElementById(`caldav-binding-card-${b.id}`);
|
|
if (!card) continue;
|
|
card.querySelector(".caldav-binding-edit-btn")?.addEventListener("click", () => openBindingModal(b));
|
|
card.querySelector(".caldav-binding-delete-btn")?.addEventListener("click", () => deleteBinding(b));
|
|
const toggle = card.querySelector(".caldav-binding-enabled-toggle") as HTMLInputElement | null;
|
|
toggle?.addEventListener("change", () => toggleBindingEnabled(b, toggle.checked));
|
|
}
|
|
}
|
|
|
|
function renderBindingCard(b: UserCalendarBinding): string {
|
|
const label = b.display_name || b.calendar_path;
|
|
const scope = scopeLabel(b);
|
|
const last = b.last_sync_at ? fmtDateTime(b.last_sync_at) : t("caldav.never");
|
|
const err = b.last_sync_error ? `<span class="caldav-status-error">${esc(b.last_sync_error)}</span>` : "";
|
|
return `<div class="caldav-binding-card" id="caldav-binding-card-${esc(b.id)}">
|
|
<div class="caldav-binding-card-row">
|
|
<div class="caldav-binding-card-title">
|
|
<strong>${esc(label)}</strong>
|
|
<span class="caldav-binding-scope-chip">${esc(scope)}</span>
|
|
</div>
|
|
<label class="caldav-toggle-label">
|
|
<input type="checkbox" class="caldav-binding-enabled-toggle" ${b.enabled ? "checked" : ""} />
|
|
<span data-i18n="caldav.bindings.card.enabled">Aktiv</span>
|
|
</label>
|
|
</div>
|
|
<div class="caldav-binding-card-row caldav-binding-card-meta">
|
|
<span class="caldav-binding-path">${esc(b.calendar_path)}</span>
|
|
<span class="caldav-binding-last-sync">${esc(t("caldav.status.last_sync"))} ${esc(last)} ${err}</span>
|
|
</div>
|
|
<div class="caldav-binding-card-actions">
|
|
<button type="button" class="btn-secondary caldav-binding-edit-btn" data-i18n="caldav.bindings.card.edit">Bearbeiten</button>
|
|
<button type="button" class="btn-danger caldav-binding-delete-btn" data-i18n="caldav.bindings.card.remove">Entfernen</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function scopeLabel(b: UserCalendarBinding): string {
|
|
switch (b.scope_kind) {
|
|
case "all_visible":
|
|
return t("caldav.bindings.scope.all_visible");
|
|
case "personal_only":
|
|
return t("caldav.bindings.scope.personal_only");
|
|
case "project": {
|
|
const p = bindingProjects.find((p) => p.id === b.scope_id);
|
|
const name = p ? p.title || p.reference || p.id.slice(0, 8) : "?";
|
|
return `${t("caldav.bindings.scope.project")}: ${name}`;
|
|
}
|
|
default:
|
|
return b.scope_kind;
|
|
}
|
|
}
|
|
|
|
async function loadBindingProjects(): Promise<void> {
|
|
if (bindingProjects.length) return;
|
|
try {
|
|
const resp = await fetch("/api/projects");
|
|
if (resp.ok) bindingProjects = (await resp.json()) as ProjectListItem[];
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
async function loadDiscoveredCalendars(): Promise<void> {
|
|
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
|
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.loading"))}</option>`;
|
|
try {
|
|
const resp = await fetch("/api/caldav-discover");
|
|
if (!resp.ok) {
|
|
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
|
|
supportsMKCalendar = null;
|
|
syncBindingSourceModeUI();
|
|
return;
|
|
}
|
|
const data = (await resp.json()) as {
|
|
calendars: DiscoveredCalendar[];
|
|
supports_mkcalendar?: boolean | null;
|
|
};
|
|
discoveredCalendars = data.calendars || [];
|
|
supportsMKCalendar = data.supports_mkcalendar ?? null;
|
|
if (!discoveredCalendars.length) {
|
|
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_empty"))}</option>`;
|
|
} else {
|
|
sel.innerHTML = discoveredCalendars
|
|
.map((c) => `<option value="${esc(c.href)}">${esc(c.display_name || c.href)}</option>`)
|
|
.join("");
|
|
}
|
|
syncBindingSourceModeUI();
|
|
} catch {
|
|
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
|
|
supportsMKCalendar = null;
|
|
syncBindingSourceModeUI();
|
|
}
|
|
}
|
|
|
|
// syncBindingSourceModeUI shows / hides the "Neuen Kalender erstellen"
|
|
// radio + the Google-degrade notice based on the cached
|
|
// supports_mkcalendar capability. Also flips the visible input
|
|
// (dropdown vs URL text box) to match the currently selected mode.
|
|
function syncBindingSourceModeUI(): void {
|
|
const createRow = document.getElementById("caldav-binding-source-mode-create-row");
|
|
const degrade = document.getElementById("caldav-binding-degrade-notice");
|
|
if (createRow) createRow.style.display = supportsMKCalendar === true ? "" : "none";
|
|
if (degrade) degrade.style.display = supportsMKCalendar === false ? "" : "none";
|
|
|
|
// If supports_mkcalendar flipped to false while "create" was selected,
|
|
// fall back to "existing" so the user isn't staring at a hidden radio.
|
|
if (supportsMKCalendar !== true) {
|
|
const createRadio = document.querySelector(
|
|
'input[name="caldav-binding-source-mode"][value="create"]',
|
|
) as HTMLInputElement | null;
|
|
if (createRadio?.checked) {
|
|
const existing = document.querySelector(
|
|
'input[name="caldav-binding-source-mode"][value="existing"]',
|
|
) as HTMLInputElement | null;
|
|
if (existing) existing.checked = true;
|
|
}
|
|
}
|
|
|
|
const mode = currentBindingSourceMode();
|
|
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
|
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
|
sel.style.display = mode === "existing" ? "" : "none";
|
|
customInput.style.display = mode === "custom" ? "" : "none";
|
|
}
|
|
|
|
function currentBindingSourceMode(): "existing" | "create" | "custom" {
|
|
const checked = document.querySelector(
|
|
'input[name="caldav-binding-source-mode"]:checked',
|
|
) as HTMLInputElement | null;
|
|
return (checked?.value as "existing" | "create" | "custom") ?? "existing";
|
|
}
|
|
|
|
function openBindingModal(b: UserCalendarBinding | null) {
|
|
editingBindingID = b ? b.id : null;
|
|
const modal = document.getElementById("caldav-binding-modal")!;
|
|
const title = document.getElementById("caldav-binding-modal-title")!;
|
|
const submitBtn = document.getElementById("caldav-binding-submit-btn")!;
|
|
const sourceField = document.getElementById("caldav-binding-source-field")!;
|
|
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
|
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
|
|
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
|
const msg = document.getElementById("caldav-binding-msg")!;
|
|
msg.textContent = "";
|
|
|
|
if (b) {
|
|
title.textContent = t("caldav.bindings.modal.edit_title");
|
|
submitBtn.textContent = t("caldav.bindings.modal.submit_edit");
|
|
sourceField.style.display = "none";
|
|
nameInput.value = b.display_name;
|
|
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="${b.scope_kind}"]`) as HTMLInputElement | null;
|
|
if (radio) radio.checked = true;
|
|
} else {
|
|
title.textContent = t("caldav.bindings.modal.add_title");
|
|
submitBtn.textContent = t("caldav.bindings.modal.submit_add");
|
|
sourceField.style.display = "";
|
|
// Reset the 3-way source-mode radio to "existing" (most common path).
|
|
const existingRadio = document.querySelector(
|
|
'input[name="caldav-binding-source-mode"][value="existing"]',
|
|
) as HTMLInputElement | null;
|
|
if (existingRadio) existingRadio.checked = true;
|
|
customInput.value = "";
|
|
nameInput.value = "";
|
|
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="all_visible"]`) as HTMLInputElement;
|
|
radio.checked = true;
|
|
void loadDiscoveredCalendars();
|
|
}
|
|
|
|
// Project picker — populate options when project scope is picked.
|
|
projectSel.innerHTML = bindingProjects
|
|
.map((p) => `<option value="${esc(p.id)}">${esc((p.title || p.reference || p.id.slice(0, 8)))}</option>`)
|
|
.join("");
|
|
if (b && b.scope_kind === "project" && b.scope_id) {
|
|
projectSel.value = b.scope_id;
|
|
projectSel.disabled = false;
|
|
}
|
|
syncBindingScopeUI();
|
|
syncBindingSourceModeUI();
|
|
|
|
modal.style.display = "flex";
|
|
}
|
|
|
|
function closeBindingModal() {
|
|
document.getElementById("caldav-binding-modal")!.style.display = "none";
|
|
editingBindingID = null;
|
|
}
|
|
|
|
function syncBindingScopeUI(): void {
|
|
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
|
|
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
|
projectSel.disabled = scope !== "project";
|
|
}
|
|
|
|
async function submitBindingModal(ev: Event): Promise<void> {
|
|
ev.preventDefault();
|
|
const msg = document.getElementById("caldav-binding-msg")!;
|
|
msg.textContent = "";
|
|
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
|
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
|
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
|
|
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
|
const submitBtn = document.getElementById("caldav-binding-submit-btn") as HTMLButtonElement;
|
|
|
|
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
|
|
if (!scope) {
|
|
msg.textContent = t("caldav.bindings.error.scope");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
if (scope === "project" && !projectSel.value) {
|
|
msg.textContent = t("caldav.bindings.error.scope_project");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
|
|
submitBtn.disabled = true;
|
|
try {
|
|
if (editingBindingID) {
|
|
const patchPayload: Record<string, unknown> = {
|
|
display_name: nameInput.value.trim(),
|
|
scope_kind: scope,
|
|
enabled: true,
|
|
};
|
|
if (scope === "project") patchPayload.scope_id = projectSel.value;
|
|
const resp = await fetch(`/api/caldav-bindings/${editingBindingID}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(patchPayload),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}) as { error?: string });
|
|
msg.textContent = err.error || t("caldav.error.generic");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
} else {
|
|
const mode = currentBindingSourceMode();
|
|
if (mode === "create") {
|
|
// Slice 2c MKCALENDAR path.
|
|
const displayName = nameInput.value.trim();
|
|
if (!displayName) {
|
|
msg.textContent = t("caldav.bindings.error.create_name_required");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
const createPayload: Record<string, unknown> = {
|
|
display_name: displayName,
|
|
scope_kind: scope,
|
|
};
|
|
if (scope === "project") createPayload.scope_id = projectSel.value;
|
|
const resp = await fetch("/api/caldav-mkcalendar", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(createPayload),
|
|
});
|
|
if (resp.status === 501) {
|
|
// Race: probe flipped to false between modal-open and submit.
|
|
// Re-sync the UI and surface a helpful message.
|
|
supportsMKCalendar = false;
|
|
syncBindingSourceModeUI();
|
|
msg.textContent = t("caldav.bindings.error.create_unsupported");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
if (resp.status === 409) {
|
|
msg.textContent = t("caldav.bindings.error.create_name_taken");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}) as { error?: string });
|
|
msg.textContent = err.error || t("caldav.error.generic");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
} else {
|
|
// existing | custom — POST /api/caldav-bindings with the path.
|
|
const path = mode === "custom" ? customInput.value.trim() : sel.value;
|
|
if (!path) {
|
|
msg.textContent = t("caldav.bindings.error.path");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
const postPayload: Record<string, unknown> = {
|
|
calendar_path: path,
|
|
display_name: nameInput.value.trim(),
|
|
scope_kind: scope,
|
|
enabled: true,
|
|
};
|
|
if (scope === "project") postPayload.scope_id = projectSel.value;
|
|
if (!postPayload.display_name && mode === "existing") {
|
|
const opt = sel.options[sel.selectedIndex];
|
|
postPayload.display_name = opt ? opt.text : "";
|
|
}
|
|
const resp = await fetch("/api/caldav-bindings", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(postPayload),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}) as { error?: string });
|
|
msg.textContent = err.error || t("caldav.error.generic");
|
|
msg.className = "form-msg form-msg-error";
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
closeBindingModal();
|
|
await loadBindings();
|
|
} catch {
|
|
msg.textContent = t("caldav.error.generic");
|
|
msg.className = "form-msg form-msg-error";
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function deleteBinding(b: UserCalendarBinding): Promise<void> {
|
|
if (!confirm(t("caldav.bindings.delete.confirm"))) return;
|
|
try {
|
|
const resp = await fetch(`/api/caldav-bindings/${b.id}`, { method: "DELETE" });
|
|
if (!resp.ok && resp.status !== 204 && resp.status !== 202) {
|
|
alert(t("caldav.bindings.delete.failed"));
|
|
return;
|
|
}
|
|
await loadBindings();
|
|
} catch {
|
|
alert(t("caldav.bindings.delete.failed"));
|
|
}
|
|
}
|
|
|
|
async function toggleBindingEnabled(b: UserCalendarBinding, enabled: boolean): Promise<void> {
|
|
try {
|
|
const resp = await fetch(`/api/caldav-bindings/${b.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
if (resp.ok) {
|
|
b.enabled = enabled;
|
|
}
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
|
|
// --- "Meine Partner Units" card on the profile tab -------------------------
|
|
//
|
|
// Read-only summary of the current user's structural memberships. Membership
|
|
// writes are admin-driven via /admin/partner-units.
|
|
|
|
interface PartnerUnit {
|
|
id: string;
|
|
name: string;
|
|
lead_user_id?: string | null;
|
|
office: string;
|
|
}
|
|
|
|
interface UserOption {
|
|
id: string;
|
|
display_name: string;
|
|
email: string;
|
|
}
|
|
|
|
let userOptions: UserOption[] = [];
|
|
|
|
async function loadUserOptions(): Promise<void> {
|
|
if (userOptions.length) return;
|
|
try {
|
|
const resp = await fetch("/api/users");
|
|
if (resp.ok) userOptions = (await resp.json()) as UserOption[];
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
async function renderMyPartnerUnits(): Promise<void> {
|
|
const container = document.getElementById("partner-unit-my");
|
|
if (!container || !me) return;
|
|
try {
|
|
const resp = await fetch("/api/partner-units?include=members");
|
|
if (!resp.ok) return;
|
|
type PUWithMembers = PartnerUnit & {
|
|
lead_display_name?: string;
|
|
members: { user_id: string; display_name: string; email: string }[];
|
|
};
|
|
const all = (await resp.json()) as PUWithMembers[];
|
|
const mine = all.filter((u) => u.members.some((m) => m.user_id === me!.id));
|
|
|
|
if (!mine.length) {
|
|
container.innerHTML = `<p class="form-hint">${esc(t("partner_unit.none") || "Sie sind noch keiner Partner Unit zugeordnet.")}</p>`;
|
|
return;
|
|
}
|
|
container.innerHTML = mine
|
|
.map(
|
|
(u) => `<div class="partner-unit-card">
|
|
<h3>${esc(u.name)}</h3>
|
|
<p class="form-hint">${esc(tDyn("office." + u.office) || u.office)}${u.lead_display_name ? ` · <strong>${esc(u.lead_display_name)}</strong>` : ""}</p>
|
|
<p class="form-hint"><strong>${u.members.length}</strong> ${esc(t("partner_unit.members_label") || "Mitglieder")}</p>
|
|
<ul class="partner-unit-member-list">
|
|
${u.members
|
|
.map((m) => `<li>${esc(m.display_name)} <span class="form-hint">(${esc(m.email)})</span></li>`)
|
|
.join("")}
|
|
</ul>
|
|
</div>`,
|
|
)
|
|
.join("");
|
|
} catch {
|
|
// ignore — leave previous render
|
|
}
|
|
}
|
|
|
|
// --- Export tab (t-paliad-214 Slice 1) -------------------------------------
|
|
|
|
// Personal data export. One button; on click hits GET /api/me/export and the
|
|
// browser handles the download via Content-Disposition. We use an anchor +
|
|
// hidden iframe pattern so any non-200 response can surface inline instead
|
|
// of silently triggering a save dialog with an error-html body.
|
|
async function loadExportTab(): Promise<void> {
|
|
// Nothing to fetch on render; the tab is static text + button. Wired in
|
|
// the DOMContentLoaded handler.
|
|
}
|
|
|
|
function runExport(): void {
|
|
const msg = document.getElementById("export-msg");
|
|
const btn = document.getElementById("export-btn") as HTMLButtonElement | null;
|
|
if (msg) msg.textContent = "";
|
|
if (btn) btn.disabled = true;
|
|
// Trigger a navigation to the endpoint. The server sets
|
|
// Content-Disposition: attachment which the browser respects.
|
|
// We use a transient <a download> so the click goes through the
|
|
// normal download path even on browsers that try to render text/json.
|
|
const a = document.createElement("a");
|
|
a.href = "/api/me/export";
|
|
// download="" tells the browser to keep the server-provided filename
|
|
// when one is set via Content-Disposition.
|
|
a.download = "";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
// Re-enable after a short timeout so users can re-trigger if needed.
|
|
// We don't try to detect download completion — there's no portable
|
|
// browser API for it.
|
|
if (btn) {
|
|
setTimeout(() => {
|
|
btn.disabled = false;
|
|
if (msg)
|
|
msg.textContent =
|
|
t("einstellungen.export.started") ||
|
|
"Download gestartet. Falls nichts passiert, prüfen Sie Ihren Browser-Downloadordner.";
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
// --- Namensschemata tab (t-paliad-356 Slice 4) ------------------------------
|
|
//
|
|
// Per-artifact token-template editor. All parsing, validation and preview
|
|
// rendering happen server-side (the nomen engine is the single source of
|
|
// truth); this client only inserts {tokens} at the cursor, debounces a preview
|
|
// request, and persists via PUT/DELETE.
|
|
|
|
interface NameVar {
|
|
var: string;
|
|
label: string;
|
|
label_en: string;
|
|
}
|
|
|
|
interface NameArtifactCard {
|
|
artifact_id: string;
|
|
label: string;
|
|
label_en: string;
|
|
template: string;
|
|
system_template: string;
|
|
is_override: boolean;
|
|
firm_is_set: boolean;
|
|
firm_template: string;
|
|
palette: NameVar[];
|
|
preview_full: string;
|
|
preview_empty: string;
|
|
}
|
|
|
|
let nameCards: NameArtifactCard[] = [];
|
|
let nameIsAdmin = false;
|
|
const namePreviewTimers = new Map<string, number>();
|
|
|
|
function nameVarLabel(v: NameVar): string {
|
|
return getLang() === "en" ? v.label_en : v.label;
|
|
}
|
|
|
|
function artifactLabel(c: NameArtifactCard): string {
|
|
return getLang() === "en" ? c.label_en : c.label;
|
|
}
|
|
|
|
async function loadNamesTab(): Promise<void> {
|
|
const loading = document.getElementById("names-loading");
|
|
const list = document.getElementById("names-list");
|
|
if (!list) return;
|
|
try {
|
|
const resp = await fetch("/api/me/name-compositions");
|
|
if (!resp.ok) {
|
|
if (loading) loading.textContent = t("einstellungen.names.error.load");
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
nameCards = (data.artifacts ?? []) as NameArtifactCard[];
|
|
nameIsAdmin = data.is_admin === true;
|
|
} catch {
|
|
if (loading) loading.textContent = t("einstellungen.names.error.load");
|
|
return;
|
|
}
|
|
if (loading) loading.style.display = "none";
|
|
list.style.display = "";
|
|
renderNameCards();
|
|
}
|
|
|
|
function renderNameCards(): void {
|
|
const list = document.getElementById("names-list");
|
|
if (!list) return;
|
|
list.innerHTML = nameCards.map(nameCardHTML).join("");
|
|
for (const card of nameCards) wireNameCard(card.artifact_id);
|
|
}
|
|
|
|
function nameCardHTML(c: NameArtifactCard): string {
|
|
const id = c.artifact_id;
|
|
const chips = c.palette
|
|
.map(
|
|
(v) =>
|
|
`<button type="button" class="names-chip" data-var="${esc(v.var)}" data-art="${esc(id)}">${esc(nameVarLabel(v))}</button>`,
|
|
)
|
|
.join("");
|
|
return `
|
|
<div class="names-artifact" data-art="${esc(id)}">
|
|
<div class="names-artifact-head">
|
|
<h2>${esc(artifactLabel(c))}</h2>
|
|
${nameBadgeHTML(c)}
|
|
</div>
|
|
<div class="names-palette" id="names-palette-${esc(id)}">${chips}</div>
|
|
<input type="text" class="names-template-input" id="names-input-${esc(id)}"
|
|
value="${esc(c.template)}" autocomplete="off" spellcheck="false" />
|
|
<p class="form-msg form-msg-error names-error" id="names-error-${esc(id)}" style="display:none"></p>
|
|
<div class="names-preview">
|
|
<div class="names-preview-row">
|
|
<span class="names-preview-label" data-i18n="einstellungen.names.preview.sample">${esc(t("einstellungen.names.preview.sample"))}</span>
|
|
<code class="names-preview-value" id="names-full-${esc(id)}">${esc(c.preview_full)}</code>
|
|
</div>
|
|
<div class="names-preview-row">
|
|
<span class="names-preview-label" data-i18n="einstellungen.names.preview.empty">${esc(t("einstellungen.names.preview.empty"))}</span>
|
|
<code class="names-preview-value" id="names-empty-${esc(id)}">${esc(c.preview_empty)}</code>
|
|
</div>
|
|
</div>
|
|
<p class="form-msg names-saved" id="names-saved-${esc(id)}"></p>
|
|
<div class="form-actions">
|
|
<button type="button" class="btn-secondary" id="names-reset-${esc(id)}" data-i18n="einstellungen.names.reset">${esc(t("einstellungen.names.reset"))}</button>
|
|
<button type="button" class="btn-primary btn-cta-lime" id="names-save-${esc(id)}" data-i18n="einstellungen.save">${esc(t("einstellungen.save"))}</button>
|
|
</div>
|
|
${nameIsAdmin ? nameFirmAdminHTML(c) : ""}
|
|
</div>`;
|
|
}
|
|
|
|
// Badge: "Angepasst" when the user has their own override, else "Firmenstandard"
|
|
// when the firm default is the source of the shown name. Hidden otherwise.
|
|
function nameBadgeHTML(c: NameArtifactCard): string {
|
|
const id = c.artifact_id;
|
|
if (c.is_override) {
|
|
return `<span class="names-badge" id="names-badge-${esc(id)}">${esc(t("einstellungen.names.override_badge"))}</span>`;
|
|
}
|
|
if (c.firm_is_set) {
|
|
return `<span class="names-badge names-badge--firm" id="names-badge-${esc(id)}">${esc(t("einstellungen.names.firm_badge"))}</span>`;
|
|
}
|
|
return `<span class="names-badge" id="names-badge-${esc(id)}" style="display:none"></span>`;
|
|
}
|
|
|
|
// Admin-only firm-default controls (mirrors the firm-dashboard-default promote
|
|
// pattern). "Set as firm default" takes whatever is in the template field;
|
|
// "Clear" reverts the firm tier to the system default for everyone.
|
|
function nameFirmAdminHTML(c: NameArtifactCard): string {
|
|
const id = c.artifact_id;
|
|
const status = c.firm_is_set
|
|
? `${esc(t("einstellungen.names.firm.status_set"))} <code>${esc(c.firm_template)}</code>`
|
|
: esc(t("einstellungen.names.firm.status_unset"));
|
|
return `
|
|
<div class="names-firm-admin" id="names-firm-${esc(id)}">
|
|
<h3 class="names-firm-heading" data-i18n="einstellungen.names.firm.heading">${esc(t("einstellungen.names.firm.heading"))}</h3>
|
|
<p class="form-hint names-firm-status" id="names-firm-status-${esc(id)}">${status}</p>
|
|
<p class="form-msg names-firm-msg" id="names-firm-msg-${esc(id)}"></p>
|
|
<div class="form-actions">
|
|
<button type="button" class="btn-danger" id="names-firm-clear-${esc(id)}" data-i18n="einstellungen.names.firm.clear"
|
|
style="${c.firm_is_set ? "" : "display:none"}">${esc(t("einstellungen.names.firm.clear"))}</button>
|
|
<button type="button" class="btn-secondary" id="names-firm-set-${esc(id)}" data-i18n="einstellungen.names.firm.set">${esc(t("einstellungen.names.firm.set"))}</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function wireNameCard(id: string): void {
|
|
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
|
if (!input) return;
|
|
input.addEventListener("input", () => scheduleNamePreview(id));
|
|
document.querySelectorAll<HTMLButtonElement>(`.names-chip[data-art="${cssEscapeAttr(id)}"]`).forEach((chip) => {
|
|
chip.addEventListener("click", () => insertNameToken(id, chip.getAttribute("data-var") ?? ""));
|
|
});
|
|
document.getElementById(`names-reset-${id}`)?.addEventListener("click", () => resetNameComposition(id));
|
|
document.getElementById(`names-save-${id}`)?.addEventListener("click", () => saveNameComposition(id));
|
|
document.getElementById(`names-firm-set-${id}`)?.addEventListener("click", () => setFirmNameComposition(id));
|
|
document.getElementById(`names-firm-clear-${id}`)?.addEventListener("click", () => clearFirmNameComposition(id));
|
|
}
|
|
|
|
// Artifact ids are [a-z_] only, but keep the attribute-selector value safe.
|
|
function cssEscapeAttr(s: string): string {
|
|
return s.replace(/["\\]/g, "\\$&");
|
|
}
|
|
|
|
function insertNameToken(id: string, varName: string): void {
|
|
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
|
if (!input || !varName) return;
|
|
const token = `{${varName}}`;
|
|
const start = input.selectionStart ?? input.value.length;
|
|
const end = input.selectionEnd ?? input.value.length;
|
|
input.value = input.value.slice(0, start) + token + input.value.slice(end);
|
|
const caret = start + token.length;
|
|
input.focus();
|
|
input.setSelectionRange(caret, caret);
|
|
scheduleNamePreview(id);
|
|
}
|
|
|
|
function scheduleNamePreview(id: string): void {
|
|
clearSavedMsg(id);
|
|
const existing = namePreviewTimers.get(id);
|
|
if (existing) window.clearTimeout(existing);
|
|
namePreviewTimers.set(id, window.setTimeout(() => void runNamePreview(id), 250));
|
|
}
|
|
|
|
async function runNamePreview(id: string): Promise<void> {
|
|
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
|
if (!input) return;
|
|
const template = input.value;
|
|
try {
|
|
const resp = await fetch("/api/me/name-compositions/preview", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ artifact_id: id, template }),
|
|
});
|
|
if (!resp.ok) {
|
|
setNameError(id, t("einstellungen.names.error.invalid"));
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
if (data.ok) {
|
|
setNamePreview(id, data.preview_full, data.preview_empty);
|
|
clearNameError(id);
|
|
} else {
|
|
setNameError(id, t("einstellungen.names.error.invalid"));
|
|
}
|
|
} catch {
|
|
setNameError(id, t("einstellungen.names.error.invalid"));
|
|
}
|
|
}
|
|
|
|
function setNamePreview(id: string, full: string, empty: string): void {
|
|
const f = document.getElementById(`names-full-${id}`);
|
|
const e = document.getElementById(`names-empty-${id}`);
|
|
if (f) f.textContent = full;
|
|
if (e) e.textContent = empty;
|
|
}
|
|
|
|
function setNameError(id: string, msg: string): void {
|
|
const err = document.getElementById(`names-error-${id}`);
|
|
if (err) {
|
|
err.textContent = msg;
|
|
err.style.display = "";
|
|
}
|
|
const save = document.getElementById(`names-save-${id}`) as HTMLButtonElement | null;
|
|
if (save) save.disabled = true;
|
|
}
|
|
|
|
function clearNameError(id: string): void {
|
|
const err = document.getElementById(`names-error-${id}`);
|
|
if (err) {
|
|
err.textContent = "";
|
|
err.style.display = "none";
|
|
}
|
|
const save = document.getElementById(`names-save-${id}`) as HTMLButtonElement | null;
|
|
if (save) save.disabled = false;
|
|
}
|
|
|
|
function clearSavedMsg(id: string): void {
|
|
const saved = document.getElementById(`names-saved-${id}`);
|
|
if (saved) saved.textContent = "";
|
|
}
|
|
|
|
function applyNameCard(updated: NameArtifactCard): void {
|
|
const idx = nameCards.findIndex((c) => c.artifact_id === updated.artifact_id);
|
|
if (idx >= 0) nameCards[idx] = updated;
|
|
const id = updated.artifact_id;
|
|
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
|
if (input) input.value = updated.template;
|
|
setNamePreview(id, updated.preview_full, updated.preview_empty);
|
|
clearNameError(id);
|
|
updateNameBadge(updated);
|
|
updateFirmStatus(updated);
|
|
}
|
|
|
|
// updateNameBadge reflects the override → firm → none state on the chip.
|
|
function updateNameBadge(c: NameArtifactCard): void {
|
|
const badge = document.getElementById(`names-badge-${c.artifact_id}`);
|
|
if (!badge) return;
|
|
if (c.is_override) {
|
|
badge.textContent = t("einstellungen.names.override_badge");
|
|
badge.classList.remove("names-badge--firm");
|
|
badge.style.display = "";
|
|
} else if (c.firm_is_set) {
|
|
badge.textContent = t("einstellungen.names.firm_badge");
|
|
badge.classList.add("names-badge--firm");
|
|
badge.style.display = "";
|
|
} else {
|
|
badge.style.display = "none";
|
|
}
|
|
}
|
|
|
|
// updateFirmStatus refreshes the admin firm-default status line + clear button.
|
|
function updateFirmStatus(c: NameArtifactCard): void {
|
|
const status = document.getElementById(`names-firm-status-${c.artifact_id}`);
|
|
if (status) {
|
|
if (c.firm_is_set) {
|
|
status.innerHTML = `${esc(t("einstellungen.names.firm.status_set"))} <code>${esc(c.firm_template)}</code>`;
|
|
} else {
|
|
status.textContent = t("einstellungen.names.firm.status_unset");
|
|
}
|
|
}
|
|
const clearBtn = document.getElementById(`names-firm-clear-${c.artifact_id}`);
|
|
if (clearBtn) clearBtn.style.display = c.firm_is_set ? "" : "none";
|
|
}
|
|
|
|
async function setFirmNameComposition(id: string): Promise<void> {
|
|
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
|
const msg = document.getElementById(`names-firm-msg-${id}`);
|
|
if (!input) return;
|
|
try {
|
|
const resp = await fetch(`/api/admin/name-compositions/${encodeURIComponent(id)}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ template: input.value }),
|
|
});
|
|
if (!resp.ok) {
|
|
if (msg) {
|
|
msg.textContent = t("einstellungen.names.error.invalid");
|
|
msg.className = "form-msg form-msg-error names-firm-msg";
|
|
}
|
|
return;
|
|
}
|
|
const updated = (await resp.json()) as NameArtifactCard;
|
|
// The admin PUT response carries no user override; preserve the caller's
|
|
// own is_override/template view by merging only the firm fields.
|
|
mergeFirmFields(id, updated);
|
|
if (msg) {
|
|
msg.textContent = t("einstellungen.names.firm.saved");
|
|
msg.className = "form-msg form-msg-success names-firm-msg";
|
|
}
|
|
} catch {
|
|
if (msg) {
|
|
msg.textContent = t("einstellungen.names.error.invalid");
|
|
msg.className = "form-msg form-msg-error names-firm-msg";
|
|
}
|
|
}
|
|
}
|
|
|
|
async function clearFirmNameComposition(id: string): Promise<void> {
|
|
const msg = document.getElementById(`names-firm-msg-${id}`);
|
|
try {
|
|
const resp = await fetch(`/api/admin/name-compositions/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
if (!resp.ok) {
|
|
if (msg) {
|
|
msg.textContent = t("einstellungen.names.error.invalid");
|
|
msg.className = "form-msg form-msg-error names-firm-msg";
|
|
}
|
|
return;
|
|
}
|
|
const updated = (await resp.json()) as NameArtifactCard;
|
|
mergeFirmFields(id, updated);
|
|
if (msg) {
|
|
msg.textContent = t("einstellungen.names.firm.cleared");
|
|
msg.className = "form-msg form-msg-success names-firm-msg";
|
|
}
|
|
} catch {
|
|
if (msg) {
|
|
msg.textContent = t("einstellungen.names.error.invalid");
|
|
msg.className = "form-msg form-msg-error names-firm-msg";
|
|
}
|
|
}
|
|
}
|
|
|
|
// mergeFirmFields applies the firm-tier fields from an admin PUT/DELETE
|
|
// response onto the stored card without disturbing the caller's own
|
|
// user-override view, then refreshes the badge + firm status.
|
|
function mergeFirmFields(id: string, fromAdmin: NameArtifactCard): void {
|
|
const idx = nameCards.findIndex((c) => c.artifact_id === id);
|
|
if (idx < 0) return;
|
|
nameCards[idx].firm_is_set = fromAdmin.firm_is_set;
|
|
nameCards[idx].firm_template = fromAdmin.firm_template;
|
|
updateNameBadge(nameCards[idx]);
|
|
updateFirmStatus(nameCards[idx]);
|
|
}
|
|
|
|
async function saveNameComposition(id: string): Promise<void> {
|
|
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
|
if (!input) return;
|
|
try {
|
|
const resp = await fetch(`/api/me/name-compositions/${encodeURIComponent(id)}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ template: input.value }),
|
|
});
|
|
if (!resp.ok) {
|
|
setNameError(id, t("einstellungen.names.error.invalid"));
|
|
return;
|
|
}
|
|
const updated = (await resp.json()) as NameArtifactCard;
|
|
applyNameCard(updated);
|
|
const saved = document.getElementById(`names-saved-${id}`);
|
|
if (saved) {
|
|
saved.textContent = t("einstellungen.names.saved");
|
|
saved.className = "form-msg form-msg-success names-saved";
|
|
}
|
|
} catch {
|
|
setNameError(id, t("einstellungen.names.error.invalid"));
|
|
}
|
|
}
|
|
|
|
async function resetNameComposition(id: string): Promise<void> {
|
|
try {
|
|
const resp = await fetch(`/api/me/name-compositions/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
if (!resp.ok) {
|
|
setNameError(id, t("einstellungen.names.error.invalid"));
|
|
return;
|
|
}
|
|
const updated = (await resp.json()) as NameArtifactCard;
|
|
applyNameCard(updated);
|
|
const saved = document.getElementById(`names-saved-${id}`);
|
|
if (saved) {
|
|
saved.textContent = t("einstellungen.names.reset_done");
|
|
saved.className = "form-msg form-msg-success names-saved";
|
|
}
|
|
} catch {
|
|
setNameError(id, t("einstellungen.names.error.invalid"));
|
|
}
|
|
}
|
|
|
|
// Re-localise palette chips + artifact headings on language change without
|
|
// rebuilding the cards (which would discard in-progress edits).
|
|
function relocaliseNameCards(): void {
|
|
for (const card of nameCards) {
|
|
const head = document.querySelector(`.names-artifact[data-art="${cssEscapeAttr(card.artifact_id)}"] h2`);
|
|
if (head) head.textContent = artifactLabel(card);
|
|
const badge = document.getElementById(`names-badge-${card.artifact_id}`);
|
|
if (badge && badge.style.display !== "none") badge.textContent = t("einstellungen.names.override_badge");
|
|
for (const v of card.palette) {
|
|
const chip = document.querySelector(
|
|
`.names-chip[data-art="${cssEscapeAttr(card.artifact_id)}"][data-var="${cssEscapeAttr(v.var)}"]`,
|
|
);
|
|
if (chip) chip.textContent = nameVarLabel(v);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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);
|
|
|
|
// CalDAV bindings (Slice 2b + 2c) — add/edit modal wiring.
|
|
document.getElementById("caldav-bindings-add-btn")?.addEventListener("click", () => openBindingModal(null));
|
|
document.getElementById("caldav-binding-modal-close")?.addEventListener("click", closeBindingModal);
|
|
document.getElementById("caldav-binding-cancel-btn")?.addEventListener("click", closeBindingModal);
|
|
document.getElementById("caldav-binding-form")?.addEventListener("submit", submitBindingModal);
|
|
document.querySelectorAll('input[name="caldav-binding-source-mode"]').forEach((el) => {
|
|
el.addEventListener("change", syncBindingSourceModeUI);
|
|
});
|
|
document.querySelectorAll('input[name="caldav-binding-scope"]').forEach((el) => {
|
|
el.addEventListener("change", syncBindingScopeUI);
|
|
});
|
|
const exportBtn = document.getElementById("export-btn");
|
|
if (exportBtn) exportBtn.addEventListener("click", runExport);
|
|
|
|
onLangChange(() => {
|
|
if (loadedTabs.has("profil")) renderOfficeOptions();
|
|
if (loadedTabs.has("caldav")) {
|
|
renderCalDAVStatus();
|
|
void loadCalDAVLog();
|
|
}
|
|
if (loadedTabs.has("names")) relocaliseNameCards();
|
|
});
|
|
|
|
showTab(parseTab(), false);
|
|
});
|