feat: unify tenant admin ui and add photo moderation

This commit is contained in:
Codex Agent
2025-11-07 13:50:55 +01:00
parent 9cc9950b0c
commit 253239455b
14 changed files with 995 additions and 583 deletions

View File

@@ -27,6 +27,10 @@ import {
FrostedSurface,
tenantHeroPrimaryButtonClass,
tenantHeroSecondaryButtonClass,
SectionCard,
SectionHeader,
StatCarousel,
ActionGrid,
} from '../components/tenant';
import type { ChecklistStep } from '../components/tenant';
@@ -267,6 +271,44 @@ export default function DashboardPage() {
}, [summary, events]);
const primaryEventSlug = readiness.primaryEventSlug;
const statItems = React.useMemo(
() => ([
{
key: 'activeEvents',
label: translate('overview.stats.activeEvents'),
value: summary?.active_events ?? publishedEvents.length,
hint: translate('overview.stats.publishedHint', { count: publishedEvents.length }),
icon: <CalendarDays className="h-4 w-4" />,
},
{
key: 'newPhotos',
label: translate('overview.stats.newPhotos'),
value: summary?.new_photos ?? 0,
icon: <Camera className="h-4 w-4" />,
},
{
key: 'taskProgress',
label: translate('overview.stats.taskProgress'),
value: `${Math.round(summary?.task_progress ?? 0)}%`,
icon: <Users className="h-4 w-4" />,
},
activePackage
? {
key: 'package',
label: translate('overview.stats.activePackage', 'Aktives Paket'),
value: activePackage.package_name,
icon: <PackageIcon className="h-4 w-4" />,
}
: null,
].filter(Boolean) as {
key: string;
label: string;
value: string | number;
hint?: string;
icon?: React.ReactNode;
}[]),
[summary, publishedEvents.length, translate, activePackage],
);
const onboardingChecklist = React.useMemo<ChecklistStep[]>(() => {
const steps: ChecklistStep[] = [
@@ -428,7 +470,7 @@ export default function DashboardPage() {
</Button>
);
const heroAside = (
<FrostedSurface className="w-full rounded-2xl border-white/30 bg-white/90 p-5 text-slate-900 shadow-lg shadow-rose-300/20 backdrop-blur">
<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>
@@ -441,32 +483,58 @@ export default function DashboardPage() {
);
const readinessCompleteLabel = translate('readiness.complete', 'Erledigt');
const readinessPendingLabel = translate('readiness.pending', 'Noch offen');
const quickActionItems = React.useMemo(
() => [
{
key: 'create',
label: translate('quickActions.createEvent.label'),
description: translate('quickActions.createEvent.description'),
icon: <Plus className="h-5 w-5" />,
onClick: () => navigate(ADMIN_EVENT_CREATE_PATH),
},
{
key: 'photos',
label: translate('quickActions.moderatePhotos.label'),
description: translate('quickActions.moderatePhotos.description'),
icon: <Camera className="h-5 w-5" />,
onClick: () => navigate(ADMIN_EVENTS_PATH),
},
{
key: 'tasks',
label: translate('quickActions.organiseTasks.label'),
description: translate('quickActions.organiseTasks.description'),
icon: <ClipboardList className="h-5 w-5" />,
onClick: () => navigate(buildEngagementTabPath('tasks')),
},
{
key: 'packages',
label: translate('quickActions.managePackages.label'),
description: translate('quickActions.managePackages.description'),
icon: <Sparkles className="h-5 w-5" />,
onClick: () => navigate(ADMIN_BILLING_PATH),
},
{
key: 'marketing',
label: marketingDashboardLabel,
description: marketingDashboardDescription,
icon: <ArrowUpRight className="h-5 w-5" />,
onClick: () => window.location.assign('/dashboard'),
},
],
[translate, navigate, marketingDashboardLabel, marketingDashboardDescription],
);
const actions = (
<>
<Button
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" /> {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" /> {translate('actions.allEvents')}
</Button>
{events.length === 0 && (
<Button
variant="outline"
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" /> {translate('actions.guidedSetup')}
</Button>
)}
</>
const layoutActions = (
<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>
);
return (
<AdminLayout title={greetingTitle} subtitle={subtitle} actions={actions}>
<AdminLayout title={greetingTitle} subtitle={subtitle} actions={layoutActions}>
{errorMessage && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard.alerts.errorTitle')}</AlertTitle>
@@ -481,17 +549,17 @@ export default function DashboardPage() {
<TenantHeroCard
badge={heroBadge}
title={greetingTitle}
description={subtitle}
supporting={[heroDescription, heroSupportingCopy]}
description={heroDescription}
supporting={[heroSupportingCopy]}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
{events.length === 0 && (
<Card className="border-0 bg-brand-card shadow-brand-primary">
<CardHeader className="space-y-3">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<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>
@@ -499,14 +567,12 @@ export default function DashboardPage() {
{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>{translate('welcomeCard.body1')}</p>
<p>{translate('welcomeCard.body2')}</p>
</div>
<CardContent className="flex flex-col gap-3 text-sm text-slate-600">
<p>{translate('welcomeCard.body1')}</p>
<p>{translate('welcomeCard.body2')}</p>
<Button
size="lg"
className="rounded-full bg-rose-500 text-white shadow-lg shadow-rose-400/40 hover:bg-rose-500/90"
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')}
@@ -515,47 +581,19 @@ export default function DashboardPage() {
</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="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-brand-rose" />
{translate('overview.title')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{translate('overview.description')}
</CardDescription>
</div>
<Badge className="bg-brand-rose-soft text-brand-rose">
{activePackage?.package_name ?? translate('overview.noPackage')}
</Badge>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<StatCard
label={translate('overview.stats.activeEvents')}
value={summary?.active_events ?? publishedEvents.length}
hint={translate('overview.stats.publishedHint', { count: publishedEvents.length })}
icon={<CalendarDays className="h-5 w-5 text-brand-rose" />}
/>
<StatCard
label={translate('overview.stats.newPhotos')}
value={summary?.new_photos ?? 0}
icon={<Camera className="h-5 w-5 text-fuchsia-500" />}
/>
<StatCard
label={translate('overview.stats.taskProgress')}
value={`${Math.round(summary?.task_progress ?? 0)}%`}
icon={<Users className="h-5 w-5 text-amber-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>
<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>
{primaryEventLimits ? (
<Card className="border-0 bg-brand-card shadow-brand-primary">
@@ -627,48 +665,14 @@ export default function DashboardPage() {
</Card>
) : null}
<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">{translate('quickActions.title')}</CardTitle>
<CardDescription className="text-sm text-slate-600">
{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={translate('quickActions.createEvent.label')}
description={translate('quickActions.createEvent.description')}
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
/>
<QuickAction
icon={<Camera className="h-5 w-5" />}
label={translate('quickActions.moderatePhotos.label')}
description={translate('quickActions.moderatePhotos.description')}
onClick={() => navigate(ADMIN_EVENTS_PATH)}
/>
<QuickAction
icon={<Users className="h-5 w-5" />}
label={translate('quickActions.organiseTasks.label')}
description={translate('quickActions.organiseTasks.description')}
onClick={() => navigate(buildEngagementTabPath('tasks'))}
/>
<QuickAction
icon={<Sparkles className="h-5 w-5" />}
label={translate('quickActions.managePackages.label')}
description={translate('quickActions.managePackages.description')}
onClick={() => navigate(ADMIN_BILLING_PATH)}
/>
<QuickAction
icon={<ArrowUpRight className="h-5 w-5" />}
label={marketingDashboardLabel}
description={marketingDashboardDescription}
onClick={() => window.location.assign('/dashboard')}
/>
</CardContent>
</Card>
<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}
@@ -683,20 +687,20 @@ export default function DashboardPage() {
fallbackActionLabel={onboardingFallbackCta}
/>
<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">
<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>
<CardTitle className="text-xl text-slate-900">{translate('upcoming.title')}</CardTitle>
<CardDescription className="text-sm text-slate-600">
{translate('upcoming.description')}
</CardDescription>
<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" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
<Button variant="outline" size="sm" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
<Settings className="h-4 w-4" />
{translate('upcoming.settings')}
</Button>
</CardHeader>
<CardContent className="space-y-3">
</div>
<div className="space-y-3">
{upcomingEvents.length === 0 ? (
<EmptyState
message={translate('upcoming.empty.message')}
@@ -719,8 +723,8 @@ export default function DashboardPage() {
/>
))
)}
</CardContent>
</Card>
</div>
</section>
</>
)}
</AdminLayout>
@@ -933,30 +937,6 @@ function StatCard({
);
}
function QuickAction({
icon,
label,
description,
onClick,
}: {
icon: React.ReactNode;
label: string;
description: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="group flex flex-col items-start gap-2 rounded-2xl border border-slate-100 bg-white/85 p-4 text-left shadow-sm transition duration-200 ease-out hover:-translate-y-0.5 hover:border-brand-rose-soft hover:shadow-md focus:outline-none focus:ring-2 focus:ring-brand-rose/40 dark:border-slate-800/70 dark:bg-slate-950/80"
>
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose shadow-sm shadow-rose-200/60 transition-transform duration-200 group-hover:scale-105">{icon}</span>
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{label}</span>
<span className="text-xs text-slate-600 dark:text-slate-400">{description}</span>
</button>
);
}
function UpcomingEventRow({
event,
onView,
@@ -979,7 +959,7 @@ function UpcomingEventRow({
: labels.noDate;
return (
<div className="flex flex-col gap-2 rounded-2xl border border-sky-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-white/30 dark:border-white/10 dark:bg-white/5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">{formattedDate}</span>
<div className="flex items-center gap-2">
@@ -990,7 +970,7 @@ function UpcomingEventRow({
size="sm"
variant="outline"
onClick={onView}
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
className="rounded-full border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
>
{labels.open}
</Button>