Dateien
AegisSight-Monitor-Verwaltung/src/static/js/app.js
claude-dev c5f63b1d14 fix(sources): Feinschliff nach Konsistenz-Pruefung + UI-Rueckmeldungen
Echte Fehler:
- esc() maskiert jetzt auch Anfuehrungszeichen. Quellennamen mit
  Apostroph (z.B. LExpress) brachen sonst die Zeilen-Knoepfe.
- formatDateTime parst SQLite-UTC-Zeitstempel jetzt als UTC statt
  Lokalzeit (Zeiten waren um den Zeitzonen-Offset verschoben).
- Audit-Filter Bis-Datum schliesst den gewaehlten Tag jetzt komplett ein.

UI-Rueckmeldungen:
- Beschreibung in der Vorschlags-Tabelle bricht mehrzeilig um statt
  abgeschnitten zu werden.
- Bestaetigen-Knopf im Dialog ist standardmaessig Gold, Rot nur noch bei
  destruktiven Aktionen (Org/Nutzer/Quelle loeschen, X-Konto entfernen).
- Alle >= 0.85 genehmigen ist ausgegraut, solange keine Klassifikationen
  ausstehen.

Konsistenz:
- GET /suggestions deckelt pending nicht mehr auf 50 (Badge und Tabelle
  liefen auseinander), bearbeitete weiterhin letzte 50.
- /stats zaehlt jetzt alle Quellen wie die Liste (nicht nur aktive),
  Grundquellen/Kundenquellen-Aufteilung wird immer angezeigt.
- Health-Spalte sortiert nach Schweregrad statt alphabetisch.
- Aufgaben-Badge laedt schon beim Seitenstart, nicht erst beim Reiterklick.
- Loesung-suchen-Knopf stellt nach Fehler das Icon wieder her, native
  confirm()-Dialoge durch showConfirm ersetzt.
- Tote Endpoints GET /health (Alt-Liste) und GET /classification/stats
  entfernt, tote CSS-Bloecke (activity-*, badge-health-*,
  review-conf-filter) geloescht.
- Smoke-Test deckt jetzt alle Klassifikations-/Upload-/Sync-Endpoints ab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 18:48:16 +00:00

1479 Zeilen
57 KiB
JavaScript

