Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).

Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
This commit is contained in:
Codex Agent
2025-11-01 19:50:17 +01:00
parent 2c14493604
commit 79b209de9a
55 changed files with 3348 additions and 462 deletions

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Loader2, RefreshCw, Sparkles } from 'lucide-react';
import { AlertTriangle, Loader2, RefreshCw, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@@ -12,6 +12,8 @@ import { AdminLayout } from '../components/AdminLayout';
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
import { isAuthError } from '../auth/tokens';
type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string };
export default function BillingPage() {
const { t, i18n } = useTranslation(['management', 'dashboard']);
const locale = React.useMemo(
@@ -112,6 +114,11 @@ export default function BillingPage() {
</Button>
);
const activeWarnings = React.useMemo(
() => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'),
[activePackage, t, formatDate],
);
return (
<AdminLayout
title={t('billing.title')}
@@ -146,33 +153,52 @@ export default function BillingPage() {
</CardHeader>
<CardContent>
{activePackage ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<InfoCard
label={t('billing.sections.overview.cards.package.label')}
value={activePackage.package_name}
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.price.label')}
value={formatCurrency(activePackage.price ?? null, activePackage.currency ?? 'EUR')}
tone="sky"
helper={activePackage.currency ?? 'EUR'}
/>
<InfoCard
label={t('billing.sections.overview.cards.expires.label')}
value={formatDate(activePackage.expires_at)}
tone="emerald"
helper={t('billing.sections.overview.cards.expires.helper')}
/>
<div className="space-y-4">
{activeWarnings.length > 0 && (
<div className="space-y-2">
{activeWarnings.map((warning) => (
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
>
<AlertDescription className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4" />
{warning.message}
</AlertDescription>
</Alert>
))}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<InfoCard
label={t('billing.sections.overview.cards.package.label')}
value={activePackage.package_name}
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.price.label')}
value={formatCurrency(activePackage.price ?? null, activePackage.currency ?? 'EUR')}
tone="sky"
helper={activePackage.currency ?? 'EUR'}
/>
<InfoCard
label={t('billing.sections.overview.cards.expires.label')}
value={formatDate(activePackage.expires_at)}
tone="emerald"
helper={t('billing.sections.overview.cards.expires.helper')}
/>
</div>
</div>
) : (
<EmptyState message={t('billing.sections.overview.empty')} />
@@ -194,16 +220,20 @@ export default function BillingPage() {
{packages.length === 0 ? (
<EmptyState message={t('billing.sections.packages.empty')} />
) : (
packages.map((pkg) => (
<PackageCard
key={pkg.id}
pkg={pkg}
isActive={Boolean(pkg.active)}
labels={packageLabels}
formatDate={formatDate}
formatCurrency={formatCurrency}
/>
))
packages.map((pkg) => {
const warnings = buildPackageWarnings(pkg, t, formatDate, 'billing.sections.packages.card.warnings');
return (
<PackageCard
key={pkg.id}
pkg={pkg}
isActive={Boolean(pkg.active)}
labels={packageLabels}
formatDate={formatDate}
formatCurrency={formatCurrency}
warnings={warnings}
/>
);
})
)}
</CardContent>
</Card>
@@ -369,6 +399,7 @@ function PackageCard({
labels,
formatDate,
formatCurrency,
warnings = [],
}: {
pkg: TenantPackageSummary;
isActive: boolean;
@@ -381,9 +412,26 @@ function PackageCard({
};
formatDate: (value: string | null | undefined) => string;
formatCurrency: (value: number | null | undefined, currency?: string) => string;
warnings?: PackageWarning[];
}) {
return (
<div className="rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-sm">
{warnings.length > 0 && (
<div className="mb-3 space-y-2">
{warnings.map((warning) => (
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
>
<AlertDescription className="flex items-center gap-2 text-xs">
<AlertTriangle className="h-4 w-4" />
{warning.message}
</AlertDescription>
</Alert>
))}
</div>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold text-slate-900">{pkg.package_name}</h3>
@@ -422,6 +470,60 @@ function EmptyState({ message }: { message: string }) {
);
}
function buildPackageWarnings(
pkg: TenantPackageSummary | null | undefined,
translate: (key: string, options?: Record<string, unknown>) => string,
formatDate: (value: string | null | undefined) => string,
keyPrefix: string,
): PackageWarning[] {
if (!pkg) {
return [];
}
const warnings: PackageWarning[] = [];
const remaining = typeof pkg.remaining_events === 'number' ? pkg.remaining_events : null;
if (remaining !== null) {
if (remaining <= 0) {
warnings.push({
id: `${pkg.id}-no-events`,
tone: 'danger',
message: translate(`${keyPrefix}.noEvents`),
});
} else if (remaining <= 2) {
warnings.push({
id: `${pkg.id}-low-events`,
tone: 'warning',
message: translate(`${keyPrefix}.lowEvents`, { remaining }),
});
}
}
const expiresAt = pkg.expires_at ? new Date(pkg.expires_at) : null;
if (expiresAt && !Number.isNaN(expiresAt.getTime())) {
const now = new Date();
const diffMillis = expiresAt.getTime() - now.getTime();
const diffDays = Math.ceil(diffMillis / (1000 * 60 * 60 * 24));
const formatted = formatDate(pkg.expires_at);
if (diffDays < 0) {
warnings.push({
id: `${pkg.id}-expired`,
tone: 'danger',
message: translate(`${keyPrefix}.expired`, { date: formatted }),
});
} else if (diffDays <= 14) {
warnings.push({
id: `${pkg.id}-expires`,
tone: 'warning',
message: translate(`${keyPrefix}.expiresSoon`, { date: formatted }),
});
}
}
return warnings;
}
function BillingSkeleton() {
return (
<div className="grid gap-6">