/* Verwaltungsportal - Frontend Logic */ "use strict"; const API = { token: localStorage.getItem("token"), async request(path, opts = {}) { const headers = { "Content-Type": "application/json" }; if (this.token) headers["Authorization"] = `Bearer ${this.token}`; const res = await fetch(path, { ...opts, headers }); if (res.status === 401) { localStorage.removeItem("token"); localStorage.removeItem("username"); window.location.href = "/"; return; } if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `Fehler ${res.status}`); } if (res.status === 204) return null; return res.json(); }, get(path) { return this.request(path); }, post(path, body) { return this.request(path, { method: "POST", body: JSON.stringify(body) }); }, put(path, body) { return this.request(path, { method: "PUT", body: body ? JSON.stringify(body) : undefined }); }, del(path) { return this.request(path, { method: "DELETE" }); }, async upload(path, formData) { const headers = {}; if (this.token) headers["Authorization"] = `Bearer ${this.token}`; const res = await fetch(path, { method: "POST", headers, body: formData }); if (res.status === 401) { localStorage.removeItem("token"); localStorage.removeItem("username"); window.location.href = "/"; return; } if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `Fehler ${res.status}`); } return res.json(); }, }; // --- Theme (Dark = Standard, Wahl bleibt in localStorage erhalten) --- const ThemeManager = { _key: "portal_theme", init() { const theme = localStorage.getItem(this._key) === "light" ? "light" : "dark"; this._apply(theme); }, toggle() { const next = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light"; localStorage.setItem(this._key, next); this._apply(next); }, _apply(theme) { if (theme === "light") document.documentElement.setAttribute("data-theme", "light"); else document.documentElement.removeAttribute("data-theme"); const el = document.getElementById("theme-toggle"); if (el) { el.classList.remove("dark", "light"); el.classList.add(theme); el.setAttribute("aria-checked", theme === "dark" ? "true" : "false"); } }, }; // Wirksame Credits-Sätze aus der Preistabelle, für alle Anzeigetexte let PORTAL_TARIFF = null; async function loadTariffInfo() { try { const data = await API.get("/api/pricing/tariff"); PORTAL_TARIFF = data.tariff || null; const el = document.getElementById("tariffInfoText"); if (el && PORTAL_TARIFF) { const t = PORTAL_TARIFF; const n = (v) => Number(v).toLocaleString("de-DE"); el.textContent = `Zur Einordnung. Ein Live-Lauf kostet ${n(t.monitor_adhoc)} Credits, ` + `das Anlegen einer Recherche ${n(t.monitor_research * 3)} (drei Durchläufe), ` + `jede Vertiefung ${n(t.monitor_research)}, Chat und Beschreibungs-Assistent je ${n(t.chat)}.`; } } catch (e) { /* statischer Text bleibt als Rückfallebene stehen */ } } // Sprung zum (nachgeladenen) Verbrauchsrechner-Tab function openRechnerTab() { const tab = document.querySelector('.nav-tab[data-section="rechner"]'); if (tab) tab.click(); } // Tab-Wechsel per Name, nutzt die vorhandene Nav-Logik function gotoSection(name) { const tab = document.querySelector(`.nav-tab[data-section="${name}"]`); if (tab) tab.click(); } // Dashboard-Kacheln als Sprungziele verdrahten (Klick + Enter/Leertaste) function wireStatCardLinks() { document.querySelectorAll("#statsGrid .stat-card-link").forEach((card) => { const go = () => { if (card.dataset.href) window.open(card.dataset.href, "_blank", "noopener"); else if (card.dataset.goto) gotoSection(card.dataset.goto); }; card.addEventListener("click", go); card.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); go(); } }); }); } // --- State --- let currentOrgId = null; let orgsCache = []; // --- Init --- document.addEventListener("DOMContentLoaded", () => { if (!API.token) { window.location.href = "/"; return; } ThemeManager.init(); // Barrierefreiheits-Panel (a11y.js, identische Datei wie im Monitor) if (typeof A11yManager !== "undefined") A11yManager.init(); document.getElementById("headerUser").textContent = localStorage.getItem("username") || ""; document.getElementById("logoutBtn").addEventListener("click", logout); setupNavTabs(); setupOrgDetailTabs(); setupForms(); setupTranslation(); loadDashboard(); loadTariffInfo(); loadDashboardTokenStats(); loadTranslationStatus(); loadOrgs(); }); function logout() { localStorage.removeItem("token"); localStorage.removeItem("username"); window.location.href = "/"; } // --- Navigation --- function setupNavTabs() { document.querySelectorAll(".nav-tabs:not(#orgDetailTabs):not(#sourceSubTabs) .nav-tab").forEach(tab => { tab.addEventListener("click", () => { const section = tab.dataset.section; document.querySelectorAll(".nav-tabs:not(#orgDetailTabs):not(#sourceSubTabs) .nav-tab").forEach(t => t.classList.remove("active")); tab.classList.add("active"); document.querySelectorAll(".app-content > .section").forEach(s => s.classList.remove("active")); document.getElementById(`sec-${section}`).classList.add("active"); if (section === "dashboard") loadTranslationStatus(); if (section === "audit" && typeof loadAudit === "function") loadAudit(); }); }); } function setupOrgDetailTabs() { // Das Org-Detail ist eine durchgehende Seite ohne Unterreiter oder // Sprungmarken, hier bleibt nur der Zurück-Knopf zu verdrahten. document.getElementById("orgBackBtn").addEventListener("click", () => { document.getElementById("orgListView").style.display = ""; document.getElementById("orgDetail").classList.remove("active"); currentOrgId = null; }); } // --- Dashboard --- async function loadDashboard() { try { const stats = await API.get("/api/dashboard/stats"); // Kacheln sind Sprungziele. data-goto wechselt den Tab, data-href oeffnet // extern (Vorfaelle leben im Monitor, nicht im Portal). document.getElementById("statsGrid").innerHTML = ` `; wireStatCardLinks(); // Expiring licenses const expList = document.getElementById("expiringList"); if (stats.expiring_licenses.length === 0) { expList.innerHTML = '
  • Keine ablaufenden Lizenzen
  • '; } else { expList.innerHTML = stats.expiring_licenses.map(l => ` `).join(""); } } catch (err) { console.error("Dashboard laden fehlgeschlagen:", err); } } // --- Organizations --- let orgUsageMap = {}; // org_id -> Kontingent-Daten aus /api/token-usage/overview async function loadOrgs() { try { // Kontingent-Stand parallel holen, damit die Tabelle ihn direkt zeigt const [orgs, overview] = await Promise.all([ API.get("/api/orgs"), API.get("/api/token-usage/overview").catch(() => []), ]); orgsCache = orgs; orgUsageMap = {}; (overview || []).forEach(o => { orgUsageMap[o.org_id] = o; }); renderOrgTable(orgsCache); } catch (err) { console.error("Orgs laden fehlgeschlagen:", err); } } // Kontingent-Zelle: Verbraucht/Verfuegbar mit Mini-Balken, ∞ bei unlimitiert function orgUsageCell(orgId) { const u = orgUsageMap[orgId]; if (!u) return '-'; if (u.unlimited_budget) return ''; const avail = u.credits_available || 0; if (!avail) return '-'; const used = Math.round(u.credits_used || 0); const pct = Math.min(100, Math.round((used / avail) * 100)); const cls = pct > 80 ? "critical" : (pct > 50 ? "warning" : ""); return `
    ${used.toLocaleString('de-DE')} / ${Math.round(avail).toLocaleString('de-DE')}
    `; } function renderOrgTable(orgs) { const tbody = document.getElementById("orgTable"); if (orgs.length === 0) { tbody.innerHTML = 'Keine Organisationen'; return; } tbody.innerHTML = orgs.map(o => ` ${esc(o.name)} ${o.user_count} ${o.license_type || "Keine"} ${orgUsageCell(o.id)} ${o.is_active ? "Aktiv" : "Inaktiv"} `).join(""); } // Search filter document.addEventListener("DOMContentLoaded", () => { const searchEl = document.getElementById("orgSearch"); if (searchEl) { searchEl.addEventListener("input", () => { const q = searchEl.value.toLowerCase(); const filtered = orgsCache.filter(o => o.name.toLowerCase().includes(q) || o.slug.toLowerCase().includes(q) ); renderOrgTable(filtered); }); } }); // --- Open Org Detail --- let currentOrgData = null; // zuletzt geladene Org (fuer Inline-Einstellungen) function renderOrgHeader(org) { document.getElementById("orgDetailHeader").innerHTML = `

    ${esc(org.name)}

    `; } async function openOrg(orgId) { currentOrgId = orgId; document.getElementById("orgListView").style.display = "none"; document.getElementById("orgDetail").classList.add("active"); window.scrollTo({ top: 0 }); try { const org = await API.get(`/api/orgs/${orgId}`); currentOrgData = org; renderOrgHeader(org); // Alle Bereiche sofort laden, es gibt keine Unterreiter mehr loadOrgUsers(orgId); loadOrgLicenses(orgId); loadOrgTokenUsage(orgId); } catch (err) { console.error("Org laden fehlgeschlagen:", err); } } // --- Inline-Einstellungen (ersetzen den frueheren Einstellungen-Block) --- async function _saveOrgSetting(patch, meldung) { try { await API.put(`/api/orgs/${currentOrgId}`, patch); currentOrgData = await API.get(`/api/orgs/${currentOrgId}`); renderOrgHeader(currentOrgData); loadOrgLicenses(currentOrgId); loadOrgs(); showToast(meldung, "success"); } catch (err) { showToast(err.message, "error"); loadOrgLicenses(currentOrgId); // Auswahl auf den echten Stand zuruecksetzen } } function setOrgActive(wert) { _saveOrgSetting({ is_active: wert === "true" }, wert === "true" ? "Organisation aktiviert" : "Organisation deaktiviert"); } function setOrgLanguage(wert) { _saveOrgSetting({ output_language: wert }, "Pipeline-Sprache gespeichert"); } // Verbrauchszähler direkt an der Kachel korrigieren (Kulanz, Fehlbereinigung) async function korrigiereVerbrauch() { const input = document.getElementById("korrCreditsUsed"); if (!input || input.value === "") { showToast("Bitte den neuen Zählerstand eintragen", "error"); return; } const n = parseFloat(input.value); if (isNaN(n) || n < 0) { showToast("Der Zählerstand muss 0 oder größer sein", "error"); return; } showConfirm( "Verbrauch korrigieren", "Verbrauchszähler dieser Organisation auf " + n.toLocaleString("de-DE") + " Credits setzen? Der Kunde sieht die Änderung sofort.", async () => { try { const licenses = await API.get("/api/licenses?org_id=" + currentOrgId); const aktiv = licenses.find(l => l.status === "active"); if (!aktiv) { showToast("Keine aktive Lizenz gefunden", "error"); return; } await API.put("/api/token-usage/budget/" + aktiv.id, { credits_used: n }); input.value = ""; showToast("Verbrauchszähler gesetzt", "success"); loadOrgTokenUsage(currentOrgId); } catch (err) { showToast(err.message, "error"); } } ); } // Max Nutzer der aktiven Lizenz direkt in der Tabelle ändern async function setLicenseMaxUsers(licId, wert) { const n = parseInt(wert, 10); if (!n || n < 1 || n > 1000) { showToast("Max Nutzer muss zwischen 1 und 1000 liegen", "error"); loadOrgLicenses(currentOrgId); return; } try { await API.put(`/api/token-usage/budget/${licId}`, { max_users: n }); showToast("Maximale Nutzerzahl gespeichert", "success"); loadOrgLicenses(currentOrgId); loadOrgUsers(currentOrgId); // Limit-Anzeige über der Nutzerliste zieht mit } catch (err) { showToast(err.message, "error"); loadOrgLicenses(currentOrgId); } } function renameOrg() { const input = document.getElementById("renameOrgName"); const errEl = document.getElementById("renameOrgError"); if (errEl) errEl.style.display = "none"; if (input && currentOrgData) input.value = currentOrgData.name; openModal("modalRenameOrg"); if (input) input.focus(); } function confirmDeleteOrg() { showConfirm( "Organisation löschen", "Soll die Organisation mit allen Nutzern und Lizenzen endgültig gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden.", async () => { try { await API.del(`/api/orgs/${currentOrgId}`); document.getElementById("orgListView").style.display = ""; document.getElementById("orgDetail").classList.remove("active"); currentOrgId = null; currentOrgData = null; loadOrgs(); loadDashboard(); } catch (err) { showToast(err.message, "error"); } }, { danger: true } ); } // --- Org Users --- async function loadOrgUsers(orgId) { try { const users = await API.get(`/api/users?org_id=${orgId}`); const licenses = await API.get(`/api/licenses?org_id=${orgId}`); const activeLic = licenses.find(l => l.status === "active"); const activeUsers = users.filter(u => u.is_active).length; document.getElementById("userLimitInfo").textContent = activeLic ? `${activeUsers} / ${activeLic.max_users} Nutzer` : "Keine aktive Lizenz"; const tbody = document.getElementById("userTable"); if (users.length === 0) { tbody.innerHTML = 'Keine Nutzer'; return; } tbody.innerHTML = users.map(u => ` ${esc(u.email)} ${u.is_active ? "Aktiv" : "Inaktiv"} ${u.globe_access ? '' : ''} ${u.network_access ? '' : ''} ${u.is_active ? `` : `` } `).join(""); } catch (err) { console.error("Nutzer laden fehlgeschlagen:", err); } } async function changeRole(userId, role) { try { await API.put(`/api/users/${userId}/role?role=${role}`); } catch (err) { showToast(err.message, "error"); if (currentOrgId) loadOrgUsers(currentOrgId); } } async function toggleUser(userId, activate) { try { await API.put(`/api/users/${userId}/${activate ? "activate" : "deactivate"}`); if (currentOrgId) loadOrgUsers(currentOrgId); } catch (err) { showToast(err.message, "error"); } } async function toggleGlobeAccess(userId) { try { await API.put("/api/users/" + userId + "/globe-access"); if (currentOrgId) loadOrgUsers(currentOrgId); } catch (err) { showToast(err.message, "error"); if (currentOrgId) loadOrgUsers(currentOrgId); } } async function toggleNetworkAccess(userId) { try { await API.put("/api/users/" + userId + "/network-access"); if (currentOrgId) loadOrgUsers(currentOrgId); } catch (err) { showToast(err.message, "error"); if (currentOrgId) loadOrgUsers(currentOrgId); } } function confirmDeleteUser(userId, email) { showConfirm( "Nutzer löschen", `Soll der Nutzer "${email}" endgültig gelöscht werden?`, async () => { try { await API.del(`/api/users/${userId}`); if (currentOrgId) loadOrgUsers(currentOrgId); } catch (err) { showToast(err.message, "error"); } }, { danger: true } ); } // --- Org Licenses --- async function loadOrgLicenses(orgId) { try { const licenses = await API.get(`/api/licenses?org_id=${orgId}`); const tbody = document.getElementById("licenseTable"); if (licenses.length === 0) { tbody.innerHTML = 'Keine Lizenzen'; return; } // Pipeline-Sprache ist eine Org-Einstellung und steht nur in der aktiven // Zeile (Sofort-Speicherung). Max Nutzer ist dort direkt editierbar. const org = currentOrgData || {}; const spracheZelle = ` `; tbody.innerHTML = licenses.map(l => ` ${l.license_type} ${l.status === "active" ? `` : l.max_users} ${formatDate(l.valid_from)} ${l.valid_until ? formatDate(l.valid_until) : "Unbegrenzt"} ${l.status} ${l.status === "active" ? spracheZelle : '–'} ${l.status === "active" ? ` ` : ""} `).join(""); } catch (err) { console.error("Lizenzen laden fehlgeschlagen:", err); } } // Lizenz verlängern über ein eigenes Modal statt des rohen Browser-prompt() let _extendLicenseId = null; function extendLicense(licId) { _extendLicenseId = licId; const errEl = document.getElementById("extendLicenseError"); if (errEl) errEl.style.display = "none"; openModal("modalExtendLicense"); const input = document.getElementById("extendDays"); if (input) { input.value = "365"; input.focus(); } } document.addEventListener("DOMContentLoaded", function () { const form = document.getElementById("extendLicenseForm"); if (!form) return; form.addEventListener("submit", async function (e) { e.preventDefault(); const errEl = document.getElementById("extendLicenseError"); if (errEl) errEl.style.display = "none"; const days = parseInt(document.getElementById("extendDays").value); if (!days || days < 1) return; try { await API.put(`/api/licenses/${_extendLicenseId}/extend?days=${days}`); closeModal("modalExtendLicense"); if (currentOrgId) loadOrgLicenses(currentOrgId); loadDashboard(); } catch (err) { if (errEl) { errEl.textContent = err.message; errEl.style.display = "block"; } } }); }); // Der Widerrufen-Knopf ist bewusst entfernt. Eine neue Lizenz widerruft die // alte automatisch, und einen Kunden abschalten erledigt der Org-Status- // Umschalter in der Kopfzeile. Der API-Endpoint /revoke bleibt bestehen. // --- Expiring Licenses (global view) --- async function loadExpiringLicenses() { // Der globale Lizenzen-Reiter wurde entfernt, die Ablauf-Liste lebt auf dem // Dashboard. Falls die Elemente fehlen, ist hier nichts zu tun. const daysEl = document.getElementById("expiringDays"); if (!daysEl || !document.getElementById("expiringTable")) return; const days = daysEl.value; try { const licenses = await API.get(`/api/licenses/expiring?days=${days}`); const tbody = document.getElementById("expiringTable"); if (licenses.length === 0) { tbody.innerHTML = `Keine ablaufenden Lizenzen in den nächsten ${days} Tagen`; return; } tbody.innerHTML = licenses.map(l => ` ${esc(l.org_name)} ${l.license_type} ${l.max_users} ${formatDate(l.valid_until)} `).join(""); } catch (err) { console.error("Ablaufende Lizenzen laden fehlgeschlagen:", err); } } document.addEventListener("DOMContentLoaded", () => { const sel = document.getElementById("expiringDays"); if (sel) sel.addEventListener("change", loadExpiringLicenses); }); function switchToOrg(orgId) { // Switch to orgs tab and open detail document.querySelectorAll(".nav-tabs:not(#orgDetailTabs):not(#sourceSubTabs) .nav-tab").forEach(t => t.classList.remove("active")); document.querySelector('.nav-tab[data-section="orgs"]').classList.add("active"); document.querySelectorAll(".app-content > .section").forEach(s => s.classList.remove("active")); document.getElementById("sec-orgs").classList.add("active"); openOrg(orgId); } // --- Forms --- function setupForms() { // New Org document.getElementById("newOrgBtn").addEventListener("click", () => openModal("modalNewOrg")); document.getElementById("newOrgForm").addEventListener("submit", async (e) => { e.preventDefault(); const errEl = document.getElementById("newOrgError"); errEl.style.display = "none"; // Slug wird unsichtbar aus dem Namen erzeugt, bei Kollision haengt der // Submit-Handler unten selbststaendig eine Nummer an. const baseSlug = (document.getElementById("newOrgSlug").value || document.getElementById("newOrgName").value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")) || "organisation"; const body = { name: document.getElementById("newOrgName").value, slug: baseSlug, output_language: document.getElementById("newOrgLanguage").value || "de", }; // Optional direkt eine Lizenz mit Credits-Kontingent mitgeben const withLic = document.getElementById("newOrgWithLicense"); if (withLic && withLic.checked) { const licType = document.getElementById("newOrgLicType").value; const lic = { license_type: licType, max_users: parseInt(document.getElementById("newOrgLicMaxUsers").value) || 5, unlimited_budget: !!document.getElementById("newOrgLicUnlimited").checked, // Periode und Uebertrag IMMER senden, sonst laesst sich der // Uebertrag ueber das Formular nie abschalten credits_period: document.getElementById("newOrgLicCreditsPeriod").value, credits_rollover: !!document.getElementById("newOrgLicRollover").checked, }; if (licType !== "permanent") { lic.duration_days = parseInt(document.getElementById("newOrgLicDuration").value) || undefined; } if (!lic.unlimited_budget) { const ct = document.getElementById("newOrgLicCreditsTotal"); if (ct && ct.value) lic.credits_total = parseInt(ct.value); } const warn = document.getElementById("newOrgLicWarnPercent"); if (warn && warn.value) lic.budget_warning_percent = parseInt(warn.value); body.license = lic; } try { let angelegt = false; for (let i = 0; i < 8 && !angelegt; i++) { body.slug = i === 0 ? baseSlug : baseSlug + "-" + (i + 1); try { await API.post("/api/orgs", body); angelegt = true; } catch (err) { if (!(err.message || "").includes("Slug bereits vergeben") || i === 7) throw err; } } closeModal("modalNewOrg"); document.getElementById("newOrgForm").reset(); onNewOrgLicenseToggle(); loadOrgs(); loadDashboard(); } catch (err) { errEl.textContent = err.message; errEl.style.display = "block"; } }); // Auto-generate slug from name document.getElementById("newOrgName").addEventListener("input", (e) => { const slug = e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); document.getElementById("newOrgSlug").value = slug; }); // New User document.getElementById("newUserBtn").addEventListener("click", () => openModal("modalNewUser")); document.getElementById("newUserForm").addEventListener("submit", async (e) => { e.preventDefault(); const errEl = document.getElementById("newUserError"); errEl.style.display = "none"; try { await API.post(`/api/users?org_id=${currentOrgId}`, { email: document.getElementById("newUserEmail").value, role: document.getElementById("newUserRole").value, }); closeModal("modalNewUser"); document.getElementById("newUserForm").reset(); loadOrgUsers(currentOrgId); } catch (err) { errEl.textContent = err.message; errEl.style.display = "block"; } }); // New License document.getElementById("newLicenseBtn").addEventListener("click", () => { document.getElementById("newLicenseForm").reset(); openModal("modalNewLicense"); }); document.getElementById("newLicType").addEventListener("change", (e) => { const durationGroup = document.getElementById("durationGroup"); if (e.target.value === "permanent") { durationGroup.style.display = "none"; } else { durationGroup.style.display = ""; document.getElementById("newLicDuration").value = e.target.value === "trial" ? "14" : "365"; } }); document.getElementById("newLicenseForm").addEventListener("submit", async (e) => { e.preventDefault(); const errEl = document.getElementById("newLicError"); errEl.style.display = "none"; const licType = document.getElementById("newLicType").value; const body = { organization_id: currentOrgId, license_type: licType, max_users: parseInt(document.getElementById("newLicMaxUsers").value), }; if (licType !== "permanent") { body.duration_days = parseInt(document.getElementById("newLicDuration").value); } const unlimitedEl = document.getElementById('newLicUnlimited'); body.unlimited_budget = !!(unlimitedEl && unlimitedEl.checked); // Periode und Uebertrag IMMER senden (nicht nur bei gesetztem Wert), // sonst laesst sich der Uebertrag nie abschalten body.credits_period = document.getElementById('newLicCreditsPeriod').value; body.credits_rollover = !!document.getElementById('newLicRollover').checked; if (!body.unlimited_budget) { const creditsTotal = document.getElementById('newLicCreditsTotal'); if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value); } const warnEl = document.getElementById('newLicWarnPercent'); if (warnEl && warnEl.value) body.budget_warning_percent = parseInt(warnEl.value); try { await API.post("/api/licenses", body); closeModal("modalNewLicense"); loadOrgLicenses(currentOrgId); loadDashboard(); } catch (err) { errEl.textContent = err.message; errEl.style.display = "block"; } }); // Org umbenennen (kleines Modal, Rest der frueheren Einstellungen ist // inline in Kopfzeile und Lizenz-Tabelle gewandert) const renameForm = document.getElementById("renameOrgForm"); if (renameForm) { renameForm.addEventListener("submit", async (e) => { e.preventDefault(); const errEl = document.getElementById("renameOrgError"); if (errEl) errEl.style.display = "none"; try { await API.put(`/api/orgs/${currentOrgId}`, { name: document.getElementById("renameOrgName").value, }); closeModal("modalRenameOrg"); currentOrgData = await API.get(`/api/orgs/${currentOrgId}`); renderOrgHeader(currentOrgData); loadOrgs(); } catch (err) { if (errEl) { errEl.textContent = err.message; errEl.style.display = "block"; } } }); } } // --- Modal helpers --- function openModal(id) { document.getElementById(id).classList.add("active"); } function closeModal(id) { document.getElementById(id).classList.remove("active"); } // Confirm dialog let confirmCallback = null; let confirmResolver = null; function showConfirm(title, text, callback, opts) { // Promise-Stil erlaubt opts als dritten Parameter: showConfirm(t, x, {danger:true}) if (callback && typeof callback === "object") { opts = callback; callback = null; } opts = opts || {}; document.getElementById("confirmTitle").textContent = title; document.getElementById("confirmText").textContent = text; // Bestätigen-Knopf ist standardmäßig Gold. Rot nur für destruktive // Aktionen (Löschen, Entfernen), per opts.danger = true. const okBtn = document.getElementById("confirmOkBtn"); if (okBtn) { okBtn.classList.toggle("btn-danger", !!opts.danger); okBtn.classList.toggle("btn-primary", !opts.danger); } // Backward-compat: legacy Callback wird bei OK aufgerufen confirmCallback = callback || null; openModal("modalConfirm"); return new Promise((resolve) => { if (confirmResolver) confirmResolver(false); // alten Resolver schliessen confirmResolver = resolve; }); } function showToast(msg, type) { type = type || "info"; const c = document.getElementById("toastContainer"); if (!c) { console.log("[toast]", type, msg); return; } const el = document.createElement("div"); el.className = "toast toast-" + type; el.textContent = msg; c.appendChild(el); setTimeout(() => { el.classList.add("toast-out"); setTimeout(() => el.remove(), 220); }, type === "error" ? 6000 : 3500); } document.addEventListener("DOMContentLoaded", () => { document.getElementById("confirmOkBtn").addEventListener("click", async () => { closeModal("modalConfirm"); const cb = confirmCallback; const rs = confirmResolver; confirmCallback = null; confirmResolver = null; if (cb) { try { await cb(); } catch (e) { showToast(e.message || String(e), "error"); } } if (rs) rs(true); }); // Cancel/Close -> resolver(false) function _confirmCancel() { const rs = confirmResolver; confirmCallback = null; confirmResolver = null; if (rs) rs(false); } document.getElementById("confirmCancelBtn")?.addEventListener("click", _confirmCancel); document.querySelector("#modalConfirm .modal-close")?.addEventListener("click", _confirmCancel); }); // --- Utilities --- function esc(str) { if (!str) return ""; const div = document.createElement("div"); div.textContent = str; // Auch Anführungszeichen maskieren. Viele Templates setzen esc() in // HTML-Attribute (onclick='...', title="...") ein, dort bricht ein // Apostroph im Quellennamen (z.B. L'Express) sonst das Markup. return div.innerHTML.replace(/"/g, """).replace(/'/g, "'"); } function formatDate(iso) { if (!iso) return "-"; try { const d = new Date(iso); return d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); } catch { return iso; } } // ===== Artikel-Übersetzung ===== let translationPollTimer = null; function setupTranslation() { const runBtn = document.getElementById("translationRunBtn"); const cancelBtn = document.getElementById("translationCancelBtn"); if (runBtn) runBtn.addEventListener("click", startTranslation); if (cancelBtn) cancelBtn.addEventListener("click", cancelTranslation); } function formatDuration(seconds) { seconds = Math.max(0, Math.round(seconds || 0)); if (seconds < 60) return seconds + " Sek."; const min = Math.round(seconds / 60); if (min < 60) return min + " Min."; const h = Math.floor(min / 60), m = min % 60; return h + " Std. " + (m ? m + " Min." : "").trim(); } function renderTranslation(st) { const info = document.getElementById("translationInfo"); const wrap = document.getElementById("translationProgressWrap"); const bar = document.getElementById("translationProgressBar"); const ptext = document.getElementById("translationProgressText"); const runBtn = document.getElementById("translationRunBtn"); const cancelBtn = document.getElementById("translationCancelBtn"); if (!info || !runBtn) return; if (st.running) { runBtn.style.display = "none"; cancelBtn.style.display = ""; wrap.style.display = ""; const pct = st.total > 0 ? Math.round((st.done / st.total) * 100) : 0; bar.style.width = pct + "%"; ptext.textContent = `${st.done} / ${st.total} verarbeitet, ${st.translated} übersetzt (${pct}%)`; info.textContent = "Übersetzung läuft…"; return; } runBtn.style.display = ""; cancelBtn.style.display = "none"; wrap.style.display = "none"; let resultLine = ""; if (st.finished_at && (st.total > 0 || st.error)) { if (st.error) { resultLine = `Letzter Lauf mit Fehler beendet: ${st.error}. `; } else if (st.cancelled) { resultLine = `Letzter Lauf abgebrochen, ${st.translated} von ${st.total} Artikeln übersetzt. `; } else { resultLine = `Letzter Lauf abgeschlossen, ${st.translated} Artikel übersetzt. `; } } if (st.pending > 0) { const est = st.estimate || {}; info.textContent = resultLine + `${st.pending} Artikel ohne deutsche Übersetzung. ` + `Geschätzt: ${formatDuration(est.seconds)}, ca. $${est.cost_usd}.`; runBtn.disabled = false; } else { info.textContent = resultLine + "Alle Artikel sind übersetzt."; runBtn.disabled = true; } } async function loadTranslationStatus() { try { const st = await API.get("/api/translation/status"); renderTranslation(st); if (st.running && !translationPollTimer) { translationPollTimer = setInterval(pollTranslation, 3000); } } catch (e) { const info = document.getElementById("translationInfo"); if (info) info.textContent = "Status nicht abrufbar: " + (e.message || e); } } async function pollTranslation() { try { const st = await API.get("/api/translation/status"); renderTranslation(st); if (!st.running) { clearInterval(translationPollTimer); translationPollTimer = null; if (st.error) { showToast("Übersetzung mit Fehler beendet", "error"); } else if (st.cancelled) { showToast(`Übersetzung abgebrochen, ${st.translated} übersetzt`, "info"); } else { showToast(`Übersetzung fertig: ${st.translated} Artikel`, "success"); } } } catch (e) { console.warn("Translation-Poll fehlgeschlagen:", e); } } async function startTranslation() { let st; try { st = await API.get("/api/translation/status"); } catch (e) { showToast(e.message || "Status nicht abrufbar", "error"); return; } if (st.running) { showToast("Es läuft bereits eine Übersetzung", "info"); return; } if (!st.pending) { showToast("Es gibt nichts zu übersetzen", "info"); return; } const est = st.estimate || {}; const ok = await showConfirm( "Übersetzung starten", `${st.pending} Artikel werden ins Deutsche übersetzt. ` + `Geschätzte Dauer: ${formatDuration(est.seconds)}, geschätzte Kosten: ca. $${est.cost_usd}. ` + `Der Lauf kann jederzeit abgebrochen werden.` ); if (!ok) return; try { const res = await API.post("/api/translation/run", {}); if (res && res.status === "started") { showToast(`Übersetzung gestartet (${res.pending} Artikel)`, "success"); await loadTranslationStatus(); if (!translationPollTimer) { translationPollTimer = setInterval(pollTranslation, 3000); } } else { showToast("Es gibt nichts zu übersetzen", "info"); loadTranslationStatus(); } } catch (e) { showToast(e.message || "Start fehlgeschlagen", "error"); } } async function cancelTranslation() { try { await API.post("/api/translation/cancel", {}); showToast("Übersetzung wird abgebrochen…", "info"); } catch (e) { showToast(e.message || "Abbruch fehlgeschlagen", "error"); } } // ===== Token-Nutzung ===== async function loadOrgTokenUsage(orgId) { try { const [current, monthly] = await Promise.all([ API.get('/api/token-usage/' + orgId + '/current'), API.get('/api/token-usage/' + orgId), ]); const budget = current.budget || {}; const usage = current.usage || {}; const unlimited = !!budget.unlimited_budget; const el = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; }; el('tokenCreditsUsed', budget.credits_used != null ? Math.round(budget.credits_used).toLocaleString('de-DE') : '-'); if (unlimited) { el('tokenCreditsRemaining', '∞ Unbegrenzt'); } else { var remainingTxt = budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-'; if (budget.credits_carried > 0 && budget.credits_remaining != null) { remainingTxt += ' (inkl. ' + Math.round(budget.credits_carried).toLocaleString('de-DE') + ' Übertrag)'; } el('tokenCreditsRemaining', remainingTxt); } el('tokenCostUsd', usage.total_cost_usd != null ? '$' + Number(usage.total_cost_usd).toFixed(2) : '-'); // Source-Split anzeigen var bySource = current.usage_by_source || {}; var splitHtml = ''; if (bySource.monitor) splitHtml += 'Monitor $' + Number(bySource.monitor.total_cost_usd).toFixed(2) + ' (' + bySource.monitor.api_calls + ' Calls) '; if (bySource.globe) splitHtml += 'Globe $' + Number(bySource.globe.total_cost_usd).toFixed(2) + ' (' + bySource.globe.api_calls + ' Calls)'; var splitEl = document.getElementById('tokenSourceSplit'); if (splitEl) splitEl.innerHTML = splitHtml; const bar = document.getElementById('tokenBudgetBar'); const percentEl = document.getElementById('tokenBudgetPercent'); const barWrap = bar ? bar.parentElement : null; if (unlimited) { if (bar) bar.style.width = '0%'; if (percentEl) percentEl.textContent = 'Unbegrenzt'; if (barWrap) barWrap.style.opacity = '0.4'; } else { const percent = budget.credits_percent_used || 0; if (bar) { bar.style.width = Math.min(100, percent) + '%'; bar.classList.remove('warning', 'critical', 'over-limit'); if (percent > 100) bar.classList.add('over-limit'); else if (percent > 80) bar.classList.add('critical'); else if (percent > 50) bar.classList.add('warning'); } if (percentEl) { percentEl.textContent = percent > 100 ? ('UEBER LIMIT (' + percent.toFixed(1) + '%)') : (percent.toFixed(1) + '%'); percentEl.style.color = percent > 100 ? 'var(--danger-text, #991b1b)' : ''; percentEl.style.fontWeight = percent > 100 ? '700' : ''; } if (barWrap) barWrap.style.opacity = '1'; } fillBudgetForm(budget); const tbody = document.getElementById('tokenMonthlyTable'); if (tbody) { tbody.innerHTML = monthly.map(function(m) { var srcLabel = (m.source || 'monitor') === 'globe' ? 'Globe' : 'Monitor'; var srcClass = (m.source || 'monitor') === 'globe' ? 'badge-globe' : 'badge-monitor'; return '' + '' + esc(m.year_month) + '' + '' + srcLabel + '' + '' + m.refresh_count + '' + '' + m.api_calls.toLocaleString('de-DE') + '' + '' + m.input_tokens.toLocaleString('de-DE') + '' + '' + m.output_tokens.toLocaleString('de-DE') + '' + '$' + Number(m.total_cost_usd).toFixed(2) + '' + ''; }).join(''); } } catch (err) { console.error('Token-Usage laden fehlgeschlagen:', err); } } // Budget-Formular Felder befuellen function fillBudgetForm(budget) { const el = (id, val) => { const e = document.getElementById(id); if (e && val != null) e.value = val; }; el('editCreditsTotal', budget.credits_total); el('editWarnPercent', budget.budget_warning_percent); const periodSel = document.getElementById('editCreditsPeriod'); if (periodSel) periodSel.value = budget.credits_period || 'monthly'; const rolloverCb = document.getElementById('editCreditsRollover'); if (rolloverCb) rolloverCb.checked = !!budget.credits_rollover; onEditPeriodChange(); const cb = document.getElementById('editUnlimitedBudget'); if (cb) { cb.checked = !!budget.unlimited_budget; onUnlimitedToggle(); } } // Unlimited-Toggle: Felder ausgrauen wenn aktiv function onUnlimitedToggle() { const cb = document.getElementById('editUnlimitedBudget'); const isUnlimited = cb && cb.checked; ['editCreditsTotal','editCreditsPeriod','editCreditsRollover','editWarnPercent'].forEach(function(id) { const e = document.getElementById(id); if (e) { e.disabled = isUnlimited; e.style.opacity = isUnlimited ? '0.4' : '1'; } }); if (!isUnlimited) onEditPeriodChange(); } // Bei Periode "Gesamtlaufzeit" ist der Monats-Uebertrag bedeutungslos function onEditPeriodChange() { const sel = document.getElementById('editCreditsPeriod'); const cb = document.getElementById('editCreditsRollover'); if (!sel || !cb) return; const unlimitedCb = document.getElementById('editUnlimitedBudget'); cb.disabled = sel.value === 'total' || !!(unlimitedCb && unlimitedCb.checked); cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1'; } // Unlimited-Toggle im Lizenz-Modal function onNewLicUnlimitedToggle() { const cb = document.getElementById('newLicUnlimited'); const isUnlimited = cb && cb.checked; ['newLicCreditsTotal','newLicCreditsPeriod','newLicRollover','newLicWarnPercent'].forEach(function(id) { const e = document.getElementById(id); if (e) { e.disabled = isUnlimited; e.style.opacity = isUnlimited ? '0.4' : '1'; } }); if (!isUnlimited) onNewLicPeriodChange(); } function onNewLicPeriodChange() { const sel = document.getElementById('newLicCreditsPeriod'); const cb = document.getElementById('newLicRollover'); if (!sel || !cb) return; cb.disabled = sel.value === 'total'; cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1'; } // === Lizenz-Abschnitt im Org-Modal === function onNewOrgLicenseToggle() { const cb = document.getElementById('newOrgWithLicense'); const fields = document.getElementById('newOrgLicenseFields'); if (fields) fields.style.display = (cb && cb.checked) ? '' : 'none'; } function onNewOrgLicTypeChange() { const type = document.getElementById('newOrgLicType').value; const group = document.getElementById('newOrgLicDurationGroup'); if (group) group.style.display = type === 'permanent' ? 'none' : ''; if (type !== 'permanent') { document.getElementById('newOrgLicDuration').value = type === 'trial' ? '14' : '365'; } } function onNewOrgLicUnlimitedToggle() { const cb = document.getElementById('newOrgLicUnlimited'); const isUnlimited = cb && cb.checked; ['newOrgLicCreditsTotal','newOrgLicCreditsPeriod','newOrgLicRollover','newOrgLicWarnPercent'].forEach(function(id) { const e = document.getElementById(id); if (e) { e.disabled = isUnlimited; e.style.opacity = isUnlimited ? '0.4' : '1'; } }); if (!isUnlimited) onNewOrgLicPeriodChange(); } function onNewOrgLicPeriodChange() { const sel = document.getElementById('newOrgLicCreditsPeriod'); const cb = document.getElementById('newOrgLicRollover'); if (!sel || !cb) return; cb.disabled = sel.value === 'total'; cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1'; } async function loadDashboardTokenStats() { try { // Aktueller Monat im Vordergrund, Lebenszeit-Summe klein darunter. // months=0 liefert beides in einem Aufruf (kpi = gesamt, Serien je Monat). const data = await API.get('/api/statistik?months=0'); const grid = document.getElementById('statsGrid'); if (grid && data && data.kpi) { const jetzt = new Date(); const monat = jetzt.getFullYear() + '-' + String(jetzt.getMonth() + 1).padStart(2, '0'); let monatKosten = 0; (data.monat_quelle || []).forEach(function(r) { if (r.monat === monat) monatKosten += r.kosten || 0; }); let monatLaeufe = 0; (data.monat_betrieb || []).forEach(function(r) { if (r.monat === monat) monatLaeufe = r.completed || 0; }); const monatName = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'][jetzt.getMonth()] + ' ' + String(jetzt.getFullYear()).slice(2); const fmtUsd = function(n) { return '$' + Number(n || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }; // Sprung-Kachel zum Statistik-Tab. Handler direkt anhaengen, denn die // Kachel entsteht NACH wireStatCardLinks() (ein erneuter Aufruf dort // wuerde die anderen Kacheln doppelt binden). const card = document.createElement('div'); card.className = 'stat-card stat-card-link'; card.setAttribute('role', 'link'); card.setAttribute('tabindex', '0'); card.title = 'Zur Statistik'; card.innerHTML = '
    API-Kosten ' + monatName + '
    ' + '
    ' + fmtUsd(monatKosten) + '
    ' + '
    ' + monatLaeufe.toLocaleString('de-DE') + ' Läufe · gesamt ' + fmtUsd(data.kpi.kosten_usd) + '
    '; const springe = () => gotoSection('statistik'); card.addEventListener('click', springe); card.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); springe(); } }); grid.appendChild(card); } } catch (err) { console.error('Token-Overview laden fehlgeschlagen:', err); } } // Budget-Formular Submit document.addEventListener('DOMContentLoaded', function() { var form = document.getElementById('tokenBudgetForm'); if (form) { form.addEventListener('submit', async function(e) { e.preventDefault(); var msgEl = document.getElementById('tokenBudgetMsg'); if (msgEl) msgEl.textContent = 'Speichern...'; // Lizenz-ID fuer die aktuelle Org ermitteln try { var licenses = await API.get('/api/licenses?org_id=' + currentOrgId); var activeLic = licenses.find(function(l) { return l.status === 'active'; }); if (!activeLic) { if (msgEl) msgEl.textContent = 'Keine aktive Lizenz gefunden'; return; } var body = {}; var unlimitedCb = document.getElementById('editUnlimitedBudget'); var isUnlimited = !!(unlimitedCb && unlimitedCb.checked); body.unlimited_budget = isUnlimited; var creditsTotal = document.getElementById('editCreditsTotal'); if (!isUnlimited) { if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value); // Periode und Uebertrag IMMER senden (nicht ueber das if-value-Muster), // sonst laesst sich der Uebertrag nie abschalten und die Periode nie // zurueckstellen. Laufzeitfelder (credits_carried, credits_period_start, // budget_warning_sent) sendet das Portal grundsaetzlich NICHT. body.credits_period = document.getElementById('editCreditsPeriod').value; body.credits_rollover = !!document.getElementById('editCreditsRollover').checked; var warnPercent = document.getElementById('editWarnPercent'); if (warnPercent && warnPercent.value) body.budget_warning_percent = parseInt(warnPercent.value); } var result = await API.put('/api/token-usage/budget/' + activeLic.id, body); if (msgEl) { if (result && result.warning) { msgEl.textContent = result.warning; msgEl.style.color = 'var(--danger-text, #991b1b)'; } else { msgEl.textContent = 'Gespeichert!'; msgEl.style.color = ''; } } setTimeout(function() { if (msgEl) { msgEl.textContent = ''; msgEl.style.color = ''; } }, 5000); // Daten neu laden loadOrgTokenUsage(currentOrgId); } catch (err) { if (msgEl) msgEl.textContent = 'Fehler: ' + err.message; console.error('Budget speichern fehlgeschlagen:', err); } }); } }); // === Source-Meta (Kategorien + Typen aus dem Backend) === window.META = { categories: [], types: [] }; window.CATEGORY_LABELS = {}; window.TYPE_LABELS = {}; async function loadMeta() { try { const data = await API.get("/api/sources/meta"); window.META = data; window.CATEGORY_LABELS = Object.fromEntries((data.categories || []).map(c => [c.key, c.label])); window.TYPE_LABELS = Object.fromEntries((data.types || []).map(t => [t.key, t.label])); return data; } catch (err) { console.warn("loadMeta:", err); return null; } } function categoryLabel(key) { return window.CATEGORY_LABELS[key] || key || ""; } function typeLabel(key) { return window.TYPE_LABELS[key] || key || ""; } function populateSelect(el, items, allLabel) { if (!el) return; const current = el.value; el.innerHTML = ''; items.forEach(it => { const opt = document.createElement("option"); opt.value = it.key; opt.textContent = it.label; el.appendChild(opt); }); if (current && items.some(it => it.key === current)) el.value = current; } // Checkbox-Dropdown (Standard für alle Filter im Portal). Ein Knopf mit dem // Filternamen klappt eine Liste von Checkboxen auf (Mehrfachauswahl). // selected ist ein Set mit den angehakten Keys. Nichts angehakt = keine // Einschränkung. onChange wird nach jedem Toggle aufgerufen, das Panel // bleibt dabei offen. function renderFilterGroup(container, title, items, selected, onChange) { if (!container) return; const wrap = document.createElement("div"); wrap.className = "filter-dd"; const btn = document.createElement("button"); btn.type = "button"; btn.className = "filter-dd-btn"; btn.title = "Nichts angehakt = alle"; const updateBtn = () => { btn.innerHTML = esc(title) + (selected.size ? ` ${selected.size}` : "") + ' '; btn.classList.toggle("has-selection", selected.size > 0); }; updateBtn(); const panel = document.createElement("div"); panel.className = "filter-dd-panel"; (items || []).forEach((it) => { const label = document.createElement("label"); label.className = "filter-check"; const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = selected.has(it.key); cb.addEventListener("change", () => { if (cb.checked) selected.add(it.key); else selected.delete(it.key); updateBtn(); onChange(); }); label.appendChild(cb); label.appendChild(document.createTextNode(it.label)); panel.appendChild(label); }); if (!(items || []).length) { const empty = document.createElement("div"); empty.className = "text-muted"; empty.style.fontSize = "12px"; empty.textContent = "Keine Werte vorhanden"; panel.appendChild(empty); } btn.addEventListener("click", (e) => { e.stopPropagation(); const wasOpen = wrap.classList.contains("open"); closeAllFilterDropdowns(); if (!wasOpen) wrap.classList.add("open"); }); panel.addEventListener("click", (e) => e.stopPropagation()); wrap.appendChild(btn); wrap.appendChild(panel); container.appendChild(wrap); } function closeAllFilterDropdowns() { document.querySelectorAll(".filter-dd.open").forEach((el) => el.classList.remove("open")); } // Klick irgendwo anders schließt offene Filter-Dropdowns document.addEventListener("click", () => closeAllFilterDropdowns()); document.addEventListener("DOMContentLoaded", () => { // Beim Page-Load Meta einmalig laden (asynchron, blockiert nicht) if (window.API && (localStorage.getItem("token") || window.location.pathname === "/dashboard")) { loadMeta(); } }); // --- Statistik und Verbrauchsrechner nachladen (eigenstaendige Module) --- // async=false erzwingt die Ausfuehrungs-Reihenfolge (dynamische Scripts sind // sonst async), damit der Statistik-Tab links vom Verbrauchsrechner landet. (function () { ["/static/js/statistik.js?v=20260725a", "/static/js/rechner.js?v=20260725b"].forEach(function (src) { var s = document.createElement("script"); s.src = src; s.async = false; document.head.appendChild(s); }); })();