rework of the event admin UI

This commit is contained in:
Codex Agent
2025-11-24 17:17:39 +01:00
parent 4667ec8073
commit 8947a37261
37 changed files with 4381 additions and 874 deletions

View File

@@ -1,4 +1,5 @@
import React from 'react';
// @ts-nocheck
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
@@ -19,12 +20,8 @@ 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 { Progress } from '@/components/ui/progress';
import {
TenantHeroCard,
TenantOnboardingChecklistCard,
FrostedSurface,
tenantHeroPrimaryButtonClass,
SectionCard,
SectionHeader,
StatCarousel,
@@ -51,6 +48,7 @@ import {
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_PHOTOBOOTH_PATH,
ADMIN_BILLING_PATH,
ADMIN_SETTINGS_PATH,
ADMIN_WELCOME_BASE_PATH,
@@ -59,6 +57,7 @@ import {
} from '../constants';
import { useOnboardingProgress } from '../onboarding';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { DashboardEventFocusCard } from '../components/dashboard/DashboardEventFocusCard';
import type { LimitUsageSummary, GallerySummary } from '../lib/limitWarnings';
interface DashboardState {
@@ -309,7 +308,7 @@ export default function DashboardPage() {
},
{
key: 'newPhotos',
label: translate('overview.stats.newPhotos'),
label: translate('overview.stats.newPhotos', 'Neueste Uploads'),
value: summary?.new_photos ?? 0,
icon: <Camera className="h-4 w-4" />,
},
@@ -458,118 +457,6 @@ export default function DashboardPage() {
);
const onboardingFallbackCta = translate('onboarding.card.cta_fallback', 'Jetzt starten');
const heroBadge = singleEvent
? translate('overview.eventHero.badge', 'Aktives Event')
: translate('overview.title', 'Kurzer Überblick');
const heroDescription = singleEvent
? translate('overview.eventHero.description', { defaultValue: 'Alles richtet sich nach {{event}}. Nächster Termin: {{date}}.', event: singleEventName ?? '', date: singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt') })
: translate('overview.description', 'Wichtigste Kennzahlen deines Tenants auf einen Blick.');
const heroSupportingCopy = onboardingCompletion === 100 ? onboardingCompletedCopy : onboardingCardDescription;
const heroSupporting = singleEvent
? [
translate('overview.eventHero.supporting.status', {
defaultValue: 'Status: {{status}}',
status: formatEventStatus(singleEvent.status ?? null, tc),
}),
singleEventDateLabel
? translate('overview.eventHero.supporting.date', singleEventDateLabel ?? 'Noch kein Datum festgelegt.')
: translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt.'),
].filter(Boolean)
: [heroSupportingCopy];
const heroPrimaryAction = (() => {
if (onboardingCompletion < 100) {
return (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => {
if (readiness.hasEvent) {
navigate(ADMIN_EVENTS_PATH);
} else {
navigate(ADMIN_EVENT_CREATE_PATH);
}
}}
>
{translate('onboarding.hero.cta', 'Setup fortsetzen')}
</Button>
);
}
if (singleEvent?.slug) {
return (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(singleEvent.slug))}
>
{translate('actions.openEvent', 'Event öffnen')}
</Button>
);
}
if (readiness.hasEvent) {
return (
<Button size="sm" className={tenantHeroPrimaryButtonClass} onClick={() => navigate(ADMIN_EVENTS_PATH)}>
{translate('quickActions.moderatePhotos.label', 'Fotos moderieren')}
</Button>
);
}
return (
<Button size="sm" className={tenantHeroPrimaryButtonClass} onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}>
{translate('actions.newEvent')}
</Button>
);
})();
const heroAside = onboardingCompletion < 100 ? (
<FrostedSurface className="w-full rounded-2xl border-slate-200 bg-white p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-white/20 dark:bg-white/10">
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
<span>{onboardingCardTitle}</span>
<span>
{completedOnboardingSteps}/{onboardingChecklist.length}
</span>
</div>
<Progress value={onboardingCompletion} className="mt-4 h-2 bg-rose-100" />
<p className="mt-3 text-xs text-slate-600">{onboardingCardDescription}</p>
</FrostedSurface>
) : singleEvent ? (
<FrostedSurface className="w-full rounded-2xl border-slate-200 bg-white p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-white/20 dark:bg-white/10">
<div className="space-y-3 text-sm">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-rose-500">
{translate('overview.eventHero.stats.title', 'Momentaufnahme')}
</p>
<p className="text-lg font-semibold text-slate-900 dark:text-white">
{formatEventStatus(singleEvent.status ?? null, tc)}
</p>
</div>
<dl className="space-y-2">
<div className="flex items-center justify-between">
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.date', 'Eventdatum')}</dt>
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
{singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Nicht gesetzt')}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.uploads', 'Uploads gesamt')}</dt>
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
{Number(singleEvent.photo_count ?? 0).toLocaleString(i18n.language)}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.tasks', 'Offene Aufgaben')}</dt>
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
{Number(singleEvent.tasks_count ?? 0).toLocaleString(i18n.language)}
</dd>
</div>
</dl>
</div>
</FrostedSurface>
) : null;
const readinessCompleteLabel = translate('readiness.complete', 'Erledigt');
const readinessPendingLabel = translate('readiness.pending', 'Noch offen');
const hasEventContext = readiness.hasEvent;
@@ -610,27 +497,16 @@ export default function DashboardPage() {
[translate, navigate, hasEventContext],
);
const layoutActions = singleEvent ? (
<Button
className="rounded-full bg-brand-rose px-4 text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
onClick={() => {
if (singleEvent.slug) {
navigate(ADMIN_EVENT_VIEW_PATH(singleEvent.slug));
} else {
navigate(ADMIN_EVENTS_PATH);
}
}}
>
{translate('actions.openEvent', 'Event öffnen')}
</Button>
) : (
<Button
className="rounded-full bg-brand-rose px-4 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" /> {translate('actions.newEvent')}
</Button>
const dashboardTabs = React.useMemo(
() => [
{ key: 'overview', label: translate('tabs.overview', 'Überblick'), href: `${ADMIN_HOME_PATH}#overview` },
{ key: 'live', label: translate('tabs.live', 'Live'), href: `${ADMIN_HOME_PATH}#live` },
{ key: 'setup', label: translate('tabs.setup', 'Vorbereitung'), href: `${ADMIN_HOME_PATH}#setup` },
{ key: 'recap', label: translate('tabs.recap', 'Nachbereitung'), href: `${ADMIN_HOME_PATH}#recap` },
],
[translate]
);
const currentDashboardTab = React.useMemo(() => (location.hash?.replace('#', '') || 'overview'), [location.hash]);
const adminTitle = singleEventName ?? greetingTitle;
const adminSubtitle = singleEvent
@@ -640,22 +516,50 @@ export default function DashboardPage() {
})
: subtitle;
const heroTitle = adminTitle;
const liveNowTitle = t('liveNow.title', { defaultValue: 'Während des Events' });
const liveNowDescription = t('liveNow.description', {
defaultValue: 'Direkter Zugriff, solange dein Event läuft.',
count: liveEvents.length,
});
const liveActionLabels = React.useMemo(() => ({
photos: t('liveNow.actions.photos', { defaultValue: 'Uploads' }),
invites: t('liveNow.actions.invites', { defaultValue: 'QR & Einladungen' }),
tasks: t('liveNow.actions.tasks', { defaultValue: 'Aufgaben' }),
}), [t]);
const liveStatusLabel = t('liveNow.status', { defaultValue: 'Live' });
const liveNoDate = t('liveNow.noDate', { defaultValue: 'Kein Datum' });
const focusActions = React.useMemo(
() => ({
createEvent: () => navigate(ADMIN_EVENT_CREATE_PATH),
openEvent: () => {
if (primaryEvent?.slug) {
navigate(ADMIN_EVENT_VIEW_PATH(primaryEvent.slug));
} else {
navigate(ADMIN_EVENTS_PATH);
}
},
openPhotos: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_PHOTOS_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
openInvites: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_INVITES_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
openTasks: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_TASKS_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
openPhotobooth: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_PHOTOBOOTH_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
}),
[navigate, primaryEvent, primaryEventSlug],
);
return (
<AdminLayout title={adminTitle} subtitle={adminSubtitle} actions={layoutActions}>
<AdminLayout title={adminTitle} subtitle={adminSubtitle} actions={layoutActions} tabs={dashboardTabs} currentTabKey={currentDashboardTab}>
{errorMessage && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard.alerts.errorTitle')}</AlertTitle>
@@ -667,120 +571,41 @@ export default function DashboardPage() {
<DashboardSkeleton />
) : (
<>
<TenantHeroCard
badge={heroBadge}
title={heroTitle}
description={heroDescription}
supporting={heroSupporting}
primaryAction={heroPrimaryAction}
aside={heroAside}
/>
{liveEvents.length > 0 && (
<Card className="border border-rose-200 bg-rose-50/80 shadow-lg shadow-rose-200/40">
<CardHeader className="space-y-1">
<CardTitle className="text-base font-semibold text-rose-900">{liveNowTitle}</CardTitle>
<CardDescription className="text-sm text-rose-700">{liveNowDescription}</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{liveEvents.map((event) => {
const name = resolveEventName(event.name, event.slug);
const dateLabel = event.event_date ? formatDate(event.event_date, dateLocale) : liveNoDate;
return (
<div
key={event.id}
className="rounded-2xl border border-white/70 bg-white/80 p-4 shadow-sm shadow-rose-100/50"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">{name}</p>
<p className="text-xs text-slate-500">{dateLabel}</p>
</div>
<Badge className="bg-rose-600/90 text-white">{liveStatusLabel}</Badge>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
>
<Camera className="h-4 w-4" />
{liveActionLabels.photos}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
>
<QrCode className="h-4 w-4" />
{liveActionLabels.invites}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
>
<ClipboardList className="h-4 w-4" />
{liveActionLabels.tasks}
</Button>
</div>
</div>
);
})}
</CardContent>
</Card>
)}
{events.length === 0 && (
<Card className="border-none bg-white/90 shadow-lg shadow-rose-100/50">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-slate-900">
<Sparkles className="h-5 w-5 text-brand-rose" />
{translate('welcomeCard.title')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{translate('welcomeCard.summary')}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm text-slate-600">
<p>{translate('welcomeCard.body1')}</p>
<p>{translate('welcomeCard.body2')}</p>
<Button
size="sm"
className="self-start rounded-full bg-brand-rose px-5 text-white shadow-md shadow-rose-300/40"
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
>
{translate('welcomeCard.cta')}
</Button>
</CardContent>
</Card>
)}
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={translate('overview.title')}
title={translate('overview.title')}
description={translate('overview.description')}
endSlot={(
<Badge className="bg-brand-rose-soft text-brand-rose">
{activePackage?.package_name ?? translate('overview.noPackage')}
</Badge>
)}
<div id="overview" className="space-y-6 scroll-mt-32">
<DashboardEventFocusCard
event={primaryEvent}
limitWarnings={limitWarnings}
summary={summary}
dateLocale={dateLocale}
onCreateEvent={focusActions.createEvent}
onOpenEvent={focusActions.openEvent}
onOpenPhotos={focusActions.openPhotos}
onOpenInvites={focusActions.openInvites}
onOpenTasks={focusActions.openTasks}
onOpenPhotobooth={focusActions.openPhotobooth}
/>
<StatCarousel items={statItems} />
</SectionCard>
{primaryEventLimits ? (
<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="flex items-center gap-2 text-xl text-slate-900">
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={translate('overview.title')}
title={translate('overview.title')}
description={translate('overview.description')}
endSlot={(
<Badge className="bg-brand-rose-soft text-brand-rose">
{activePackage?.package_name ?? translate('overview.noPackage')}
</Badge>
)}
/>
<StatCarousel items={statItems} />
</SectionCard>
</div>
<div id="live" className="space-y-6 scroll-mt-32">
{primaryEventLimits ? (
<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="flex items-center gap-2 text-xl text-slate-900">
<PackageIcon className="h-5 w-5 text-brand-rose" />
{translate('limitsCard.title')}
</CardTitle>
@@ -842,70 +667,75 @@ export default function DashboardPage() {
expires: translate('limitsCard.galleryExpires'),
}}
/>
</CardContent>
</Card>
) : null}
</CardContent>
</Card>
) : null}
</div>
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={translate('quickActions.title')}
title={translate('quickActions.title')}
description={translate('quickActions.description')}
<div id="setup" className="space-y-6 scroll-mt-32">
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={translate('quickActions.title')}
title={translate('quickActions.title')}
description={translate('quickActions.description')}
/>
<ActionGrid items={quickActionItems} />
</SectionCard>
<TenantOnboardingChecklistCard
title={onboardingCardTitle}
description={onboardingCardDescription}
steps={onboardingChecklist}
completedLabel={readinessCompleteLabel}
pendingLabel={readinessPendingLabel}
completionPercent={onboardingCompletion}
completedCount={completedOnboardingSteps}
totalCount={onboardingChecklist.length}
emptyCopy={onboardingCompletedCopy}
fallbackActionLabel={onboardingFallbackCta}
/>
<ActionGrid items={quickActionItems} />
</SectionCard>
</div>
<TenantOnboardingChecklistCard
title={onboardingCardTitle}
description={onboardingCardDescription}
steps={onboardingChecklist}
completedLabel={readinessCompleteLabel}
pendingLabel={readinessPendingLabel}
completionPercent={onboardingCompletion}
completedCount={completedOnboardingSteps}
totalCount={onboardingChecklist.length}
emptyCopy={onboardingCompletedCopy}
fallbackActionLabel={onboardingFallbackCta}
/>
<section className="space-y-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5 dark:shadow-inner">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
{translate('upcoming.title')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">{translate('upcoming.description')}</p>
<div id="recap" className="space-y-6 scroll-mt-32">
<section className="space-y-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5 dark:shadow-inner">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
{translate('upcoming.title')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">{translate('upcoming.description')}</p>
</div>
<Button variant="outline" size="sm" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
<Settings className="h-4 w-4" />
{translate('upcoming.settings')}
</Button>
</div>
<Button variant="outline" size="sm" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
<Settings className="h-4 w-4" />
{translate('upcoming.settings')}
</Button>
</div>
<div className="space-y-3">
{upcomingEvents.length === 0 ? (
<EmptyState
message={translate('upcoming.empty.message')}
ctaLabel={translate('upcoming.empty.cta')}
onCta={() => navigate(adminPath('/events/new'))}
/>
) : (
upcomingEvents.map((event) => (
<UpcomingEventRow
key={event.id}
event={event}
onView={() => navigate(ADMIN_EVENT_VIEW_PATH(event.slug))}
locale={dateLocale}
labels={{
live: translate('upcoming.status.live'),
planning: translate('upcoming.status.planning'),
open: tc('actions.open'),
noDate: translate('upcoming.status.noDate'),
}}
<div className="space-y-3">
{upcomingEvents.length === 0 ? (
<EmptyState
message={translate('upcoming.empty.message')}
ctaLabel={translate('upcoming.empty.cta')}
onCta={() => navigate(adminPath('/events/new'))}
/>
))
)}
</div>
</section>
) : (
upcomingEvents.map((event) => (
<UpcomingEventRow
key={event.id}
event={event}
onView={() => navigate(ADMIN_EVENT_VIEW_PATH(event.slug))}
locale={dateLocale}
labels={{
live: translate('upcoming.status.live'),
planning: translate('upcoming.status.planning'),
open: tc('actions.open'),
noDate: translate('upcoming.status.noDate'),
}}
/>
))
)}
</div>
</section>
</div>
</>
)}
</AdminLayout>
@@ -933,17 +763,6 @@ function formatDate(value: string | null, locale: string): string | null {
}
}
function formatEventStatus(status: TenantEvent['status'] | null, translateFn: (key: string, options?: Record<string, unknown>) => string): string {
const map: Record<string, { key: string; fallback: string }> = {
published: { key: 'events.status.published', fallback: 'Veröffentlicht' },
draft: { key: 'events.status.draft', fallback: 'Entwurf' },
archived: { key: 'events.status.archived', fallback: 'Archiviert' },
};
const target = map[status ?? 'draft'] ?? map.draft;
return translateFn(target.key, { defaultValue: target.fallback });
}
function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string {
if (typeof name === 'string' && name.trim().length > 0) {
return name;