Drei Vereinfachungen auf Nutzerwunsch. 1. Das Org-Detail hat keine Unterreiter mehr. Verbrauch & Kontingent, Nutzer, Lizenzen und Einstellungen stehen untereinander auf einer Seite (Verbrauch optisch zuerst, per CSS-Order ohne Block-Verschiebung), die bisherigen Reiter sind Sprungmarken, die nur scrollen. Alle Bereiche laden sofort, der Org-Kopf zeigt den Kontingent-Stand als Kurzinfo. 2. Die Organisations-Tabelle bekommt eine Kontingent-Spalte mit Verbraucht/Verfügbar und Mini-Balken (gelb ab 50, rot ab 80 Prozent), Datenquelle ist die vorhandene Overview-API. Ganze Zeile klickbar. 3. Der globale Hauptreiter "Lizenzen" entfällt, er zeigte nur die Ablauf-Liste, die bereits auf dem Dashboard steht. Die Einträge dort öffnen jetzt direkt die jeweilige Organisation, die Kachel "Aktive Lizenzen" führt zur Organisations-Verwaltung. loadExpiringLicenses bleibt als abgesicherter No-op für Alt-Aufrufer erhalten. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1295 Zeilen
50 KiB
JavaScript
1295 Zeilen
50 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");
|
|
}
|
|
},
|
|
};
|
|
|
|
// 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();
|
|
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):not(#healthSubTabs) .nav-tab").forEach(tab => {
|
|
tab.addEventListener("click", () => {
|
|
const section = tab.dataset.section;
|
|
document.querySelectorAll(".nav-tabs:not(#orgDetailTabs):not(#sourceSubTabs):not(#healthSubTabs) .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, die Pillen scrollen nur noch
|
|
// zum jeweiligen Bereich, statt Unterreiter umzuschalten.
|
|
document.querySelectorAll("#orgDetailTabs .nav-tab").forEach(tab => {
|
|
tab.addEventListener("click", () => {
|
|
document.querySelectorAll("#orgDetailTabs .nav-tab").forEach(t => t.classList.remove("active"));
|
|
tab.classList.add("active");
|
|
const block = document.getElementById(`sub-${tab.dataset.subtab}`);
|
|
if (block) block.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
});
|
|
});
|
|
|
|
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>
|
|
<div class="stat-card stat-card-link" data-href="https://monitor.aegis-sight.de" role="link" tabindex="0" title="Vorfälle liegen im Monitor (öffnet neuen Tab)">
|
|
<div class="stat-label">Vorfälle</div>
|
|
<div class="stat-value">${stats.incidents.total}</div>
|
|
<div class="stat-sub">${stats.incidents.active} aktiv</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("");
|
|
}
|
|
|
|
// Recent activity
|
|
const actEl = document.getElementById("recentActivity");
|
|
if (stats.recent_activity.length === 0) {
|
|
actEl.innerHTML = '<div class="text-muted">Keine Aktivität</div>';
|
|
} else {
|
|
actEl.innerHTML = stats.recent_activity.map(a => `
|
|
<div class="activity-item">
|
|
<div class="activity-icon ${a.type}">${a.type === "org" ? "O" : "U"}</div>
|
|
<div>
|
|
<div>${esc(a.label)}</div>
|
|
<div class="text-muted" style="font-size: 12px;">${formatDate(a.created_at)}</div>
|
|
</div>
|
|
</div>
|
|
`).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">∞</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="7" 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 class="text-secondary">${esc(o.slug)}</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 ---
|
|
async function openOrg(orgId) {
|
|
currentOrgId = orgId;
|
|
document.getElementById("orgListView").style.display = "none";
|
|
document.getElementById("orgDetail").classList.add("active");
|
|
|
|
// Erste Pille aktiv (Verbrauch), Ansicht nach oben
|
|
document.querySelectorAll("#orgDetailTabs .nav-tab").forEach(t => t.classList.remove("active"));
|
|
const firstPill = document.querySelector("#orgDetailTabs .nav-tab");
|
|
if (firstPill) firstPill.classList.add("active");
|
|
window.scrollTo({ top: 0 });
|
|
|
|
try {
|
|
const org = await API.get(`/api/orgs/${orgId}`);
|
|
document.getElementById("orgDetailHeader").innerHTML = `
|
|
<h2>${esc(org.name)} <span class="badge badge-${org.is_active ? 'active' : 'inactive'}" style="font-size: 12px; vertical-align: middle;">${org.is_active ? "Aktiv" : "Inaktiv"}</span></h2>
|
|
<div class="text-secondary mt-8">Slug: ${esc(org.slug)} | Nutzer: ${org.user_count} | Lizenz: ${org.license_type || "Keine"} | Kontingent: <span id="orgKpiCredits">wird geladen …</span></div>
|
|
`;
|
|
|
|
document.getElementById("editOrgName").value = org.name;
|
|
document.getElementById("editOrgActive").value = org.is_active ? "true" : "false";
|
|
const langEl = document.getElementById("editOrgLanguage");
|
|
if (langEl) langEl.value = org.output_language || "de";
|
|
|
|
// Alle Bereiche sofort laden, es gibt keine Unterreiter mehr
|
|
loadOrgUsers(orgId);
|
|
loadOrgLicenses(orgId);
|
|
loadOrgTokenUsage(orgId);
|
|
} catch (err) {
|
|
console.error("Org laden fehlgeschlagen:", err);
|
|
}
|
|
}
|
|
|
|
// --- 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");
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// --- 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="6" class="text-muted">Keine Lizenzen</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = licenses.map(l => `
|
|
<tr>
|
|
<td><span class="badge badge-${l.license_type}">${l.license_type}</span></td>
|
|
<td>${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>
|
|
<td>
|
|
${l.status === "active" ? `
|
|
<button class="btn btn-secondary btn-small" onclick="extendLicense(${l.id})">Verlängern</button>
|
|
<button class="btn btn-danger btn-small" onclick="confirmRevokeLicense(${l.id})">Widerrufen</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"; }
|
|
}
|
|
});
|
|
});
|
|
|
|
function confirmRevokeLicense(licId) {
|
|
showConfirm(
|
|
"Lizenz widerrufen",
|
|
"Soll die Lizenz wirklich widerrufen werden? Nutzer können dann nur noch lesen.",
|
|
async () => {
|
|
try {
|
|
await API.put(`/api/licenses/${licId}/revoke`);
|
|
if (currentOrgId) loadOrgLicenses(currentOrgId);
|
|
} catch (err) {
|
|
showToast(err.message, "error");
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// --- 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):not(#healthSubTabs) .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";
|
|
const body = {
|
|
name: document.getElementById("newOrgName").value,
|
|
slug: document.getElementById("newOrgSlug").value,
|
|
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");
|
|
const cpc = document.getElementById("newOrgLicCostPerCredit");
|
|
if (ct && ct.value) lic.credits_total = parseInt(ct.value);
|
|
if (cpc && cpc.value) lic.cost_per_credit = parseFloat(cpc.value);
|
|
}
|
|
const warn = document.getElementById("newOrgLicWarnPercent");
|
|
if (warn && warn.value) lic.budget_warning_percent = parseInt(warn.value);
|
|
body.license = lic;
|
|
}
|
|
try {
|
|
await API.post("/api/orgs", body);
|
|
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');
|
|
const costPerCredit = document.getElementById('newLicCostPerCredit');
|
|
const budgetUsd = document.getElementById('newLicBudgetUsd');
|
|
if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value);
|
|
if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value);
|
|
if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.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 Edit
|
|
document.getElementById("orgEditForm").addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
try {
|
|
await API.put(`/api/orgs/${currentOrgId}`, {
|
|
name: document.getElementById("editOrgName").value,
|
|
is_active: document.getElementById("editOrgActive").value === "true",
|
|
output_language: document.getElementById("editOrgLanguage").value || "de",
|
|
});
|
|
openOrg(currentOrgId);
|
|
loadOrgs();
|
|
loadDashboard();
|
|
} catch (err) {
|
|
showToast(err.message, "error");
|
|
}
|
|
});
|
|
|
|
// Delete Org
|
|
document.getElementById("deleteOrgBtn").addEventListener("click", () => {
|
|
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;
|
|
loadOrgs();
|
|
loadDashboard();
|
|
} catch (err) {
|
|
showToast(err.message, "error");
|
|
}
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
// --- 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) {
|
|
document.getElementById("confirmTitle").textContent = title;
|
|
document.getElementById("confirmText").textContent = text;
|
|
// 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;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
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');
|
|
el('tokenBudgetUsd', '—');
|
|
} 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('tokenBudgetUsd', budget.token_budget_usd != null ? '$' + Number(budget.token_budget_usd).toFixed(2) : '-');
|
|
}
|
|
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);
|
|
|
|
// Kontingent-Kurzinfo im Org-Kopf
|
|
const kpi = document.getElementById("orgKpiCredits");
|
|
if (kpi) {
|
|
if (unlimited) kpi.textContent = "unbegrenzt";
|
|
else if (budget.credits_available) {
|
|
const pct = budget.credits_percent_used != null ? ` (${budget.credits_percent_used.toFixed(0)}% verbraucht)` : "";
|
|
kpi.textContent = `${Math.round(budget.credits_used || 0).toLocaleString("de-DE")} von ${Math.round(budget.credits_available).toLocaleString("de-DE")} Credits${pct}`;
|
|
} else kpi.textContent = "kein Kontingent";
|
|
}
|
|
|
|
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('editCostPerCredit', budget.cost_per_credit);
|
|
el('editBudgetUsd', budget.token_budget_usd);
|
|
el('editCreditsUsed', budget.credits_used ? Math.round(budget.credits_used) : 0);
|
|
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();
|
|
}
|
|
|
|
// Laufzeitwerte (verwaltet der Monitor, hier nur Anzeige)
|
|
const txt = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
|
|
const carried = budget.credits_carried || 0;
|
|
txt('viewCreditsCarried', carried > 0 ? Math.round(carried).toLocaleString('de-DE') + ' Credits' : 'Kein Übertrag');
|
|
txt('viewCreditsPeriodStart', budget.credits_period_start || 'Noch nicht initialisiert');
|
|
txt('viewCreditsAvailable', budget.unlimited_budget
|
|
? '∞ Unbegrenzt'
|
|
: (budget.credits_available != null ? Math.round(budget.credits_available).toLocaleString('de-DE') + ' Credits' : '-'));
|
|
}
|
|
|
|
// Unlimited-Toggle: Felder ausgrauen wenn aktiv
|
|
function onUnlimitedToggle() {
|
|
const cb = document.getElementById('editUnlimitedBudget');
|
|
const isUnlimited = cb && cb.checked;
|
|
['editCreditsTotal','editCostPerCredit','editBudgetUsd','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','newLicCostPerCredit','newLicBudgetUsd','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','newOrgLicCostPerCredit','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 {
|
|
const data = await API.get('/api/token-usage/overview');
|
|
const grid = document.getElementById('statsGrid');
|
|
if (grid && data.length > 0) {
|
|
const totals = data.reduce(function(acc, org) {
|
|
return {
|
|
cost: acc.cost + (org.total_cost_usd || 0),
|
|
refreshes: acc.refreshes + (org.total_refreshes || 0),
|
|
};
|
|
}, { cost: 0, refreshes: 0 });
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'stat-card';
|
|
card.innerHTML = '<div class="stat-label">API-Kosten gesamt</div>' +
|
|
'<div class="stat-value">$' + totals.cost.toFixed(2) + '</div>' +
|
|
'<div class="stat-sub">' + totals.refreshes + ' Refreshes</div>';
|
|
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');
|
|
var costPerCredit = document.getElementById('editCostPerCredit');
|
|
var budgetUsd = document.getElementById('editBudgetUsd');
|
|
var creditsUsed = document.getElementById('editCreditsUsed');
|
|
|
|
if (!isUnlimited) {
|
|
if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value);
|
|
if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value);
|
|
if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.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);
|
|
}
|
|
// credits_used immer mitsenden (auch im Unlimited-Modus, fuer Korrekturen)
|
|
if (creditsUsed && creditsUsed.value !== '') body.credits_used = parseFloat(creditsUsed.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;
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
// Beim Page-Load Meta einmalig laden (asynchron, blockiert nicht)
|
|
if (window.API && (localStorage.getItem("token") || window.location.pathname === "/dashboard")) {
|
|
loadMeta();
|
|
}
|
|
});
|
|
|
|
// --- Verbrauchsrechner nachladen (eigenstaendiges Modul, siehe rechner.js) ---
|
|
(function () {
|
|
var s = document.createElement("script");
|
|
s.src = "/static/js/rechner.js?v=20260725a";
|
|
document.head.appendChild(s);
|
|
})();
|