überarbeitung des event-admins fortgesetzt
This commit is contained in:
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user