/* 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 = `
<div class="stat-card stat-card-link" data-goto="orgs" role="link" tabindex="0" title="Zu den Organisationen">
<div class="stat-label">Organisationen</div>
<div class="stat-value">${stats.organizations.total}</div>
<div class="stat-sub">${stats.organizations.active} aktiv</div>
</div>
<div class="stat-card stat-card-link" data-goto="orgs" role="link" tabindex="0" title="Nutzer werden je Organisation verwaltet">
<div class="stat-label">Nutzer</div>
<div class="stat-value">${stats.users.total}</div>
<div class="stat-sub">${stats.users.active} aktiv</div>
</div>
<div class="stat-card stat-card-link" data-goto="orgs" role="link" tabindex="0" title="Lizenzen werden je Organisation verwaltet">
<div class="stat-label">Aktive Lizenzen</div>
<div class="stat-value">${stats.licenses.active}</div>
</div>
`;
wireStatCardLinks();
// Expiring licenses
const expList = document.getElementById("expiringList");
if (stats.expiring_licenses.length === 0) {
expList.innerHTML = '<li class="text-muted" style="padding: 8px 0;">Keine ablaufenden Lizenzen</li>';
} else {
expList.innerHTML = stats.expiring_licenses.map(l => `
<li class="expiring-item expiring-item-link" role="link" tabindex="0" title="Organisation öffnen"
onclick="event.stopPropagation(); switchToOrg(${l.organization_id});">
<span>${esc(l.org_name)} <span class="badge badge-${l.license_type}">${l.license_type}</span></span>
<span class="expiring-date">${formatDate(l.valid_until)}</span>
</li>
`).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 '<span class="text-muted">-</span>';
if (u.unlimited_budget) return '<span class="text-muted" title="Unbegrenztes Budget">&infin;</span>';
const avail = u.credits_available || 0;
if (!avail) return '<span class="text-muted" title="Kein Kontingent hinterlegt">-</span>';
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 `
<div title="${used.toLocaleString('de-DE')} von ${Math.round(avail).toLocaleString('de-DE')} Credits verbraucht (${pct}%)">
<span style="font-size:12px;">${used.toLocaleString('de-DE')} / ${Math.round(avail).toLocaleString('de-DE')}</span>
<div class="mini-bar"><div class="mini-bar-fill ${cls}" style="width:${pct}%"></div></div>
</div>`;
}
function renderOrgTable(orgs) {
const tbody = document.getElementById("orgTable");
if (orgs.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">Keine Organisationen</td></tr>';
return;
}
tbody.innerHTML = orgs.map(o => `
<tr class="org-row" onclick="openOrg(${o.id})" title="Organisation öffnen">
<td><a href="#" class="text-accent" style="text-decoration: none;" onclick="openOrg(${o.id}); return false;">${esc(o.name)}</a></td>
<td>${o.user_count}</td>
<td><span class="badge badge-${o.license_type || 'none'}">${o.license_type || "Keine"}</span></td>
<td>${orgUsageCell(o.id)}</td>
<td><span class="badge badge-${o.is_active ? 'active' : 'inactive'}">${o.is_active ? "Aktiv" : "Inaktiv"}</span></td>
<td>
<button class="btn btn-secondary btn-small" onclick="event.stopPropagation(); openOrg(${o.id})">Details</button>
</td>
</tr>
`).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 = `
<div style="display:flex; align-items:flex-start; gap:10px; flex-wrap:wrap;">
<div style="flex:1; min-width:260px;">
<h2 style="display:flex; align-items:center; gap:14px; flex-wrap:wrap;">${esc(org.name)}
<div class="org-status-toggle" role="group" aria-label="Status der Organisation, Klick speichert sofort">
<button type="button" class="ost-btn ost-aktiv${org.is_active ? " on" : ""}" onclick="setOrgActive('true')" title="Organisation aktivieren">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Aktiv
</button>
<button type="button" class="ost-btn ost-inaktiv${org.is_active ? "" : " on"}" onclick="setOrgActive('false')" title="Organisation deaktivieren">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
Deaktiviert
</button>
</div>
</h2>
</div>
<button class="btn btn-secondary btn-small" onclick="renameOrg()" title="Organisation umbenennen">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
Umbenennen
</button>
<button class="btn btn-danger btn-small" onclick="confirmDeleteOrg()" title="Organisation endgültig löschen">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Löschen
</button>
</div>
`;
}
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 = '<tr><td colspan="5" class="text-muted">Keine Nutzer</td></tr>';
return;
}
tbody.innerHTML = users.map(u => `
<tr>
<td>${esc(u.email)}</td>
<td>
<select class="btn btn-secondary btn-small" onchange="changeRole(${u.id}, this.value)" style="padding: 4px 8px;">
<option value="member" ${u.role === "member" ? "selected" : ""}>Mitglied</option>
<option value="org_admin" ${u.role === "org_admin" ? "selected" : ""}>Org-Admin</option>
</select>
</td>
<td><span class="badge badge-${u.is_active ? 'active' : 'inactive'}">${u.is_active ? "Aktiv" : "Inaktiv"}</span></td>
<td style="text-align:center">${u.globe_access ? '<button class="btn btn-small" style="background:#00cc66;color:#fff;border:none;min-width:50px" onclick="toggleGlobeAccess(' + u.id + ')">An</button>' : '<button class="btn btn-secondary btn-small" style="min-width:50px" onclick="toggleGlobeAccess(' + u.id + ')">Aus</button>'}</td>
<td style="text-align:center">${u.network_access ? '<button class="btn btn-small" style="background:#f0b429;color:#0f172a;border:none;min-width:50px" onclick="toggleNetworkAccess(' + u.id + ')">An</button>' : '<button class="btn btn-secondary btn-small" style="min-width:50px" onclick="toggleNetworkAccess(' + u.id + ')">Aus</button>'}</td>
<td>
${u.is_active
? `<button class="btn btn-secondary btn-small" onclick="toggleUser(${u.id}, false)">Deaktivieren</button>`
: `<button class="btn btn-success btn-small" onclick="toggleUser(${u.id}, true)">Aktivieren</button>`
}
<button class="btn btn-danger btn-small" onclick="confirmDeleteUser(${u.id}, '${esc(u.email)}')">Löschen</button>
</td>
</tr>
`).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 = '<tr><td colspan="7" class="text-muted">Keine Lizenzen</td></tr>';
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 = `
<td><select style="width:auto; min-width:100px;" onchange="setOrgLanguage(this.value)" title="Ausgabesprache der KI-Pipeline für alle Nutzer dieser Organisation">
<option value="de"${(org.output_language || "de") === "de" ? " selected" : ""}>Deutsch</option>
<option value="en"${org.output_language === "en" ? " selected" : ""}>English</option>
</select></td>`;
tbody.innerHTML = licenses.map(l => `
<tr>
<td><span class="badge badge-${l.license_type}">${l.license_type}</span></td>
<td>${l.status === "active"
? `<input type="number" value="${l.max_users}" min="1" max="1000" style="width:70px;"
onchange="setLicenseMaxUsers(${l.id}, this.value)" title="Maximale Nutzerzahl, speichert sofort">`
: l.max_users}</td>
<td>${formatDate(l.valid_from)}</td>
<td>${l.valid_until ? formatDate(l.valid_until) : "Unbegrenzt"}</td>
<td><span class="badge badge-${l.status}">${l.status}</span></td>
${l.status === "active" ? spracheZelle : '<td class="text-muted">–</td>'}
<td>
${l.status === "active" ? `
<button class="btn btn-secondary btn-small" onclick="extendLicense(${l.id})">Verlängern</button>
` : ""}
</td>
</tr>
`).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 = `<tr><td colspan="5" class="text-muted">Keine ablaufenden Lizenzen in den nächsten ${days} Tagen</td></tr>`;
return;
}
tbody.innerHTML = licenses.map(l => `
<tr>
<td><a href="#" class="text-accent" style="text-decoration: none;" onclick="switchToOrg(${l.organization_id}); return false;">${esc(l.org_name)}</a></td>
<td><span class="badge badge-${l.license_type}">${l.license_type}</span></td>
<td>${l.max_users}</td>
<td class="text-warning">${formatDate(l.valid_until)}</td>
<td>
<button class="btn btn-secondary btn-small" onclick="extendLicense(${l.id})">Verlängern</button>
</td>
</tr>
`).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, "&quot;").replace(/'/g, "&#39;");
}
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 += '<span class="source-badge badge-monitor">Monitor</span> $' + Number(bySource.monitor.total_cost_usd).toFixed(2) + ' (' + bySource.monitor.api_calls + ' Calls) ';
if (bySource.globe) splitHtml += '<span class="source-badge badge-globe">Globe</span> $' + 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 '<tr>' +
'<td>' + esc(m.year_month) + '</td>' +
'<td><span class="source-badge ' + srcClass + '">' + srcLabel + '</span></td>' +
'<td>' + m.refresh_count + '</td>' +
'<td>' + m.api_calls.toLocaleString('de-DE') + '</td>' +
'<td>' + m.input_tokens.toLocaleString('de-DE') + '</td>' +
'<td>' + m.output_tokens.toLocaleString('de-DE') + '</td>' +
'<td>$' + Number(m.total_cost_usd).toFixed(2) + '</td>' +
'</tr>';
}).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 = '<div class="stat-label">API-Kosten ' + monatName + '</div>' +
'<div class="stat-value">' + fmtUsd(monatKosten) + '</div>' +
'<div class="stat-sub">' + monatLaeufe.toLocaleString('de-DE') + ' Läufe · gesamt ' + fmtUsd(data.kpi.kosten_usd) + '</div>';
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 = '<option value="">' + (allLabel || "Alle") + '</option>';
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 ? ` <span class="filter-dd-count">${selected.size}</span>` : "")
+ ' <span class="filter-dd-caret">&#9662;</span>';
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);
});
})();