feat(struktur): weniger Klicks, Org-Detail als eine Seite, Kontingent in der Übersicht
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>
Dieser Commit ist enthalten in:
@@ -137,21 +137,20 @@ function setupNavTabs() {
|
||||
document.getElementById(`sec-${section}`).classList.add("active");
|
||||
|
||||
if (section === "dashboard") loadTranslationStatus();
|
||||
if (section === "licenses") loadExpiringLicenses();
|
||||
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", () => {
|
||||
const subtab = tab.dataset.subtab;
|
||||
document.querySelectorAll("#orgDetailTabs .nav-tab").forEach(t => t.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
document.querySelectorAll("#orgDetail > .section").forEach(s => s.classList.remove("active"));
|
||||
document.getElementById(`sub-${subtab}`).classList.add("active");
|
||||
if (subtab === 'tokens' && currentOrgId) loadOrgTokenUsage(currentOrgId);
|
||||
const block = document.getElementById(`sub-${tab.dataset.subtab}`);
|
||||
if (block) block.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,7 +179,7 @@ async function loadDashboard() {
|
||||
<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="licenses" role="link" tabindex="0" title="Zu den Lizenzen">
|
||||
<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>
|
||||
@@ -198,7 +197,8 @@ async function loadDashboard() {
|
||||
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">
|
||||
<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>
|
||||
@@ -226,30 +226,57 @@ async function loadDashboard() {
|
||||
}
|
||||
|
||||
// --- Organizations ---
|
||||
let orgUsageMap = {}; // org_id -> Kontingent-Daten aus /api/token-usage/overview
|
||||
|
||||
async function loadOrgs() {
|
||||
try {
|
||||
orgsCache = await API.get("/api/orgs");
|
||||
// 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="6" class="text-muted">Keine Organisationen</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Keine Organisationen</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = orgs.map(o => `
|
||||
<tr>
|
||||
<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="openOrg(${o.id})">Details</button>
|
||||
<button class="btn btn-secondary btn-small" onclick="event.stopPropagation(); openOrg(${o.id})">Details</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
@@ -275,17 +302,17 @@ async function openOrg(orgId) {
|
||||
document.getElementById("orgListView").style.display = "none";
|
||||
document.getElementById("orgDetail").classList.add("active");
|
||||
|
||||
// Reset to users tab
|
||||
// Erste Pille aktiv (Verbrauch), Ansicht nach oben
|
||||
document.querySelectorAll("#orgDetailTabs .nav-tab").forEach(t => t.classList.remove("active"));
|
||||
document.querySelector('#orgDetailTabs .nav-tab[data-subtab="users"]').classList.add("active");
|
||||
document.querySelectorAll("#orgDetail > .section").forEach(s => s.classList.remove("active"));
|
||||
document.getElementById("sub-users").classList.add("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"}</div>
|
||||
<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;
|
||||
@@ -293,8 +320,10 @@ async function openOrg(orgId) {
|
||||
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);
|
||||
}
|
||||
@@ -449,7 +478,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
await API.put(`/api/licenses/${_extendLicenseId}/extend?days=${days}`);
|
||||
closeModal("modalExtendLicense");
|
||||
if (currentOrgId) loadOrgLicenses(currentOrgId);
|
||||
loadExpiringLicenses();
|
||||
loadDashboard();
|
||||
} catch (err) {
|
||||
if (errEl) { errEl.textContent = err.message; errEl.style.display = "block"; }
|
||||
}
|
||||
@@ -473,7 +502,11 @@ function confirmRevokeLicense(licId) {
|
||||
|
||||
// --- Expiring Licenses (global view) ---
|
||||
async function loadExpiringLicenses() {
|
||||
const days = document.getElementById("expiringDays").value;
|
||||
// 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");
|
||||
@@ -974,6 +1007,16 @@ async function loadOrgTokenUsage(orgId) {
|
||||
|
||||
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) {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren