fixed event join token handling in the event admin. created new seeders with new tenants and package purchases. added new playwright test scenarios.

This commit is contained in:
Codex Agent
2025-10-26 14:44:47 +01:00
parent 6290a3a448
commit ecf5a23b28
59 changed files with 3900 additions and 691 deletions

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { CreditCard, Download, Loader2, RefreshCw, Sparkles } from 'lucide-react';
import { Loader2, RefreshCw, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@@ -9,21 +9,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Separator } from '@/components/ui/separator';
import { AdminLayout } from '../components/AdminLayout';
import {
CreditLedgerEntry,
getCreditBalance,
getCreditLedger,
getTenantPackagesOverview,
PaginationMeta,
TenantPackageSummary,
} from '../api';
import { getTenantPackagesOverview, TenantPackageSummary } from '../api';
import { isAuthError } from '../auth/tokens';
type LedgerState = {
entries: CreditLedgerEntry[];
meta: PaginationMeta | null;
};
export default function BillingPage() {
const { t, i18n } = useTranslation(['management', 'dashboard']);
const locale = React.useMemo(
@@ -31,13 +19,10 @@ export default function BillingPage() {
[i18n.language]
);
const [balance, setBalance] = React.useState<number>(0);
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
const [ledger, setLedger] = React.useState<LedgerState>({ entries: [], meta: null });
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [loadingMore, setLoadingMore] = React.useState(false);
const formatDate = React.useCallback(
(value: string | null | undefined) => {
@@ -57,111 +42,53 @@ export default function BillingPage() {
[locale]
);
const resolveReason = React.useCallback(
(reason: string) => {
switch (reason) {
case 'purchase':
return t('management.billing.ledger.reasons.purchase', 'Credit Kauf');
case 'usage':
return t('management.billing.ledger.reasons.usage', 'Verbrauch');
case 'manual':
return t('management.billing.ledger.reasons.manual', 'Manuelle Anpassung');
default:
return reason;
}
},
[t]
);
const packageLabels = React.useMemo(
() => ({
statusActive: t('management.billing.packages.card.statusActive', 'Aktiv'),
statusInactive: t('management.billing.packages.card.statusInactive', 'Inaktiv'),
used: t('management.billing.packages.card.used', 'Genutzte Events'),
available: t('management.billing.packages.card.available', 'Verfügbar'),
expires: t('management.billing.packages.card.expires', 'Ablauf'),
statusActive: t('billing.sections.packages.card.statusActive'),
statusInactive: t('billing.sections.packages.card.statusInactive'),
used: t('billing.sections.packages.card.used'),
available: t('billing.sections.packages.card.available'),
expires: t('billing.sections.packages.card.expires'),
}),
[t]
);
React.useEffect(() => {
void loadAll();
}, []);
async function loadAll() {
const loadAll = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const [balanceResult, packagesResult, ledgerResult] = await Promise.all([
safeCall(() => getCreditBalance()),
safeCall(() => getTenantPackagesOverview()),
safeCall(() => getCreditLedger(1)),
]);
if (balanceResult?.balance !== undefined) {
setBalance(balanceResult.balance);
}
if (packagesResult) {
setPackages(packagesResult.packages);
setActivePackage(packagesResult.activePackage);
}
if (ledgerResult) {
setLedger({ entries: ledgerResult.data, meta: ledgerResult.meta });
} else {
setLedger({ entries: [], meta: null });
}
const packagesResult = await getTenantPackagesOverview();
setPackages(packagesResult.packages);
setActivePackage(packagesResult.activePackage);
} catch (err) {
if (!isAuthError(err)) {
setError(t('management.billing.errors.load', 'Billing Daten konnten nicht geladen werden.'));
setError(t('billing.errors.load'));
}
} finally {
setLoading(false);
}
}
}, [t]);
async function loadMore() {
if (!ledger.meta || loadingMore) {
return;
}
const { current_page, last_page } = ledger.meta;
if (current_page >= last_page) {
return;
}
setLoadingMore(true);
try {
const next = await getCreditLedger(current_page + 1);
setLedger({
entries: [...ledger.entries, ...next.data],
meta: next.meta,
});
} catch (err) {
if (!isAuthError(err)) {
setError(t('management.billing.errors.more', 'Weitere Ledger Eintraege konnten nicht geladen werden.'));
}
} finally {
setLoadingMore(false);
}
}
React.useEffect(() => {
void loadAll();
}, [loadAll]);
const actions = (
<Button variant="outline" onClick={() => void loadAll()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t('management.billing.actions.refresh', 'Aktualisieren')}
{t('billing.actions.refresh')}
</Button>
);
return (
<AdminLayout
title={t('management.billing.title', 'Billing und Credits')}
subtitle={t('management.billing.subtitle', 'Verwalte Guthaben, Pakete und Abrechnungen.')}
title={t('billing.title')}
subtitle={t('billing.subtitle')}
actions={actions}
>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard.alerts.errorTitle', 'Fehler')}</AlertTitle>
<AlertTitle>{t('dashboard:alerts.errorTitle')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
@@ -174,43 +101,50 @@ export default function BillingPage() {
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<CreditCard className="h-5 w-5 text-pink-500" />
{t('management.billing.sections.overview.title', 'Credits und Status')}
<Sparkles className="h-5 w-5 text-pink-500" />
{t('billing.sections.overview.title')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('management.billing.sections.overview.description', 'Dein aktuelles Guthaben und das aktive Reseller Paket.')}
{t('billing.sections.overview.description')}
</CardDescription>
</div>
<Badge className={activePackage ? 'bg-pink-500/10 text-pink-700' : 'bg-slate-200 text-slate-700'}>
{activePackage ? activePackage.package_name : 'Kein aktives Paket'}
{activePackage ? activePackage.package_name : t('billing.sections.overview.emptyBadge')}
</Badge>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<InfoCard
label={t('management.billing.sections.overview.cards.balance.label', 'Verfügbare Credits')}
value={balance}
tone="pink"
/>
<InfoCard
label={t('management.billing.sections.overview.cards.used.label', 'Genutzte Events')}
value={activePackage?.used_events ?? 0}
tone="amber"
helper={t('management.billing.sections.overview.cards.used.helper', {
count: activePackage?.remaining_events ?? 0,
})}
/>
<InfoCard
label={t('management.billing.sections.overview.cards.price.label', 'Preis (netto)')}
value={formatCurrency(activePackage?.price ?? null, activePackage?.currency ?? 'EUR')}
tone="sky"
helper={activePackage?.currency ?? 'EUR'}
/>
<InfoCard
label={t('management.billing.sections.overview.cards.expires.label', 'Ablauf')}
value={formatDate(activePackage?.expires_at)}
tone="emerald"
helper={t('management.billing.sections.overview.cards.expires.helper', 'Automatisch verlängern, falls aktiv')}
/>
<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>
) : (
<EmptyState message={t('billing.sections.overview.empty')} />
)}
</CardContent>
</Card>
@@ -218,15 +152,15 @@ export default function BillingPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-amber-500" />
{t('management.billing.packages.title', 'Paket Historie')}
{t('billing.sections.packages.title')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('management.billing.packages.description', 'Übersicht über aktive und vergangene Reseller Pakete.')}
{t('billing.sections.packages.description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{packages.length === 0 ? (
<EmptyState message={t('management.billing.packages.empty', 'Noch keine Pakete gebucht.')} />
<EmptyState message={t('billing.sections.packages.empty')} />
) : (
packages.map((pkg) => (
<PackageCard
@@ -242,61 +176,11 @@ export default function BillingPage() {
</CardContent>
</Card>
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-sky-500" />
{t('management.billing.ledger.title', 'Credit Ledger')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('management.billing.ledger.description', 'Alle Zu- und Abbuchungen deines Credits-Kontos.')}
</CardDescription>
</div>
<Button variant="outline" size="sm">
<Download className="h-4 w-4" />
{t('management.billing.actions.exportCsv', 'Export als CSV')}
</Button>
</CardHeader>
<CardContent className="space-y-2">
{ledger.entries.length === 0 ? (
<EmptyState message={t('management.billing.ledger.empty', 'Noch keine Ledger-Einträge vorhanden.')} />
) : (
<>
{ledger.entries.map((entry) => (
<LedgerRow
key={`${entry.id}-${entry.created_at}`}
entry={entry}
resolveReason={resolveReason}
formatDate={formatDate}
/>
))}
{ledger.meta && ledger.meta.current_page < ledger.meta.last_page && (
<Button variant="outline" className="w-full" onClick={() => void loadMore()} disabled={loadingMore}>
{loadingMore ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.billing.ledger.loadMore', 'Mehr laden')}
</Button>
)}
</>
)}
</CardContent>
</Card>
</>
)}
</AdminLayout>
);
}
async function safeCall<T>(callback: () => Promise<T>): Promise<T | null> {
try {
return await callback();
} catch (error) {
if (!isAuthError(error)) {
console.warn('[Tenant Billing] optional endpoint fehlgeschlagen', error);
}
return null;
}
}
function InfoCard({
label,
value,
@@ -372,33 +256,6 @@ function PackageCard({
);
}
function LedgerRow({
entry,
resolveReason,
formatDate,
}: {
entry: CreditLedgerEntry;
resolveReason: (reason: string) => string;
formatDate: (value: string | null | undefined) => string;
}) {
const positive = entry.delta >= 0;
return (
<div className="flex flex-col gap-2 rounded-2xl border border-slate-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold text-slate-900">{resolveReason(entry.reason)}</p>
{entry.note && <p className="text-xs text-slate-500">{entry.note}</p>}
</div>
<div className="flex items-center gap-4">
<span className={`text-sm font-semibold ${positive ? 'text-emerald-600' : 'text-rose-600'}`}>
{positive ? '+' : ''}
{entry.delta}
</span>
<span className="text-xs text-slate-500">{formatDate(entry.created_at)}</span>
</div>
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-8 text-center">

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CalendarDays, Camera, CreditCard, Sparkles, Users, Plus, Settings } from 'lucide-react';
import { CalendarDays, Camera, Sparkles, Users, Plus, Settings } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -11,7 +11,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { AdminLayout } from '../components/AdminLayout';
import {
DashboardSummary,
getCreditBalance,
getDashboardSummary,
getEvents,
getTenantPackagesOverview,
@@ -35,7 +34,6 @@ import { useOnboardingProgress } from '../onboarding';
interface DashboardState {
summary: DashboardSummary | null;
events: TenantEvent[];
credits: number;
activePackage: TenantPackageSummary | null;
loading: boolean;
errorKey: string | null;
@@ -46,11 +44,23 @@ export default function DashboardPage() {
const location = useLocation();
const { user } = useAuth();
const { progress, markStep } = useOnboardingProgress();
const { t, i18n } = useTranslation(['dashboard', 'common']);
const { t, i18n } = useTranslation('dashboard', { keyPrefix: 'dashboard' });
const { t: tc } = useTranslation('common');
const translate = React.useCallback(
(key: string, options?: Record<string, unknown>) => {
const value = t(key, options);
if (value === `dashboard.${key}`) {
const fallback = i18n.t(`dashboard:${key}`, options);
return fallback === `dashboard:${key}` ? value : fallback;
}
return value;
},
[t, i18n],
);
const [state, setState] = React.useState<DashboardState>({
summary: null,
events: [],
credits: 0,
activePackage: null,
loading: true,
errorKey: null,
@@ -60,10 +70,9 @@ export default function DashboardPage() {
let cancelled = false;
(async () => {
try {
const [summary, events, credits, packages] = await Promise.all([
const [summary, events, packages] = await Promise.all([
getDashboardSummary().catch(() => null),
getEvents().catch(() => [] as TenantEvent[]),
getCreditBalance().catch(() => ({ balance: 0 })),
getTenantPackagesOverview().catch(() => ({ packages: [], activePackage: null })),
]);
@@ -71,12 +80,11 @@ export default function DashboardPage() {
return;
}
const fallbackSummary = buildSummaryFallback(events, credits.balance ?? 0, packages.activePackage);
const fallbackSummary = buildSummaryFallback(events, packages.activePackage);
setState({
summary: summary ?? fallbackSummary,
events,
credits: credits.balance ?? 0,
activePackage: packages.activePackage,
loading: false,
errorKey: null,
@@ -97,7 +105,7 @@ export default function DashboardPage() {
};
}, []);
const { summary, events, credits, activePackage, loading, errorKey } = state;
const { summary, events, activePackage, loading, errorKey } = state;
React.useEffect(() => {
if (loading) {
@@ -112,10 +120,10 @@ export default function DashboardPage() {
}
}, [loading, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
const greetingName = user?.name ?? t('dashboard.welcome.fallbackName');
const greetingTitle = t('dashboard.welcome.greeting', { name: greetingName });
const subtitle = t('dashboard.welcome.subtitle');
const errorMessage = errorKey ? t(`dashboard.errors.${errorKey}`) : null;
const greetingName = user?.name ?? translate('welcome.fallbackName');
const greetingTitle = translate('welcome.greeting', { name: greetingName });
const subtitle = translate('welcome.subtitle');
const errorMessage = errorKey ? translate(`errors.${errorKey}`) : null;
const dateLocale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const upcomingEvents = getUpcomingEvents(events);
@@ -127,10 +135,10 @@ export default function DashboardPage() {
className="bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
>
<Plus className="h-4 w-4" /> {t('dashboard.actions.newEvent')}
<Plus className="h-4 w-4" /> {translate('actions.newEvent')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-brand-rose-soft text-brand-rose">
<CalendarDays className="h-4 w-4" /> {t('dashboard.actions.allEvents')}
<CalendarDays className="h-4 w-4" /> {translate('actions.allEvents')}
</Button>
{events.length === 0 && (
<Button
@@ -138,7 +146,7 @@ export default function DashboardPage() {
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
>
<Sparkles className="h-4 w-4" /> {t('dashboard.actions.guidedSetup')}
<Sparkles className="h-4 w-4" /> {translate('actions.guidedSetup')}
</Button>
)}
</>
@@ -162,24 +170,23 @@ export default function DashboardPage() {
<CardHeader className="space-y-3">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-brand-rose" />
{t('dashboard.welcomeCard.title')}
{translate('welcomeCard.title')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Lerne die Storytelling-Elemente kennen, ¤hle dein Paket und erstelle dein erstes Event mit
geführten Schritten.
{translate('welcomeCard.summary')}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-2 text-sm text-slate-600">
<p>{t('dashboard.welcomeCard.body1')}</p>
<p>{t('dashboard.welcomeCard.body2')}</p>
<p>{translate('welcomeCard.body1')}</p>
<p>{translate('welcomeCard.body2')}</p>
</div>
<Button
size="lg"
className="rounded-full bg-rose-500 text-white shadow-lg shadow-rose-400/40 hover:bg-rose-500/90"
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
>
{t('dashboard.welcomeCard.cta')}
{translate('welcomeCard.cta')}
</Button>
</CardContent>
</Card>
@@ -190,74 +197,75 @@ export default function DashboardPage() {
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-brand-rose" />
{t('dashboard.overview.title')}
{translate('overview.title')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('dashboard.overview.description')}
{translate('overview.description')}
</CardDescription>
</div>
<Badge className="bg-brand-rose-soft text-brand-rose">
{activePackage?.package_name ?? t('dashboard.overview.noPackage')}
{activePackage?.package_name ?? translate('overview.noPackage')}
</Badge>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<StatCard
label={t('dashboard.overview.stats.activeEvents')}
label={translate('overview.stats.activeEvents')}
value={summary?.active_events ?? publishedEvents.length}
hint={t('dashboard.overview.stats.publishedHint', { count: publishedEvents.length })}
hint={translate('overview.stats.publishedHint', { count: publishedEvents.length })}
icon={<CalendarDays className="h-5 w-5 text-brand-rose" />}
/>
<StatCard
label={t('dashboard.overview.stats.newPhotos')}
label={translate('overview.stats.newPhotos')}
value={summary?.new_photos ?? 0}
icon={<Camera className="h-5 w-5 text-fuchsia-500" />}
/>
<StatCard
label={t('dashboard.overview.stats.taskProgress')}
label={translate('overview.stats.taskProgress')}
value={`${Math.round(summary?.task_progress ?? 0)}%`}
icon={<Users className="h-5 w-5 text-amber-500" />}
/>
<StatCard
label={t('dashboard.overview.stats.credits')}
value={credits}
hint={credits <= 1 ? t('dashboard.overview.stats.lowCredits') : undefined}
icon={<CreditCard className="h-5 w-5 text-sky-500" />}
/>
{activePackage ? (
<StatCard
label={translate('overview.stats.activePackage')}
value={activePackage.package_name}
icon={<Sparkles className="h-5 w-5 text-sky-500" />}
/>
) : null}
</CardContent>
</Card>
<Card className="border-0 bg-brand-card shadow-brand-primary">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-xl text-slate-900">{t('dashboard.quickActions.title')}</CardTitle>
<CardTitle className="text-xl text-slate-900">{translate('quickActions.title')}</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('dashboard.quickActions.description')}
{translate('quickActions.description')}
</CardDescription>
</div>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<QuickAction
icon={<Plus className="h-5 w-5" />}
label={t('dashboard.quickActions.createEvent.label')}
description={t('dashboard.quickActions.createEvent.description')}
label={translate('quickActions.createEvent.label')}
description={translate('quickActions.createEvent.description')}
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
/>
<QuickAction
icon={<Camera className="h-5 w-5" />}
label={t('dashboard.quickActions.moderatePhotos.label')}
description={t('dashboard.quickActions.moderatePhotos.description')}
label={translate('quickActions.moderatePhotos.label')}
description={translate('quickActions.moderatePhotos.description')}
onClick={() => navigate(ADMIN_EVENTS_PATH)}
/>
<QuickAction
icon={<Users className="h-5 w-5" />}
label={t('dashboard.quickActions.organiseTasks.label')}
description={t('dashboard.quickActions.organiseTasks.description')}
label={translate('quickActions.organiseTasks.label')}
description={translate('quickActions.organiseTasks.description')}
onClick={() => navigate(ADMIN_TASKS_PATH)}
/>
<QuickAction
icon={<CreditCard className="h-5 w-5" />}
label={t('dashboard.quickActions.manageCredits.label')}
description={t('dashboard.quickActions.manageCredits.description')}
icon={<Sparkles className="h-5 w-5" />}
label={translate('quickActions.managePackages.label')}
description={translate('quickActions.managePackages.description')}
onClick={() => navigate(ADMIN_BILLING_PATH)}
/>
</CardContent>
@@ -266,21 +274,21 @@ export default function DashboardPage() {
<Card className="border-0 bg-brand-card shadow-brand-primary">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-xl text-slate-900">{t('dashboard.upcoming.title')}</CardTitle>
<CardTitle className="text-xl text-slate-900">{translate('upcoming.title')}</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('dashboard.upcoming.description')}
{translate('upcoming.description')}
</CardDescription>
</div>
<Button variant="outline" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
<Settings className="h-4 w-4" />
{t('dashboard.upcoming.settings')}
{translate('upcoming.settings')}
</Button>
</CardHeader>
<CardContent className="space-y-3">
{upcomingEvents.length === 0 ? (
<EmptyState
message={t('dashboard.upcoming.empty.message')}
ctaLabel={t('dashboard.upcoming.empty.cta')}
message={translate('upcoming.empty.message')}
ctaLabel={translate('upcoming.empty.cta')}
onCta={() => navigate(adminPath('/events/new'))}
/>
) : (
@@ -291,10 +299,10 @@ export default function DashboardPage() {
onView={() => navigate(ADMIN_EVENT_VIEW_PATH(event.slug))}
locale={dateLocale}
labels={{
live: t('dashboard.upcoming.status.live'),
planning: t('dashboard.upcoming.status.planning'),
open: t('common:actions.open'),
noDate: t('dashboard.upcoming.status.noDate'),
live: translate('upcoming.status.live'),
planning: translate('upcoming.status.planning'),
open: tc('actions.open'),
noDate: translate('upcoming.status.noDate'),
}}
/>
))
@@ -309,7 +317,6 @@ export default function DashboardPage() {
function buildSummaryFallback(
events: TenantEvent[],
balance: number,
activePackage: TenantPackageSummary | null
): DashboardSummary {
const activeEvents = events.filter((event) => Boolean(event.is_active || event.status === 'published'));
@@ -319,7 +326,6 @@ function buildSummaryFallback(
active_events: activeEvents.length,
new_photos: totalPhotos,
task_progress: 0,
credit_balance: balance,
upcoming_events: activeEvents.length,
active_package: activePackage
? {
@@ -471,10 +477,3 @@ function DashboardSkeleton() {
);
}
function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') {
return name;
}
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? 'Unbenanntes Event';
}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Camera, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
@@ -10,6 +10,7 @@ import { AdminLayout } from '../components/AdminLayout';
import {
createInviteLink,
EventJoinToken,
EventJoinTokenLayout,
EventStats as TenantEventStats,
getEvent,
getEventJoinTokens,
@@ -151,7 +152,7 @@ export default function EventDetailPage() {
}));
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'Token konnte nicht deaktiviert werden.' }));
setState((prev) => ({ ...prev, error: 'Einladung konnte nicht deaktiviert werden.' }));
}
} finally {
setRevokingId(null);
@@ -263,22 +264,22 @@ export default function EventDetailPage() {
<Card id="join-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Share2 className="h-5 w-5 text-amber-500" /> Einladungen & Drucklayouts
<Share2 className="h-5 w-5 text-amber-500" /> Einladungslinks &amp; QR-Layouts
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Verwalte Join-Tokens fuer dein Event. Jede Einladung enthaelt einen eindeutigen Token, QR-Code und
downloadbare PDF/SVG-Layouts.
Teile Gaesteeinladungen als Link oder drucke sie als fertige Layouts mit QR-Code - ganz ohne technisches
Vokabular.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-700">
<div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
<p>
Teile den generierten Link oder drucke die Layouts aus, um Gaeste sicher ins Event zu leiten. Tokens lassen
sich jederzeit rotieren oder deaktivieren.
Teile den generierten Link oder drucke eine Vorlage aus, um Gaeste sicher ins Event zu leiten. Einladungen
kannst du jederzeit erneuern oder deaktivieren.
</p>
{tokens.length > 0 && (
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600">
Aktive Tokens: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
Aktive Einladungen: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
{tokens.length}
</p>
)}
@@ -286,7 +287,7 @@ export default function EventDetailPage() {
<Button onClick={handleInvite} disabled={creatingToken} className="w-full">
{creatingToken ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
Join-Token erzeugen
Einladung erstellen
</Button>
{inviteLink && (
@@ -298,7 +299,7 @@ export default function EventDetailPage() {
<div className="space-y-3">
{tokens.length > 0 ? (
tokens.map((token) => (
<JoinTokenRow
<InvitationCard
key={token.id}
token={token}
onCopy={() => handleCopy(token)}
@@ -308,8 +309,8 @@ export default function EventDetailPage() {
))
) : (
<div className="rounded-lg border border-slate-200 bg-white/80 p-4 text-xs text-slate-500">
Noch keine Tokens vorhanden. Erzeuge jetzt den ersten Token, um QR-Codes und Drucklayouts
herunterzuladen.
Es gibt noch keine Einladungslinks. Erstelle jetzt den ersten Link, um QR-Layouts mit QR-Code
herunterzuladen und zu teilen.
</div>
)}
</div>
@@ -371,7 +372,7 @@ function StatChip({ label, value }: { label: string; value: string | number }) {
);
}
function JoinTokenRow({
function InvitationCard({
token,
onCopy,
onRevoke,
@@ -383,121 +384,150 @@ function JoinTokenRow({
revoking: boolean;
}) {
const status = getTokenStatus(token);
const availableLayouts = Array.isArray(token.layouts) ? token.layouts : [];
const layouts = Array.isArray(token.layouts) ? token.layouts : [];
const usageLabel = token.usage_limit ? `${token.usage_count} / ${token.usage_limit}` : `${token.usage_count}`;
const metadata = (token.metadata ?? {}) as Record<string, unknown>;
const isAutoGenerated = Boolean(metadata.auto_generated);
const statusClassname =
status === 'Aktiv'
? 'bg-emerald-100 text-emerald-700'
: status === 'Abgelaufen'
? 'bg-orange-100 text-orange-700'
: 'bg-slate-200 text-slate-700';
return (
<div className="flex flex-col gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3">
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-800">{token.label || `Einladung #${token.id}`}</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
status === 'Aktiv'
? 'bg-emerald-100 text-emerald-700'
: status === 'Abgelaufen'
? 'bg-orange-100 text-orange-700'
: 'bg-slate-200 text-slate-700'
}`}
>
{status}
</span>
</div>
<p className="break-all font-mono text-xs text-slate-600">{token.url}</p>
<div className="flex flex-wrap gap-3 text-xs text-slate-500">
<span>
Nutzung: {token.usage_count}
{token.usage_limit ? ` / ${token.usage_limit}` : ''}
</span>
{token.expires_at && <span>Gültig bis {formatDateTime(token.expires_at)}</span>}
{token.created_at && <span>Erstellt {formatDateTime(token.created_at)}</span>}
</div>
{availableLayouts.length > 0 && (
<div className="space-y-3 rounded-xl border border-amber-100 bg-white/80 p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-amber-600">Drucklayouts</div>
<div className="grid gap-3 sm:grid-cols-2">
{availableLayouts.map((layout) => {
const formatEntries = Array.isArray(layout.formats)
? layout.formats
.map((format) => {
const normalized = String(format ?? '').toLowerCase();
const href =
layout.download_urls?.[normalized] ??
layout.download_urls?.[String(format ?? '')] ??
null;
return {
format: normalized,
label: String(format ?? '').toUpperCase(),
href,
};
})
.filter((entry) => Boolean(entry.href))
: [];
if (formatEntries.length === 0) {
return null;
}
return (
<div key={layout.id} className="flex flex-col gap-2 rounded-lg border border-amber-200 bg-white p-3 shadow-sm">
<div>
<div className="text-sm font-semibold text-slate-800">{layout.name}</div>
{layout.subtitle && <div className="text-xs text-slate-500">{layout.subtitle}</div>}
</div>
<div className="flex flex-wrap gap-2">
{formatEntries.map((entry) => (
<Button
asChild
key={`${layout.id}-${entry.format}`}
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<a href={entry.href as string} target="_blank" rel="noreferrer">
<Download className="mr-1 h-3 w-3" />
{entry.label}
</a>
</Button>
))}
</div>
</div>
);
})}
</div>
<div className="flex flex-col gap-4 rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-md shadow-amber-100/40">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-900">{token.label?.trim() || `Einladung #${token.id}`}</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusClassname}`}>{status}</span>
{isAutoGenerated ? (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700">
Standard
</span>
) : null}
</div>
)}
{!availableLayouts.length && token.layouts_url && (
<div className="rounded-xl border border-amber-100 bg-white/70 p-3 text-xs text-slate-600">
Drucklayouts stehen für diesen Token bereit. Öffne den Layout-Link, um PDF- oder SVG-Versionen zu laden.
<div className="flex flex-wrap items-center gap-2">
<span className="break-all rounded-md border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700">
{token.url}
</span>
<Button
variant="outline"
size="sm"
onClick={onCopy}
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<Copy className="mr-1 h-3 w-3" />
Link kopieren
</Button>
</div>
)}
</div>
<div className="flex flex-wrap gap-2 md:items-center md:justify-start">
{token.layouts_url && (
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
<span>Nutzung: {usageLabel}</span>
{token.expires_at ? <span>Gültig bis {formatDateTime(token.expires_at)}</span> : null}
{token.created_at ? <span>Erstellt am {formatDateTime(token.created_at)}</span> : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
{token.layouts_url ? (
<Button
asChild
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<a href={token.layouts_url} target="_blank" rel="noreferrer">
<Download className="mr-1 h-3 w-3" />
Layout-Übersicht
</a>
</Button>
) : null}
<Button
asChild
variant="ghost"
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100"
onClick={onRevoke}
disabled={revoking || token.revoked_at !== null || !token.is_active}
className="text-slate-600 hover:bg-slate-100 disabled:opacity-50"
>
<a href={token.layouts_url} target="_blank" rel="noreferrer">
<Download className="h-3 w-3" />
<span className="ml-1">Layouts</span>
</a>
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deaktivieren'}
</Button>
)}
<Button variant="outline" size="sm" onClick={onCopy} className="border-amber-200 text-amber-700 hover:bg-amber-100">
Kopieren
</Button>
<Button
variant="ghost"
size="sm"
onClick={onRevoke}
disabled={revoking || token.revoked_at !== null || !token.is_active}
className="text-slate-600 hover:bg-slate-100 disabled:opacity-50"
>
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deaktivieren'}
</Button>
</div>
</div>
{layouts.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{layouts.map((layout) => (
<LayoutPreviewCard key={layout.id} layout={layout} />
))}
</div>
) : token.layouts_url ? (
<div className="rounded-xl border border-amber-100 bg-amber-50/60 p-3 text-xs text-amber-800">
Für diese Einladung stehen Layouts bereit. Öffne die Übersicht, um PDF- oder SVG-Versionen zu laden.
</div>
) : null}
</div>
);
}
function LayoutPreviewCard({ layout }: { layout: EventJoinTokenLayout }) {
const gradient = layout.preview?.background_gradient;
const stops = Array.isArray(gradient?.stops) ? gradient?.stops ?? [] : [];
const gradientStyle = stops.length
? {
backgroundImage: `linear-gradient(${gradient?.angle ?? 135}deg, ${stops.join(', ')})`,
}
: {
backgroundColor: layout.preview?.background ?? '#F8FAFC',
};
const textColor = layout.preview?.text ?? '#0F172A';
const formats = Array.isArray(layout.formats) ? layout.formats : [];
return (
<div className="overflow-hidden rounded-xl border border-amber-100 bg-white shadow-sm">
<div className="relative h-28">
<div className="absolute inset-0" style={gradientStyle} />
<div className="absolute inset-0 flex flex-col justify-between p-3 text-xs" style={{ color: textColor }}>
<span className="w-fit rounded-full bg-white/30 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide">
QR-Layout
</span>
<div>
<div className="text-sm font-semibold leading-tight">{layout.name}</div>
{layout.subtitle ? (
<div className="text-[11px] opacity-80">{layout.subtitle}</div>
) : null}
</div>
</div>
</div>
<div className="space-y-3 p-3">
{layout.description ? <p className="text-xs text-slate-600">{layout.description}</p> : null}
<div className="flex flex-wrap gap-2">
{formats.map((format) => {
const key = String(format ?? '').toLowerCase();
const href = layout.download_urls?.[key] ?? layout.download_urls?.[String(format ?? '')] ?? null;
if (!href) {
return null;
}
const label = String(format ?? '').toUpperCase() || 'PDF';
return (
<Button
asChild
key={`${layout.id}-${label}`}
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<a href={href} target="_blank" rel="noreferrer">
<Download className="mr-1 h-3 w-3" />
{label}
</a>
</Button>
);
})}
</div>
</div>
</div>
);
@@ -547,4 +577,3 @@ function renderName(name: TenantEvent['name']): string {
}
return 'Unbenanntes Event';
}

View File

@@ -4,27 +4,50 @@ import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { AdminLayout } from '../components/AdminLayout';
import { createEvent, getEvent, updateEvent, getPackages } from '../api';
import { createEvent, getEvent, getTenantPackagesOverview, updateEvent, getPackages, getEventTypes } from '../api';
import { isAuthError } from '../auth/tokens';
import { ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
interface EventFormState {
name: string;
slug: string;
date: string;
eventTypeId: number | null;
package_id: number;
isPublished: boolean;
}
type PackageHighlight = {
label: string;
value: string;
};
const FEATURE_LABELS: Record<string, string> = {
basic_uploads: 'Uploads inklusive',
unlimited_sharing: 'Unbegrenztes Teilen',
no_watermark: 'Kein Wasserzeichen',
custom_branding: 'Eigenes Branding',
custom_tasks: 'Eigene Aufgaben',
watermark_allowed: 'Wasserzeichen erlaubt',
branding_allowed: 'Branding-Optionen',
};
type EventPackageMeta = {
id: number;
name: string;
purchasedAt: string | null;
expiresAt: string | null;
};
export default function EventFormPage() {
const params = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams();
@@ -36,7 +59,8 @@ export default function EventFormPage() {
name: '',
slug: '',
date: '',
package_id: 1, // Default Free package
eventTypeId: null,
package_id: 0,
isPublished: false,
});
const [autoSlug, setAutoSlug] = React.useState(true);
@@ -44,12 +68,65 @@ export default function EventFormPage() {
const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
const [eventPackageMeta, setEventPackageMeta] = React.useState<EventPackageMeta | null>(null);
const { data: packages, isLoading: packagesLoading } = useQuery({
queryKey: ['packages', 'endcustomer'],
queryFn: () => getPackages('endcustomer'),
});
const { data: eventTypes, isLoading: eventTypesLoading } = useQuery({
queryKey: ['tenant', 'event-types'],
queryFn: getEventTypes,
});
const { data: packageOverview, isLoading: overviewLoading } = useQuery({
queryKey: ['tenant', 'packages', 'overview'],
queryFn: getTenantPackagesOverview,
});
const activePackage = packageOverview?.activePackage ?? null;
React.useEffect(() => {
if (isEdit || !activePackage?.package_id) {
return;
}
setForm((prev) => {
if (prev.package_id === activePackage.package_id) {
return prev;
}
return {
...prev,
package_id: activePackage.package_id,
};
});
setReadOnlyPackageName((prev) => prev ?? activePackage.package_name);
}, [isEdit, activePackage]);
React.useEffect(() => {
if (isEdit) {
return;
}
if (!eventTypes || eventTypes.length === 0) {
return;
}
setForm((prev) => {
if (prev.eventTypeId) {
return prev;
}
return {
...prev,
eventTypeId: eventTypes[0]!.id,
};
});
}, [eventTypes, isEdit]);
React.useEffect(() => {
let cancelled = false;
if (!isEdit || !slugParam) {
@@ -69,9 +146,20 @@ export default function EventFormPage() {
name,
slug: event.slug,
date: event.event_date ? event.event_date.slice(0, 10) : '',
eventTypeId: event.event_type_id ?? prev.eventTypeId,
isPublished: event.status === 'published',
package_id: event.package?.id ? Number(event.package.id) : prev.package_id,
}));
setOriginalSlug(event.slug);
setReadOnlyPackageName(event.package?.name ?? null);
setEventPackageMeta(event.package
? {
id: Number(event.package.id),
name: event.package.name ?? (typeof event.package === 'string' ? event.package : ''),
purchasedAt: event.package.purchased_at ?? null,
expiresAt: event.package.expires_at ?? null,
}
: null);
setAutoSlug(false);
} catch (err) {
if (!isAuthError(err)) {
@@ -116,17 +204,30 @@ export default function EventFormPage() {
return;
}
if (!form.eventTypeId) {
setError('Bitte waehle einen Event-Typ aus.');
return;
}
setSaving(true);
setError(null);
const status: 'draft' | 'published' | 'archived' = form.isPublished ? 'published' : 'draft';
const packageIdForSubmit = form.package_id || activePackage?.package_id || null;
const shouldIncludePackage = !isEdit
&& packageIdForSubmit
&& (!activePackage?.package_id || packageIdForSubmit !== activePackage.package_id);
const payload = {
name: trimmedName,
slug: trimmedSlug,
package_id: form.package_id,
date: form.date || undefined,
event_type_id: form.eventTypeId,
event_date: form.date || undefined,
status,
...(shouldIncludePackage && packageIdForSubmit
? { package_id: Number(packageIdForSubmit) }
: {}),
};
try {
@@ -148,6 +249,77 @@ export default function EventFormPage() {
}
}
const effectivePackageId = form.package_id || activePackage?.package_id || null;
const selectedPackage = React.useMemo(() => {
if (!packages || !packages.length) {
return null;
}
if (effectivePackageId) {
return packages.find((pkg) => pkg.id === effectivePackageId) ?? null;
}
return null;
}, [packages, effectivePackageId]);
React.useEffect(() => {
if (!readOnlyPackageName && selectedPackage?.name) {
setReadOnlyPackageName(selectedPackage.name);
}
}, [readOnlyPackageName, selectedPackage]);
const packageNameDisplay = readOnlyPackageName
?? selectedPackage?.name
?? ((overviewLoading || packagesLoading) ? 'Paket wird geladen…' : 'Kein aktives Paket gefunden');
const packagePriceLabel = selectedPackage?.price !== undefined && selectedPackage?.price !== null
? formatCurrency(selectedPackage.price)
: null;
const packageHighlights = React.useMemo<PackageHighlight[]>(() => {
const highlights: PackageHighlight[] = [];
if (selectedPackage?.max_photos) {
highlights.push({
label: 'Fotos',
value: `${selectedPackage.max_photos.toLocaleString('de-DE')} Bilder`,
});
}
if (selectedPackage?.max_guests) {
highlights.push({
label: 'Gäste',
value: `${selectedPackage.max_guests.toLocaleString('de-DE')} Personen`,
});
}
if (selectedPackage?.gallery_days) {
highlights.push({
label: 'Galerie',
value: `${selectedPackage.gallery_days} Tage online`,
});
}
return highlights;
}, [selectedPackage]);
const featureTags = React.useMemo(() => {
if (!selectedPackage?.features) {
return [];
}
return Object.entries(selectedPackage.features)
.filter(([, enabled]) => Boolean(enabled))
.map(([key]) => FEATURE_LABELS[key] ?? key.replace(/_/g, ' '));
}, [selectedPackage]);
const packageExpiresLabel = formatDate(eventPackageMeta?.expiresAt ?? activePackage?.expires_at ?? null);
const remainingEventsLabel = typeof activePackage?.remaining_events === 'number'
? `Noch ${activePackage.remaining_events} Event${activePackage.remaining_events === 1 ? '' : 's'} in deinem Paket`
: null;
const actions = (
<Button
variant="outline"
@@ -205,8 +377,8 @@ export default function EventFormPage() {
onChange={(e) => handleSlugChange(e.target.value)}
/>
<p className="text-xs text-slate-500">
Diese Kennung wird intern verwendet. Gaeste erhalten Zugriff ausschliesslich ueber Join-Tokens und deren
QR-/Layout-Downloads.
Diese Kennung wird intern verwendet. Gaeste betreten dein Event ausschliesslich ueber ihre
Einladungslinks und die dazugehoerigen QR-Layouts.
</p>
</div>
<div className="space-y-2">
@@ -219,56 +391,107 @@ export default function EventFormPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="package_id">Package</Label>
<Label htmlFor="event-type">Event-Typ</Label>
<Select
value={form.package_id.toString()}
onValueChange={(value) => setForm((prev) => ({ ...prev, package_id: parseInt(value, 10) }))}
disabled={packagesLoading || !packages?.length}
value={form.eventTypeId ? String(form.eventTypeId) : undefined}
onValueChange={(value) => setForm((prev) => ({ ...prev, eventTypeId: Number(value) }))}
disabled={eventTypesLoading || !eventTypes?.length}
>
<SelectTrigger>
<SelectValue placeholder={packagesLoading ? 'Pakete werden geladen...' : 'Waehlen Sie ein Package'} />
<SelectTrigger id="event-type">
<SelectValue
placeholder={eventTypesLoading ? 'Event-Typ wird geladen…' : 'Event-Typ auswaehlen'}
/>
</SelectTrigger>
{packages?.length ? (
<SelectContent>
{packages.map((pkg) => (
<SelectItem key={pkg.id} value={pkg.id.toString()}>
{pkg.name} - {pkg.price} EUR ({pkg.max_photos} Fotos)
</SelectItem>
))}
</SelectContent>
) : null}
<SelectContent>
{eventTypes?.map((eventType) => (
<SelectItem key={eventType.id} value={String(eventType.id)}>
{eventType.icon ? `${eventType.icon} ${eventType.name}` : eventType.name}
</SelectItem>
))}
</SelectContent>
</Select>
{packagesLoading ? (
<p className="text-xs text-slate-500">Pakete werden geladen...</p>
{!eventTypesLoading && (!eventTypes || eventTypes.length === 0) ? (
<p className="text-xs text-amber-600">
Keine Event-Typen verfuegbar. Bitte lege einen Typ im Adminbereich an.
</p>
) : null}
{!packagesLoading && (!packages || packages.length === 0) ? (
<p className="text-xs text-red-500">Keine Pakete verfuegbar. Bitte pruefen Sie Ihre Einstellungen.</p>
) : null}
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">Package-Details</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Package auswaehlen</DialogTitle>
<DialogDescription>Waehlen Sie das Package fuer Ihr Event. Hoehere Packages bieten mehr Limits und Features.</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{packages?.map((pkg) => (
<div key={pkg.id} className="p-4 border rounded">
<h3 className="font-semibold">{pkg.name}</h3>
<p>{pkg.price} EUR</p>
<ul className="text-sm">
<li>Max Fotos: {pkg.max_photos}</li>
<li>Max Gaeste: {pkg.max_guests}</li>
<li>Galerie: {pkg.gallery_days} Tage</li>
<li>Features: {Object.keys(pkg.features).filter(k => pkg.features[k]).join(', ')}</li>
</ul>
</div>
))}
</div>
<div className="sm:col-span-2">
<Card className="border-0 bg-gradient-to-br from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-xl shadow-pink-500/25">
<CardHeader className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<Badge className="bg-white/25 text-white backdrop-blur">
{isEdit ? 'Gebuchtes Paket' : 'Aktives Paket'}
</Badge>
{packagePriceLabel ? (
<span className="text-sm font-semibold uppercase tracking-widest text-white/90">
{packagePriceLabel}
</span>
) : null}
</div>
</DialogContent>
</Dialog>
<div className="space-y-2">
<CardTitle className="text-2xl font-semibold tracking-tight text-white">
{packageNameDisplay}
</CardTitle>
<CardDescription className="text-sm text-pink-50">
Du nutzt dieses Paket für dein Event. Upgrades und Add-ons folgen bald bis dahin kannst du alle enthaltenen Leistungen voll ausschöpfen.
</CardDescription>
</div>
{packageExpiresLabel ? (
<p className="text-xs font-medium uppercase tracking-wide text-white/80">
Galerie aktiv bis {packageExpiresLabel}
</p>
) : null}
{remainingEventsLabel ? (
<p className="text-xs text-white/80">{remainingEventsLabel}</p>
) : null}
</CardHeader>
<CardContent className="space-y-5 pb-6">
{packageHighlights.length ? (
<div className="grid gap-3 sm:grid-cols-3">
{packageHighlights.map((highlight) => (
<div key={`${highlight.label}-${highlight.value}`} className="rounded-xl bg-white/15 px-4 py-3 text-sm">
<p className="text-white/70">{highlight.label}</p>
<p className="font-semibold text-white">{highlight.value}</p>
</div>
))}
</div>
) : null}
{featureTags.length ? (
<div className="flex flex-wrap gap-2">
{featureTags.map((feature) => (
<Badge key={feature} variant="secondary" className="bg-white/15 text-white backdrop-blur">
{feature}
</Badge>
))}
</div>
) : (
<p className="text-sm text-white/75">
{(packagesLoading || overviewLoading)
? 'Paketdetails werden geladen...'
: 'Für dieses Paket sind aktuell keine besonderen Features hinterlegt.'}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
className="bg-white/20 text-white hover:bg-white/30"
onClick={() => navigate(ADMIN_BILLING_PATH)}
>
Abrechnung öffnen
</Button>
<Button
type="button"
variant="ghost"
className="text-white/70 hover:bg-white/10"
disabled
>
Upgrade-Optionen demnächst
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
@@ -291,7 +514,7 @@ export default function EventFormPage() {
<div className="flex flex-wrap gap-3">
<Button
type="submit"
disabled={saving || !form.name.trim() || !form.slug.trim()}
disabled={saving || !form.name.trim() || !form.slug.trim() || !form.eventTypeId}
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
>
{saving ? (
@@ -326,6 +549,43 @@ function FormSkeleton() {
);
}
function formatCurrency(value: number | null | undefined): string | null {
if (value === null || value === undefined) {
return null;
}
try {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: value % 1 === 0 ? 0 : 2,
}).format(value);
} catch {
return `${value}`;
}
}
function formatDate(value: string | null | undefined): string | null {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
try {
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(date);
} catch {
return date.toISOString().slice(0, 10);
}
}
function slugify(value: string): string {
return value
.normalize('NFKD')

View File

@@ -350,6 +350,11 @@ function TaskDialog({
saving: boolean;
isEditing: boolean;
}) {
const handleFormSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSubmit(event);
}, [onSubmit]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
@@ -357,7 +362,7 @@ function TaskDialog({
<DialogTitle>{isEditing ? 'Task bearbeiten' : 'Neuen Task anlegen'}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={onSubmit}>
<form className="space-y-4" onSubmit={handleFormSubmit}>
<div className="space-y-2">
<Label htmlFor="task-title">Titel</Label>
<Input

View File

@@ -42,7 +42,6 @@ vi.mock('../../onboarding', () => ({
vi.mock('../../api', () => ({
getDashboardSummary: vi.fn().mockResolvedValue(null),
getEvents: vi.fn().mockResolvedValue([]),
getCreditBalance: vi.fn().mockResolvedValue({ balance: 0 }),
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
}));