Refine dashboard overview layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-22 16:24:48 +01:00
parent 4f3503e3f4
commit 8aa2efdd9a
3 changed files with 155 additions and 116 deletions

View File

@@ -35,19 +35,25 @@ function translateLimits(t: any) {
// --- TAMAGUI-ALIGNED PRIMITIVES --- // --- TAMAGUI-ALIGNED PRIMITIVES ---
function DashboardCard({ children, style, ...rest }: React.ComponentProps<typeof Card>) { function DashboardCard({
children,
style,
variant = 'default',
...rest
}: React.ComponentProps<typeof Card> & { variant?: 'default' | 'embedded' }) {
const theme = useAdminTheme(); const theme = useAdminTheme();
const isEmbedded = variant === 'embedded';
return ( return (
<Card <Card
backgroundColor={theme.surface} backgroundColor={theme.surface}
borderRadius={20} borderRadius={isEmbedded ? 16 : 20}
borderWidth={1} borderWidth={1}
borderColor={theme.border} borderColor={theme.border}
padding="$3.5" padding="$3.5"
shadowColor={theme.shadow} shadowColor={theme.shadow}
shadowOpacity={0.16} shadowOpacity={isEmbedded ? 0 : 0.16}
shadowRadius={16} shadowRadius={isEmbedded ? 0 : 16}
shadowOffset={{ width: 0, height: 10 }} shadowOffset={isEmbedded ? { width: 0, height: 0 } : { width: 0, height: 10 }}
style={style} style={style}
{...rest} {...rest}
> >
@@ -60,26 +66,33 @@ function SectionHeader({
title, title,
subtitle, subtitle,
action, action,
showSeparator = true,
compact = false,
}: { }: {
title: string; title: string;
subtitle?: string; subtitle?: string;
action?: React.ReactNode; action?: React.ReactNode;
showSeparator?: boolean;
compact?: boolean;
}) { }) {
const theme = useAdminTheme(); const theme = useAdminTheme();
const titleSize = compact ? '$md' : '$lg';
const subtitleSize = compact ? '$xs' : '$sm';
const spacing = compact ? '$1' : '$1.5';
return ( return (
<YStack space="$1.5"> <YStack space={spacing}>
<XStack alignItems="center" justifyContent="space-between"> <XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$lg" fontWeight="800" color={theme.textStrong}> <Text fontSize={titleSize} fontWeight="800" color={theme.textStrong}>
{title} {title}
</Text> </Text>
{action ?? null} {action ?? null}
</XStack> </XStack>
{subtitle ? ( {subtitle ? (
<Text fontSize="$sm" color={theme.muted}> <Text fontSize={subtitleSize} color={theme.muted}>
{subtitle} {subtitle}
</Text> </Text>
) : null} ) : null}
<Separator backgroundColor={theme.border} opacity={0.6} /> {showSeparator ? <Separator backgroundColor={theme.border} opacity={0.6} /> : null}
</YStack> </YStack>
); );
} }
@@ -104,6 +117,7 @@ export default function MobileDashboardPage() {
const { t, i18n } = useTranslation(['management', 'dashboard', 'mobile']); const { t, i18n } = useTranslation(['management', 'dashboard', 'mobile']);
const { events, activeEvent, hasEvents, isLoading, selectEvent } = useEventContext(); const { events, activeEvent, hasEvents, isLoading, selectEvent } = useEventContext();
const { user } = useAuth(); const { user } = useAuth();
const theme = useAdminTheme();
const isMember = user?.role === 'member'; const isMember = user?.role === 'member';
// --- LOGIC --- // --- LOGIC ---
@@ -184,11 +198,17 @@ export default function MobileDashboardPage() {
return ( return (
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}> <MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
<DashboardCard padding="$0">
<YStack padding="$3.5" space="$2">
<SectionHeader <SectionHeader
title={t('dashboard:overview.title', 'At a glance')} title={t('dashboard:overview.title', 'At a glance')}
subtitle={t('dashboard:overview.description', 'Key customer metrics at a glance.')} subtitle={t('dashboard:overview.description', 'Key customer metrics at a glance.')}
showSeparator={false}
compact
/> />
</YStack>
<Separator backgroundColor={theme.border} opacity={0.6} />
<YStack padding="$3.5" space="$2.5">
{/* 1. LIFECYCLE HERO */} {/* 1. LIFECYCLE HERO */}
<LifecycleHero <LifecycleHero
event={activeEvent} event={activeEvent}
@@ -196,6 +216,7 @@ export default function MobileDashboardPage() {
locale={locale} locale={locale}
navigate={navigate} navigate={navigate}
readiness={readiness} readiness={readiness}
variant="embedded"
/> />
{/* 1b. SETUP CHECKLIST */} {/* 1b. SETUP CHECKLIST */}
@@ -203,11 +224,14 @@ export default function MobileDashboardPage() {
<SetupChecklist <SetupChecklist
steps={readiness.steps} steps={readiness.steps}
title={t('management:photobooth.checklist.title', 'Checklist')} title={t('management:photobooth.checklist.title', 'Checklist')}
variant="embedded"
/> />
)} )}
{/* 2. PULSE STRIP */} {/* 2. PULSE STRIP */}
<PulseStrip event={activeEvent} stats={stats} /> <PulseStrip event={activeEvent} stats={stats} />
</YStack>
</DashboardCard>
{/* 3. ALERTS */} {/* 3. ALERTS */}
<AlertsSection event={activeEvent} stats={stats} t={t} /> <AlertsSection event={activeEvent} stats={stats} t={t} />
@@ -250,9 +274,12 @@ function getEventPhase(event: TenantEvent): EventPhase {
return 'setup'; return 'setup';
} }
function LifecycleHero({ event, stats, locale, navigate, readiness }: any) { function LifecycleHero({ event, stats, locale, navigate, readiness, variant = 'default' }: any) {
const theme = useAdminTheme(); const theme = useAdminTheme();
const { t } = useTranslation(['management', 'dashboard']); const { t } = useTranslation(['management', 'dashboard']);
const isEmbedded = variant === 'embedded';
const cardVariant = isEmbedded ? 'embedded' : 'default';
const cardPadding = isEmbedded ? '$3' : '$3.5';
if (!event) return null; if (!event) return null;
const phase = getEventPhase(event); const phase = getEventPhase(event);
@@ -275,11 +302,13 @@ function LifecycleHero({ event, stats, locale, navigate, readiness }: any) {
<YStack space="$2"> <YStack space="$2">
<Header /> <Header />
<DashboardCard <DashboardCard
variant={cardVariant}
padding={cardPadding}
backgroundColor={theme.primary} backgroundColor={theme.primary}
borderColor="transparent" borderColor="transparent"
style={{ backgroundImage: 'linear-gradient(135deg, #4F46E5 0%, #4338CA 100%)' }} style={{ backgroundImage: 'linear-gradient(135deg, #4F46E5 0%, #4338CA 100%)' }}
> >
<YStack space="$3"> <YStack space={isEmbedded ? '$2.5' : '$3'}>
<XStack alignItems="center" justifyContent="space-between"> <XStack alignItems="center" justifyContent="space-between">
<YStack space="$1"> <YStack space="$1">
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
@@ -321,8 +350,8 @@ function LifecycleHero({ event, stats, locale, navigate, readiness }: any) {
return ( return (
<YStack space="$2"> <YStack space="$2">
<Header /> <Header />
<DashboardCard> <DashboardCard variant={cardVariant} padding={cardPadding}>
<YStack space="$3"> <YStack space={isEmbedded ? '$2.5' : '$3'}>
<XStack alignItems="center" space="$2.5"> <XStack alignItems="center" space="$2.5">
<YStack width={40} height={40} borderRadius={20} backgroundColor={theme.success} alignItems="center" justifyContent="center"> <YStack width={40} height={40} borderRadius={20} backgroundColor={theme.success} alignItems="center" justifyContent="center">
<CheckCircle2 size={20} color="white" /> <CheckCircle2 size={20} color="white" />
@@ -379,8 +408,8 @@ function LifecycleHero({ event, stats, locale, navigate, readiness }: any) {
return ( return (
<YStack space="$2"> <YStack space="$2">
<Header /> <Header />
<DashboardCard> <DashboardCard variant={cardVariant} padding={cardPadding}>
<YStack space="$3"> <YStack space={isEmbedded ? '$2.5' : '$3'}>
<XStack alignItems="center" justifyContent="space-between"> <XStack alignItems="center" justifyContent="space-between">
<YStack> <YStack>
<Text fontSize="$xs" color={theme.muted} fontWeight="700" textTransform="uppercase"> <Text fontSize="$xs" color={theme.muted} fontWeight="700" textTransform="uppercase">
@@ -437,30 +466,27 @@ function PulseStrip({ event, stats }: any) {
const pendingCount = stats?.pending_photos ?? event?.pending_photo_count ?? 0; const pendingCount = stats?.pending_photos ?? event?.pending_photo_count ?? 0;
return ( return (
<YStack space="$2">
<SectionHeader title={t('management:eventMenu.summary', 'Overview')} />
<KpiStrip items={[ <KpiStrip items={[
{ {
icon: ImageIcon, icon: ImageIcon,
value: uploadCount, value: uploadCount,
label: t('management:events.list.stats.photos', 'Photos'), label: t('management:events.list.stats.photos', 'Photos'),
color: theme.primary color: theme.primary,
}, },
{ {
icon: Users, icon: Users,
value: guestCount, value: guestCount,
label: t('management:events.list.stats.guests', 'Guests'), label: t('management:events.list.stats.guests', 'Guests'),
color: theme.textStrong color: theme.textStrong,
}, },
{ {
icon: ShieldCheck, icon: ShieldCheck,
value: pendingCount, value: pendingCount,
label: t('management:photos.filters.pending', 'Pending'), label: t('management:photos.filters.pending', 'Pending'),
note: pendingCount > 0 ? t('management:common.actionNeeded', 'Review') : undefined, note: pendingCount > 0 ? t('management:common.actionNeeded', 'Review') : undefined,
color: pendingCount > 0 ? '#8B5CF6' : theme.textMuted color: pendingCount > 0 ? '#8B5CF6' : theme.textMuted,
} },
]} /> ]} />
</YStack>
); );
} }

View File

@@ -259,6 +259,8 @@ vi.mock('../theme', () => ({
useAdminTheme: () => ({ useAdminTheme: () => ({
textStrong: '#0f172a', textStrong: '#0f172a',
text: '#0f172a', text: '#0f172a',
textMuted: '#94a3b8',
accent: '#7c3aed',
muted: '#64748b', muted: '#64748b',
border: '#e2e8f0', border: '#e2e8f0',
surface: '#ffffff', surface: '#ffffff',

View File

@@ -1,33 +1,50 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Card } from '@tamagui/card';
import { YGroup } from '@tamagui/group';
import { ListItem } from '@tamagui/list-item';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { CheckCircle2, Circle, ChevronDown, ChevronUp } from 'lucide-react'; import { CheckCircle2, Circle, ChevronDown, ChevronUp } from 'lucide-react';
import { Separator } from 'tamagui';
import { useAdminTheme } from '../theme'; import { useAdminTheme } from '../theme';
import { ReadinessStep } from '../hooks/useEventReadiness'; import { ReadinessStep } from '../hooks/useEventReadiness';
import { adminPath } from '../../constants'; import { adminPath } from '../../constants';
import { PillBadge } from './Primitives';
export function SetupChecklist({ steps, title }: { steps: ReadinessStep[]; title: string }) { export function SetupChecklist({
steps,
title,
variant = 'card',
}: {
steps: ReadinessStep[];
title: string;
variant?: 'card' | 'embedded';
}) {
const theme = useAdminTheme(); const theme = useAdminTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const isAllComplete = steps.every(s => s.isComplete); const isAllComplete = steps.every(s => s.isComplete);
const [collapsed, setCollapsed] = React.useState(isAllComplete); const [collapsed, setCollapsed] = React.useState(isAllComplete);
const isEmbedded = variant === 'embedded';
const completedCount = steps.filter(s => s.isComplete).length; const completedCount = steps.filter(s => s.isComplete).length;
return ( return (
<YStack <Card
backgroundColor={theme.surface} backgroundColor={theme.surface}
borderRadius={16} borderRadius={isEmbedded ? 16 : 20}
borderWidth={1} borderWidth={1}
borderColor={theme.border} borderColor={theme.border}
padding="$0" padding="$0"
overflow="hidden" overflow="hidden"
style={{ boxShadow: `0 2px 8px ${theme.shadow}` }} shadowColor={theme.shadow}
shadowOpacity={isEmbedded ? 0 : 0.16}
shadowRadius={isEmbedded ? 0 : 16}
shadowOffset={isEmbedded ? { width: 0, height: 0 } : { width: 0, height: 10 }}
> >
<Pressable onPress={() => setCollapsed(!collapsed)}> <Pressable onPress={() => setCollapsed(!collapsed)}>
<XStack padding="$3.5" paddingVertical="$3" alignItems="center" justifyContent="space-between"> <XStack padding="$3" paddingVertical="$2.5" alignItems="center" justifyContent="space-between">
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}> <Text fontSize="$xs" fontWeight="700" color={theme.muted} textTransform="uppercase" letterSpacing={1}>
{title} {title}
@@ -48,60 +65,54 @@ export function SetupChecklist({ steps, title }: { steps: ReadinessStep[]; title
{!collapsed && ( {!collapsed && (
<YStack> <YStack>
<Separator backgroundColor={theme.border} opacity={0.6} />
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: theme.border, overflow: "hidden" } as any)}>
{steps.map((step, index) => { {steps.map((step, index) => {
const isNext = !step.isComplete && steps.slice(0, index).every(s => s.isComplete); const isNext = !step.isComplete && steps.slice(0, index).every(s => s.isComplete);
return ( return (
<Pressable <YGroup.Item key={step.id}>
key={step.id} <ListItem
hoverTheme
pressTheme
paddingVertical="$2"
paddingHorizontal="$3"
backgroundColor={isNext ? theme.surfaceMuted : 'transparent'}
onPress={() => navigate(adminPath(step.targetPath))} onPress={() => navigate(adminPath(step.targetPath))}
style={{ title={
backgroundColor: isNext ? theme.surface : 'transparent', <XStack alignItems="center" space="$2.5">
}}
>
<XStack
paddingHorizontal="$3.5"
paddingVertical="$3"
alignItems="center"
space="$3"
borderTopWidth={1}
borderColor={theme.border}
>
{step.isComplete ? ( {step.isComplete ? (
<CheckCircle2 size={20} color={theme.successText} /> <CheckCircle2 size={18} color={theme.successText} />
) : isNext ? ( ) : isNext ? (
<Circle size={20} color={theme.primary} strokeWidth={2.5} /> <Circle size={18} color={theme.primary} strokeWidth={2.5} />
) : ( ) : (
<Circle size={20} color={theme.border} /> <Circle size={18} color={theme.border} />
)} )}
<YStack flex={1} space="$0.5">
<Text <Text
fontSize="$sm" fontSize="$sm"
fontWeight={isNext ? "700" : "500"} fontWeight={isNext ? '700' : '500'}
color={step.isComplete ? theme.muted : theme.textStrong} color={step.isComplete ? theme.muted : theme.textStrong}
textDecorationLine={step.isComplete ? 'line-through' : 'none'} textDecorationLine={step.isComplete ? 'line-through' : 'none'}
> >
{step.label} {step.label}
</Text> </Text>
{step.description && !step.isComplete && ( </XStack>
}
subTitle={
step.description && !step.isComplete ? (
<Text fontSize="$xs" color={theme.muted}> <Text fontSize="$xs" color={theme.muted}>
{step.description} {step.description}
</Text> </Text>
)} ) : undefined
</YStack> }
iconAfter={isNext ? <PillBadge tone="success">Start</PillBadge> : undefined}
{isNext && ( />
<YStack backgroundColor={theme.primary} borderRadius={999} paddingHorizontal="$2.5" paddingVertical="$1.5"> </YGroup.Item>
<Text fontSize="$xs" color="white" fontWeight="700">Start</Text>
</YStack>
)}
</XStack>
</Pressable>
); );
})} })}
</YGroup>
</YStack> </YStack>
)} )}
</YStack> </Card>
); );
} }