Files
fotospiel-app/resources/js/admin/pages/EventDetailPage.tsx
2025-12-10 15:49:08 +01:00

427 lines
14 KiB
TypeScript

import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, BarChart2, Camera, CheckCircle2, ChevronLeft, Circle, QrCode, Settings, Sparkles, Users } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Pressable } from '@tamagui/react-native-web-lite';
import { AdminLayout } from '../components/AdminLayout';
import { AppCard, PrimaryCTA, StatusPill, BottomNav } from '../tamagui/primitives';
import {
TenantEvent,
EventStats,
EventToolkit,
getEvent,
getEventStats,
getEventToolkit,
toggleEvent,
} from '../api';
import { getApiErrorMessage } from '../lib/apiError';
import { isAuthError } from '../auth/tokens';
import {
adminPath,
ADMIN_EVENTS_PATH,
ADMIN_EVENT_BRANDING_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_PHOTOBOOTH_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_VIEW_PATH,
} from '../constants';
type DetailState = {
event: TenantEvent | null;
stats: EventStats | null;
toolkit: EventToolkit | null;
loading: boolean;
busy: boolean;
error: string | null;
};
export default function EventDetailPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const { t: tCommon } = useTranslation('common');
const [state, setState] = React.useState<DetailState>({
event: null,
stats: null,
toolkit: null,
loading: true,
busy: false,
error: null,
});
const load = React.useCallback(async () => {
if (!slug) {
setState({ event: null, stats: null, toolkit: null, loading: false, busy: false, error: t('events.errors.missingSlug', 'Kein Event ausgewählt.') });
return;
}
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const [eventData, statsData, toolkitData] = await Promise.all([getEvent(slug), getEventStats(slug), getEventToolkit(slug)]);
setState((prev) => ({
...prev,
event: eventData,
stats: statsData,
toolkit: toolkitData,
loading: false,
}));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
loading: false,
error: getApiErrorMessage(error, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')),
}));
} else {
setState((prev) => ({ ...prev, loading: false }));
}
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
const { event, stats, toolkit, loading, busy, error } = state;
const eventName = resolveName(event?.name) ?? t('events.placeholders.untitled', 'Unbenanntes Event');
const tasksSummary = toolkit?.tasks?.summary;
const inviteSummary = toolkit?.invites?.summary;
const kpis = [
{
label: t('events.detail.kpi.tasks', 'Tasks Completed'),
value: tasksSummary ? `${tasksSummary.completed}/${tasksSummary.total}` : '—',
icon: Sparkles,
tone: '#22c55e',
},
{
label: t('events.detail.kpi.guests', 'Guests Registered'),
value: inviteSummary?.total ?? event?.active_invites_count ?? '—',
icon: Users,
tone: '#2563eb',
},
{
label: t('events.detail.kpi.photos', 'Images Uploaded'),
value: stats?.uploads_total ?? event?.photo_count ?? '—',
icon: Camera,
tone: '#8b5cf6',
},
];
const actions = [
{
key: 'tasks',
label: t('events.quick.tasks', 'Tasks & Checklists'),
icon: Sparkles,
to: ADMIN_EVENT_TASKS_PATH(event?.slug ?? ''),
color: '#60a5fa',
},
{
key: 'qr',
label: t('events.quick.qr', 'QR Code Layouts'),
icon: QrCode,
to: `${ADMIN_EVENT_INVITES_PATH(event?.slug ?? '')}?tab=layout`,
color: '#fbbf24',
},
{
key: 'images',
label: t('events.quick.images', 'Image Management'),
icon: Camera,
to: ADMIN_EVENT_PHOTOS_PATH(event?.slug ?? ''),
color: '#a855f7',
},
{
key: 'guests',
label: t('events.quick.guests', 'Guest Management'),
icon: Users,
to: ADMIN_EVENT_MEMBERS_PATH(event?.slug ?? ''),
color: '#4ade80',
},
{
key: 'branding',
label: t('events.quick.branding', 'Branding & Theme'),
icon: Sparkles,
to: ADMIN_EVENT_BRANDING_PATH(event?.slug ?? ''),
color: '#fb7185',
},
{
key: 'photobooth',
label: t('events.quick.moderation', 'Photo Moderation'),
icon: BarChart2,
to: ADMIN_EVENT_PHOTOBOOTH_PATH(event?.slug ?? ''),
color: '#38bdf8',
},
];
async function handleToggle() {
if (!event?.slug) {
return;
}
setState((prev) => ({ ...prev, busy: true }));
try {
const updated = await toggleEvent(event.slug);
setState((prev) => ({ ...prev, busy: false, event: updated }));
} catch (err) {
setState((prev) => ({
...prev,
busy: false,
error: isAuthError(err) ? prev.error : getApiErrorMessage(err, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')),
}));
}
}
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.')}>
<AppCard>
<Text fontSize="$sm" color="$color">
{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.')}
</Text>
<PrimaryCTA label={t('events.actions.backToList', 'Zurück zur Liste')} onPress={() => navigate(ADMIN_EVENTS_PATH)} />
</AppCard>
</AdminLayout>
);
}
return (
<AdminLayout title={eventName} subtitle={t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und QR-Codes deines Events im Blick.')} disableCommandShelf>
<YStack space="$3" maxWidth={640} marginHorizontal="auto" paddingBottom="$9">
<XStack alignItems="center" space="$3">
<Pressable onPress={() => navigate(ADMIN_EVENTS_PATH)}>
<XStack alignItems="center" space="$2">
<ChevronLeft size={20} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF">
{t('events.actions.back', 'Back')}
</Text>
</XStack>
</Pressable>
<Text fontSize="$lg" fontWeight="700" flex={1}>
{eventName}
</Text>
<Pressable onPress={() => navigate(adminPath('/settings'))}>
<Settings size={20} color="#0f172a" />
</Pressable>
</XStack>
{error ? (
<AppCard>
<Text fontWeight="700" color="#b91c1c">
{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}
</Text>
<Text color="$color">{error}</Text>
</AppCard>
) : null}
<AppCard space="$3">
<Text fontSize="$xs" letterSpacing={2} textTransform="uppercase" color="$color">
{t('events.workspace.hero.badge', 'Event')}
</Text>
<Text fontSize="$xl" fontWeight="800" color="$color">
{eventName}
</Text>
<Text fontSize="$sm" color="$color">
{formatDate(event?.event_date) ?? t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und QR-Code für dieses Event.')}
</Text>
<XStack alignItems="center" space="$2">
<StatusPill tone={event?.status === 'published' ? 'success' : 'warning'}>
{statusLabel(event, tCommon)}
</StatusPill>
<StatusPill tone="muted">{resolveLocation(event)}</StatusPill>
</XStack>
<XStack space="$2">
<PrimaryCTA label={t('events.actions.openGallery', 'Event öffnen')} onPress={() => navigate(ADMIN_EVENT_VIEW_PATH(event?.slug ?? ''))} />
<Button
flex={1}
height={56}
borderRadius="$card"
backgroundColor="white"
color="$primary"
borderColor="$muted"
borderWidth={1}
onPress={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event?.slug ?? ''))}
>
{t('events.list.actions.photos', 'Fotos moderieren')}
</Button>
</XStack>
<Button
height={48}
borderRadius="$pill"
backgroundColor="white"
borderColor="$muted"
borderWidth={1}
color="$color"
onPress={() => handleToggle()}
disabled={busy}
pressStyle={{ opacity: 0.8 }}
>
<XStack alignItems="center" justifyContent="center" space="$2">
{event?.is_active ? <CheckCircle2 size={18} color="#16a34a" /> : <Circle size={18} color="#9ca3af" />}
<Text fontWeight="700" color="$color">
{event?.is_active ? t('events.workspace.actions.pause', 'Event pausieren') : t('events.workspace.actions.activate', 'Event aktivieren')}
</Text>
</XStack>
</Button>
</AppCard>
{loading ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<AppCard key={`kpi-skeleton-${idx}`} height={80} opacity={0.5} />
))}
</YStack>
) : (
<XStack space="$2" flexWrap="wrap">
{kpis.map((kpi) => (
<KpiCard key={kpi.label} label={kpi.label} value={kpi.value} tone={kpi.tone} icon={kpi.icon} />
))}
</XStack>
)}
<AppCard>
<Text fontSize="$md" fontWeight="800" color="$color">
{t('events.detail.managementTitle', 'Event Management')}
</Text>
<Text fontSize="$sm" color="$color">
{t('events.detail.managementSubtitle', 'Schnelle Aktionen für Aufgaben, Gäste und Layouts.')}
</Text>
<XStack flexWrap="wrap" space="$2" marginTop="$3">
{actions.map((action) => (
<Pressable key={action.key} style={{ width: '48%' }} onPress={() => navigate(action.to)}>
<YStack
borderRadius="$card"
padding="$3"
backgroundColor="#f8fafc"
borderWidth={1}
borderColor="$muted"
space="$2"
minHeight={110}
justifyContent="space-between"
>
<XStack alignItems="center" space="$2">
<XStack
width={36}
height={36}
borderRadius="$pill"
backgroundColor={action.color}
alignItems="center"
justifyContent="center"
>
<action.icon size={18} color="white" />
</XStack>
<Text fontSize="$sm" fontWeight="700" color="$color">
{action.label}
</Text>
</XStack>
<Text fontSize="$xs" color="$color">
{t('events.detail.open', 'Open')}
</Text>
</YStack>
</Pressable>
))}
</XStack>
</AppCard>
</YStack>
<BottomNav
active="events"
onNavigate={(key) => {
if (key === 'analytics') {
navigate(adminPath('/dashboard'));
} else if (key === 'settings') {
navigate(adminPath('/settings'));
} else {
navigate(ADMIN_EVENTS_PATH);
}
}}
/>
</AdminLayout>
);
}
function resolveName(name: TenantEvent['name'] | undefined | null): string | null {
if (!name) return null;
if (typeof name === 'string') return name;
if (typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? null;
}
return null;
}
function resolveLocation(event: TenantEvent | null): string {
if (!event?.settings) return 'Location tbd';
const maybeAddress =
(event.settings as Record<string, unknown>).location ??
(event.settings as Record<string, unknown>).address ??
(event.settings as Record<string, unknown>).city;
if (typeof maybeAddress === 'string' && maybeAddress.trim()) {
return maybeAddress;
}
return 'Location tbd';
}
function formatDate(value?: string | null): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
}
function statusLabel(event: TenantEvent | null, t: ReturnType<typeof useTranslation>['t']): string {
if (!event) return t('events.status.draft', 'Entwurf');
if (event.status === 'published') {
return t('events.status.published', 'Live');
}
if (event.status === 'archived') {
return t('events.status.archived', 'Archiviert');
}
return t('events.status.draft', 'Entwurf');
}
function KpiCard({
label,
value,
tone,
icon: IconCmp,
}: {
label: string;
value: string | number;
tone: string;
icon: React.ComponentType<{ size?: number; color?: string }>;
}) {
return (
<YStack
width="31%"
minWidth={180}
borderRadius="$card"
borderWidth={1}
borderColor="$muted"
backgroundColor="white"
padding="$3"
space="$1.5"
shadowColor="#0f172a"
shadowOpacity={0.05}
shadowRadius={12}
shadowOffset={{ width: 0, height: 6 }}
>
<XStack alignItems="center" space="$2">
<XStack width={32} height={32} borderRadius="$pill" backgroundColor={`${tone}22`} alignItems="center" justifyContent="center">
<IconCmp size={16} color={tone} />
</XStack>
<Text fontSize="$xs" color="$color">
{label}
</Text>
</XStack>
<Text fontSize="$xl" fontWeight="800" color="$color">
{value}
</Text>
</YStack>
);
}