483 lines
24 KiB
TypeScript
483 lines
24 KiB
TypeScript
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 & 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>
|
||
);
|
||
}
|