427 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|