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:
claude-dev
2026-07-25 14:21:57 +00:00
Ursprung 7751657444
Commit a639579366
3 geänderte Dateien mit 115 neuen und 62 gelöschten Zeilen

Datei anzeigen

@@ -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">&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>';
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) {