Files
fotospiel-app/resources/js/admin/mobile/DashboardPage.tsx
Codex Agent 73e550ee87 Implemented a shared mobile shell and navigation aligned to the new architecture, plus refactored the dashboard and
tab flows.

  - Added a dynamic MobileShell with sticky header (notification bell with badge, quick QR when an event is
    active, event switcher for multi-event users) and stabilized bottom tabs (home, tasks, uploads, profile)
    driven by useMobileNav (resources/js/admin/mobile/components/MobileShell.tsx, components/BottomNav.tsx, hooks/
    useMobileNav.ts).
  - Centralized event handling now supports 0/1/many-event states without auto-selecting in multi-tenant mode and
    exposes helper flags/activeSlug for consumers (resources/js/admin/context/EventContext.tsx).
  - Rebuilt the mobile dashboard into explicit states: onboarding/no-event, single-event focus, and multi-event picker
    with featured/secondary actions, KPI strip, and alerts (resources/js/admin/mobile/DashboardPage.tsx).
  - Introduced tab entry points that respect event context and prompt selection when needed (resources/js/admin/
    mobile/TasksTabPage.tsx, UploadsTabPage.tsx). Refreshed tasks/uploads detail screens to use the new shell and sync
    event selection (resources/js/admin/mobile/EventTasksPage.tsx, EventPhotosPage.tsx).
  - Updated mobile routes and existing screens to the new tab keys and header/footer behavior (resources/js/admin/
    router.tsx, mobile/* pages, i18n nav/header strings).
2025-12-10 16:13:44 +01:00

366 lines
12 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 } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { CalendarDays, Image as ImageIcon, ListTodo, QrCode, Settings, Users, Sparkles } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, renderEventLocation } from './components/MobileShell';
import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge } from './components/Primitives';
import { adminPath } from '../constants';
import { useEventContext } from '../context/EventContext';
import { getEventStats, EventStats, TenantEvent } from '../api';
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
export default function MobileDashboardPage() {
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading } = useEventContext();
const { data: stats, isLoading: statsLoading } = useQuery<EventStats | null>({
queryKey: ['mobile', 'dashboard', 'stats', activeEvent?.slug],
enabled: Boolean(activeEvent?.slug),
queryFn: async () => {
if (!activeEvent?.slug) return null;
return await getEventStats(activeEvent.slug);
},
});
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
if (isLoading) {
return (
<MobileShell activeTab="home" title={t('events.list.dashboardTitle', 'Dashboard')}>
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`sk-${idx}`} height={110} opacity={0.6} />
))}
</YStack>
</MobileShell>
);
}
if (!hasEvents) {
return (
<MobileShell activeTab="home" title={t('events.list.dashboardTitle', 'Dashboard')}>
<OnboardingEmptyState />
</MobileShell>
);
}
if (hasMultipleEvents && !activeEvent) {
return (
<MobileShell activeTab="home" title={t('events.list.dashboardTitle', 'Dashboard')}>
<EventPickerList events={events} locale={locale} />
</MobileShell>
);
}
return (
<MobileShell
activeTab="home"
title={resolveEventDisplayName(activeEvent ?? undefined)}
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
>
<FeaturedActions
onReviewPhotos={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/photos`))}
onManageTasks={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/tasks`))}
onShowQr={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
/>
<SecondaryGrid
event={activeEvent}
onGuests={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
onPrint={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))}
onInvites={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/members`))}
onSettings={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}`))}
/>
<KpiStrip event={activeEvent} stats={stats} loading={statsLoading} locale={locale} />
<AlertsAndHints event={activeEvent} stats={stats} />
</MobileShell>
);
}
function OnboardingEmptyState() {
const { t } = useTranslation('management');
const navigate = useNavigate();
return (
<YStack space="$3">
<MobileCard alignItems="flex-start" space="$3">
<Text fontSize="$lg" fontWeight="800" color="#111827">
{t('events.list.empty.title', 'Create your first event')}
</Text>
<Text fontSize="$sm" color="#4b5563">
{t('events.list.empty.description', 'Start an event to manage tasks, QR posters and uploads.')}
</Text>
<CTAButton label={t('events.actions.create', 'Create Event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
<CTAButton label={t('events.actions.preview', 'View Demo')} tone="ghost" onPress={() => navigate(adminPath('/mobile/events'))} />
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="800" color="#111827">
{t('events.list.empty.highlights', 'What you can do')}
</Text>
<YStack space="$1.5">
{[
t('events.quick.images', 'Review photos & uploads'),
t('events.quick.tasks', 'Assign tasks & challenges'),
t('events.quick.qr', 'Share QR posters'),
t('events.quick.guests', 'Invite helpers & guests'),
].map((item) => (
<XStack key={item} alignItems="center" space="$2">
<PillBadge tone="muted">{item}</PillBadge>
</XStack>
))}
</YStack>
</MobileCard>
</YStack>
);
}
function EventPickerList({ events, locale }: { events: TenantEvent[]; locale: string }) {
const { t } = useTranslation('management');
const { selectEvent } = useEventContext();
return (
<YStack space="$2">
<Text fontSize="$sm" color="#111827" fontWeight="700">
{t('events.detail.pickEvent', 'Select an event')}
</Text>
{events.map((event) => (
<Pressable
key={event.slug}
onPress={() => selectEvent(event.slug ?? null)}
>
<MobileCard borderColor="#e5e7eb" space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color="#111827">
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color="#6b7280">
{formatEventDate(event.event_date, locale) ?? t('events.status.draft', 'Draft')}
</Text>
</YStack>
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
{event.status === 'published' ? t('events.status.published', 'Live') : t('events.status.draft', 'Draft')}
</PillBadge>
</XStack>
</MobileCard>
</Pressable>
))}
</YStack>
);
}
function FeaturedActions({
onReviewPhotos,
onManageTasks,
onShowQr,
}: {
onReviewPhotos: () => void;
onManageTasks: () => void;
onShowQr: () => void;
}) {
const { t } = useTranslation('management');
const cards = [
{
key: 'photos',
label: t('events.quick.images', 'Review Photos'),
desc: t('events.quick.images.desc', 'Moderate uploads and highlights'),
icon: ImageIcon,
color: '#0ea5e9',
action: onReviewPhotos,
},
{
key: 'tasks',
label: t('events.quick.tasks', 'Manage Tasks & Challenges'),
desc: t('events.quick.tasks.desc', 'Assign and track progress'),
icon: ListTodo,
color: '#22c55e',
action: onManageTasks,
},
{
key: 'qr',
label: t('events.quick.qr', 'Show / Share QR Code'),
desc: t('events.quick.qr.desc', 'Posters, cards, and links'),
icon: QrCode,
color: '#f59e0b',
action: onShowQr,
},
];
return (
<YStack space="$2">
{cards.map((card) => (
<Pressable key={card.key} onPress={card.action}>
<MobileCard borderColor={`${card.color}44`} backgroundColor={`${card.color}0f`} space="$2.5">
<XStack alignItems="center" space="$3">
<XStack width={44} height={44} borderRadius={14} backgroundColor={card.color} alignItems="center" justifyContent="center">
<card.icon size={20} color="white" />
</XStack>
<YStack space="$1" flex={1}>
<Text fontSize="$md" fontWeight="800" color="#111827">
{card.label}
</Text>
<Text fontSize="$xs" color="#334155">
{card.desc}
</Text>
</YStack>
<Text fontSize="$xl" color="#94a3b8">
˃
</Text>
</XStack>
</MobileCard>
</Pressable>
))}
</YStack>
);
}
function SecondaryGrid({
event,
onGuests,
onPrint,
onInvites,
onSettings,
}: {
event: TenantEvent | null;
onGuests: () => void;
onPrint: () => void;
onInvites: () => void;
onSettings: () => void;
}) {
const { t } = useTranslation('management');
const tiles = [
{
icon: Users,
label: t('events.quick.guests', 'Guest management'),
color: '#60a5fa',
action: onGuests,
},
{
icon: QrCode,
label: t('events.quick.prints', 'Print & poster downloads'),
color: '#fbbf24',
action: onPrint,
},
{
icon: Sparkles,
label: t('events.quick.invites', 'Team / helper invites'),
color: '#a855f7',
action: onInvites,
},
{
icon: Settings,
label: t('events.quick.settings', 'Event settings'),
color: '#10b981',
action: onSettings,
},
];
return (
<YStack space="$2" marginTop="$2">
<Text fontSize="$sm" fontWeight="800" color="#111827">
{t('events.quick.more', 'Shortcuts')}
</Text>
<XStack flexWrap="wrap" space="$2">
{tiles.map((tile) => (
<ActionTile key={tile.label} icon={tile.icon} label={tile.label} color={tile.color} onPress={tile.action} />
))}
</XStack>
{event ? (
<MobileCard backgroundColor="#f8fafc" borderColor="#e2e8f0" space="$1.5">
<Text fontSize="$sm" fontWeight="700" color="#0f172a">
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color="#475569">
{renderEventLocation(event)}
</Text>
</MobileCard>
) : null}
</YStack>
);
}
function KpiStrip({ event, stats, loading, locale }: { event: TenantEvent | null; stats: EventStats | null | undefined; loading: boolean; locale: string }) {
const { t } = useTranslation('management');
if (!event) return null;
const kpis = [
{
label: t('events.detail.kpi.tasks', 'Open tasks'),
value: event.tasks_count ?? '—',
icon: ListTodo,
},
{
label: t('events.detail.kpi.photos', 'Photos'),
value: stats?.uploads_total ?? event.photo_count ?? '—',
icon: ImageIcon,
},
{
label: t('events.detail.kpi.guests', 'Guests'),
value: event.active_invites_count ?? event.total_invites_count ?? '—',
icon: Users,
},
];
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color="#111827">
{t('dashboard.kpis', 'Key Performance Indicators')}
</Text>
{loading ? (
<XStack space="$2" flexWrap="wrap">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`kpi-${idx}`} height={90} width="32%" />
))}
</XStack>
) : (
<XStack space="$2" flexWrap="wrap">
{kpis.map((kpi) => (
<KpiTile key={kpi.label} icon={kpi.icon} label={kpi.label} value={kpi.value ?? '—'} />
))}
</XStack>
)}
<Text fontSize="$xs" color="#94a3b8">
{formatEventDate(event.event_date, locale) ?? ''}
</Text>
</YStack>
);
}
function AlertsAndHints({ event, stats }: { event: TenantEvent | null; stats: EventStats | null | undefined }) {
const { t } = useTranslation('management');
if (!event) return null;
const alerts: string[] = [];
if (stats?.pending_photos) {
alerts.push(t('events.alerts.pendingPhotos', '{{count}} new uploads awaiting moderation', { count: stats.pending_photos }));
}
if (event.tasks_count) {
alerts.push(t('events.alerts.tasksOpen', '{{count}} tasks due or open', { count: event.tasks_count }));
}
if (alerts.length === 0) {
return null;
}
return (
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color="#111827">
{t('alerts.title', 'Alerts')}
</Text>
{alerts.map((alert) => (
<MobileCard key={alert} backgroundColor="#fff7ed" borderColor="#fed7aa" space="$2">
<Text fontSize="$sm" color="#9a3412">
{alert}
</Text>
</MobileCard>
))}
</YStack>
);
}