feat: Credits-System — Token-Usage-Router, Budget-Verwaltung, Frontend-Übersicht
- Neuer Router /api/token-usage mit Overview, Org-Detail, Monatsstatistik - Budget-Felder (credits_total, cost_per_credit, token_budget_usd) bei Lizenz-Erstellung - Token-Nutzung Sub-Tab in Org-Detail mit Verbrauchsbalken und Monatstabelle - Dashboard Stat-Card für API-Kosten gesamt - CSS Dark-Theme Styling für Token-Komponenten
Dieser Commit ist enthalten in:
@@ -763,3 +763,17 @@ tr:hover td {
|
||||
.cat-header-count::after {
|
||||
content: ")";
|
||||
}
|
||||
|
||||
/* ===== Token-Nutzung Tab ===== */
|
||||
.token-overview { padding: 4px 0; }
|
||||
.token-stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; }
|
||||
.token-stat-card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center; }
|
||||
.token-stat-label { font-size: 12px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
|
||||
.token-stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); }
|
||||
.token-budget-bar-section { margin-bottom: 16px; }
|
||||
.token-budget-label { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.token-budget-bar-container { width: 100%; height: 10px; background: var(--bg-tertiary); border-radius: 5px; overflow: hidden; }
|
||||
.token-budget-bar { height: 100%; border-radius: 5px; background: var(--accent); transition: width 0.6s ease, background-color 0.3s ease; }
|
||||
.token-budget-bar.warning { background: #e67e22; }
|
||||
.token-budget-bar.critical { background: #e74c3c; }
|
||||
@media (max-width: 768px) { .token-stats-row { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
<button class="nav-tab active" 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">Token-Nutzung</button>
|
||||
</div>
|
||||
|
||||
<!-- Users Sub-Tab -->
|
||||
@@ -161,6 +162,52 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="sub-tokens" style="display:none;">
|
||||
<div class="token-overview">
|
||||
<div class="token-stats-row" id="tokenStatsRow">
|
||||
<div class="token-stat-card">
|
||||
<div class="token-stat-label">Verbrauchte Credits</div>
|
||||
<div class="token-stat-value" id="tokenCreditsUsed">-</div>
|
||||
</div>
|
||||
<div class="token-stat-card">
|
||||
<div class="token-stat-label">Verbleibend</div>
|
||||
<div class="token-stat-value" id="tokenCreditsRemaining">-</div>
|
||||
</div>
|
||||
<div class="token-stat-card">
|
||||
<div class="token-stat-label">Budget (USD)</div>
|
||||
<div class="token-stat-value" id="tokenBudgetUsd">-</div>
|
||||
</div>
|
||||
<div class="token-stat-card">
|
||||
<div class="token-stat-label">Kosten (USD)</div>
|
||||
<div class="token-stat-value" id="tokenCostUsd">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-budget-bar-section">
|
||||
<div class="token-budget-label">
|
||||
<span>Budget-Auslastung</span>
|
||||
<span id="tokenBudgetPercent">0%</span>
|
||||
</div>
|
||||
<div class="token-budget-bar-container">
|
||||
<div class="token-budget-bar" id="tokenBudgetBar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 style="margin-top:24px;">Monatlicher Verlauf</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Monat</th>
|
||||
<th>Refreshes</th>
|
||||
<th>API-Calls</th>
|
||||
<th>Input-Tokens</th>
|
||||
<th>Output-Tokens</th>
|
||||
<th>Kosten (USD)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tokenMonthlyTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -388,6 +435,19 @@
|
||||
<input type="number" id="newLicDuration" value="14" min="1" max="3650">
|
||||
<div class="text-muted mt-8" style="font-size: 12px;">Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="newLicCreditsTotal">Credits-Kontingent</label>
|
||||
<input type="number" id="newLicCreditsTotal" placeholder="z.B. 600000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newLicCostPerCredit">Kosten pro Credit (USD)</label>
|
||||
<input type="number" id="newLicCostPerCredit" step="0.0001" placeholder="z.B. 0.0033">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newLicBudgetUsd">Budget-Limit (USD)</label>
|
||||
<input type="number" id="newLicBudgetUsd" step="0.01" placeholder="z.B. 2000">
|
||||
</div>
|
||||
<div id="newLicError" class="error-msg" style="display:none"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -43,6 +43,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
setupOrgDetailTabs();
|
||||
setupForms();
|
||||
loadDashboard();
|
||||
loadDashboardTokenStats();
|
||||
loadOrgs();
|
||||
});
|
||||
|
||||
@@ -75,6 +76,7 @@ function setupOrgDetailTabs() {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -465,6 +467,12 @@ function setupForms() {
|
||||
if (licType !== "permanent") {
|
||||
body.duration_days = parseInt(document.getElementById("newLicDuration").value);
|
||||
}
|
||||
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);
|
||||
try {
|
||||
await API.post("/api/licenses", body);
|
||||
closeModal("modalNewLicense");
|
||||
@@ -557,3 +565,74 @@ function formatDate(iso) {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ===== 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 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') : '-');
|
||||
el('tokenCreditsRemaining', budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-');
|
||||
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) : '-');
|
||||
|
||||
const bar = document.getElementById('tokenBudgetBar');
|
||||
const percentEl = document.getElementById('tokenBudgetPercent');
|
||||
const percent = budget.credits_percent_used || 0;
|
||||
if (bar) {
|
||||
bar.style.width = Math.min(100, percent) + '%';
|
||||
bar.classList.remove('warning', 'critical');
|
||||
if (percent > 80) bar.classList.add('critical');
|
||||
else if (percent > 50) bar.classList.add('warning');
|
||||
}
|
||||
if (percentEl) percentEl.textContent = percent.toFixed(1) + '%';
|
||||
|
||||
const tbody = document.getElementById('tokenMonthlyTable');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = monthly.map(function(m) {
|
||||
return '<tr>' +
|
||||
'<td>' + esc(m.year_month) + '</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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren