Limit-Status im Upload-Flow anzeigen (Warnbanner + Sperrzustände).
Upload-Fehlercodes auswerten und freundliche Dialoge zeigen.
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user