- Tenant-Admin-PWA: Neues /event-admin/welcome Onboarding mit WelcomeHero, Packages-, Order-Summary- und Event-Setup-Pages, Zustandsspeicher, Routing-Guard und Dashboard-CTA für Erstnutzer; Filament-/admin-Login via Custom-View behoben.
- Brand/Theming: Marketing-Farb- und Typographievariablen in `resources/css/app.css` eingeführt, AdminLayout, Dashboardkarten und Onboarding-Komponenten entsprechend angepasst; Dokumentation (`docs/todo/tenant-admin-onboarding-fusion.md`, `docs/changes/...`) aktualisiert. - Checkout & Payments: Checkout-, PayPal-Controller und Tests für integrierte Stripe/PayPal-Flows sowie Paket-Billing-Abläufe überarbeitet; neue PayPal SDK-Factory und Admin-API-Helper (`resources/js/admin/api.ts`) schaffen Grundlage für Billing/Members/Tasks-Seiten. - DX & Tests: Neue Playwright/E2E-Struktur (docs/testing/e2e.md, `tests/e2e/tenant-onboarding-flow.test.ts`, Utilities), E2E-Tenant-Seeder und zusätzliche Übersetzungen/Factories zur Unterstützung der neuen Flows. - Marketing-Kommunikation: Automatische Kontakt-Bestätigungsmail (`ContactConfirmation` + Blade-Template) implementiert; Guest-PWA unter `/event` erreichbar. - Nebensitzung: Blogsystem gefixt und umfassenden BlogPostSeeder für Beispielinhalte angelegt.
This commit is contained in:
448
resources/js/admin/pages/DashboardPage.tsx
Normal file
448
resources/js/admin/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { CalendarDays, Camera, CreditCard, Sparkles, Users, Plus, Settings } 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,
|
||||
getCreditBalance,
|
||||
getDashboardSummary,
|
||||
getEvents,
|
||||
getTenantPackagesOverview,
|
||||
TenantEvent,
|
||||
TenantPackageSummary,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { useAuth } from '../auth/context';
|
||||
import {
|
||||
adminPath,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENTS_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[];
|
||||
credits: number;
|
||||
activePackage: TenantPackageSummary | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user } = useAuth();
|
||||
const { progress, markStep } = useOnboardingProgress();
|
||||
const [state, setState] = React.useState<DashboardState>({
|
||||
summary: null,
|
||||
events: [],
|
||||
credits: 0,
|
||||
activePackage: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [summary, events, credits, packages] = await Promise.all([
|
||||
getDashboardSummary().catch(() => null),
|
||||
getEvents().catch(() => [] as TenantEvent[]),
|
||||
getCreditBalance().catch(() => ({ balance: 0 })),
|
||||
getTenantPackagesOverview().catch(() => ({ packages: [], activePackage: null })),
|
||||
]);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackSummary = buildSummaryFallback(events, credits.balance ?? 0, packages.activePackage);
|
||||
|
||||
setState({
|
||||
summary: summary ?? fallbackSummary,
|
||||
events,
|
||||
credits: credits.balance ?? 0,
|
||||
activePackage: packages.activePackage,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: 'Dashboard konnte nicht geladen werden.',
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { summary, events, credits, activePackage, loading, error } = 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 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" /> Neues Event
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-brand-rose-soft text-brand-rose">
|
||||
<CalendarDays className="h-4 w-4" /> Alle Events
|
||||
</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" /> Guided Setup
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={`Hallo ${user?.name ?? 'Tenant-Admin'}!`}
|
||||
subtitle="Behalte deine Events, Credits und Aufgaben im Blick."
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler</AlertTitle>
|
||||
<AlertDescription>{error}</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" />
|
||||
Starte mit der Welcome Journey
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Lerne die Storytelling-Elemente kennen, wähle dein Paket und erstelle dein erstes Event mit
|
||||
geführten Schritten.
|
||||
</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>Wir begleiten dich durch Pakete, Aufgaben und Galerie-Konfiguration, damit dein Event glänzt.</p>
|
||||
<p>Du kannst jederzeit zur Welcome Journey zurückkehren, auch wenn bereits Events laufen.</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)}
|
||||
>
|
||||
Jetzt starten
|
||||
</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" />
|
||||
Kurzer Ueberblick
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Wichtigste Kennzahlen deines Tenants auf einen Blick.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="bg-brand-rose-soft text-brand-rose">
|
||||
{activePackage?.package_name ?? 'Kein aktives Package'}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
label="Aktive Events"
|
||||
value={summary?.active_events ?? publishedEvents.length}
|
||||
hint={`${publishedEvents.length} veroeffentlicht`}
|
||||
icon={<CalendarDays className="h-5 w-5 text-brand-rose" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Neue Fotos (7 Tage)"
|
||||
value={summary?.new_photos ?? 0}
|
||||
icon={<Camera className="h-5 w-5 text-fuchsia-500" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Task-Fortschritt"
|
||||
value={`${Math.round(summary?.task_progress ?? 0)}%`}
|
||||
icon={<Users className="h-5 w-5 text-amber-500" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Credits"
|
||||
value={credits}
|
||||
hint={credits <= 1 ? 'Auffuellen empfohlen' : undefined}
|
||||
icon={<CreditCard className="h-5 w-5 text-sky-500" />}
|
||||
/>
|
||||
</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">Schnellaktionen</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Starte durch mit den wichtigsten Aktionen.
|
||||
</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="Event erstellen"
|
||||
description="Plane dein naechstes Highlight."
|
||||
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Camera className="h-5 w-5" />}
|
||||
label="Fotos moderieren"
|
||||
description="Pruefe neue Uploads."
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
label="Tasks organisieren"
|
||||
description="Sorge fuer klare Verantwortungen."
|
||||
onClick={() => navigate(ADMIN_TASKS_PATH)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<CreditCard className="h-5 w-5" />}
|
||||
label="Credits verwalten"
|
||||
description="Sieh dir Balance & Ledger an."
|
||||
onClick={() => navigate(ADMIN_BILLING_PATH)}
|
||||
/>
|
||||
</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">Kommende Events</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Die naechsten Termine inklusive Status & Zugriff.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Einstellungen oeffnen
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<EmptyState
|
||||
message="Noch keine Termine geplant. Lege dein erstes Event an!"
|
||||
ctaLabel="Event planen"
|
||||
onCta={() => navigate(adminPath('/events/new'))}
|
||||
/>
|
||||
) : (
|
||||
upcomingEvents.map((event) => (
|
||||
<UpcomingEventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
onView={() => navigate(ADMIN_EVENT_VIEW_PATH(event.slug))}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function buildSummaryFallback(
|
||||
events: TenantEvent[],
|
||||
balance: number,
|
||||
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,
|
||||
credit_balance: balance,
|
||||
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);
|
||||
}
|
||||
|
||||
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 }: { event: TenantEvent; onView: () => void }) {
|
||||
const date = event.event_date ? new Date(event.event_date) : null;
|
||||
const formattedDate = date
|
||||
? date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
: 'Kein Datum';
|
||||
|
||||
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>
|
||||
<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' ? 'Live' : 'In Planung'}
|
||||
</Badge>
|
||||
<Button size="sm" variant="outline" onClick={onView} className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40">
|
||||
Oeffnen
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') {
|
||||
return name;
|
||||
}
|
||||
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? 'Unbenanntes Event';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user