feat(license): Kundenzähler und Upgrade-Hinweise in der Lizenzübersicht (#117)

Zeigt jetzt den tatsächlichen Kundenstand (aktive + gesperrte Organisationen)
gegen die Kundengrenze, nicht nur die Grenze selbst. Dazu Warnungen bei
erreichter Grenze (≥100%), kurz davor (≥90%) und bei bald endendem
Testzeitraum (<7 Tage) - erste Teile des Lizenzübersicht-Tickets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-10-01 10:50:49 +02:00
parent d033088223
commit c52996b559
2 changed files with 7 additions and 2 deletions

View file

@ -19,7 +19,8 @@ export const licenseModule: KcModule = {
requirePermission(req, 'license.read'); requirePermission(req, 'license.read');
const ent = await getEntitlement(); const ent = await getEntitlement();
const row = await one('SELECT instance_id, license_key_enc IS NOT NULL AS has_key, last_error, last_attempt_at FROM license_state WHERE id = 1'); const row = await one('SELECT instance_id, license_key_enc IS NOT NULL AS has_key, last_error, last_attempt_at FROM license_state WHERE id = 1');
return { ...ent, instanceId: row?.instance_id ?? null, hasKey: !!row?.has_key, lastError: row?.last_error ?? null, lastAttemptAt: row?.last_attempt_at ?? null }; const customers = await one("SELECT COUNT(*) AS n FROM organizations WHERE status IN ('active','suspended')");
return { ...ent, instanceId: row?.instance_id ?? null, hasKey: !!row?.has_key, lastError: row?.last_error ?? null, lastAttemptAt: row?.last_attempt_at ?? null, customerCount: Number(customers?.n ?? 0) };
}); });
app.put('/admin/license/settings', async (req) => { app.put('/admin/license/settings', async (req) => {

View file

@ -8,6 +8,7 @@ interface Status {
valid: boolean; plan: string | null; modules: string[]; customerLimit: number | null; userLimit: number | null; valid: boolean; plan: string | null; modules: string[]; customerLimit: number | null; userLimit: number | null;
trial: boolean; expiresAt: string | null; configured: boolean; source: 'live' | 'offline-grace' | 'unchecked' | 'unconfigured'; trial: boolean; expiresAt: string | null; configured: boolean; source: 'live' | 'offline-grace' | 'unchecked' | 'unconfigured';
checkedAt: string | null; message: string | null; instanceId: string | null; hasKey: boolean; lastError: string | null; lastAttemptAt: string | null; checkedAt: string | null; message: string | null; instanceId: string | null; hasKey: boolean; lastError: string | null; lastAttemptAt: string | null;
customerCount: number;
} }
const SOURCE_LABEL: Record<Status['source'], string> = { live: 'aktuell geprüft', 'offline-grace': 'Offline-Gnadenzeit', unchecked: 'noch nicht geprüft', unconfigured: 'nicht eingerichtet' }; const SOURCE_LABEL: Record<Status['source'], string> = { live: 'aktuell geprüft', 'offline-grace': 'Offline-Gnadenzeit', unchecked: 'noch nicht geprüft', unconfigured: 'nicht eingerichtet' };
const MODULE_LABEL: Record<string, string> = { billing: 'Rechnungen', domains: 'Domains', provisioning: 'Provisionierung', discord: 'Discord', automation: 'Automatisierung' }; const MODULE_LABEL: Record<string, string> = { billing: 'Rechnungen', domains: 'Domains', provisioning: 'Provisionierung', discord: 'Discord', automation: 'Automatisierung' };
@ -40,13 +41,16 @@ export default function LizenzPage() {
{!s.configured && <Alert kind="warn">Noch kein Lizenzschlüssel hinterlegt. Solange keiner hinterlegt ist, gilt keine Kunden-/Modulgrenze.</Alert>} {!s.configured && <Alert kind="warn">Noch kein Lizenzschlüssel hinterlegt. Solange keiner hinterlegt ist, gilt keine Kunden-/Modulgrenze.</Alert>}
{s.configured && s.source === 'unchecked' && <Alert kind="info">Lizenzschlüssel hinterlegt, erste Prüfung steht noch aus.</Alert>} {s.configured && s.source === 'unchecked' && <Alert kind="info">Lizenzschlüssel hinterlegt, erste Prüfung steht noch aus.</Alert>}
{s.configured && s.source !== 'unchecked' && !s.valid && <Alert kind="err">Lizenz ungültig: {s.message ?? s.lastError ?? 'unbekannter Grund'}. Neue Kunden und kommerzielle Aktionen sind pausiert, bestehende Daten bleiben zugänglich.</Alert>} {s.configured && s.source !== 'unchecked' && !s.valid && <Alert kind="err">Lizenz ungültig: {s.message ?? s.lastError ?? 'unbekannter Grund'}. Neue Kunden und kommerzielle Aktionen sind pausiert, bestehende Daten bleiben zugänglich.</Alert>}
{s.valid && s.customerLimit !== null && s.customerCount >= s.customerLimit && <Alert kind="err">Kundengrenze erreicht ({s.customerCount}/{s.customerLimit}). Neue Kunden können erst nach einem Upgrade angelegt werden.</Alert>}
{s.valid && s.customerLimit !== null && s.customerCount < s.customerLimit && s.customerCount >= s.customerLimit * 0.9 && <Alert kind="warn">Kundengrenze bald erreicht ({s.customerCount}/{s.customerLimit}). Ein Upgrade lohnt sich bald.</Alert>}
{s.valid && s.trial && s.expiresAt && new Date(s.expiresAt).getTime() - Date.now() < 7 * 86400000 && <Alert kind="warn">Testzeitraum endet bald ({fmt(s.expiresAt)}). Danach sind neue Kunden und kommerzielle Aktionen pausiert, bis ein Plan gewählt wird.</Alert>}
<div className="card"> <div className="card">
<h3>Status</h3> <h3>Status</h3>
<dl className="row" style={{ flexWrap: 'wrap', gap: '8px 32px' }}> <dl className="row" style={{ flexWrap: 'wrap', gap: '8px 32px' }}>
<div><dt className="small muted">Plan</dt><dd>{s.plan ?? '–'}{s.trial ? ' (Test)' : ''}</dd></div> <div><dt className="small muted">Plan</dt><dd>{s.plan ?? '–'}{s.trial ? ' (Test)' : ''}</dd></div>
<div><dt className="small muted">Module</dt><dd>{s.modules.length ? s.modules.map((m) => MODULE_LABEL[m] ?? m).join(', ') : '–'}</dd></div> <div><dt className="small muted">Module</dt><dd>{s.modules.length ? s.modules.map((m) => MODULE_LABEL[m] ?? m).join(', ') : '–'}</dd></div>
<div><dt className="small muted">Kundengrenze</dt><dd>{s.customerLimit === null ? 'unbegrenzt' : s.customerLimit}</dd></div> <div><dt className="small muted">Kunden</dt><dd>{s.customerCount}{s.customerLimit === null ? ' (unbegrenzt)' : ` / ${s.customerLimit}`}</dd></div>
<div><dt className="small muted">Nutzer-/Slotlimit</dt><dd>{s.userLimit === null ? 'unbegrenzt' : s.userLimit}</dd></div> <div><dt className="small muted">Nutzer-/Slotlimit</dt><dd>{s.userLimit === null ? 'unbegrenzt' : s.userLimit}</dd></div>
<div><dt className="small muted">Gültig bis</dt><dd>{s.expiresAt ? fmt(s.expiresAt) : 'unbegrenzt'}</dd></div> <div><dt className="small muted">Gültig bis</dt><dd>{s.expiresAt ? fmt(s.expiresAt) : 'unbegrenzt'}</dd></div>
<div><dt className="small muted">Zuletzt geprüft</dt><dd>{s.checkedAt ? fmt(s.checkedAt) : '–'} ({SOURCE_LABEL[s.source]})</dd></div> <div><dt className="small muted">Zuletzt geprüft</dt><dd>{s.checkedAt ? fmt(s.checkedAt) : '–'} ({SOURCE_LABEL[s.source]})</dd></div>