überarbeitung des event-admins fortgesetzt

This commit is contained in:
Codex Agent
2025-11-25 13:03:42 +01:00
parent fd788ef770
commit 596dcbf18a
20 changed files with 998 additions and 2210 deletions

View File

@@ -19,14 +19,7 @@ import {
PaginationMeta,
} from '../api';
import { isAuthError } from '../auth/tokens';
import {
TenantHeroCard,
FrostedSurface,
tenantHeroPrimaryButtonClass,
tenantHeroSecondaryButtonClass,
SectionCard,
SectionHeader,
} from '../components/tenant';
import { FrostedSurface, SectionCard, SectionHeader } from '../components/tenant';
type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string };
@@ -177,10 +170,6 @@ export default function BillingPage() {
void loadAll();
}, [loadAll]);
const activeWarnings = React.useMemo(
() => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'),
[activePackage, t, formatDate],
);
const hasMoreAddons = React.useMemo(() => {
if (!addonMeta) {
return false;
@@ -188,107 +177,63 @@ export default function BillingPage() {
return addonMeta.current_page < addonMeta.last_page;
}, [addonMeta]);
const heroBadge = t('billing.hero.badge', 'Abrechnung');
const heroDescription = t('billing.hero.description', 'Behalte Laufzeiten, Rechnungen und Limits deiner Pakete im Blick.');
const heroSupporting: string[] = [
activePackage
? t('billing.hero.summary.active', 'Aktives Paket: {{name}}', { name: activePackage.package_name })
: t('billing.hero.summary.inactive', 'Noch kein aktives Paket wählt ein Kontingent, das zu euch passt.'),
t('billing.hero.summary.transactions', '{{count}} Zahlungen synchronisiert', { count: transactions.length })
];
const packagesHref = `/${i18n.language?.split('-')[0] ?? 'de'}/packages`;
const heroPrimaryAction = (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => void loadAll(true)}
disabled={loading}
>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{t('billing.actions.refresh')}
</Button>
const computedRemainingEvents = React.useMemo(() => {
if (!activePackage) {
return null;
}
const used = activePackage.used_events ?? 0;
if (activePackage.remaining_events !== null && activePackage.remaining_events !== undefined) {
return activePackage.remaining_events;
}
const allowance = activePackage.package_limits?.max_events_per_year ?? 1;
return Math.max(0, allowance - used);
}, [activePackage]);
const normalizedActivePackage = React.useMemo(() => {
if (!activePackage) return null;
return {
...activePackage,
remaining_events: computedRemainingEvents ?? activePackage.remaining_events,
};
}, [activePackage, computedRemainingEvents]);
const topWarning = React.useMemo(() => {
const warnings = buildPackageWarnings(
normalizedActivePackage,
(key, options) => t(key, options),
formatDate,
'billing.sections.overview.warnings',
);
return warnings[0];
}, [formatDate, normalizedActivePackage, t]);
const activeWarnings = React.useMemo(
() => buildPackageWarnings(normalizedActivePackage, t, formatDate, 'billing.sections.overview.warnings'),
[normalizedActivePackage, t, formatDate],
);
const heroSecondaryAction = (
<Button
size="sm"
className={tenantHeroSecondaryButtonClass}
onClick={() => window.location.assign(packagesHref)}
>
{t('billing.actions.explorePackages', 'Pakete vergleichen')}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
);
const nextRenewalLabel = t('billing.hero.nextRenewal', 'Verlängerung am');
const topWarning = activeWarnings[0];
const billingStats = React.useMemo(
() => [
{
key: 'package',
label: t('billing.stats.package.label', 'Aktives Paket'),
value: activePackage?.package_name ?? t('billing.stats.package.empty', 'Keines'),
helper: activePackage?.expires_at
? t('billing.stats.package.helper', { date: formatDate(activePackage.expires_at) })
: t('billing.stats.package.helper', { date: '—' }),
tone: 'pink' as const,
},
const billingStats = React.useMemo(() => {
if (!activePackage) {
return [] as const;
}
const used = activePackage.used_events ?? 0;
const remaining = computedRemainingEvents ?? 0;
return [
{
key: 'events',
label: t('billing.stats.events.label', 'Genutzte Events'),
value: activePackage?.used_events ?? 0,
helper: t('billing.stats.events.helper', { count: activePackage?.remaining_events ?? 0 }),
value: used,
helper: t('billing.stats.events.helper', { count: remaining }),
tone: 'amber' as const,
},
{
key: 'addons',
label: t('billing.stats.addons.label', 'Add-ons'),
value: addonHistory.length,
helper: t('billing.stats.addons.helper', 'Historie insgesamt'),
tone: 'sky' as const,
},
{
key: 'transactions',
label: t('billing.stats.transactions.label', 'Transaktionen'),
value: transactions.length,
helper: t('billing.stats.transactions.helper', 'Synchronisierte Zahlungen'),
tone: 'emerald' as const,
},
],
[activePackage, addonHistory.length, transactions.length, formatDate, t]
);
const heroAside = (
<FrostedSurface className="space-y-4 border-white/25 p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-slate-800/70 dark:bg-slate-950/85">
<div>
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">
{t('billing.hero.activePackage', 'Aktuelles Paket')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-slate-100">
{activePackage?.package_name ?? t('billing.hero.activeFallback', 'Noch nicht ausgewählt')}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{nextRenewalLabel}</p>
<p className="mt-1 text-sm font-medium text-slate-800 dark:text-slate-200">{formatDate(activePackage?.expires_at)}</p>
</div>
{topWarning ? (
<div className="rounded-xl border border-amber-200/60 bg-amber-50/80 p-3 text-xs text-amber-800 shadow-inner shadow-amber-200/40 dark:border-amber-500/50 dark:bg-amber-500/15 dark:text-amber-200">
{topWarning.message}
</div>
) : null}
</FrostedSurface>
);
];
}, [activePackage, computedRemainingEvents, t]);
return (
<AdminLayout title={t('billing.title')} subtitle={t('billing.subtitle')}>
<TenantHeroCard
badge={heroBadge}
title={t('billing.title')}
description={heroDescription}
supporting={heroSupporting}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard:alerts.errorTitle')}</AlertTitle>
@@ -300,7 +245,6 @@ export default function BillingPage() {
<BillingSkeleton />
) : (
<>
<BillingStatGrid stats={billingStats} />
<BillingWarningBanner warnings={activeWarnings} t={t} />
<SectionCard className="mt-6 space-y-5">
<SectionHeader
@@ -322,14 +266,14 @@ export default function BillingPage() {
tone="pink"
helper={t('billing.sections.overview.cards.package.helper')}
/>
<InfoCard
label={t('billing.sections.overview.cards.used.label')}
value={activePackage.used_events ?? 0}
tone="amber"
helper={t('billing.sections.overview.cards.used.helper', {
count: activePackage.remaining_events ?? 0,
})}
/>
<InfoCard
label={t('billing.sections.overview.cards.used.label')}
value={activePackage.used_events ?? 0}
tone="amber"
helper={t('billing.sections.overview.cards.used.helper', {
count: computedRemainingEvents ?? 0,
})}
/>
<InfoCard
label={t('billing.sections.overview.cards.price.label')}
value={formatCurrency(activePackage.price ?? null, activePackage.currency ?? 'EUR')}
@@ -811,8 +755,14 @@ function buildPackageWarnings(
const warnings: PackageWarning[] = [];
const remaining = typeof pkg.remaining_events === 'number' ? pkg.remaining_events : null;
const allowance = pkg.package_limits?.max_events_per_year;
const used = pkg.used_events ?? 0;
const totalEvents = allowance ?? (remaining !== null ? remaining + used : null);
if (remaining !== null) {
// Warnungen nur, wenn das Paket tatsächlich mehr als 1 Event umfasst oder das Limit unbekannt ist.
const shouldWarn = totalEvents === null ? true : totalEvents > 1;
if (remaining !== null && shouldWarn) {
if (remaining <= 0) {
warnings.push({
id: `${pkg.id}-no-events`,