Files
fotospiel-app/resources/js/pages/dashboard.tsx

483 lines
24 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 { useMemo, useState } from 'react';
import { AlertTriangle, CalendarDays, Camera, ClipboardList, Package, Sparkles, TrendingUp, UserRound, Key } from 'lucide-react';
import { Head, Link, router, usePage } from '@inertiajs/react';
import AppLayout from '@/layouts/app-layout';
import { dashboard } from '@/routes';
import { edit as passwordSettings } from '@/routes/password';
import profileRoutes from '@/routes/profile';
import { send as resendVerificationRoute } from '@/routes/verification';
import { type BreadcrumbItem, type SharedData } from '@/types';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
type DashboardMetrics = {
total_events: number;
active_events: number;
published_events: number;
events_with_tasks: number;
upcoming_events: number;
new_photos: number;
task_progress: number;
credit_balance: number | null;
active_package: {
name: string;
expires_at: string | null;
remaining_events: number | null;
} | null;
};
type DashboardEvent = {
id: number;
name: string;
slug: string | null;
status: string | null;
isActive: boolean;
date: string | null;
photosCount: number;
tasksCount: number;
joinTokensCount: number;
};
type DashboardPurchase = {
id: number;
packageName: string;
price: number | null;
purchasedAt: string | null;
type: string | null;
provider: string | null;
};
type TenantSummary = {
id: number;
name: string;
eventCreditsBalance: number | null;
subscriptionStatus: string | null;
subscriptionExpiresAt: string | null;
activePackage: {
name: string;
price: number | null;
expiresAt: string | null;
remainingEvents: number | null;
} | null;
} | null;
type DashboardPageProps = {
metrics: DashboardMetrics | null;
upcomingEvents: DashboardEvent[];
recentPurchases: DashboardPurchase[];
latestPurchase: DashboardPurchase | null;
tenant: TenantSummary;
emailVerification: {
mustVerify: boolean;
verified: boolean;
};
};
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Dashboard',
href: dashboard().url,
},
];
export default function Dashboard() {
const { metrics, upcomingEvents, recentPurchases, latestPurchase, tenant, emailVerification, locale } = usePage<SharedData & DashboardPageProps>().props;
const [verificationSent, setVerificationSent] = useState(false);
const [sendingVerification, setSendingVerification] = useState(false);
const needsEmailVerification = emailVerification.mustVerify && !emailVerification.verified;
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
}), [locale]);
const currencyFormatter = useMemo(() => new Intl.NumberFormat(locale ?? 'de-DE', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 2,
}), [locale]);
const taskProgress = metrics?.task_progress ?? 0;
const checklistItems = [
{
key: 'verify-email',
title: 'E-Mail-Adresse bestätigen',
description: 'Bestätige deine Adresse, um Einladungen zu versenden und Benachrichtigungen zu erhalten.',
done: !needsEmailVerification,
},
{
key: 'create-event',
title: 'Dein erstes Event erstellen',
description: 'Starte mit einem Event-Blueprint und passe Agenda, Uploadregeln und Branding an.',
done: (metrics?.total_events ?? 0) > 0,
},
{
key: 'publish-event',
title: 'Event veröffentlichen',
description: 'Schalte dein Event frei, damit Gäste über den Link Fotos hochladen können.',
done: (metrics?.published_events ?? 0) > 0,
},
{
key: 'invite-guests',
title: 'Gästelink teilen',
description: 'Nutze QR-Code oder Link, um Gäste einzuladen und erste Uploads zu sammeln.',
done: upcomingEvents.some((event) => event.joinTokensCount > 0),
},
{
key: 'collect-photos',
title: 'Fotos sammeln',
description: 'Spare Zeit bei der Nachbereitung: neue Uploads erscheinen direkt in deinem Event.',
done: (metrics?.new_photos ?? 0) > 0,
},
];
const quickActions = [
{
key: 'tenant-admin',
label: 'Event-Admin öffnen',
description: 'Detaillierte Eventverwaltung, Moderation und Live-Features.',
href: '/event-admin',
icon: Sparkles,
},
{
key: 'profile',
label: 'Profil verwalten',
description: 'Kontaktinformationen, Sprache und E-Mail-Adresse anpassen.',
href: profileRoutes.index().url,
icon: UserRound,
},
{
key: 'password',
label: 'Passwort aktualisieren',
description: 'Sichere dein Konto mit einem aktuellen Passwort.',
href: passwordSettings().url,
icon: Key,
},
{
key: 'packages',
label: 'Pakete entdecken',
description: 'Mehr Events oder Speicher buchen du bleibst flexibel.',
href: `/${locale ?? 'de'}/packages`,
icon: Package,
},
];
const stats = [
{
key: 'active-events',
label: 'Aktive Events',
value: metrics?.active_events ?? 0,
description: metrics?.active_events
? 'Events sind live und für Gäste sichtbar.'
: 'Noch kein Event veröffentlicht starte heute!',
icon: CalendarDays,
},
{
key: 'upcoming-events',
label: 'Bevorstehende Events',
value: metrics?.upcoming_events ?? 0,
description: metrics?.upcoming_events
? 'Planung läuft behalte Checklisten und Aufgaben im Blick.'
: 'Lass dich vom Assistenten beim Planen unterstützen.',
icon: TrendingUp,
},
{
key: 'new-photos',
label: 'Neue Fotos (7 Tage)',
value: metrics?.new_photos ?? 0,
description: metrics && metrics.new_photos > 0
? 'Frisch eingetroffene Erinnerungen deiner Gäste.'
: 'Sammle erste Uploads über QR-Code oder Direktlink.',
icon: Camera,
},
{
key: 'credit-balance',
label: 'Event Credits',
value: tenant?.eventCreditsBalance ?? 0,
description: tenant?.eventCreditsBalance
? 'Verfügbare Credits für neue Events.'
: 'Buche Pakete oder Credits, um weitere Events zu planen.',
icon: Package,
extra: metrics?.active_package?.remaining_events ?? null,
},
{
key: 'task-progress',
label: 'Event-Checkliste',
value: `${taskProgress}%`,
description: taskProgress > 0
? 'Starker Fortschritt! Halte deine Aufgabenliste aktuell.'
: 'Nutze Aufgaben und Vorlagen für einen strukturierten Ablauf.',
icon: ClipboardList,
},
];
const handleResendVerification = () => {
setSendingVerification(true);
setVerificationSent(false);
router.post(resendVerificationRoute(), {}, {
preserveScroll: true,
onSuccess: () => setVerificationSent(true),
onFinish: () => setSendingVerification(false),
});
};
const renderPrice = (price: number | null) => {
if (price === null) {
return '—';
}
try {
return currencyFormatter.format(price);
} catch (error) {
return `${price.toFixed(2)}`;
}
};
const formatDate = (value: string | null) => (value ? dateFormatter.format(new Date(value)) : '—');
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Dashboard" />
<div className="mt-8 flex flex-1 flex-col gap-8 pb-16">
{needsEmailVerification && (
<Alert variant="warning" className="border-amber-300 bg-amber-100/80 text-amber-950 dark:border-amber-600 dark:bg-amber-900/30 dark:text-amber-100">
<AlertTriangle className="size-5" />
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<AlertTitle>Bitte bestätige deine E-Mail-Adresse</AlertTitle>
<AlertDescription>
Du kannst alle Funktionen erst vollständig nutzen, sobald deine E-Mail-Adresse bestätigt ist.
Prüfe dein Postfach oder fordere einen neuen Link an.
</AlertDescription>
</div>
<div className="flex flex-col gap-2 sm:items-end">
<Button size="sm" variant="outline" onClick={handleResendVerification} disabled={sendingVerification}>
{sendingVerification ? 'Sende...' : 'Link erneut senden'}
</Button>
{verificationSent && <span className="text-xs text-muted-foreground">Wir haben dir gerade einen neuen Bestätigungslink geschickt.</span>}
</div>
</div>
</Alert>
)}
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.key} className="bg-gradient-to-br from-background to-muted/60 shadow-sm">
<CardHeader className="flex flex-row items-start justify-between gap-2 pb-2">
<div>
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
<div className="mt-2 text-3xl font-semibold">{stat.value}</div>
</div>
<span className="rounded-full border bg-background/70 p-2 text-muted-foreground">
<stat.icon className="size-5" />
</span>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>{stat.description}</p>
{stat.key === 'credit-balance' && stat.extra !== null && (
<div className="text-xs text-muted-foreground">{stat.extra} weitere Events im aktuellen Paket enthalten.</div>
)}
{stat.key === 'task-progress' && (
<div className="space-y-1">
<Progress value={taskProgress} />
<span className="text-xs text-muted-foreground">{taskProgress}% deiner Event-Checkliste erledigt.</span>
</div>
)}
</CardContent>
</Card>
))}
</div>
<div className="grid gap-6 xl:grid-cols-3">
<Card className="xl:col-span-2">
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Bevorstehende Events</CardTitle>
<p className="text-sm text-muted-foreground">Status, Uploads und Aufgaben deiner nächsten Events im Überblick.</p>
</div>
<Badge variant={upcomingEvents.length > 0 ? 'secondary' : 'outline'}>
{upcomingEvents.length > 0 ? `${upcomingEvents.length} geplant` : 'Noch kein Event geplant'}
</Badge>
</CardHeader>
<CardContent className="space-y-4">
{upcomingEvents.length === 0 && (
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-6 text-center">
<p className="text-sm text-muted-foreground">
Plane dein erstes Event und begleite den gesamten Ablauf vom Briefing bis zur Nachbereitung direkt hier im Dashboard.
</p>
</div>
)}
{upcomingEvents.map((event) => (
<div key={event.id} className="rounded-lg border border-border/60 bg-background/80 p-4 shadow-sm">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-base font-semibold leading-tight">{event.name}</h3>
<p className="text-sm text-muted-foreground">
{formatDate(event.date)} · {event.status === 'published' || event.isActive ? 'Live' : 'In Vorbereitung'}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-muted px-2 py-1">{event.photosCount} Fotos</span>
<span className="rounded-full bg-muted px-2 py-1">{event.tasksCount} Aufgaben</span>
<span className="rounded-full bg-muted px-2 py-1">{event.joinTokensCount} Links</span>
</div>
</div>
</div>
))}
</CardContent>
</Card>
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>Nächstes Paket &amp; Credits</CardTitle>
<p className="text-sm text-muted-foreground">Behalte Laufzeiten und verfügbaren Umfang stets im Blick.</p>
</CardHeader>
<CardContent className="space-y-3">
{tenant?.activePackage ? (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{tenant.activePackage.name}</span>
<Badge variant="outline">{tenant.activePackage.remainingEvents ?? 0} Events übrig</Badge>
</div>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Läuft ab</span>
<span>{formatDate(tenant.activePackage.expiresAt)}</span>
</div>
<div className="flex items-center justify-between">
<span>Preis</span>
<span>{renderPrice(tenant.activePackage.price)}</span>
</div>
</div>
{latestPurchase && (
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
Zuletzt gebucht am {formatDate(latestPurchase.purchasedAt)} via {latestPurchase.provider?.toUpperCase() ?? 'Checkout'}.
</div>
)}
</div>
) : (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-4 text-sm text-muted-foreground">
Noch kein aktives Paket. <Link href={`/${locale ?? 'de'}/packages`} className="font-medium underline underline-offset-4">Jetzt Paket auswählen</Link> und direkt Events planen.
</div>
)}
<Separator />
<div className="flex items-center justify-between text-sm">
<span>Event Credits insgesamt</span>
<span className="font-medium">{tenant?.eventCreditsBalance ?? 0}</span>
</div>
<div className="text-xs text-muted-foreground">
Credits werden bei neuen Events automatisch verbraucht. Zusätzliche Kontingente kannst du jederzeit buchen.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Dein Start in 5 Schritten</CardTitle>
<p className="text-sm text-muted-foreground">Folge den wichtigsten Schritten, um dein Event reibungslos aufzusetzen.</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-3">
{checklistItems.map((item) => (
<div key={item.key} className="flex gap-3 rounded-md border border-border/60 bg-background/50 p-3">
<Checkbox checked={item.done} className="mt-1" readOnly />
<div>
<p className="text-sm font-medium leading-tight">{item.title}</p>
<p className="text-xs text-muted-foreground">{item.description}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
<Card>
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Aktuelle Buchungen</CardTitle>
<p className="text-sm text-muted-foreground">Verfolge deine gebuchten Pakete und Erweiterungen.</p>
</div>
<Badge variant="outline">{recentPurchases.length} Einträge</Badge>
</CardHeader>
<CardContent>
{recentPurchases.length === 0 ? (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-6 text-center text-sm text-muted-foreground">
Noch keine Buchungen sichtbar. Starte jetzt und sichere dir dein erstes Kontingent.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Paket</TableHead>
<TableHead className="hidden sm:table-cell">Typ</TableHead>
<TableHead className="hidden md:table-cell">Anbieter</TableHead>
<TableHead className="hidden md:table-cell">Datum</TableHead>
<TableHead className="text-right">Preis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentPurchases.map((purchase) => (
<TableRow key={purchase.id}>
<TableCell className="font-medium">{purchase.packageName}</TableCell>
<TableCell className="hidden capitalize text-muted-foreground sm:table-cell">{purchase.type ?? '—'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{purchase.provider ?? 'Checkout'}</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">{formatDate(purchase.purchasedAt)}</TableCell>
<TableCell className="text-right font-medium">{renderPrice(purchase.price)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Schnellzugriff</CardTitle>
<p className="text-sm text-muted-foreground">Alles Wichtige für den nächsten Schritt nur einen Klick entfernt.</p>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{quickActions.map((action) => (
<div key={action.key} className="flex flex-col justify-between gap-3 rounded-lg border border-border/60 bg-background/60 p-4">
<div className="flex items-center gap-3">
<span className="rounded-full bg-muted p-2 text-muted-foreground">
<action.icon className="size-5" />
</span>
<div>
<p className="text-sm font-semibold leading-tight">{action.label}</p>
<p className="text-xs text-muted-foreground">{action.description}</p>
</div>
</div>
<div className="flex justify-end">
<Button asChild variant="ghost" size="sm">
<Link href={action.href} prefetch>
Weiter
</Link>
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</div>
</AppLayout>
);
}