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

@@ -1401,3 +1401,37 @@ details.adv-section .adv-hint { grid-column: 1 / -1; font-size: 12px; color: var
.card-link:hover, .card-link:focus-visible { border-color: var(--accent); }
.card-link .card-link-chevron { color: var(--text-muted); transition: color 0.15s ease, transform 0.15s ease; }
.card-link:hover .card-link-chevron { color: var(--accent); transform: translateX(2px); }
/* === Org-Detail als durchgehende Seite (statt Unterreitern) === */
.detail-panel.active { display: flex; flex-direction: column; }
#orgDetailTabs { order: 1; position: sticky; top: 61px; background: var(--bg-primary); z-index: 50; }
.org-block { margin-top: 26px; scroll-margin-top: 120px; }
#sub-tokens { order: 2; }
#sub-users { order: 3; }
#sub-org-licenses { order: 4; }
#sub-settings { order: 5; }
.org-block-title {
font-size: 16px;
margin-bottom: 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
}
/* === Kontingent-Spalte in der Org-Tabelle === */
.org-row { cursor: pointer; }
.mini-bar {
width: 90px;
height: 5px;
border-radius: 3px;
background: var(--bg-tertiary);
overflow: hidden;
margin-top: 3px;
}
.mini-bar-fill { height: 100%; background: var(--success); border-radius: 3px; }
.mini-bar-fill.warning { background: var(--warning); }
.mini-bar-fill.critical { background: var(--danger); }
/* === Klickbare Eintraege in der Ablauf-Liste === */
.expiring-item-link { cursor: pointer; border-radius: var(--radius); transition: background 0.15s ease; }
.expiring-item-link:hover, .expiring-item-link:focus-visible { background: var(--bg-hover); }

Datei anzeigen

@@ -6,7 +6,7 @@
<title>AegisSight Monitor-Verwaltung</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="apple-touch-icon" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css?v=20260725b">
<link rel="stylesheet" href="/static/css/style.css?v=20260725c">
<script>(function(){var t=localStorage.getItem('portal_theme');if(t==='light')document.documentElement.setAttribute('data-theme','light');try{var a=JSON.parse(localStorage.getItem('osint_a11y')||'{}');Object.keys(a).forEach(function(k){if(a[k])document.documentElement.setAttribute('data-a11y-'+k,'true');});}catch(e){}})()</script>
<style>
@@ -51,10 +51,6 @@
<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="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></svg>
Organisationen
</button>
<button class="nav-tab" data-section="licenses">
<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="M2 18v3c0 .6.4 1 1 1h4v-3h3v-3h2l1.4-1.4a6.5 6.5 0 1 0-4-4Z"/><circle cx="16.5" cy="7.5" r=".5"/></svg>
Lizenzen
</button>
<button class="nav-tab" data-section="sources">
<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="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>
Quellen
@@ -70,7 +66,7 @@
<div class="stats-grid" id="statsGrid"></div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div class="card card-link" onclick="gotoSection('licenses')" role="link" tabindex="0" title="Zu den Lizenzen">
<div class="card card-link" onclick="gotoSection('orgs')" role="link" tabindex="0" title="Zu den Organisationen (Klick auf einen Eintrag öffnet die Organisation)">
<div class="card-header">
<h2>Bald ablaufende Lizenzen</h2>
<svg class="card-link-chevron" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
@@ -137,6 +133,7 @@
<th>Slug</th>
<th>Nutzer</th>
<th>Lizenz</th>
<th>Kontingent</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
@@ -152,15 +149,19 @@
<button class="detail-back" id="orgBackBtn">&larr; Zurück</button>
<div id="orgDetailHeader"></div>
<!-- Alles auf einer Seite: die Pillen scrollen nur zu den Bereichen,
sie schalten nichts mehr um. Anzeige-Reihenfolge steuert CSS (order),
Verbrauch steht optisch zuerst. -->
<div class="nav-tabs mt-16" id="orgDetailTabs">
<button class="nav-tab active" data-subtab="users">Nutzer</button>
<button class="nav-tab active" data-subtab="tokens">Verbrauch</button>
<button class="nav-tab" data-subtab="users">Nutzer</button>
<button class="nav-tab" data-subtab="org-licenses">Lizenzen</button>
<button class="nav-tab" data-subtab="settings">Einstellungen</button>
<button class="nav-tab" data-subtab="tokens">Verbrauch</button>
</div>
<!-- Users Sub-Tab -->
<div class="section active" id="sub-users">
<!-- Bereich: Nutzer -->
<div class="org-block" id="sub-users">
<h2 class="org-block-title">Nutzer</h2>
<div class="action-bar">
<span class="text-secondary" id="userLimitInfo"></span>
<button class="btn btn-primary" id="newUserBtn"><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"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Nutzer anlegen</button>
@@ -184,8 +185,9 @@
</div>
</div>
<!-- Licenses Sub-Tab -->
<div class="section" id="sub-org-licenses">
<!-- Bereich: Lizenzen -->
<div class="org-block" id="sub-org-licenses">
<h2 class="org-block-title">Lizenzen</h2>
<div class="action-bar">
<span></span>
<button class="btn btn-primary" id="newLicenseBtn"><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"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Neue Lizenz</button>
@@ -209,8 +211,9 @@
</div>
</div>
<!-- Settings Sub-Tab -->
<div class="section" id="sub-settings">
<!-- Bereich: Einstellungen -->
<div class="org-block" id="sub-settings">
<h2 class="org-block-title">Einstellungen</h2>
<div class="card">
<div class="card-body">
<form id="orgEditForm">
@@ -242,7 +245,8 @@
</div>
</div>
<div class="section" id="sub-tokens">
<div class="org-block" id="sub-tokens">
<h2 class="org-block-title">Verbrauch &amp; Kontingent</h2>
<div class="token-overview">
<!-- Kundensicht: das, was der Kunde im Monitor auch sieht -->
<h3 style="margin-bottom:8px;">Kundensicht (Credits)</h3>
@@ -398,34 +402,6 @@
</div>
</div>
<!-- Licenses Section (global overview) -->
<div class="section" id="sec-licenses">
<div class="card">
<div class="card-header">
<h2>Bald ablaufende Lizenzen</h2>
<select id="expiringDays" style="background: var(--bg-primary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-primary); padding: 4px 8px; font-size: 13px;">
<option value="14">14 Tage</option>
<option value="30" selected>30 Tage</option>
<option value="90">90 Tage</option>
</select>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Organisation</th>
<th>Typ</th>
<th>Max Nutzer</th>
<th>Läuft ab</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="expiringTable"></tbody>
</table>
</div>
</div>
</div>
<!-- Sources Section -->
<div class="section" id="sec-sources">
<div class="nav-tabs" id="sourceSubTabs">
@@ -1241,7 +1217,7 @@
</div>
<script src="/static/js/a11y.js?v=20260725a"></script>
<script src="/static/js/app.js?v=20260725c"></script>
<script src="/static/js/app.js?v=20260725d"></script>
<script src="/static/js/sources.js?v=20260522x2"></script>
<script src="/static/js/x-scraper.js?v=20260522a"></script>
<script src="/static/js/source-health.js?v=20260509l"></script>

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) {