Neuer Menüpunkt "Lizenzen" (Personal) bzw. "Meine Lizenzen" (Kunden): - Übersicht mit Kennzahlen, Filtern (aktiv, läuft in 30 Tagen ab, gesperrt, abgelaufen, ohne Kunde) und Suche; Dashboard-Kachel - Vergabe mit Vertrag (normaler Bestellweg) oder ohne Berechnung direkt im Lizenzsystem: Lizenz, Test (Trial) oder Add-on zu einer Basislizenz - Detailseite: Schlüssel, Geräte (Aktivierungen) freigeben, Sperren/Entsperren, Verlängern, Widerrufen, Limits, Funktionsumfang (Entitlement), Produktwechsel bzw. Testumwandlung, Add-ons, Verlauf - Kunden-Selbstbedienung: Inhaber/Admin geben eigene Geräte frei Verwaltungs-Client für das Lizenzsystem in @kc/connector-licensing (createLicensingAdmin), Fehlermeldungen des Lizenzsystems werden verständlich weitergereicht. Alle Änderungen im Audit-Protokoll; der lokale Stand wird danach sofort aktualisiert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
53 lines
6.3 KiB
TypeScript
53 lines
6.3 KiB
TypeScript
'use client';
|
||
import { useEffect, useState } from 'react';
|
||
import Link from 'next/link';
|
||
import { api, errMsg } from '@/lib/api';
|
||
import { useSession } from '@/lib/session';
|
||
import { Alert, Empty } from '@/components/ui';
|
||
|
||
interface Sys { jobs: Record<string, number>; oldestPendingJob: string | null; backup?: { configured: boolean; lastRunAt?: string | null; ok?: boolean; ageHours?: number | null; stale?: boolean; error?: string | null; sizeBytes?: number | null; targets?: { name: string; ok: boolean }[]; lastRestoreTestAt?: string | null; restoreOk?: boolean | null; restoreStale?: boolean } }
|
||
export default function Dashboard() {
|
||
const { me, can } = useSession();
|
||
const [mine, setMine] = useState<{ contracts: { status: string; cancelEffectiveAt: string | null }[]; resources: { state: string; stale: boolean }[]; orders: { status: string }[] } | null>(null);
|
||
const [licenses, setLicenses] = useState<{ active: number; expiring: number; unassigned: number } | null>(null);
|
||
const [customers, setCustomers] = useState<number | null>(null); const [sys, setSys] = useState<Sys | null>(null); const [err, setErr] = useState('');
|
||
useEffect(() => {
|
||
if (me?.kind === 'customer') {
|
||
Promise.all([api<any[]>('GET', '/contracts'), api<any[]>('GET', '/resources'), api<any[]>('GET', '/orders')]).then(([contracts, resources, orders]) => setMine({ contracts, resources, orders })).catch((e) => setErr(errMsg(e)));
|
||
return;
|
||
}
|
||
if (!me || me.kind !== 'staff') return;
|
||
if (can('customers.read')) api<unknown[]>('GET', '/admin/customers').then((l) => setCustomers(l.length)).catch((e) => setErr(errMsg(e)));
|
||
if (can('jobs.read')) api<Sys>('GET', '/admin/system').then(setSys).catch((e) => setErr(errMsg(e)));
|
||
if (can('licenses.read')) api<{ active: number; expiring: number; unassigned: number }>('GET', '/admin/licenses/summary').then(setLicenses).catch(() => undefined);
|
||
}, [me, can]);
|
||
if (!me) return null;
|
||
const failed = sys ? (sys.jobs.failed ?? 0) + (sys.jobs.needs_review ?? 0) : 0;
|
||
return (<>
|
||
<h1>Hallo {me.user.name.split(' ')[0]}</h1>
|
||
{err && <Alert kind="err">{err}</Alert>}
|
||
{me.kind === 'customer' ? (<>
|
||
{me.organizations.map((o) => (
|
||
<div className="card" key={o.id}><h2>{o.name}</h2><p className="muted">Kundennummer {o.customerNumber}</p><Link className="btn" href="/organisation">Organisation ansehen</Link></div>
|
||
))}
|
||
{mine && mine.contracts.length === 0 && mine.resources.length === 0 && mine.orders.length === 0 && <div className="card"><Empty title="Noch keine Produkte">Sobald Produkte, Verträge, Rechnungen oder Tickets für Ihre Organisation vorhanden sind, erscheinen sie hier.</Empty></div>}
|
||
{mine && (mine.contracts.length > 0 || mine.resources.length > 0 || mine.orders.length > 0) && <div className="grid">
|
||
<div className="card"><div className="stat">{mine.contracts.filter((c) => c.status === 'active').length}</div><p className="muted">Aktive Verträge</p>{mine.contracts.some((c) => c.cancelEffectiveAt) && <p className="small"><span className="badge warn"><span aria-hidden="true">▲</span>Gekündigter Vertrag vorhanden</span></p>}<Link href="/vertraege">Verträge ansehen</Link></div>
|
||
<div className="card"><div className="stat">{mine.resources.length}</div><p className="muted">Ressourcen und Lizenzen</p>{mine.resources.some((r) => r.stale) && <p className="small"><span className="badge warn"><span aria-hidden="true">▲</span>Stand teilweise veraltet</span></p>}<Link href="/ressourcen">Ressourcen ansehen</Link></div>
|
||
{mine.orders.filter((o) => ['pending_approval', 'provisioning', 'failed'].includes(o.status)).length > 0 && <div className="card"><div className="stat">{mine.orders.filter((o) => ['pending_approval', 'provisioning', 'failed'].includes(o.status)).length}</div><p className="muted">Bestellungen in Bearbeitung</p><Link href="/bestellungen">Bestellungen ansehen</Link></div>}
|
||
</div>}
|
||
</>) : (<>
|
||
<div className="grid">
|
||
{customers !== null && <div className="card"><div className="stat">{customers}</div><p className="muted">Kunden</p><Link href="/admin/kunden">Kunden verwalten</Link></div>}
|
||
{licenses && <div className="card"><div className="stat">{licenses.active}</div><p className="muted">Aktive Lizenzen</p>{licenses.expiring > 0 && <p className="small"><span className="badge warn"><span aria-hidden="true">▲</span>{licenses.expiring} laufen in 30 Tagen ab</span></p>}{licenses.unassigned > 0 && <p className="small"><span className="badge warn"><span aria-hidden="true">▲</span>{licenses.unassigned} ohne Kunde</span></p>}<Link href="/lizenzen">Lizenzen verwalten</Link></div>}
|
||
{sys && <div className="card"><div className="stat">{sys.jobs.scheduled ?? 0}</div><p className="muted">Wartende Aufträge</p></div>}
|
||
{sys?.backup && <div className="card"><h3>Backup</h3><p className="small"><Link href="/einstellungen/backup">Details und Einstellungen</Link></p>
|
||
{!sys.backup.configured ? <><p className="muted">Nicht eingerichtet.</p><span className="badge warn"><span aria-hidden="true">▲</span>Kein Backup</span></>
|
||
: <><p>{sys.backup.stale ? <span className="badge err"><span aria-hidden="true">✕</span>{sys.backup.ok ? 'Veraltet' : 'Fehlgeschlagen'}</span> : <span className="badge ok"><span aria-hidden="true">✓</span>Aktuell</span>}</p>
|
||
<p className="small muted">Letzter Lauf: {sys.backup.lastRunAt ? new Date(sys.backup.lastRunAt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }) : '–'}{sys.backup.sizeBytes ? ` · ${(sys.backup.sizeBytes / 1024).toFixed(0)} KB` : ''}<br />Ziele: {sys.backup.targets?.length ? sys.backup.targets.map((t) => `${t.ok ? '✓' : '✕'} ${t.name}`).join(', ') : '–'}<br />Wiederherstellungstest: {sys.backup.lastRestoreTestAt ? `${sys.backup.restoreOk ? '✓' : '✕'} ${new Date(sys.backup.lastRestoreTestAt).toLocaleDateString('de-DE')}` : 'noch nie'}{sys.backup.restoreStale ? ' (überfällig)' : ''}</p>
|
||
{sys.backup.error && <p className="small" style={{ color: 'var(--err)' }}>{sys.backup.error}</p>}</>}</div>}
|
||
{sys && <div className="card"><div className="stat">{failed}</div><p className="muted">Aufträge mit Fehlern / Prüfbedarf</p>{failed > 0 && <span className="badge err"><span aria-hidden="true">✕</span>Prüfen</span>}</div>}
|
||
</div>
|
||
</>)}
|
||
</>);
|
||
}
|