zu fabricjs gewechselt, noch nicht funktionsfähig

This commit is contained in:
Codex Agent
2025-10-31 20:19:09 +01:00
parent 06df61f706
commit eb0c31c90b
33 changed files with 7718 additions and 2062 deletions

View File

@@ -1,162 +1,195 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Camera, Heart, Loader2, RefreshCw, Sparkles, QrCode } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
ArrowLeft,
Camera,
CheckCircle2,
ChevronRight,
Circle,
Download,
Loader2,
MessageSquare,
Printer,
QrCode,
RefreshCw,
Smile,
Sparkles,
Users,
} from 'lucide-react';
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 { AdminLayout } from '../components/AdminLayout';
import {
EventStats as TenantEventStats,
EventToolkit,
EventToolkitTask,
TenantEvent,
TenantPhoto,
TenantEventStats,
getEvent,
getEventStats,
TenantEvent,
getEventToolkit,
toggleEvent,
submitTenantFeedback,
} from '../api';
import { isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
ADMIN_EVENT_EDIT_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_TOOLKIT_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
} from '../constants';
interface State {
type EventDetailPageProps = {
mode?: 'detail' | 'toolkit';
};
type ToolkitState = {
data: EventToolkit | null;
loading: boolean;
error: string | null;
};
type WorkspaceState = {
event: TenantEvent | null;
stats: TenantEventStats | null;
error: string | null;
loading: boolean;
busy: boolean;
}
error: string | null;
};
export default function EventDetailPage() {
const params = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams();
const slug = params.slug ?? searchParams.get('slug') ?? null;
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps): JSX.Element {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t } = useTranslation('management');
const [state, setState] = React.useState<State>({
const slug = slugParam ?? null;
const [state, setState] = React.useState<WorkspaceState>({
event: null,
stats: null,
error: null,
loading: true,
busy: false,
error: null,
});
const [toolkit, setToolkit] = React.useState<ToolkitState>({ data: null, loading: true, error: null });
const load = React.useCallback(async () => {
if (!slug) {
setState((prev) => ({ ...prev, loading: false, error: 'Kein Event-Slug angegeben.' }));
setState({ event: null, stats: null, loading: false, busy: false, error: t('events.errors.missingSlug', 'Kein Event ausgewählt.') });
setToolkit({ data: null, loading: false, error: null });
return;
}
setState((prev) => ({ ...prev, loading: true, error: null }));
setToolkit((prev) => ({ ...prev, loading: true, error: null }));
try {
const [eventData, statsData] = await Promise.all([
getEvent(slug),
getEventStats(slug),
]);
setState((prev) => ({
...prev,
event: eventData,
stats: statsData,
loading: false,
}));
} catch (err) {
if (isAuthError(err)) return;
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false }));
const [eventData, statsData] = await Promise.all([getEvent(slug), getEventStats(slug)]);
setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false }));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: t('events.errors.loadFailed', 'Event konnte nicht geladen werden.'), loading: false }));
}
}
}, [slug]);
try {
const toolkitData = await getEventToolkit(slug);
setToolkit({ data: toolkitData, loading: false, error: null });
} catch (error) {
if (!isAuthError(error)) {
setToolkit({ data: null, loading: false, error: t('toolkit.errors.loadFailed', 'Toolkit-Daten konnten nicht geladen werden.') });
}
}
}, [slug, t]);
React.useEffect(() => {
load();
void load();
}, [load]);
async function handleToggle() {
if (!slug) return;
async function handleToggle(): Promise<void> {
if (!slug) {
return;
}
setState((prev) => ({ ...prev, busy: true, error: null }));
try {
const updated = await toggleEvent(slug);
setState((prev) => ({
...prev,
event: updated,
stats: prev.stats ? { ...prev.stats, status: updated.status, is_active: Boolean(updated.is_active) } : prev.stats,
busy: false,
event: updated,
stats: prev.stats
? {
...prev.stats,
status: updated.status,
is_active: Boolean(updated.is_active),
}
: prev.stats,
}));
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'Status konnte nicht angepasst werden.', busy: false }));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, busy: false, error: t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.') }));
} else {
setState((prev) => ({ ...prev, busy: false }));
}
}
}
const { event, stats, error, loading, busy } = state;
const eventDisplayName = event ? renderName(event.name) : '';
const activeInvitesCount = event?.active_invites_count ?? 0;
const totalInvitesCount = event?.total_invites_count ?? activeInvitesCount;
const { event, stats, loading, busy, error } = state;
const toolkitData = toolkit.data;
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const subtitle = mode === 'toolkit'
? t('events.workspace.toolkitSubtitle', 'Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.')
: t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
const actions = (
<>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENTS_PATH)}
className="border-pink-200 text-pink-600 hover:bg-pink-50"
>
<ArrowLeft className="h-4 w-4" /> Zurück zur Liste
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600 hover:bg-pink-50">
<ArrowLeft className="h-4 w-4" /> {t('events.actions.backToList', 'Zurück zur Liste')}
</Button>
{event && (
<>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))}
className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50"
>
Bearbeiten
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
<Sparkles className="h-4 w-4" /> {t('events.actions.edit', 'Bearbeiten')}
</Button>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_MEMBERS_PATH(event.slug))}
className="border-sky-200 text-sky-700 hover:bg-sky-50"
>
Mitglieder
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_MEMBERS_PATH(event.slug))} className="border-sky-200 text-sky-700 hover:bg-sky-50">
<Users className="h-4 w-4" /> {t('events.actions.members', 'Team & Rollen')}
</Button>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
className="border-amber-200 text-amber-600 hover:bg-amber-50"
>
Tasks
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} className="border-amber-200 text-amber-600 hover:bg-amber-50">
<Sparkles className="h-4 w-4" /> {t('events.actions.tasks', 'Aufgaben verwalten')}
</Button>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
className="border-amber-200 text-amber-600 hover:bg-amber-50"
>
QR &amp; Einladungen
<Button variant="outline" onClick={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} className="border-amber-200 text-amber-600 hover:bg-amber-50">
<QrCode className="h-4 w-4" /> {t('events.actions.invites', 'Einladungen & Layouts')}
</Button>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(event.slug))}
className="border-emerald-200 text-emerald-600 hover:bg-emerald-50"
>
Event-Day Toolkit
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))} className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Camera className="h-4 w-4" /> {t('events.actions.photos', 'Fotos moderieren')}
</Button>
<Button variant="outline" onClick={() => void load()} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t('events.actions.refresh', 'Aktualisieren')}
</Button>
</>
)}
</>
</div>
);
if (!slug) {
return (
<AdminLayout title="Event nicht gefunden" subtitle="Bitte wähle ein Event aus der Übersicht." actions={actions}>
<AdminLayout title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} subtitle={t('events.errors.notFoundCopy', 'Bitte wähle ein Event aus der Übersicht.')} actions={actions}>
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardContent className="p-6 text-sm text-slate-600">
Ohne gültigen Slug können wir keine Daten laden. Kehre zur Event-Liste zurück und wähle dort ein Event aus.
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
</CardContent>
</Card>
</AdminLayout>
@@ -164,159 +197,591 @@ export default function EventDetailPage() {
}
return (
<AdminLayout
title={event ? renderName(event.name) : 'Event wird geladen'}
subtitle="Verwalte Status, Einladungen und Statistiken deines Events."
actions={actions}
>
<AdminLayout title={eventName} subtitle={subtitle} actions={actions}>
{error && (
<Alert variant="destructive">
<AlertTitle>Aktion fehlgeschlagen</AlertTitle>
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{toolkit.error && (
<Alert variant="default">
<AlertTitle>{toolkit.error}</AlertTitle>
</Alert>
)}
{loading ? (
<DetailSkeleton />
<WorkspaceSkeleton />
) : event ? (
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
<Card className="border-0 bg-white/90 shadow-xl shadow-pink-100/60">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-pink-500" /> Eventdaten
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Grundlegende Informationen für Gäste und Moderation.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-700">
<InfoRow label="Slug" value={event.slug} />
<InfoRow label="Status" value={event.status === 'published' ? 'Veröffentlicht' : event.status} />
<InfoRow label="Datum" value={formatDate(event.event_date)} />
<InfoRow label="Aktiv" value={event.is_active ? 'Aktiv' : 'Deaktiviert'} />
<div className="flex flex-wrap gap-3 pt-2">
<Button
onClick={handleToggle}
disabled={busy}
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{event.is_active ? 'Deaktivieren' : 'Aktivieren'}
</Button>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
className="border-sky-200 text-sky-700 hover:bg-sky-50"
>
<Camera className="h-4 w-4" /> Fotos moderieren
</Button>
</div>
</CardContent>
</Card>
<div className="space-y-6">
{(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />}
<Card 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">
<QrCode className="h-5 w-5 text-amber-500" /> Einladungen &amp; QR
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Steuere QR-Einladungen, Layouts und Branding gesammelt auf einer eigenen Seite.
</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>
Aktive QR-Einladungen: {activeInvitesCount} · Gesamt erstellt: {totalInvitesCount}
</p>
<p>
Bereite deine Drucklayouts vor, personalisiere Texte und Logos und drucke sie direkt aus.
</p>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
<StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} />
<QuickActionsCard
slug={event.slug}
busy={busy}
onToggle={handleToggle}
navigate={navigate}
/>
</div>
<div className="space-y-2">
<Button
className="w-full bg-amber-500 text-white shadow-lg shadow-amber-500/20 hover:bg-amber-500/90"
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
>
Einladungen &amp; Layouts verwalten
</Button>
<p className="text-xs text-slate-500">
Du kannst bestehende Layouts duplizieren, Farben anpassen und neue PDFs generieren.
</p>
</div>
</CardContent>
</Card>
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
<Card className="border-0 bg-white/90 shadow-xl shadow-sky-100/60 lg:col-span-2">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Heart className="h-5 w-5 text-sky-500" /> Performance
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Kennzahlen zu Uploads, Highlights und Interaktion.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 md:grid-cols-4">
<StatChip label="Fotos" value={stats?.total ?? 0} />
<StatChip label="Featured" value={stats?.featured ?? 0} />
<StatChip label="Likes" value={stats?.likes ?? 0} />
<StatChip label="Uploads (7 Tage)" value={stats?.recent_uploads ?? 0} />
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<TaskOverviewCard tasks={toolkitData?.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} />
<InviteSummary
invites={toolkitData?.invites}
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
/>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<PendingPhotosCard
photos={toolkitData?.photos.pending ?? []}
navigateToModeration={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
/>
<RecentUploadsCard photos={toolkitData?.photos.recent ?? []} />
</div>
<FeedbackCard slug={event.slug} />
</div>
) : (
<Alert variant="destructive">
<AlertTitle>Event nicht gefunden</AlertTitle>
<AlertDescription>Bitte prüfe den Slug und versuche es erneut.</AlertDescription>
</Alert>
<Card className="border-0 bg-white/90 shadow-xl shadow-pink-100/60">
<CardContent className="p-6 text-sm text-slate-600">
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
</CardContent>
</Card>
)}
</AdminLayout>
);
}
function DetailSkeleton() {
function resolveName(name: TenantEvent['name']): string {
if (typeof name === 'string' && name.trim().length > 0) {
return name.trim();
}
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
}
return 'Event';
}
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: TenantEventStats | null; busy: boolean; onToggle: () => void }) {
const { t } = useTranslation('management');
const statusLabel = event.status === 'published'
? t('events.status.published', 'Veröffentlicht')
: event.status === 'draft'
? t('events.status.draft', 'Entwurf')
: t('events.status.archived', 'Archiviert');
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
<Card className="border-0 bg-white/90 shadow-xl shadow-pink-100/60">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-pink-500" />
{t('events.workspace.sections.statusTitle', 'Eventstatus & Sichtbarkeit')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('events.workspace.sections.statusSubtitle', 'Aktiviere dein Event für Gäste oder verstecke es vorübergehend.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-700">
<InfoRow icon={<Sparkles className="h-4 w-4 text-pink-500" />} label={t('events.workspace.fields.status', 'Status')} value={statusLabel} />
<InfoRow icon={<Circle className="h-4 w-4 text-amber-500" />} label={t('events.workspace.fields.active', 'Aktiv für Gäste')} value={event.is_active ? t('events.workspace.activeYes', 'Ja') : t('events.workspace.activeNo', 'Nein')} />
<InfoRow icon={<CalendarIcon />} label={t('events.workspace.fields.date', 'Eventdatum')} value={formatDate(event.event_date)} />
<InfoRow icon={<Smile className="h-4 w-4 text-rose-500" />} label={t('events.workspace.fields.eventType', 'Event-Typ')} value={resolveEventType(event)} />
{stats && (
<div className="rounded-xl border border-pink-100 bg-pink-50/60 p-4 text-xs text-pink-900">
<p className="font-semibold text-pink-700">{t('events.workspace.fields.insights', 'Letzte Aktivität')}</p>
<p>
{t('events.workspace.fields.uploadsTotal', { defaultValue: '{{count}} Uploads gesamt', count: stats.uploads_total })}
{' · '}
{t('events.workspace.fields.uploadsToday', { defaultValue: '{{count}} Uploads (24h)', count: stats.uploads_24h })}
</p>
<p>
{t('events.workspace.fields.likesTotal', { defaultValue: '{{count}} Likes vergeben', count: stats.likes_total })}
</p>
</div>
)}
<div className="flex flex-wrap gap-2 pt-2">
<Button onClick={onToggle} disabled={busy} className="bg-gradient-to-r from-pink-500 via-rose-500 to-purple-500 text-white shadow-md shadow-rose-200/60">
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : event.is_active ? <Circle className="mr-2 h-4 w-4" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{event.is_active ? t('events.workspace.actions.pause', 'Event pausieren') : t('events.workspace.actions.activate', 'Event aktivieren')}
</Button>
</div>
</CardContent>
</Card>
);
}
function QuickActionsCard({ slug, busy, onToggle, navigate }: { slug: string; busy: boolean; onToggle: () => void | Promise<void>; navigate: ReturnType<typeof useNavigate> }) {
const { t } = useTranslation('management');
const actions = [
{
icon: <Camera className="h-4 w-4" />,
label: t('events.quickActions.moderate', 'Fotos moderieren'),
onClick: () => navigate(ADMIN_EVENT_PHOTOS_PATH(slug)),
},
{
icon: <Sparkles className="h-4 w-4" />,
label: t('events.quickActions.tasks', 'Aufgaben bearbeiten'),
onClick: () => navigate(ADMIN_EVENT_TASKS_PATH(slug)),
},
{
icon: <QrCode className="h-4 w-4" />,
label: t('events.quickActions.invites', 'Layouts & QR verwalten'),
onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=layout`),
},
{
icon: <Users className="h-4 w-4" />,
label: t('events.quickActions.roles', 'Team & Rollen anpassen'),
onClick: () => navigate(ADMIN_EVENT_MEMBERS_PATH(slug)),
},
{
icon: <Printer className="h-4 w-4" />,
label: t('events.quickActions.print', 'Layouts als PDF drucken'),
onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=export`),
},
{
icon: <CheckCircle2 className="h-4 w-4" />,
label: t('events.quickActions.toggle', 'Status ändern'),
onClick: () => { void onToggle(); },
disabled: busy,
},
];
return (
<Card className="border-0 bg-white/90 shadow-xl shadow-violet-100/60">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<Sparkles className="h-5 w-5 text-violet-500" />
{t('events.quickActions.title', 'Schnellaktionen')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('events.quickActions.subtitle', 'Nutze die wichtigsten Schritte vor und während deines Events.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{actions.map((action, index) => (
<button
key={index}
type="button"
onClick={() => {
if (action.disabled) {
return;
}
const result = action.onClick();
if (result instanceof Promise) {
void result;
}
}}
disabled={action.disabled}
className="flex w-full items-center justify-between rounded-lg border border-slate-200 bg-white px-4 py-3 text-left text-sm text-slate-700 transition hover:border-violet-200 hover:bg-violet-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-violet-100 text-violet-600">
{action.icon}
</span>
{action.label}
</span>
<ChevronRight className="h-4 w-4 text-slate-400" />
</button>
))}
</CardContent>
</Card>
);
}
function MetricsGrid({ metrics, stats }: { metrics: EventToolkit['metrics'] | null | undefined; stats: TenantEventStats | null }) {
const { t } = useTranslation('management');
const cards = [
{
icon: <Camera className="h-5 w-5 text-emerald-500" />,
label: t('events.metrics.uploadsTotal', 'Uploads gesamt'),
value: metrics?.uploads_total ?? stats?.uploads_total ?? 0,
},
{
icon: <Camera className="h-5 w-5 text-sky-500" />,
label: t('events.metrics.uploads24h', 'Uploads (24h)'),
value: metrics?.uploads_24h ?? stats?.uploads_24h ?? 0,
},
{
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
label: t('events.metrics.pending', 'Fotos in Moderation'),
value: metrics?.pending_photos ?? stats?.pending_photos ?? 0,
},
{
icon: <QrCode className="h-5 w-5 text-indigo-500" />,
label: t('events.metrics.activeInvites', 'Aktive Einladungen'),
value: metrics?.active_invites ?? 0,
},
];
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{cards.map((card, index) => (
<Card key={index} className="border-0 bg-white/90 shadow-md shadow-slate-100/60">
<CardContent className="flex items-center gap-4 p-5">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100">{card.icon}</span>
<div>
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
<p className="text-2xl font-semibold text-slate-900">{card.value}</p>
</div>
</CardContent>
</Card>
))}
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
function InviteSummary({ invites, navigateToInvites }: { invites: EventToolkit['invites'] | undefined; navigateToInvites: () => void }) {
const { t } = useTranslation('management');
return (
<div className="flex flex-col gap-1 rounded-xl border border-pink-100 bg-white/70 px-3 py-2">
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
<span className="text-sm font-medium text-slate-800">{value}</span>
<Card className="border-0 bg-white/90 shadow-md shadow-amber-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<QrCode className="h-5 w-5 text-amber-500" />
{t('events.invites.title', 'QR-Einladungen')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('events.invites.subtitle', 'Behält aktive Einladungen und Layouts im Blick.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-700">
<div className="flex gap-2 text-sm text-slate-900">
<Badge variant="outline" className="border-amber-200 text-amber-700">
{t('events.invites.activeCount', { defaultValue: '{{count}} aktiv', count: invites?.summary.active ?? 0 })}
</Badge>
<Badge variant="outline" className="border-amber-200 text-amber-700">
{t('events.invites.totalCount', { defaultValue: '{{count}} gesamt', count: invites?.summary.total ?? 0 })}
</Badge>
</div>
{invites?.items?.length ? (
<ul className="space-y-2 text-xs">
{invites.items.slice(0, 3).map((invite) => (
<li key={invite.id} className="rounded-lg border border-amber-100 bg-amber-50/70 p-3">
<p className="text-sm font-semibold text-slate-900">{invite.label ?? invite.url}</p>
<p className="truncate text-[11px] text-amber-700">{invite.url}</p>
</li>
))}
</ul>
) : (
<p className="text-xs text-slate-500">{t('events.invites.empty', 'Noch keine Einladungen erstellt.')}</p>
)}
<Button variant="outline" onClick={navigateToInvites} className="border-amber-200 text-amber-700 hover:bg-amber-50">
<QrCode className="mr-2 h-4 w-4" /> {t('events.invites.manage', 'Layouts & Einladungen verwalten')}
</Button>
</CardContent>
</Card>
);
}
function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tasks'] | undefined; navigateToTasks: () => void }) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-md shadow-pink-100/60">
<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-pink-500" />
{t('events.tasks.title', 'Aktive Aufgaben')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('events.tasks.subtitle', 'Motiviere Gäste mit klaren Aufgaben & Highlights.')}
</CardDescription>
</div>
<Badge variant="outline" className="border-pink-200 text-pink-600">
{t('events.tasks.summary', {
defaultValue: '{{completed}} von {{total}} erledigt',
completed: tasks?.summary.completed ?? 0,
total: tasks?.summary.total ?? 0,
})}
</Badge>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-700">
{tasks?.items?.length ? (
<div className="space-y-2">
{tasks.items.slice(0, 4).map((task) => (
<TaskRow key={task.id} task={task} />
))}
</div>
) : (
<p className="text-xs text-slate-500">{t('events.tasks.empty', 'Noch keine Aufgaben zugewiesen.')}</p>
)}
<Button variant="outline" onClick={navigateToTasks} className="border-pink-200 text-pink-600 hover:bg-pink-50">
<Sparkles className="mr-2 h-4 w-4" /> {t('events.tasks.manage', 'Aufgabenbereich öffnen')}
</Button>
</CardContent>
</Card>
);
}
function TaskRow({ task }: { task: EventToolkitTask }) {
return (
<div className="flex items-start justify-between rounded-lg border border-pink-100 bg-white/80 px-3 py-2 text-xs text-slate-600">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900">{task.title}</p>
{task.description ? <p>{task.description}</p> : null}
</div>
<Badge variant={task.is_completed ? 'default' : 'outline'} className={task.is_completed ? 'bg-emerald-500/20 text-emerald-600' : 'border-pink-200 text-pink-600'}>
{task.is_completed ? 'Erledigt' : 'Offen'}
</Badge>
</div>
);
}
function StatChip({ label, value }: { label: string; value: string | number }) {
function PendingPhotosCard({ photos, navigateToModeration }: { photos: TenantPhoto[]; navigateToModeration: () => void }) {
const { t } = useTranslation('management');
return (
<div className="rounded-2xl border border-sky-100 bg-white/80 px-4 py-3 text-center shadow-sm">
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
<div className="text-lg font-semibold text-slate-900">{value}</div>
<Card className="border-0 bg-white/90 shadow-md shadow-slate-100/60">
<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-lg text-slate-900">
<Camera className="h-5 w-5 text-emerald-500" />
{t('events.photos.pendingTitle', 'Fotos in Moderation')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('events.photos.pendingSubtitle', 'Schnell prüfen, bevor Gäste live gehen.')}
</CardDescription>
</div>
<Badge variant="outline" className="border-emerald-200 text-emerald-600">
{t('events.photos.pendingCount', { defaultValue: '{{count}} Fotos offen', count: photos.length })}
</Badge>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-700">
{photos.length ? (
<div className="grid grid-cols-3 gap-2">
{photos.slice(0, 6).map((photo) => (
<img key={photo.id} src={photo.thumbnail_url ?? photo.url} alt={photo.caption ?? 'Foto'} className="h-24 w-full rounded-lg object-cover" />
))}
</div>
) : (
<p className="text-xs text-slate-500">{t('events.photos.pendingEmpty', 'Aktuell warten keine Fotos auf Freigabe.')}</p>
)}
<Button variant="outline" onClick={navigateToModeration} className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
<Camera className="mr-2 h-4 w-4" /> {t('events.photos.openModeration', 'Moderation öffnen')}
</Button>
</CardContent>
</Card>
);
}
function RecentUploadsCard({ photos }: { photos: TenantPhoto[] }) {
const { t } = useTranslation('management');
return (
<Card className="border-0 bg-white/90 shadow-md shadow-slate-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<Camera className="h-5 w-5 text-sky-500" />
{t('events.photos.recentTitle', 'Neueste Uploads')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('events.photos.recentSubtitle', 'Halte Ausschau nach Highlight-Momenten der Gäste.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm text-slate-700">
{photos.length ? (
<div className="grid grid-cols-3 gap-2">
{photos.slice(0, 6).map((photo) => (
<img key={photo.id} src={photo.thumbnail_url ?? photo.url} alt={photo.caption ?? 'Foto'} className="h-24 w-full rounded-lg object-cover" />
))}
</div>
) : (
<p className="text-xs text-slate-500">{t('events.photos.recentEmpty', 'Noch keine neuen Uploads.')}</p>
)}
</CardContent>
</Card>
);
}
function FeedbackCard({ slug }: { slug: string }) {
const { t } = useTranslation('management');
const [sentiment, setSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
const [message, setMessage] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [submitted, setSubmitted] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const copy = {
positive: t('events.feedback.positive', 'Super Lauf!'),
neutral: t('events.feedback.neutral', 'Läuft'),
negative: t('events.feedback.negative', 'Braucht Support'),
};
return (
<Card className="border-0 bg-white/90 shadow-md shadow-slate-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<MessageSquare className="h-5 w-5 text-slate-500" />
{t('events.feedback.title', 'Wie läuft dein Event?')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('events.feedback.subtitle', 'Feedback hilft uns, neue Features zu priorisieren.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertTitle>{t('events.feedback.errorTitle', 'Feedback konnte nicht gesendet werden.')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-wrap gap-2">
{(Object.keys(copy) as Array<'positive' | 'neutral' | 'negative'>).map((key) => (
<Button
key={key}
type="button"
variant={sentiment === key ? 'default' : 'outline'}
className={sentiment === key ? 'bg-slate-900 text-white' : 'border-slate-300 text-slate-700'}
onClick={() => setSentiment(key)}
>
{copy[key]}
</Button>
))}
</div>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder={t('events.feedback.placeholder', 'Optional: Lass uns wissen, was gut funktioniert oder wo du Unterstützung brauchst.')}
className="min-h-[120px] w-full rounded-md border border-slate-200 bg-white p-3 text-sm text-slate-700 outline-none focus:border-slate-400 focus:ring-1 focus:ring-slate-300"
/>
<Button
type="button"
className="bg-slate-900 text-white hover:bg-slate-800"
disabled={busy || submitted}
onClick={async () => {
if (busy || submitted) return;
setBusy(true);
setError(null);
try {
await submitTenantFeedback({
category: 'event_workspace',
event_slug: slug,
sentiment: sentiment ?? undefined,
message: message.trim() ? message.trim() : undefined,
});
setSubmitted(true);
} catch (err) {
setError(isAuthError(err)
? t('events.feedback.authError', 'Deine Session ist abgelaufen. Bitte melde dich erneut an.')
: t('events.feedback.genericError', 'Feedback konnte nicht gesendet werden.'));
} finally {
setBusy(false);
}
}}
>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <MessageSquare className="mr-2 h-4 w-4" />} {submitted ? t('events.feedback.submitted', 'Danke!') : t('events.feedback.submit', 'Feedback senden')}
</Button>
</CardContent>
</Card>
);
}
function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
return (
<div className="flex items-start gap-3 rounded-lg border border-slate-100 bg-white/70 px-3 py-2 text-sm text-slate-700">
<span className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600">{icon}</span>
<div className="space-y-1">
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
<p className="text-sm font-semibold text-slate-900">{value || '—'}</p>
</div>
</div>
);
}
function formatDate(iso: string | null): string {
if (!iso) return 'Noch kein Datum';
const date = new Date(iso);
function formatDate(value: string | null | undefined): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return 'Unbekanntes Datum';
return '';
}
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
return date.toLocaleDateString(undefined, { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') {
return name;
function resolveEventType(event: TenantEvent): string {
if (event.event_type?.name) {
if (typeof event.event_type.name === 'string') {
return event.event_type.name;
}
return event.event_type.name.de ?? event.event_type.name.en ?? Object.values(event.event_type.name)[0] ?? '—';
}
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event';
}
return 'Unbenanntes Event';
return '—';
}
function AlertList({ alerts }: { alerts: string[] }) {
if (!alerts.length) {
return null;
}
return (
<div className="space-y-2">
{alerts.map((alert, index) => (
<Alert key={`workspace-alert-${index}`} variant="default">
<AlertTitle>{alert}</AlertTitle>
</Alert>
))}
</div>
);
}
function CalendarIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
}
function WorkspaceSkeleton(): JSX.Element {
return (
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
<SkeletonCard />
<SkeletonCard />
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<SkeletonCard key={`metric-skeleton-${index}`} />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<SkeletonCard />
<SkeletonCard />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<SkeletonCard />
<SkeletonCard />
</div>
<SkeletonCard />
</div>
);
}
function SkeletonCard(): JSX.Element {
return <div className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-slate-100 via-white to-slate-100" />;
}