Files
fotospiel-app/resources/js/admin/pages/EventDetailPage.tsx
2025-11-12 19:31:13 +01:00

1136 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
ArrowLeft,
Bell,
Camera,
CheckCircle2,
Circle,
Clock3,
Loader2,
MessageSquare,
Printer,
QrCode,
RefreshCw,
Smile,
Sparkles,
Users,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { AdminLayout } from '../components/AdminLayout';
import {
EventToolkit,
EventToolkitTask,
TenantEvent,
TenantPhoto,
EventStats,
getEvent,
getEventStats,
getEventToolkit,
toggleEvent,
submitTenantFeedback,
updatePhotoVisibility,
} from '../api';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { getApiErrorMessage } from '../lib/apiError';
import { isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
ADMIN_EVENT_EDIT_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
} from '../constants';
import {
SectionCard,
SectionHeader,
ActionGrid,
TenantHeroCard,
} from '../components/tenant';
import { GuestBroadcastCard } from '../components/GuestBroadcastCard';
type EventDetailPageProps = {
mode?: 'detail' | 'toolkit';
};
type ToolkitState = {
data: EventToolkit | null;
loading: boolean;
error: string | null;
};
type WorkspaceState = {
event: TenantEvent | null;
stats: EventStats | null;
loading: boolean;
busy: boolean;
error: string | null;
};
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps) {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t } = useTranslation('management');
const { t: tCommon } = useTranslation('common');
const slug = slugParam ?? null;
const [state, setState] = React.useState<WorkspaceState>({
event: null,
stats: 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({ 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 (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
error: getApiErrorMessage(error, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')),
loading: false,
}));
}
}
try {
const toolkitData = await getEventToolkit(slug);
setToolkit({ data: toolkitData, loading: false, error: null });
} catch (error) {
if (!isAuthError(error)) {
setToolkit({
data: null,
loading: false,
error: getApiErrorMessage(error, t('toolkit.errors.loadFailed', 'Toolkit-Daten konnten nicht geladen werden.')),
});
}
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
async function handleToggle(): Promise<void> {
if (!slug) {
return;
}
setState((prev) => ({ ...prev, busy: true, error: null }));
try {
const updated = await toggleEvent(slug);
setState((prev) => ({
...prev,
busy: false,
event: updated,
stats: prev.stats
? {
...prev.stats,
status: updated.status,
is_active: Boolean(updated.is_active),
}
: prev.stats,
}));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
busy: false,
error: getApiErrorMessage(error, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')),
}));
} else {
setState((prev) => ({ ...prev, busy: false }));
}
}
}
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 limitWarnings = React.useMemo(
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
[event?.limits, tCommon],
);
const shownWarningToasts = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
limitWarnings.forEach((warning) => {
const id = `${warning.id}-${warning.message}`;
if (shownWarningToasts.current.has(id)) {
return;
}
shownWarningToasts.current.add(id);
toast(warning.message, {
icon: warning.tone === 'danger' ? '🚨' : '⚠️',
id,
});
});
}, [limitWarnings]);
if (!slug) {
return (
<AdminLayout
title={t('events.errors.notFoundTitle', 'Event nicht gefunden')}
subtitle={t('events.errors.notFoundCopy', 'Bitte wähle ein Event aus der Übersicht.')}
>
<SectionCard>
<p className="text-sm text-slate-600 dark:text-slate-300">
{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.')}
</p>
</SectionCard>
</AdminLayout>
);
}
return (
<AdminLayout title={eventName} subtitle={subtitle}>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{limitWarnings.length > 0 && (
<div className="mb-6 space-y-2">
{limitWarnings.map((warning) => (
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
>
<AlertDescription className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4" />
{warning.message}
</AlertDescription>
</Alert>
))}
</div>
)}
{toolkit.error && (
<Alert variant="default">
<AlertTitle>{toolkit.error}</AlertTitle>
</Alert>
)}
{loading ? (
<WorkspaceSkeleton />
) : event ? (
<div className="space-y-6">
<EventHeroCardSection
event={event}
stats={stats}
onRefresh={() => { void load(); }}
loading={state.busy}
navigate={navigate}
/>
{(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />}
<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>
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
<SectionCard className="space-y-6">
<SectionHeader
eyebrow={t('events.notifications.badge', 'Gästefeeds')}
title={t('events.notifications.panelTitle', 'Nachrichten an Gäste')}
description={t('events.notifications.panelDescription', 'Verschicke kurze Hinweise oder Hilfe an die Gästepwa. Links werden direkt im Notification-Center angezeigt.')}
/>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<GuestNotificationStatsCard notifications={toolkitData?.notifications} />
<GuestBroadcastCard eventSlug={event.slug} eventName={eventName} />
</div>
</SectionCard>
<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
slug={event.slug}
photos={toolkitData?.photos.pending ?? []}
navigateToModeration={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
/>
<RecentUploadsCard slug={event.slug} photos={toolkitData?.photos.recent ?? []} />
</div>
<FeedbackCard slug={event.slug} />
</div>
) : (
<SectionCard>
<p className="text-sm text-slate-600 dark:text-slate-300">
{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.')}
</p>
</SectionCard>
)}
</AdminLayout>
);
}
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 EventHeroCardSection({ event, stats, onRefresh, loading, navigate }: {
event: TenantEvent;
stats: EventStats | null;
onRefresh: () => void;
loading: boolean;
navigate: ReturnType<typeof useNavigate>;
}) {
const { t } = useTranslation('management');
const statusLabel = getStatusLabel(event, t);
const supporting = [
t('events.workspace.hero.status', { defaultValue: 'Status: {{status}}', status: statusLabel }),
t('events.workspace.hero.date', { defaultValue: 'Eventdatum: {{date}}', date: formatDate(event.event_date) }),
t('events.workspace.hero.metrics', {
defaultValue: 'Uploads gesamt: {{count}} · Likes: {{likes}}',
count: stats?.uploads_total ?? stats?.total ?? 0,
likes: stats?.likes_total ?? stats?.likes ?? 0,
}),
];
const aside = (
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-200">
<InfoRow
icon={<Sparkles className="h-4 w-4 text-pink-500" />}
label={t('events.workspace.fields.status', 'Status')}
value={statusLabel}
/>
<InfoRow
icon={<CalendarIcon />}
label={t('events.workspace.fields.date', 'Eventdatum')}
value={formatDate(event.event_date)}
/>
<InfoRow
icon={<Users className="h-4 w-4 text-sky-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')}
/>
</div>
);
return (
<TenantHeroCard
badge={t('events.workspace.hero.badge', 'Event')}
title={resolveName(event.name)}
description={t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.')}
supporting={supporting}
primaryAction={(
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="rounded-full 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>
)}
secondaryAction={(
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} className="rounded-full border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
<Sparkles className="h-4 w-4" /> {t('events.actions.edit', 'Bearbeiten')}
</Button>
)}
aside={aside}
>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={onRefresh}
disabled={loading}
className="rounded-full border-slate-200"
>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{t('events.actions.refresh', 'Aktualisieren')}
</Button>
</div>
</TenantHeroCard>
);
}
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: EventStats | null; busy: boolean; onToggle: () => void }) {
const { t } = useTranslation('management');
const statusLabel = getStatusLabel(event, t);
return (
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('events.workspace.sections.statusBadge', 'Status')}
title={t('events.workspace.sections.statusTitle', 'Eventstatus & Sichtbarkeit')}
description={t('events.workspace.sections.statusSubtitle', 'Aktiviere dein Event für Gäste oder verstecke es vorübergehend.')}
/>
<div className="space-y-4 text-sm text-slate-700 dark:text-slate-300">
<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 ?? stats.total ?? 0,
})}
{' · '}
{t('events.workspace.fields.uploadsToday', {
defaultValue: '{{count}} Uploads (24h)',
count: stats.uploads_24h ?? stats.recent_uploads ?? 0,
})}
</p>
<p>
{t('events.workspace.fields.likesTotal', {
defaultValue: '{{count}} Likes vergeben',
count: stats.likes_total ?? stats.likes ?? 0,
})}
</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>
</div>
</SectionCard>
);
}
function QuickActionsCard({ slug, busy, onToggle, navigate }: { slug: string; busy: boolean; onToggle: () => void | Promise<void>; navigate: ReturnType<typeof useNavigate> }) {
const { t } = useTranslation('management');
const gridItems = [
{
key: 'photos',
icon: <Camera className="h-4 w-4" />,
label: t('events.quickActions.moderate', 'Fotos moderieren'),
description: t('events.quickActions.moderateDesc', 'Prüfe Uploads und veröffentliche Highlights.'),
onClick: () => navigate(ADMIN_EVENT_PHOTOS_PATH(slug)),
},
{
key: 'tasks',
icon: <Sparkles className="h-4 w-4" />,
label: t('events.quickActions.tasks', 'Aufgaben bearbeiten'),
description: t('events.quickActions.tasksDesc', 'Passe Story-Prompts und Moderation an.'),
onClick: () => navigate(ADMIN_EVENT_TASKS_PATH(slug)),
},
{
key: 'invites',
icon: <QrCode className="h-4 w-4" />,
label: t('events.quickActions.invites', 'Layouts & QR verwalten'),
description: t('events.quickActions.invitesDesc', 'Aktualisiere QR-Kits und Einladungslayouts.'),
onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=layout`),
},
{
key: 'roles',
icon: <Users className="h-4 w-4" />,
label: t('events.quickActions.roles', 'Team & Rollen anpassen'),
description: t('events.quickActions.rolesDesc', 'Verwalte Moderatoren und Co-Leads.'),
onClick: () => navigate(ADMIN_EVENT_MEMBERS_PATH(slug)),
},
{
key: 'print',
icon: <Printer className="h-4 w-4" />,
label: t('events.quickActions.print', 'Layouts als PDF drucken'),
description: t('events.quickActions.printDesc', 'Exportiere QR-Sets für den Druck.'),
onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=export`),
},
];
return (
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('events.quickActions.badge', 'Schnellaktionen')}
title={t('events.quickActions.title', 'Schnellaktionen')}
description={t('events.quickActions.subtitle', 'Nutze die wichtigsten Schritte vor und während deines Events.')}
/>
<ActionGrid items={gridItems} columns={1} />
<div className="flex flex-wrap gap-2">
<Button onClick={() => { void onToggle(); }} disabled={busy} variant="outline" className="rounded-full border-rose-200 text-rose-600 hover:bg-rose-50">
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{t('events.quickActions.toggle', 'Status ändern')}
</Button>
</div>
</SectionCard>
);
}
function MetricsGrid({ metrics, stats }: { metrics: EventToolkit['metrics'] | null | undefined; stats: EventStats | 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) => (
<SectionCard key={card.label} className="p-4">
<div className="flex items-center gap-4">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 dark:bg-white/10">
{card.icon}
</span>
<div>
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{card.label}</p>
<p className="text-2xl font-semibold text-slate-900 dark:text-white">{card.value}</p>
</div>
</div>
</SectionCard>
))}
</div>
);
}
function InviteSummary({ invites, navigateToInvites }: { invites: EventToolkit['invites'] | undefined; navigateToInvites: () => void }) {
const { t } = useTranslation('management');
return (
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('events.invites.badge', 'Einladungen')}
title={t('events.invites.title', 'QR-Einladungen')}
description={t('events.invites.subtitle', 'Behält aktive Einladungen und Layouts im Blick.')}
/>
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
<div className="flex gap-2 text-sm text-slate-900">
<Badge variant="outline" className="border-amber-200 text-amber-700 dark:border-amber-500/30 dark:text-amber-200">
{t('events.invites.activeCount', { defaultValue: '{{count}} aktiv', count: invites?.summary.active ?? 0 })}
</Badge>
<Badge variant="outline" className="border-amber-200 text-amber-700 dark:border-amber-500/30 dark:text-amber-200">
{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>
</div>
</SectionCard>
);
}
function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tasks'] | undefined; navigateToTasks: () => void }) {
const { t } = useTranslation('management');
return (
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('events.tasks.badge', 'Aufgaben')}
title={t('events.tasks.title', 'Aktive Aufgaben')}
description={t('events.tasks.subtitle', 'Motiviere Gäste mit klaren Aufgaben & Highlights.')}
endSlot={(
<Badge variant="outline" className="border-pink-200 text-pink-600 dark:border-pink-500/30 dark:text-pink-200">
{t('events.tasks.summary', {
defaultValue: '{{completed}} von {{total}} erledigt',
completed: tasks?.summary.completed ?? 0,
total: tasks?.summary.total ?? 0,
})}
</Badge>
)}
/>
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
{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>
</div>
</SectionCard>
);
}
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 PendingPhotosCard({
slug,
photos,
navigateToModeration,
}: {
slug: string;
photos: TenantPhoto[];
navigateToModeration: () => void;
}) {
const { t } = useTranslation('management');
const [entries, setEntries] = React.useState(photos);
const [updatingId, setUpdatingId] = React.useState<number | null>(null);
React.useEffect(() => {
setEntries(photos);
}, [photos]);
const handleVisibility = async (photo: TenantPhoto, visible: boolean) => {
setUpdatingId(photo.id);
try {
const updated = await updatePhotoVisibility(slug, photo.id, visible);
setEntries((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
toast.success(
visible
? t('events.photos.toastVisible', 'Foto wieder sichtbar gemacht.')
: t('events.photos.toastHidden', 'Foto ausgeblendet.'),
);
} catch (err) {
toast.error(
isAuthError(err)
? t('events.photos.errorAuth', 'Session abgelaufen. Bitte erneut anmelden.')
: t('events.photos.errorVisibility', 'Sichtbarkeit konnte nicht geändert werden.'),
);
} finally {
setUpdatingId(null);
}
};
return (
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('events.photos.pendingBadge', 'Moderation')}
title={t('events.photos.pendingTitle', 'Fotos in Moderation')}
description={t('events.photos.pendingSubtitle', 'Schnell prüfen, bevor Gäste live gehen.')}
endSlot={(
<Badge variant="outline" className="border-emerald-200 text-emerald-600 dark:border-emerald-500/30 dark:text-emerald-200">
{t('events.photos.pendingCount', { defaultValue: '{{count}} Fotos offen', count: entries.length })}
</Badge>
)}
/>
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
{entries.length ? (
<div className="grid grid-cols-3 gap-2">
{entries.slice(0, 6).map((photo) => {
const hidden = photo.status === 'hidden';
return (
<div key={photo.id} className="relative">
<img
src={photo.thumbnail_url ?? photo.url}
alt={photo.caption ?? 'Foto'}
className={`h-24 w-full rounded-lg object-cover ${hidden ? 'opacity-60' : ''}`}
/>
<button
type="button"
onClick={() => handleVisibility(photo, hidden)}
disabled={updatingId === photo.id}
className="absolute inset-x-2 bottom-2 rounded-full bg-white/90 px-2 py-1 text-[11px] font-semibold text-slate-700 shadow disabled:opacity-60"
>
{hidden
? t('events.photos.show', 'Einblenden')
: t('events.photos.hide', 'Ausblenden')}
</button>
</div>
);
})}
</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>
</div>
</SectionCard>
);
}
function RecentUploadsCard({ slug, photos }: { slug: string; photos: TenantPhoto[] }) {
const { t } = useTranslation('management');
const [entries, setEntries] = React.useState(photos);
const [updatingId, setUpdatingId] = React.useState<number | null>(null);
React.useEffect(() => {
setEntries(photos);
}, [photos]);
const handleVisibility = async (photo: TenantPhoto, visible: boolean) => {
setUpdatingId(photo.id);
try {
const updated = await updatePhotoVisibility(slug, photo.id, visible);
setEntries((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
toast.success(
visible
? t('events.photos.toastVisible', 'Foto wieder sichtbar gemacht.')
: t('events.photos.toastHidden', 'Foto ausgeblendet.'),
);
} catch (err) {
toast.error(
isAuthError(err)
? t('events.photos.errorAuth', 'Session abgelaufen. Bitte erneut anmelden.')
: t('events.photos.errorVisibility', 'Sichtbarkeit konnte nicht geändert werden.'),
);
} finally {
setUpdatingId(null);
}
};
return (
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={t('events.photos.recentBadge', 'Uploads')}
title={t('events.photos.recentTitle', 'Neueste Uploads')}
description={t('events.photos.recentSubtitle', 'Halte Ausschau nach Highlight-Momenten der Gäste.')}
/>
<div className="space-y-2 text-sm text-slate-700 dark:text-slate-300">
{entries.length ? (
<div className="grid grid-cols-3 gap-2">
{entries.slice(0, 6).map((photo) => {
const hidden = photo.status === 'hidden';
return (
<div key={photo.id} className="relative">
<img
src={photo.thumbnail_url ?? photo.url}
alt={photo.caption ?? 'Foto'}
className={`h-24 w-full rounded-lg object-cover ${hidden ? 'opacity-60' : ''}`}
/>
<button
type="button"
onClick={() => handleVisibility(photo, hidden)}
disabled={updatingId === photo.id}
className="absolute inset-x-2 bottom-2 rounded-full bg-white/90 px-2 py-1 text-[11px] font-semibold text-slate-700 shadow disabled:opacity-60"
>
{hidden
? t('events.photos.show', 'Einblenden')
: t('events.photos.hide', 'Ausblenden')}
</button>
</div>
);
})}
</div>
) : (
<p className="text-xs text-slate-500">{t('events.photos.recentEmpty', 'Noch keine neuen Uploads.')}</p>
)}
</div>
</SectionCard>
);
}
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 (
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('events.feedback.badge', 'Feedback')}
title={t('events.feedback.title', 'Wie läuft dein Event?')}
description={t('events.feedback.subtitle', 'Feedback hilft uns, neue Features zu priorisieren.')}
/>
<div 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>
</div>
</SectionCard>
);
}
function GuestNotificationStatsCard({ notifications }: { notifications?: EventToolkit['notifications'] }) {
const { t } = useTranslation('management');
if (!notifications || notifications.summary.total === 0) {
return (
<div className="flex h-full flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center text-sm text-slate-500">
<MessageSquare className="mb-3 h-6 w-6 text-slate-400" aria-hidden />
<p>{t('events.notifications.statsEmpty', 'Noch keine Nachrichten versendet starte mit einem Broadcast.')}</p>
</div>
);
}
const { summary, recent } = notifications;
const topTypes = Object.entries(summary.by_type ?? {})
.sort(([, a], [, b]) => (b ?? 0) - (a ?? 0))
.slice(0, 3);
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<StatPill
icon={<Bell className="h-4 w-4" />}
label={t('events.notifications.statsTotal', 'Gesendete Nachrichten')}
value={summary.total}
/>
<StatPill
icon={<MessageSquare className="h-4 w-4" />}
label={t('events.notifications.statsBroadcasts', 'Broadcasts')}
value={summary.broadcasts.total}
sublabel={summary.broadcasts.last_title ?? t('events.notifications.statsBroadcastsEmpty', 'Noch kein Broadcast')}
/>
<StatPill
icon={<Clock3 className="h-4 w-4" />}
label={t('events.notifications.statsLastSent', 'Letzte Sendung')}
value={summary.last_sent_at ? formatRelativeDateTime(summary.last_sent_at) : t('events.notifications.never', 'Noch nie')}
/>
</div>
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
{t('events.notifications.topTypes', 'Beliebteste Typen')}
</p>
{topTypes.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{topTypes.map(([type, count]) => (
<Badge key={type} variant="secondary" className="gap-2">
{getNotificationTypeLabel(type, t)}
<span className="rounded-full bg-slate-200 px-2 text-xs font-semibold text-slate-700">{count as number}</span>
</Badge>
))}
</div>
) : (
<p className="mt-2 text-sm text-slate-500">{t('events.notifications.topTypesEmpty', 'Noch keine Verteilung verfügbar.')}</p>
)}
</div>
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
{t('events.notifications.recent', 'Letzte Nachrichten')}
</p>
<span className="text-xs text-slate-400">{recent.length} {t('events.notifications.recentCount', 'Einträge')}</span>
</div>
{recent.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">{t('events.notifications.recentEmpty', 'Noch keine Historie vorhanden.')}</p>
) : (
<ul className="mt-3 space-y-3">
{recent.map((item) => (
<li key={item.id} className="rounded-xl border border-slate-100 bg-white/70 px-3 py-2">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">{item.title || t('events.notifications.untitled', 'Ohne Titel')}</p>
<p className="text-xs text-slate-500">
{formatRelativeDateTime(item.created_at)} · {getAudienceLabel(item.audience_scope, t)}
</p>
</div>
<Badge variant="outline" className="text-xs capitalize">
{getNotificationTypeLabel(item.type, t)}
</Badge>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}
function StatPill({ icon, label, value, sublabel }: { icon: React.ReactNode; label: string; value: string | number; sublabel?: string | null }) {
return (
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4">
<div className="flex items-center gap-2 text-slate-500">
<span className="rounded-full bg-slate-100 p-2 text-slate-600">{icon}</span>
<p className="text-xs font-semibold uppercase tracking-wide">{label}</p>
</div>
<p className="mt-2 text-2xl font-semibold text-slate-900">{value}</p>
{sublabel && <p className="text-xs text-slate-500">{sublabel}</p>}
</div>
);
}
function formatRelativeDateTime(value?: string | null): string {
if (!value) {
return '—';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return date.toLocaleString(undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function getNotificationTypeLabel(type: string, t: ReturnType<typeof useTranslation>['t']): string {
switch (type) {
case 'broadcast':
return t('events.notifications.types.broadcast', 'Broadcast');
case 'upload_alert':
return t('events.notifications.types.upload', 'Upload-Status');
case 'support_tip':
return t('events.notifications.types.support', 'Support-Tipp');
case 'feedback_request':
return t('events.notifications.types.feedback', 'Feedback');
case 'achievement_major':
return t('events.notifications.types.achievement', 'Achievement');
case 'photo_activity':
return t('events.notifications.types.activity', 'Aktivität');
default:
return t('events.notifications.types.generic', 'System');
}
}
function getAudienceLabel(scope: string, t: ReturnType<typeof useTranslation>['t']): string {
if (scope === 'guest') {
return t('events.notifications.audienceGuest', 'Gezielte Gäste');
}
return t('events.notifications.audienceAll', 'Alle Gäste');
}
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 getStatusLabel(event: TenantEvent, t: ReturnType<typeof useTranslation>['t']): string {
if (event.status === 'published') {
return t('events.status.published', 'Veröffentlicht');
}
if (event.status === 'archived') {
return t('events.status.archived', 'Archiviert');
}
return t('events.status.draft', 'Entwurf');
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return date.toLocaleDateString(undefined, { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function resolveEventType(event: TenantEvent): string {
if (event.event_type?.name) {
if (typeof event.event_type.name === 'string') {
return event.event_type.name;
}
const translations = event.event_type.name as Record<string, string>;
return translations.de ?? translations.en ?? Object.values(translations)[0] ?? '—';
}
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() {
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() {
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() {
return <div className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-slate-100 via-white to-slate-100" />;
}