Files
fotospiel-app/resources/js/admin/pages/DashboardPage.tsx
2025-10-28 18:28:22 +01:00

789 lines
27 KiB
TypeScript

import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
CalendarDays,
Camera,
Sparkles,
Users,
Plus,
Settings,
CheckCircle2,
Circle,
QrCode,
ClipboardList,
Package as PackageIcon,
Loader2,
} from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AdminLayout } from '../components/AdminLayout';
import {
DashboardSummary,
getDashboardSummary,
getEvents,
getTenantPackagesOverview,
getEventTasks,
getEventQrInvites,
TenantEvent,
TenantPackageSummary,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { useAuth } from '../auth/context';
import {
adminPath,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENTS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_TASKS_PATH,
ADMIN_BILLING_PATH,
ADMIN_SETTINGS_PATH,
ADMIN_WELCOME_BASE_PATH,
ADMIN_EVENT_CREATE_PATH,
} from '../constants';
import { useOnboardingProgress } from '../onboarding';
interface DashboardState {
summary: DashboardSummary | null;
events: TenantEvent[];
activePackage: TenantPackageSummary | null;
loading: boolean;
errorKey: string | null;
}
type ReadinessState = {
hasEvent: boolean;
hasTasks: boolean;
hasQrInvites: boolean;
hasPackage: boolean;
primaryEventSlug: string | null;
primaryEventName: string | null;
loading: boolean;
};
export default function DashboardPage() {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const { progress, markStep } = useOnboardingProgress();
const { t, i18n } = useTranslation('dashboard', { keyPrefix: 'dashboard' });
const { t: tc } = useTranslation('common');
const translate = React.useCallback(
(key: string, options?: Record<string, unknown>) => {
const value = t(key, options);
if (value === `dashboard.${key}`) {
const fallback = i18n.t(`dashboard:${key}`, options);
return fallback === `dashboard:${key}` ? value : fallback;
}
return value;
},
[t, i18n],
);
const [state, setState] = React.useState<DashboardState>({
summary: null,
events: [],
activePackage: null,
loading: true,
errorKey: null,
});
const [readiness, setReadiness] = React.useState<ReadinessState>({
hasEvent: false,
hasTasks: false,
hasQrInvites: false,
hasPackage: false,
primaryEventSlug: null,
primaryEventName: null,
loading: false,
});
React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const [summary, events, packages] = await Promise.all([
getDashboardSummary().catch(() => null),
getEvents().catch(() => [] as TenantEvent[]),
getTenantPackagesOverview().catch(() => ({ packages: [], activePackage: null })),
]);
if (cancelled) {
return;
}
const fallbackSummary = buildSummaryFallback(events, packages.activePackage);
const primaryEvent = events[0] ?? null;
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
setReadiness({
hasEvent: events.length > 0,
hasTasks: false,
hasQrInvites: false,
hasPackage: Boolean(packages.activePackage),
primaryEventSlug: primaryEvent?.slug ?? null,
primaryEventName,
loading: Boolean(primaryEvent),
});
setState({
summary: summary ?? fallbackSummary,
events,
activePackage: packages.activePackage,
loading: false,
errorKey: null,
});
if (primaryEvent) {
try {
const [eventTasks, qrInvites] = await Promise.all([
getEventTasks(primaryEvent.id, 1),
getEventQrInvites(primaryEvent.slug),
]);
if (!cancelled) {
setReadiness((prev) => ({
...prev,
hasTasks: (eventTasks.data ?? []).length > 0,
hasQrInvites: qrInvites.length > 0,
loading: false,
}));
}
} catch (readinessError) {
if (!cancelled) {
console.warn('Failed to load readiness checklist', readinessError);
setReadiness((prev) => ({ ...prev, loading: false }));
}
}
} else if (!cancelled) {
setReadiness((prev) => ({
...prev,
hasTasks: false,
hasQrInvites: false,
loading: false,
}));
}
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
errorKey: 'loadFailed',
loading: false,
}));
}
}
})();
return () => {
cancelled = true;
};
}, []);
const { summary, events, activePackage, loading, errorKey } = state;
React.useEffect(() => {
if (loading) {
return;
}
if (!progress.eventCreated && events.length === 0 && !location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH)) {
navigate(ADMIN_WELCOME_BASE_PATH, { replace: true });
return;
}
if (events.length > 0 && !progress.eventCreated) {
markStep({ eventCreated: true });
}
}, [loading, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
const greetingName = user?.name ?? translate('welcome.fallbackName');
const greetingTitle = translate('welcome.greeting', { name: greetingName });
const subtitle = translate('welcome.subtitle');
const errorMessage = errorKey ? translate(`errors.${errorKey}`) : null;
const dateLocale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const upcomingEvents = getUpcomingEvents(events);
const publishedEvents = events.filter((event) => event.status === 'published');
const actions = (
<>
<Button
className="bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
>
<Plus className="h-4 w-4" /> {translate('actions.newEvent')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-brand-rose-soft text-brand-rose">
<CalendarDays className="h-4 w-4" /> {translate('actions.allEvents')}
</Button>
{events.length === 0 && (
<Button
variant="outline"
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
>
<Sparkles className="h-4 w-4" /> {translate('actions.guidedSetup')}
</Button>
)}
</>
);
return (
<AdminLayout title={greetingTitle} subtitle={subtitle} actions={actions}>
{errorMessage && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard.alerts.errorTitle')}</AlertTitle>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
{loading ? (
<DashboardSkeleton />
) : (
<>
{events.length === 0 && (
<Card className="border-0 bg-brand-card shadow-brand-primary">
<CardHeader className="space-y-3">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-brand-rose" />
{translate('welcomeCard.title')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{translate('welcomeCard.summary')}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-2 text-sm text-slate-600">
<p>{translate('welcomeCard.body1')}</p>
<p>{translate('welcomeCard.body2')}</p>
</div>
<Button
size="lg"
className="rounded-full bg-rose-500 text-white shadow-lg shadow-rose-400/40 hover:bg-rose-500/90"
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
>
{translate('welcomeCard.cta')}
</Button>
</CardContent>
</Card>
)}
<Card className="border-0 bg-brand-card shadow-brand-primary">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-brand-rose" />
{translate('overview.title')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{translate('overview.description')}
</CardDescription>
</div>
<Badge className="bg-brand-rose-soft text-brand-rose">
{activePackage?.package_name ?? translate('overview.noPackage')}
</Badge>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<StatCard
label={translate('overview.stats.activeEvents')}
value={summary?.active_events ?? publishedEvents.length}
hint={translate('overview.stats.publishedHint', { count: publishedEvents.length })}
icon={<CalendarDays className="h-5 w-5 text-brand-rose" />}
/>
<StatCard
label={translate('overview.stats.newPhotos')}
value={summary?.new_photos ?? 0}
icon={<Camera className="h-5 w-5 text-fuchsia-500" />}
/>
<StatCard
label={translate('overview.stats.taskProgress')}
value={`${Math.round(summary?.task_progress ?? 0)}%`}
icon={<Users className="h-5 w-5 text-amber-500" />}
/>
{activePackage ? (
<StatCard
label={translate('overview.stats.activePackage')}
value={activePackage.package_name}
icon={<Sparkles className="h-5 w-5 text-sky-500" />}
/>
) : null}
</CardContent>
</Card>
<Card className="border-0 bg-brand-card shadow-brand-primary">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-xl text-slate-900">{translate('quickActions.title')}</CardTitle>
<CardDescription className="text-sm text-slate-600">
{translate('quickActions.description')}
</CardDescription>
</div>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<QuickAction
icon={<Plus className="h-5 w-5" />}
label={translate('quickActions.createEvent.label')}
description={translate('quickActions.createEvent.description')}
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
/>
<QuickAction
icon={<Camera className="h-5 w-5" />}
label={translate('quickActions.moderatePhotos.label')}
description={translate('quickActions.moderatePhotos.description')}
onClick={() => navigate(ADMIN_EVENTS_PATH)}
/>
<QuickAction
icon={<Users className="h-5 w-5" />}
label={translate('quickActions.organiseTasks.label')}
description={translate('quickActions.organiseTasks.description')}
onClick={() => navigate(ADMIN_TASKS_PATH)}
/>
<QuickAction
icon={<Sparkles className="h-5 w-5" />}
label={translate('quickActions.managePackages.label')}
description={translate('quickActions.managePackages.description')}
onClick={() => navigate(ADMIN_BILLING_PATH)}
/>
</CardContent>
</Card>
<ReadinessCard
readiness={readiness}
labels={{
title: translate('readiness.title'),
description: translate('readiness.description'),
pending: translate('readiness.pending'),
complete: translate('readiness.complete'),
items: {
event: {
title: translate('readiness.items.event.title'),
hint: translate('readiness.items.event.hint'),
},
tasks: {
title: translate('readiness.items.tasks.title'),
hint: translate('readiness.items.tasks.hint'),
},
qr: {
title: translate('readiness.items.qr.title'),
hint: translate('readiness.items.qr.hint'),
},
package: {
title: translate('readiness.items.package.title'),
hint: translate('readiness.items.package.hint'),
},
},
actions: {
createEvent: translate('readiness.actions.createEvent'),
openTasks: translate('readiness.actions.openTasks'),
openQr: translate('readiness.actions.openQr'),
openPackages: translate('readiness.actions.openPackages'),
},
}}
onCreateEvent={() => navigate(ADMIN_EVENT_CREATE_PATH)}
onOpenTasks={() =>
readiness.primaryEventSlug
? navigate(ADMIN_EVENT_TASKS_PATH(readiness.primaryEventSlug))
: navigate(ADMIN_TASKS_PATH)
}
onOpenQr={() =>
readiness.primaryEventSlug
? navigate(`${ADMIN_EVENT_VIEW_PATH(readiness.primaryEventSlug)}#qr-invites`)
: navigate(ADMIN_EVENTS_PATH)
}
onOpenPackages={() => navigate(ADMIN_BILLING_PATH)}
/>
<Card className="border-0 bg-brand-card shadow-brand-primary">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-xl text-slate-900">{translate('upcoming.title')}</CardTitle>
<CardDescription className="text-sm text-slate-600">
{translate('upcoming.description')}
</CardDescription>
</div>
<Button variant="outline" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
<Settings className="h-4 w-4" />
{translate('upcoming.settings')}
</Button>
</CardHeader>
<CardContent className="space-y-3">
{upcomingEvents.length === 0 ? (
<EmptyState
message={translate('upcoming.empty.message')}
ctaLabel={translate('upcoming.empty.cta')}
onCta={() => navigate(adminPath('/events/new'))}
/>
) : (
upcomingEvents.map((event) => (
<UpcomingEventRow
key={event.id}
event={event}
onView={() => navigate(ADMIN_EVENT_VIEW_PATH(event.slug))}
locale={dateLocale}
labels={{
live: translate('upcoming.status.live'),
planning: translate('upcoming.status.planning'),
open: tc('actions.open'),
noDate: translate('upcoming.status.noDate'),
}}
/>
))
)}
</CardContent>
</Card>
</>
)}
</AdminLayout>
);
}
function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string {
if (typeof name === 'string' && name.trim().length > 0) {
return name;
}
if (name && typeof name === 'object') {
if (typeof name.de === 'string' && name.de.trim().length > 0) {
return name.de;
}
if (typeof name.en === 'string' && name.en.trim().length > 0) {
return name.en;
}
const first = Object.values(name).find((value) => typeof value === 'string' && value.trim().length > 0);
if (typeof first === 'string') {
return first;
}
}
return fallbackSlug || 'Event';
}
function buildSummaryFallback(
events: TenantEvent[],
activePackage: TenantPackageSummary | null
): DashboardSummary {
const activeEvents = events.filter((event) => Boolean(event.is_active || event.status === 'published'));
const totalPhotos = events.reduce((sum, event) => sum + Number(event.photo_count ?? 0), 0);
return {
active_events: activeEvents.length,
new_photos: totalPhotos,
task_progress: 0,
upcoming_events: activeEvents.length,
active_package: activePackage
? {
name: activePackage.package_name,
remaining_events: activePackage.remaining_events,
expires_at: activePackage.expires_at,
}
: null,
};
}
function getUpcomingEvents(events: TenantEvent[]): TenantEvent[] {
const now = new Date();
return events
.filter((event) => {
if (!event.event_date) return false;
const date = new Date(event.event_date);
return !Number.isNaN(date.getTime()) && date >= now;
})
.sort((a, b) => {
const dateA = a.event_date ? new Date(a.event_date).getTime() : 0;
const dateB = b.event_date ? new Date(b.event_date).getTime() : 0;
return dateA - dateB;
})
.slice(0, 4);
}
type ReadinessLabels = {
title: string;
description: string;
pending: string;
complete: string;
items: {
event: { title: string; hint: string };
tasks: { title: string; hint: string };
qr: { title: string; hint: string };
package: { title: string; hint: string };
};
actions: {
createEvent: string;
openTasks: string;
openQr: string;
openPackages: string;
};
};
function ReadinessCard({
readiness,
labels,
onCreateEvent,
onOpenTasks,
onOpenQr,
onOpenPackages,
}: {
readiness: ReadinessState;
labels: ReadinessLabels;
onCreateEvent: () => void;
onOpenTasks: () => void;
onOpenQr: () => void;
onOpenPackages: () => void;
}) {
const checklistItems = [
{
key: 'event',
icon: <CalendarDays className="h-5 w-5" />,
completed: readiness.hasEvent,
label: labels.items.event.title,
hint: labels.items.event.hint,
actionLabel: labels.actions.createEvent,
onAction: onCreateEvent,
showAction: !readiness.hasEvent,
},
{
key: 'tasks',
icon: <ClipboardList className="h-5 w-5" />,
completed: readiness.hasTasks,
label: labels.items.tasks.title,
hint: labels.items.tasks.hint,
actionLabel: labels.actions.openTasks,
onAction: onOpenTasks,
showAction: readiness.hasEvent && !readiness.hasTasks,
},
{
key: 'qr',
icon: <QrCode className="h-5 w-5" />,
completed: readiness.hasQrInvites,
label: labels.items.qr.title,
hint: labels.items.qr.hint,
actionLabel: labels.actions.openQr,
onAction: onOpenQr,
showAction: readiness.hasEvent && !readiness.hasQrInvites,
},
{
key: 'package',
icon: <PackageIcon className="h-5 w-5" />,
completed: readiness.hasPackage,
label: labels.items.package.title,
hint: labels.items.package.hint,
actionLabel: labels.actions.openPackages,
onAction: onOpenPackages,
showAction: !readiness.hasPackage,
},
] as const;
const activeEventName = readiness.primaryEventName;
return (
<Card className="border-0 bg-brand-card shadow-brand-primary">
<CardHeader className="space-y-2">
<CardTitle className="text-xl text-slate-900">{labels.title}</CardTitle>
<CardDescription className="text-sm text-slate-600">{labels.description}</CardDescription>
{activeEventName ? (
<p className="text-xs uppercase tracking-wide text-brand-rose-soft">
{activeEventName}
</p>
) : null}
</CardHeader>
<CardContent className="space-y-3">
{readiness.loading ? (
<div className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-white/70 px-4 py-3 text-xs text-slate-500">
<Loader2 className="h-4 w-4 animate-spin" />
{labels.pending}
</div>
) : (
checklistItems.map((item) => (
<ChecklistRow
key={item.key}
icon={item.icon}
label={item.label}
hint={item.hint}
completed={item.completed}
status={{ complete: labels.complete, pending: labels.pending }}
action={
item.showAction
? {
label: item.actionLabel,
onClick: item.onAction,
disabled:
(item.key === 'tasks' || item.key === 'qr') && !readiness.primaryEventSlug,
}
: undefined
}
/>
))
)}
</CardContent>
</Card>
);
}
function ChecklistRow({
icon,
label,
hint,
completed,
status,
action,
}: {
icon: React.ReactNode;
label: string;
hint: string;
completed: boolean;
status: { complete: string; pending: string };
action?: { label: string; onClick: () => void; disabled?: boolean };
}) {
return (
<div className="flex flex-col gap-3 rounded-2xl border border-brand-rose-soft/40 bg-white/85 p-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-start gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${completed ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-100 text-slate-500'}`}>
{icon}
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900">{label}</p>
<p className="text-xs text-slate-600">{hint}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className={`flex items-center gap-1 text-xs font-medium ${completed ? 'text-emerald-600' : 'text-slate-500'}`}>
{completed ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
{completed ? status.complete : status.pending}
</span>
{action ? (
<Button size="sm" variant="outline" onClick={action.onClick} disabled={action.disabled}>
{action.label}
</Button>
) : null}
</div>
</div>
);
}
function StatCard({
label,
value,
hint,
icon,
}: {
label: string;
value: string | number;
hint?: string;
icon: React.ReactNode;
}) {
return (
<div className="rounded-2xl border border-brand-rose-soft bg-brand-card p-5 shadow-md shadow-pink-100/40 transition-transform hover:-translate-y-0.5 hover:shadow-lg">
<div className="flex items-center justify-between">
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose">{icon}</span>
</div>
<div className="mt-4 text-2xl font-semibold text-slate-900">{value}</div>
{hint && <p className="mt-2 text-xs text-slate-500">{hint}</p>}
</div>
);
}
function QuickAction({
icon,
label,
description,
onClick,
}: {
icon: React.ReactNode;
label: string;
description: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex flex-col items-start gap-2 rounded-2xl border border-slate-100 bg-white/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-brand-rose-soft hover:shadow-md focus:outline-none focus:ring-2 focus:ring-brand-rose/40"
>
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose">{icon}</span>
<span className="text-sm font-semibold text-slate-900">{label}</span>
<span className="text-xs text-slate-600">{description}</span>
</button>
);
}
function UpcomingEventRow({
event,
onView,
locale,
labels,
}: {
event: TenantEvent;
onView: () => void;
locale: string;
labels: {
live: string;
planning: string;
open: string;
noDate: string;
};
}) {
const date = event.event_date ? new Date(event.event_date) : null;
const formattedDate = date
? date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' })
: labels.noDate;
return (
<div className="flex flex-col gap-2 rounded-2xl border border-sky-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">{formattedDate}</span>
<div className="flex items-center gap-2">
<Badge className={event.status === 'published' ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-600'}>
{event.status === 'published' ? labels.live : labels.planning}
</Badge>
<Button
size="sm"
variant="outline"
onClick={onView}
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
>
{labels.open}
</Button>
</div>
</div>
</div>
);
}
function EmptyState({ message, ctaLabel, onCta }: { message: string; ctaLabel: string; onCta: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-10 text-center">
<div className="rounded-full bg-brand-rose-soft p-3 text-brand-rose shadow-inner shadow-pink-200/80">
<Sparkles className="h-5 w-5" />
</div>
<p className="text-sm text-slate-600">{message}</p>
<Button onClick={onCta} className="bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]">
{ctaLabel}
</Button>
</div>
);
}
function DashboardSkeleton() {
return (
<div className="grid gap-6">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="space-y-4 rounded-2xl border border-white/60 bg-white/70 p-6 shadow-sm">
<div className="h-6 w-48 animate-pulse rounded bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((__ , cardIndex) => (
<div
key={cardIndex}
className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40"
/>
))}
</div>
</div>
))}
</div>
);
}