fix merge conflict

This commit is contained in:
Rajpreet0 2026-09-11 12:23:43 +02:00
commit d045ee1600
38 changed files with 4938 additions and 643 deletions

1072
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -57,6 +57,7 @@
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-phone-number-input": "^3.4.14", "react-phone-number-input": "^3.4.14",
"recharts": "^3.10.1",
"resend": "^6.18.0", "resend": "^6.18.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"stripe": "^22.1.1", "stripe": "^22.1.1",

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Business" ADD COLUMN "dashboardDismissedRecommendations" JSONB;

View file

@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "Business" ADD COLUMN "onboardingStartedAt" TIMESTAMP(3),
ADD COLUMN "onboardingDismissedAt" TIMESTAMP(3),
ADD COLUMN "onboardingCompletedAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "Employee" ADD COLUMN "firstStepsDismissedAt" TIMESTAMP(3);
-- Backfill: bestehende Studios haben den Wizard nie gesehen, sollen aber auch nicht
-- nachtraeglich dorthin umgeleitet werden. onboardingStartedAt = createdAt markiert sie
-- als "gesehen"; die Setup-Checkliste im Dashboard erscheint trotzdem, und der Wizard
-- bleibt manuell ueber /onboarding erreichbar.
UPDATE "Business" SET "onboardingStartedAt" = "createdAt" WHERE "onboardingStartedAt" IS NULL;

View file

@ -65,6 +65,7 @@ model Employee {
onboardingToken String? @unique onboardingToken String? @unique
onboardingTokenExpiry DateTime? onboardingTokenExpiry DateTime?
isOnboarded Boolean @default(false) isOnboarded Boolean @default(false)
firstStepsDismissedAt DateTime? // "Erste Schritte"-Karte im Dashboard weggeklickt
createdBy String? // Manager ID who created the employee createdBy String? // Manager ID who created the employee
trainings UserTraining[] trainings UserTraining[]
createdAt DateTime? @default(now()) createdAt DateTime? @default(now())
@ -97,28 +98,40 @@ model Manager {
} }
model Business { model Business {
id String @id @default(uuid()) id String @id @default(uuid())
managerId String managerId String
name String name String
address String address String
email String email String
title String title String
ustId String? ustId String?
banking String? banking String?
plan String? plan String?
subscription Json? subscription Json?
slug String? @unique slug String? @unique
isPublic Boolean @default(false) isPublic Boolean @default(false)
stripeAccountId String? @unique // Stripe Connect Account ID stripeAccountId String? @unique // Stripe Connect Account ID
stripeAccountStatus String? // "pending" | "active" | "restricted" stripeAccountStatus String? // "pending" | "active" | "restricted"
invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern
cancellationPolicy Json? // { tiers: [{ hoursBeforeStart: number, refundPercent: number }, ...] }, absteigend sortiert. null = immer 100% Rückerstattung. cancellationPolicy Json? // { tiers: [{ hoursBeforeStart: number, refundPercent: number }, ...] }, absteigend sortiert. null = immer 100% Rückerstattung.
branding Json? // { companyName, logoUrl, primaryColor, accentColor }. null = Kumasy-Standardbranding (siehe KUMASY_BRANDING). branding Json? // { companyName, logoUrl, primaryColor, accentColor }. null = Kumasy-Standardbranding (siehe KUMASY_BRANDING).
notificationSettings Json? // { "booking.created": true, ... } Opt-out pro In-App-Benachrichtigungstyp. null / fehlender Key = Registry-Default (siehe NOTIFICATION_TYPES). notificationSettings Json? // { "booking.created": true, ... } Opt-out pro In-App-Benachrichtigungstyp. null / fehlender Key = Registry-Default (siehe NOTIFICATION_TYPES).
manager Manager @relation(fields: [managerId], references: [id]) // Weggeklickte Dashboard-Empfehlungen: [{ id: "add-session:<courseId>", until: ISO-Datum }].
courses Course[] // until = Schlummer-Ende, danach taucht die Empfehlung wieder auf. Die zugehoerige
createdAt DateTime @default(now()) // Migration (20260828164044) war nur in der Datenbank, nicht im Repo - das Feld wird
updatedAt DateTime @updatedAt // aktuell von keinem Code auf dev benutzt, enthaelt aber echte Daten.
dashboardDismissedRecommendations Json?
// Onboarding-Status des Studios. Bewusst nur Meta-Zustand: der Fortschritt der
// einzelnen Schritte wird aus den vorhandenen Daten abgeleitet (siehe
// src/modules/onboarding/lib/onboarding-steps.ts), damit er nicht driften kann.
onboardingStartedAt DateTime? // Wizard erstmals geoeffnet, ab dann kein Redirect mehr
onboardingDismissedAt DateTime? // Checkliste im Dashboard ausgeblendet
onboardingCompletedAt DateTime? // Zeitpunkt, an dem erstmals alle Pflichtschritte erledigt waren
manager Manager @relation(fields: [managerId], references: [id])
courses Course[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([managerId]) @@index([managerId])
} }

View file

@ -1,11 +1,21 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { requireAuthWithData } from "@/src/lib/auth/getUser"; import { redirect } from "next/navigation";
import { requireAuthWithData, isManager } from "@/src/lib/auth/getUser";
import DashboardView from "@/src/modules/dashboard/ui/views/dashboard-view"; import DashboardView from "@/src/modules/dashboard/ui/views/dashboard-view";
import { shouldStartOnboarding } from "@/src/modules/onboarding/actions/onboarding-actions";
import DashboardLoading from "./loading"; import DashboardLoading from "./loading";
export default async function DashboardPage() { export default async function DashboardPage() {
const userData = await requireAuthWithData(); const userData = await requireAuthWithData();
// Weiches Gate: frisch registrierte Manager landen genau EINMAL im Setup-Wizard.
// Sobald er geöffnet wurde (onboardingStartedAt), führt der Weg nur noch über die
// Checkliste - der Rest der App bleibt jederzeit erreichbar, weil die Prüfung
// hier und nicht im Layout sitzt.
if (isManager(userData) && (await shouldStartOnboarding(userData.id))) {
redirect("/onboarding");
}
return ( return (
<Suspense fallback={<DashboardLoading />}> <Suspense fallback={<DashboardLoading />}>
<DashboardView userData={userData} /> <DashboardView userData={userData} />

View file

@ -1,5 +1,5 @@
import { SidebarProvider } from "@/src/components/ui/sidebar"; import { SidebarProvider } from "@/src/components/ui/sidebar";
import DashboardNavbar from "@/src/modules/dashboard/ui/components/DashboardNavbar"; import DashboardNavbarWrapper from "@/src/modules/dashboard/ui/components/DashboardNavbarWrapper";
import DashboardSidebarWrapper from "@/src/modules/dashboard/ui/components/DashboardSidebarWrapper"; import DashboardSidebarWrapper from "@/src/modules/dashboard/ui/components/DashboardSidebarWrapper";
interface Props { interface Props {
@ -16,8 +16,8 @@ const Layout = ({ children }: Props) => {
<div className="absolute inset-0 bg-[radial-gradient(ellipse_50%_40%_at_50%_120%,oklch(0.84_0.05_260/_0.3)_0%,transparent_60%)]" /> <div className="absolute inset-0 bg-[radial-gradient(ellipse_50%_40%_at_50%_120%,oklch(0.84_0.05_260/_0.3)_0%,transparent_60%)]" />
</div> </div>
<DashboardSidebarWrapper /> <DashboardSidebarWrapper />
<main className="flex flex-col h-screen w-full px-4"> <main className="flex flex-col h-screen w-full px-3 sm:px-4">
<DashboardNavbar /> <DashboardNavbarWrapper />
<div className="pb-8">{children}</div> <div className="pb-8">{children}</div>
</main> </main>
</SidebarProvider> </SidebarProvider>

View file

@ -0,0 +1,79 @@
import { redirect } from "next/navigation";
import { requireManager } from "@/src/lib/auth/getUser";
import { prisma } from "@/src/lib/prisma";
import { getBusinessBranding } from "@/src/modules/settings/actions/business-branding-actions";
import { getCancellationPolicy } from "@/src/modules/settings/actions/cancellation-settings-actions";
import {
getOnboardingProgress,
markOnboardingStarted,
} from "@/src/modules/onboarding/actions/onboarding-actions";
import {
WIZARD_STEP_IDS,
type OnboardingStepId,
type WizardStepId,
} from "@/src/modules/onboarding/lib/onboarding-steps";
import OnboardingWizardView from "@/src/modules/onboarding/ui/views/onboarding-wizard-view";
interface OnboardingPageProps {
searchParams: Promise<{ step?: string }>;
}
const isWizardStepId = (value: string): value is WizardStepId =>
(WIZARD_STEP_IDS as string[]).includes(value);
export default async function OnboardingPage({ searchParams }: OnboardingPageProps) {
const manager = await requireManager();
// Markiert den Wizard als gesehen - ab jetzt leitet das Dashboard nicht mehr
// automatisch hierher um, auch wenn der Manager gleich wieder abbricht.
await markOnboardingStarted();
const progressResult = await getOnboardingProgress();
if (!progressResult.success) {
redirect("/dashboard");
}
const { progress } = progressResult;
const [business, brandingResult, policyResult] = await Promise.all([
prisma.business.findUnique({
where: { id: progress.businessId },
select: { isPublic: true, slug: true, stripeAccountStatus: true },
}),
getBusinessBranding(progress.businessId),
getCancellationPolicy(progress.businessId),
]);
// Branding und Storno-Policy sind Pflicht-Eingaben der jeweiligen Editoren.
// Lässt sich eines von beidem nicht laden, wäre der Wizard nur halb bedienbar -
// dann lieber zurück aufs Dashboard, wo die Checkliste in die Einstellungen führt.
if (!business || !brandingResult.success || !policyResult.success) {
redirect("/dashboard");
}
const { step } = await searchParams;
const initialStep = step && isWizardStepId(step) ? step : null;
const done = Object.fromEntries(progress.steps.map(({ id, done }) => [id, done])) as Record<
OnboardingStepId,
boolean
>;
return (
<OnboardingWizardView
businessId={progress.businessId}
businessName={progress.businessName}
managerFirstName={manager.firstName}
done={done}
branding={brandingResult.branding}
policy={policyResult.policy}
isPublic={business.isPublic}
slug={business.slug}
stripeAccountStatus={business.stripeAccountStatus}
initialStep={initialStep}
/>
);
}
export const metadata = {
title: "Einrichtung",
};

View file

@ -1,21 +1,27 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { createConnectOnboardingLink } from "@/src/modules/settings/actions/stripe-connect-actions"; import { createConnectOnboardingLink } from "@/src/modules/settings/actions/stripe-connect-actions";
import { sanitizeReturnTo } from "@/src/modules/settings/lib/return-to";
/** /**
* Refresh-URL für Stripe Connect Onboarding. * Refresh-URL für Stripe Connect Onboarding.
* Wird aufgerufen wenn der Onboarding-Link abgelaufen ist - wir generieren einen neuen. * Wird aufgerufen wenn der Onboarding-Link abgelaufen ist - wir generieren einen neuen.
*
* returnTo wird dabei weitergereicht, damit auch ein neu erzeugter Link den Manager
* wieder an seinen Ausgangspunkt (z.B. den Setup-Wizard) zurückbringt.
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const businessId = request.nextUrl.searchParams.get("businessId"); const businessId = request.nextUrl.searchParams.get("businessId");
const returnTo = sanitizeReturnTo(request.nextUrl.searchParams.get("returnTo"));
const target = returnTo ?? "/settings/payments";
if (!businessId) { if (!businessId) {
return NextResponse.redirect(new URL("/settings/payments?error=missing-business", request.url)); return NextResponse.redirect(new URL(`${target}?error=missing-business`, request.url));
} }
const result = await createConnectOnboardingLink(businessId); const result = await createConnectOnboardingLink(businessId, returnTo ?? undefined);
if (!result.success || !result.url) { if (!result.success || !result.url) {
return NextResponse.redirect(new URL("/settings/payments?error=refresh-failed", request.url)); return NextResponse.redirect(new URL(`${target}?error=refresh-failed`, request.url));
} }
return NextResponse.redirect(result.url); return NextResponse.redirect(result.url);

View file

@ -1,21 +1,28 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { syncConnectAccountStatus } from "@/src/modules/settings/actions/stripe-connect-actions"; import { syncConnectAccountStatus } from "@/src/modules/settings/actions/stripe-connect-actions";
import { sanitizeReturnTo } from "@/src/modules/settings/lib/return-to";
/** /**
* Return-URL nach erfolgreichem (oder abgeschlossenem) Stripe Connect Onboarding. * Return-URL nach erfolgreichem (oder abgeschlossenem) Stripe Connect Onboarding.
* Stripe leitet den Manager hierher zurück. Wir synchronisieren dann den Account-Status. * Stripe leitet den Manager hierher zurück. Wir synchronisieren dann den Account-Status.
*
* returnTo erlaubt es dem Setup-Wizard, den Manager wieder bei sich landen zu lassen
* statt in den Zahlungseinstellungen. Der Wert wird gegen offene Redirects geprüft;
* fällt die Prüfung durch, gilt der bisherige Standardpfad.
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const businessId = request.nextUrl.searchParams.get("businessId"); const businessId = request.nextUrl.searchParams.get("businessId");
const returnTo = sanitizeReturnTo(request.nextUrl.searchParams.get("returnTo"));
const target = returnTo ?? "/settings/payments";
if (!businessId) { if (!businessId) {
return NextResponse.redirect(new URL("/settings/payments?error=missing-business", request.url)); return NextResponse.redirect(new URL(`${target}?error=missing-business`, request.url));
} }
const result = await syncConnectAccountStatus(businessId); const result = await syncConnectAccountStatus(businessId);
if (!result.success) { if (!result.success) {
return NextResponse.redirect(new URL("/settings/payments?error=sync-failed", request.url)); return NextResponse.redirect(new URL(`${target}?error=sync-failed`, request.url));
} }
return NextResponse.redirect(new URL("/settings/payments?onboarding=success", request.url)); return NextResponse.redirect(new URL(`${target}?onboarding=success`, request.url));
} }

View file

@ -6,14 +6,25 @@ import GenericDropdown from "@/src/components/layout/GenericDropdown";
import MultiSelectDropdown from "@/src/components/layout/MultiSelectDropdown"; import MultiSelectDropdown from "@/src/components/layout/MultiSelectDropdown";
import RichTextEditor from "@/src/components/layout/RichTextEditor"; import RichTextEditor from "@/src/components/layout/RichTextEditor";
import { Button } from "@/src/components/ui/button"; import { Button } from "@/src/components/ui/button";
import { Checkbox } from "@/src/components/ui/checkbox";
import { Label } from "@/src/components/ui/label";
import { createCourse, updateCourse, getMyBusinesses } from "../../actions/course-actions"; import { createCourse, updateCourse, getMyBusinesses } from "../../actions/course-actions";
import { getMyRooms } from "@/src/modules/rooms/actions/room-actions"; import { getMyRooms } from "@/src/modules/rooms/actions/room-actions";
import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions"; import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions";
import { toast } from "sonner"; import { toast } from "sonner";
import { Course } from "../../types/course.types"; import { Course } from "../../types/course.types";
import { Trash2, ImagePlus, X, Loader2, AlertTriangle, Plus } from "lucide-react"; import {
Trash2,
ImagePlus,
X,
Loader2,
AlertTriangle,
Plus,
Info,
CalendarDays,
Settings2,
FileText,
RefreshCw,
Check,
} from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import DeleteDialog from "@/src/components/layout/DeleteDialog"; import DeleteDialog from "@/src/components/layout/DeleteDialog";
import { import {
@ -454,12 +465,13 @@ const CourseCreateView = ({
return ( return (
<div> <div>
<div className="w-full flex items-center justify-between"> {/* Page header */}
<div className="w-full flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-3xl font-bold"> <h1 className="text-3xl font-bold">
{mode === "edit" ? "Kurs bearbeiten" : "Kurs anlegen"} {mode === "edit" ? "Kurs bearbeiten" : "Kurs anlegen"}
</h1> </h1>
<p className="text-xl mt-2"> <p className="text-gray-500 mt-1">
{mode === "edit" {mode === "edit"
? "Ändern Sie die Kursdetails und speichern Sie Ihre Änderungen" ? "Ändern Sie die Kursdetails und speichern Sie Ihre Änderungen"
: "Alle Kurse können nach dem Speichern angepasst oder gelöscht werden"} : "Alle Kurse können nach dem Speichern angepasst oder gelöscht werden"}
@ -474,372 +486,444 @@ const CourseCreateView = ({
</Button> </Button>
)} )}
</div> </div>
<div className="mt-6 flex flex-col gap-4 max-w-xl">
{businesses.length > 1 && (
<GenericDropdown
label="Standort"
selected={selectedBusinessId}
onSelect={setSelectedBusinessId}
options={businesses}
placeholder="Standort auswählen"
/>
)}
<div>
<InputComponent
isLabel
label="Kursnamen"
type="text"
id="course-create-name"
value={courseName}
onChange={(e) => setCourseName(e.target.value)}
/>
{errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
</div>
<MultiSelectDropdown <div className="max-w-3xl flex flex-col gap-5">
label="Sportart" {/* Section 1: Grunddaten */}
labelButton="Neue Sportart anlegen" <div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-5 sm:p-6">
selected={selectedSports} <div className="flex items-center gap-2 mb-5">
onSelect={setSelectedSports} <div className="flex items-center justify-center size-8 rounded-lg bg-[#c9a84c]/10 text-[#c9a84c]">
options={sports} <Info className="size-4" />
allowCreate={true}
onCreateOption={(newSport) => {
setSports([...sports, newSport]);
setNewlyCreatedSports([...newlyCreatedSports, newSport.value]);
}}
onDeleteOption={(valueToDelete) => {
setSports(sports.filter((s) => s.value !== valueToDelete));
setNewlyCreatedSports(newlyCreatedSports.filter((v) => v !== valueToDelete));
setSelectedSports(selectedSports.filter((v) => v !== valueToDelete));
}}
deletableValues={newlyCreatedSports}
error={errors.sport}
/>
<GenericDropdown
label="Niveau"
selected={selectedLevel}
onSelect={setSelectedLevel}
options={LEVELS}
/>
<div>
<InputComponent
isLabel
label="Datum"
type="date"
id="course-create-date"
value={courseDate}
onChange={(e) => setCourseDate(e.target.value)}
/>
{errors.date && <p className="text-red-500 text-sm mt-1">{errors.date}</p>}
</div>
<div>
<InputComponent
isLabel
label="Uhrzeit von"
type="time"
id="course-create-time-from"
value={timeFrom}
onChange={(e) => setTimeFrom(e.target.value)}
/>
{errors.timeFrom && <p className="text-red-500 text-sm mt-1">{errors.timeFrom}</p>}
</div>
<div>
<InputComponent
isLabel
label="Uhrzeit bis"
type="time"
id="course-create-time-to"
value={timeTo}
onChange={(e) => setTimeTo(e.target.value)}
/>
{errors.timeTo && <p className="text-red-500 text-sm mt-1">{errors.timeTo}</p>}
</div>
<div>
<InputComponent
isLabel
label="Preis (€)"
type="text"
id="course-create-price"
value={
isPriceFocused
? priceDisplay
: price
? parseFloat(price).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: ""
}
onChange={(e) => {
const value = e.target.value;
// Remove all non-numeric characters except comma and dot
const cleanValue = value.replace(/[^\d,\.]/g, "");
// Replace comma with dot for internal storage
const normalizedValue = cleanValue.replace(",", ".");
setPriceDisplay(value);
// Allow empty string, numbers with optional decimal point and max 2 decimal places
if (normalizedValue === "" || /^\d*\.?\d{0,2}$/.test(normalizedValue)) {
setPrice(normalizedValue);
}
}}
onFocus={() => {
setIsPriceFocused(true);
setPriceDisplay(price.replace(".", ","));
}}
onBlur={() => {
setIsPriceFocused(false);
}}
placeholder="0,00"
/>
{errors.price && <p className="text-red-500 text-sm mt-1">{errors.price}</p>}
</div>
{!isLoadingTrainers && trainers.length > 0 && (
<div>
<MultiSelectDropdown
label="Trainer"
selected={selectedTrainers}
onSelect={setSelectedTrainers}
options={trainers}
error={errors.trainers}
/>
</div>
)}
<GenericDropdown
label="Raum"
selected={selectedRoom}
onSelect={setSelectedRoom}
options={rooms}
error={errors.room}
disabled={isLoadingRooms}
placeholder={isLoadingRooms ? "Räume werden geladen..." : "Raum auswählen"}
/>
<div>
<InputComponent
isLabel
label="Maximale Teilnehmerzahl"
type="number"
id="course-create-max-participants"
value={maxParticipants}
onChange={(e) => setMaxParticipants(e.target.value)}
/>
{errors.maxParticipants && (
<p className="text-red-500 text-sm mt-1">{errors.maxParticipants}</p>
)}
</div>
{/* Kursbild */}
<div>
<p className="text-lg font-bold mb-2 mt-2">Kursbild (optional)</p>
{coverImage ? (
<div className="relative w-full h-48 rounded-xl overflow-hidden border border-gray-200">
<Image src={coverImage} alt="Kursbild" fill className="object-cover" />
<button
type="button"
onClick={() => setCoverImage(null)}
className="absolute top-2 right-2 bg-white rounded-full p-1 shadow hover:bg-gray-100"
>
<X size={16} />
</button>
</div> </div>
) : ( <h2 className="text-base font-semibold text-gray-900">Grunddaten</h2>
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-xl cursor-pointer hover:border-gray-400 transition-colors px-6"> </div>
{isUploadingImage ? ( <div className="flex flex-col gap-4">
<div className="flex flex-col items-center gap-2 w-full"> {businesses.length > 1 && (
<Loader2 size={20} className="text-gray-400 animate-spin" /> <GenericDropdown
<span className="text-sm text-gray-500"> label="Standort"
{imageUploadPhase === "optimizing" selected={selectedBusinessId}
? "Bild wird optimiert…" onSelect={setSelectedBusinessId}
: "Wird hochgeladen…"} options={businesses}
</span> placeholder="Standort auswählen"
<Progress
value={imageUploadPhase === "optimizing" ? 40 : 85}
className="w-full max-w-48"
/>
</div>
) : (
<>
<ImagePlus size={24} className="text-gray-400 mb-1" />
<span className="text-sm text-gray-500">Bild auswählen (max. 5 MB)</span>
</>
)}
<input
type="file"
accept="image/*"
className="hidden"
disabled={isUploadingImage}
onChange={handleImageUpload}
/> />
</label> )}
)}
</div>
<RichTextEditor
label="Was man mitbringen sollte"
value={description}
onChange={setDescription}
placeholder="Beschreibe, was die Teilnehmer mitbringen sollten..."
error={errors.description}
/>
<div className="flex items-center gap-2">
<Checkbox
id="course-create-standing-order"
className="shadow-none h-5 w-5 bg-white border-blue cursor-pointer"
checked={isStandingOrder}
onCheckedChange={(checked) => {
setIsStandingOrder(checked === true);
if (!checked) {
setSelectedFrequency("");
setEndDate("");
weekdays.reset();
}
}}
/>
<Label htmlFor="course-create-standing-order" className="cursor-pointer text-lg mt-2">
Als Dauerauftrag anlegen
</Label>
</div>
{isStandingOrder && (
<>
<GenericDropdown
label="Wie oft soll dieser Kurs stattfinden?"
selected={selectedFrequency}
onSelect={(value) => {
setSelectedFrequency(value);
if (value !== "custom") {
weekdays.reset();
}
}}
options={FREQUENCIES}
error={errors.frequency}
/>
<div> <div>
<InputComponent <InputComponent
isLabel isLabel
label="Enddatum des Dauerauftrags" label="Kursname"
type="date" type="text"
id="course-create-end-date" id="course-create-name"
value={endDate} value={courseName}
onChange={(e) => setEndDate(e.target.value)} onChange={(e) => setCourseName(e.target.value)}
/> />
{errors.endDate && <p className="text-red-500 text-sm mt-1">{errors.endDate}</p>} {errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<MultiSelectDropdown
label="Sportart"
labelButton="Neue Sportart anlegen"
selected={selectedSports}
onSelect={setSelectedSports}
options={sports}
allowCreate={true}
onCreateOption={(newSport) => {
setSports([...sports, newSport]);
setNewlyCreatedSports([...newlyCreatedSports, newSport.value]);
}}
onDeleteOption={(valueToDelete) => {
setSports(sports.filter((s) => s.value !== valueToDelete));
setNewlyCreatedSports(newlyCreatedSports.filter((v) => v !== valueToDelete));
setSelectedSports(selectedSports.filter((v) => v !== valueToDelete));
}}
deletableValues={newlyCreatedSports}
error={errors.sport}
/>
<GenericDropdown
label="Niveau"
selected={selectedLevel}
onSelect={setSelectedLevel}
options={LEVELS}
/>
</div>
</div>
</div>
{/* Section 2: Zeitplan */}
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-5 sm:p-6">
<div className="flex items-center gap-2 mb-5">
<div className="flex items-center justify-center size-8 rounded-lg bg-blue-50 text-blue-500">
<CalendarDays className="size-4" />
</div>
<h2 className="text-base font-semibold text-gray-900">Zeitplan</h2>
</div>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<InputComponent
isLabel
label="Datum"
type="date"
id="course-create-date"
value={courseDate}
onChange={(e) => setCourseDate(e.target.value)}
/>
{errors.date && <p className="text-red-500 text-sm mt-1">{errors.date}</p>}
</div>
<div>
<InputComponent
isLabel
label="Von"
type="time"
id="course-create-time-from"
value={timeFrom}
onChange={(e) => setTimeFrom(e.target.value)}
/>
{errors.timeFrom && <p className="text-red-500 text-sm mt-1">{errors.timeFrom}</p>}
</div>
<div>
<InputComponent
isLabel
label="Bis"
type="time"
id="course-create-time-to"
value={timeTo}
onChange={(e) => setTimeTo(e.target.value)}
/>
{errors.timeTo && <p className="text-red-500 text-sm mt-1">{errors.timeTo}</p>}
</div>
</div> </div>
{selectedFrequency === "custom" && ( {/* Dauerauftrag toggle */}
<> <button
<MultiSelectDropdown type="button"
label="An welchen Tagen soll der Kurs stattfinden?" onClick={() => {
selected={selectedWeekdays} const next = !isStandingOrder;
onSelect={weekdays.syncWeekdays} setIsStandingOrder(next);
options={WEEKDAYS} if (!next) {
error={errors.weekdays} setSelectedFrequency("");
setEndDate("");
weekdays.reset();
}
}}
className={`flex items-center gap-3 w-full rounded-xl border p-4 text-left transition-all cursor-pointer ${
isStandingOrder
? "border-[#c9a84c]/50 bg-[#c9a84c]/5"
: "border-gray-200 bg-white/40 hover:border-gray-300 hover:bg-white/60"
}`}
>
<div
className={`flex items-center justify-center size-8 rounded-full border-2 transition-all shrink-0 ${
isStandingOrder ? "border-[#c9a84c] bg-[#c9a84c]" : "border-gray-300 bg-white"
}`}
>
{isStandingOrder && <Check className="size-4 text-white" />}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">Als Dauerauftrag anlegen</p>
<p className="text-xs text-gray-500 mt-0.5">
Kurs wiederholt sich automatisch nach einem Zeitplan
</p>
</div>
<RefreshCw
className={`ml-auto size-4 shrink-0 transition-colors ${
isStandingOrder ? "text-[#c9a84c]" : "text-gray-300"
}`}
/>
</button>
{isStandingOrder && (
<div className="flex flex-col gap-4 pt-1">
<GenericDropdown
label="Wie oft soll dieser Kurs stattfinden?"
selected={selectedFrequency}
onSelect={(value) => {
setSelectedFrequency(value);
if (value !== "custom") {
weekdays.reset();
}
}}
options={FREQUENCIES}
error={errors.frequency}
/> />
{selectedWeekdays.length > 0 && ( <div>
<div className="space-y-3"> <InputComponent
<p className="text-sm font-medium">Uhrzeiten pro Tag</p> isLabel
{selectedWeekdays.map((day) => { label="Enddatum des Dauerauftrags"
const dayLabel = WEEKDAYS.find((w) => w.value === day)?.label ?? day; type="date"
const slots: WeekdaySlot[] = weekdayTimings[day]?.length id="course-create-end-date"
? weekdayTimings[day] value={endDate}
: [{ timeFrom: "", timeTo: "" }]; onChange={(e) => setEndDate(e.target.value)}
return ( />
<div key={day} className="flex gap-3"> {errors.endDate && <p className="text-red-500 text-sm mt-1">{errors.endDate}</p>}
<span className="w-24 text-sm shrink-0 pt-2.5">{dayLabel}</span> </div>
<div className="flex flex-col gap-2">
{slots.map((slot, index) => ( {selectedFrequency === "custom" && (
<div key={index} className="flex items-center gap-3"> <>
<InputComponent <MultiSelectDropdown
type="time" label="An welchen Tagen soll der Kurs stattfinden?"
id={`weekday-time-from-${day}-${index}`} selected={selectedWeekdays}
value={slot.timeFrom} onSelect={weekdays.syncWeekdays}
onChange={(e) => options={WEEKDAYS}
weekdays.updateSlot(day, index, { error={errors.weekdays}
timeFrom: e.target.value, />
})
} {selectedWeekdays.length > 0 && (
/> <div className="space-y-3">
<span className="text-sm">–</span> <p className="text-sm font-medium text-gray-700">Uhrzeiten pro Tag</p>
<InputComponent {selectedWeekdays.map((day) => {
type="time" const dayLabel = WEEKDAYS.find((w) => w.value === day)?.label ?? day;
id={`weekday-time-to-${day}-${index}`} const slots: WeekdaySlot[] = weekdayTimings[day]?.length
value={slot.timeTo} ? weekdayTimings[day]
onChange={(e) => : [{ timeFrom: "", timeTo: "" }];
weekdays.updateSlot(day, index, { return (
timeTo: e.target.value, <div key={day} className="flex gap-3">
}) <span className="w-24 text-sm text-gray-600 shrink-0 pt-2.5">
} {dayLabel}
/> </span>
{slots.length > 1 && ( <div className="flex flex-col gap-2">
<button {slots.map((slot, index) => (
type="button" <div key={index} className="flex items-center gap-3">
onClick={() => weekdays.removeSlot(day, index)} <InputComponent
aria-label="Zeitfenster entfernen" type="time"
className="text-gray-400 hover:text-red-500" id={`weekday-time-from-${day}-${index}`}
> value={slot.timeFrom}
<X size={16} /> onChange={(e) =>
</button> weekdays.updateSlot(day, index, {
)} timeFrom: e.target.value,
})
}
/>
<span className="text-sm text-gray-400">–</span>
<InputComponent
type="time"
id={`weekday-time-to-${day}-${index}`}
value={slot.timeTo}
onChange={(e) =>
weekdays.updateSlot(day, index, {
timeTo: e.target.value,
})
}
/>
{slots.length > 1 && (
<button
type="button"
onClick={() => weekdays.removeSlot(day, index)}
aria-label="Zeitfenster entfernen"
className="text-gray-400 hover:text-red-500"
>
<X size={16} />
</button>
)}
</div>
))}
<button
type="button"
onClick={() => weekdays.addSlot(day)}
className="inline-flex w-fit items-center gap-1 text-sm text-blue hover:underline"
>
<Plus size={14} />
Weiteres Zeitfenster
</button>
</div> </div>
))} </div>
<button );
type="button" })}
onClick={() => weekdays.addSlot(day)} {errors.weekdayTimings && (
className="inline-flex w-fit items-center gap-1 text-sm text-blue hover:underline" <p className="text-red-500 text-sm">{errors.weekdayTimings}</p>
> )}
<Plus size={14} /> </div>
Weiteres Zeitfenster
</button>
</div>
</div>
);
})}
{errors.weekdayTimings && (
<p className="text-red-500 text-sm">{errors.weekdayTimings}</p>
)} )}
</div> </>
)} )}
</> </div>
)} )}
</> </div>
)} </div>
<div className="flex gap-2">
{/* Section 3: Logistik */}
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-5 sm:p-6">
<div className="flex items-center gap-2 mb-5">
<div className="flex items-center justify-center size-8 rounded-lg bg-emerald-50 text-emerald-500">
<Settings2 className="size-4" />
</div>
<h2 className="text-base font-semibold text-gray-900">Logistik</h2>
</div>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<GenericDropdown
label="Raum"
selected={selectedRoom}
onSelect={setSelectedRoom}
options={rooms}
error={errors.room}
disabled={isLoadingRooms}
placeholder={isLoadingRooms ? "Räume werden geladen..." : "Raum auswählen"}
/>
{!isLoadingTrainers && trainers.length > 0 && (
<MultiSelectDropdown
label="Trainer"
selected={selectedTrainers}
onSelect={setSelectedTrainers}
options={trainers}
error={errors.trainers}
/>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<InputComponent
isLabel
label="Preis (€)"
type="text"
id="course-create-price"
value={
isPriceFocused
? priceDisplay
: price
? parseFloat(price).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: ""
}
onChange={(e) => {
const value = e.target.value;
const cleanValue = value.replace(/[^\d,\.]/g, "");
const normalizedValue = cleanValue.replace(",", ".");
setPriceDisplay(value);
if (normalizedValue === "" || /^\d*\.?\d{0,2}$/.test(normalizedValue)) {
setPrice(normalizedValue);
}
}}
onFocus={() => {
setIsPriceFocused(true);
setPriceDisplay(price.replace(".", ","));
}}
onBlur={() => {
setIsPriceFocused(false);
}}
placeholder="0,00"
/>
{errors.price && <p className="text-red-500 text-sm mt-1">{errors.price}</p>}
</div>
<div>
<InputComponent
isLabel
label="Max. Teilnehmer"
type="number"
id="course-create-max-participants"
value={maxParticipants}
onChange={(e) => setMaxParticipants(e.target.value)}
/>
{errors.maxParticipants && (
<p className="text-red-500 text-sm mt-1">{errors.maxParticipants}</p>
)}
</div>
</div>
</div>
</div>
{/* Section 4: Inhalt */}
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-5 sm:p-6">
<div className="flex items-center gap-2 mb-5">
<div className="flex items-center justify-center size-8 rounded-lg bg-purple-50 text-purple-500">
<FileText className="size-4" />
</div>
<h2 className="text-base font-semibold text-gray-900">Inhalt</h2>
</div>
<div className="flex flex-col gap-5">
<div>
<p className="text-sm font-semibold text-[#c9a84c] mb-2">Kursbild (optional)</p>
{coverImage ? (
<div className="relative w-full h-48 rounded-xl overflow-hidden border border-gray-200">
<Image src={coverImage} alt="Kursbild" fill className="object-cover" />
<button
type="button"
onClick={() => setCoverImage(null)}
className="absolute top-2 right-2 bg-white rounded-full p-1 shadow hover:bg-gray-100"
>
<X size={16} />
</button>
</div>
) : (
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-200 rounded-xl cursor-pointer hover:border-[#c9a84c]/50 hover:bg-[#c9a84c]/5 transition-colors px-6">
{isUploadingImage ? (
<div className="flex flex-col items-center gap-2 w-full">
<Loader2 size={20} className="text-gray-400 animate-spin" />
<span className="text-sm text-gray-500">
{imageUploadPhase === "optimizing"
? "Bild wird optimiert…"
: "Wird hochgeladen…"}
</span>
<Progress
value={imageUploadPhase === "optimizing" ? 40 : 85}
className="w-full max-w-48"
/>
</div>
) : (
<>
<ImagePlus size={24} className="text-gray-300 mb-1.5" />
<span className="text-sm text-gray-500 font-medium">Bild auswählen</span>
<span className="text-xs text-gray-400 mt-0.5">max. 5 MB</span>
</>
)}
<input
type="file"
accept="image/*"
className="hidden"
disabled={isUploadingImage}
onChange={handleImageUpload}
/>
</label>
)}
</div>
<RichTextEditor
label="Was man mitbringen sollte"
value={description}
onChange={setDescription}
placeholder="Beschreibe, was die Teilnehmer mitbringen sollten..."
error={errors.description}
/>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between pb-4">
<div className="flex items-center gap-2">
<Button
onClick={() => handleSubmit("published")}
disabled={isSubmitting || isUploadingImage}
className="bg-[#c9a84c] hover:bg-[#b8963e] text-white border-0"
>
{isSubmitting ? (
<>
<Loader2 className="size-4 animate-spin mr-1" />
Wird gespeichert...
</>
) : mode === "edit" ? (
"Änderungen speichern"
) : (
"Veröffentlichen"
)}
</Button>
<Button
variant="outline"
onClick={() => router.push("/courses")}
disabled={isSubmitting || isUploadingImage}
>
Abbrechen
</Button>
</div>
<Button <Button
onClick={() => handleSubmit("published")} variant="ghost"
onClick={() => handleSubmit("draft")}
disabled={isSubmitting || isUploadingImage} disabled={isSubmitting || isUploadingImage}
className="text-gray-500 hover:text-gray-700"
> >
{isSubmitting {mode === "edit" ? "Als Entwurf speichern" : "Entwurf speichern"}
? "Wird gespeichert..."
: mode === "edit"
? "Änderungen speichern"
: "Veröffentlichen"}
</Button>
<Button
variant="outline"
onClick={() => router.push("/courses")}
disabled={isSubmitting || isUploadingImage}
>
Abbrechen
</Button> </Button>
</div> </div>
<button
className="mt-6 mb-4 w-fit cursor-pointer hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => handleSubmit("draft")}
disabled={isSubmitting || isUploadingImage}
>
{mode === "edit" ? "Als Entwurf speichern" : "Entwurf speichern"}
</button>
</div> </div>
<DeleteDialog <DeleteDialog

View file

@ -0,0 +1,415 @@
"use server";
import { prisma } from "@/src/lib/prisma";
import {
requireManager,
getUserData,
isManager,
isEmployee,
getEffectiveManagerId,
} from "@/src/lib/auth/getUser";
import {
startOfDay,
endOfDay,
startOfMonth,
endOfMonth,
subDays,
subMonths,
format,
} from "date-fns";
import { de } from "date-fns/locale";
import { unstable_cache } from "next/cache";
export type DashboardStats = {
coursesToday: number;
participantsToday: number;
coursesThisMonth: number;
revenueThisMonth: number; // cents
coursesTodayYesterday: number;
participantsTodayYesterday: number;
coursesLastMonth: number;
revenueLastMonth: number; // cents
};
export type RevenuePoint = {
label: string;
revenue: number; // cents
};
export type ChartPoint = {
label: string;
revenue: number; // cents
bookings: number; // paid booking count
courses: number; // published course count
};
export type TodayCourse = {
id: string;
name: string;
timeFrom: string;
timeTo: string;
room: string;
sport: string[];
participantCount: number;
maxParticipants: number;
};
export type RecentBooking = {
id: string;
customerName: string;
courseName: string;
amountPaid: number | null;
paymentStatus: string;
createdAt: string;
};
export async function getDashboardStats(): Promise<
{ success: true; stats: DashboardStats } | { success: false; error: string }
> {
try {
const manager = await requireManager();
const managerId = manager.id;
const fn = unstable_cache(
async () => {
const now = new Date();
const todayStart = startOfDay(now);
const todayEnd = endOfDay(now);
const yesterdayStart = startOfDay(subDays(now, 1));
const yesterdayEnd = endOfDay(subDays(now, 1));
const monthStart = startOfMonth(now);
const monthEnd = endOfMonth(now);
const lastMonthStart = startOfMonth(subMonths(now, 1));
const lastMonthEnd = endOfMonth(subMonths(now, 1));
const [
coursesToday,
participantsToday,
coursesThisMonth,
coursesTodayYesterday,
participantsTodayYesterday,
coursesLastMonth,
revenueThisMonthPaid,
revenueThisMonthPartial,
revenueLastMonthPaid,
revenueLastMonthPartial,
] = await Promise.all([
prisma.course.count({
where: {
createdBy: managerId,
isStandingOrder: false,
status: "published",
date: { gte: todayStart, lte: todayEnd },
},
}),
prisma.courseBooking.count({
where: {
course: {
createdBy: managerId,
isStandingOrder: false,
date: { gte: todayStart, lte: todayEnd },
},
paymentStatus: "paid",
},
}),
prisma.course.count({
where: {
createdBy: managerId,
isStandingOrder: false,
status: "published",
date: { gte: monthStart, lte: monthEnd },
},
}),
prisma.course.count({
where: {
createdBy: managerId,
isStandingOrder: false,
status: "published",
date: { gte: yesterdayStart, lte: yesterdayEnd },
},
}),
prisma.courseBooking.count({
where: {
course: {
createdBy: managerId,
isStandingOrder: false,
date: { gte: yesterdayStart, lte: yesterdayEnd },
},
paymentStatus: "paid",
},
}),
prisma.course.count({
where: {
createdBy: managerId,
isStandingOrder: false,
status: "published",
date: { gte: lastMonthStart, lte: lastMonthEnd },
},
}),
prisma.courseBooking.aggregate({
where: {
course: { createdBy: managerId },
paymentStatus: "paid",
createdAt: { gte: monthStart, lte: monthEnd },
},
_sum: { amountPaid: true },
}),
prisma.courseBooking.aggregate({
where: {
course: { createdBy: managerId },
paymentStatus: "partially_refunded",
createdAt: { gte: monthStart, lte: monthEnd },
},
_sum: { amountPaid: true, refundedAmount: true },
}),
prisma.courseBooking.aggregate({
where: {
course: { createdBy: managerId },
paymentStatus: "paid",
createdAt: { gte: lastMonthStart, lte: lastMonthEnd },
},
_sum: { amountPaid: true },
}),
prisma.courseBooking.aggregate({
where: {
course: { createdBy: managerId },
paymentStatus: "partially_refunded",
createdAt: { gte: lastMonthStart, lte: lastMonthEnd },
},
_sum: { amountPaid: true, refundedAmount: true },
}),
]);
const revenueThisMonth =
(revenueThisMonthPaid._sum.amountPaid ?? 0) +
(revenueThisMonthPartial._sum.amountPaid ?? 0) -
(revenueThisMonthPartial._sum.refundedAmount ?? 0);
const revenueLastMonth =
(revenueLastMonthPaid._sum.amountPaid ?? 0) +
(revenueLastMonthPartial._sum.amountPaid ?? 0) -
(revenueLastMonthPartial._sum.refundedAmount ?? 0);
return {
coursesToday,
participantsToday,
coursesThisMonth,
revenueThisMonth,
coursesTodayYesterday,
participantsTodayYesterday,
coursesLastMonth,
revenueLastMonth,
};
},
[`dashboard-stats-${managerId}`],
{ tags: [`dashboard-stats-${managerId}`], revalidate: 60 }
);
const stats = await fn();
return { success: true, stats };
} catch {
return { success: false, error: "Statistiken konnten nicht geladen werden." };
}
}
export async function getDashboardRevenueChart(): Promise<
{ success: true; data: ChartPoint[] } | { success: false; error: string }
> {
try {
const manager = await requireManager();
const managerId = manager.id;
const fn = unstable_cache(
async (): Promise<ChartPoint[]> => {
const now = new Date();
const rangeStart = startOfMonth(subMonths(now, 11));
const rangeEnd = endOfMonth(now);
const [paid, partial, courses] = await Promise.all([
prisma.courseBooking.findMany({
where: {
course: { createdBy: managerId },
paymentStatus: "paid",
createdAt: { gte: rangeStart, lte: rangeEnd },
},
select: { createdAt: true, amountPaid: true },
}),
prisma.courseBooking.findMany({
where: {
course: { createdBy: managerId },
paymentStatus: "partially_refunded",
createdAt: { gte: rangeStart, lte: rangeEnd },
},
select: { createdAt: true, amountPaid: true, refundedAmount: true },
}),
prisma.course.findMany({
where: {
createdBy: managerId,
isStandingOrder: false,
status: "published",
date: { gte: rangeStart, lte: rangeEnd },
},
select: { date: true },
}),
]);
// Initialise all 12 months to zero
const byMonth = new Map<string, { revenue: number; bookings: number; courses: number }>();
for (let i = 11; i >= 0; i--) {
byMonth.set(format(subMonths(now, i), "yyyy-MM"), {
revenue: 0,
bookings: 0,
courses: 0,
});
}
for (const b of paid) {
const key = format(new Date(b.createdAt), "yyyy-MM");
const entry = byMonth.get(key);
if (entry) {
entry.revenue += b.amountPaid ?? 0;
entry.bookings += 1;
}
}
for (const b of partial) {
const key = format(new Date(b.createdAt), "yyyy-MM");
const entry = byMonth.get(key);
if (entry) {
entry.revenue += (b.amountPaid ?? 0) - (b.refundedAmount ?? 0);
entry.bookings += 1;
}
}
for (const c of courses) {
const key = format(new Date(c.date), "yyyy-MM");
const entry = byMonth.get(key);
if (entry) entry.courses += 1;
}
return Array.from(byMonth.entries()).map(([key, vals]) => {
const [year, month] = key.split("-");
const date = new Date(Number(year), Number(month) - 1, 1);
return { label: format(date, "MMM yy", { locale: de }), ...vals };
});
},
[`dashboard-chart-${managerId}`],
{ tags: [`dashboard-chart-${managerId}`], revalidate: 300 }
);
const data = await fn();
return { success: true, data };
} catch {
return { success: false, error: "Diagrammdaten konnten nicht geladen werden." };
}
}
export async function getDashboardTodayCourses(): Promise<
{ success: true; courses: TodayCourse[] } | { success: false; error: string }
> {
try {
const userData = await getUserData();
if (
!userData ||
(!isManager(userData) && !(isEmployee(userData) && userData.permissions.courses.view))
) {
return { success: false, error: "Keine Berechtigung" };
}
const managerId = getEffectiveManagerId(userData);
if (!managerId) return { success: false, error: "Kein Manager-Account verknüpft" };
const fn = unstable_cache(
async () => {
const now = new Date();
const todayStart = startOfDay(now);
const todayEnd = endOfDay(now);
const courses = await prisma.course.findMany({
where: {
createdBy: managerId,
isStandingOrder: false,
status: "published",
date: { gte: todayStart, lte: todayEnd },
},
include: {
_count: {
select: { bookings: { where: { paymentStatus: "paid" } } },
},
},
orderBy: { timeFrom: "asc" },
});
const roomIds = [...new Set(courses.map((c) => c.room).filter(Boolean))];
const rooms = roomIds.length
? await prisma.room.findMany({
where: { id: { in: roomIds } },
select: { id: true, name: true },
})
: [];
const roomNameById = new Map(rooms.map((r) => [r.id, r.name]));
return courses.map((c) => ({
id: c.id,
name: c.name,
timeFrom: c.timeFrom,
timeTo: c.timeTo,
room: roomNameById.get(c.room) ?? c.room,
sport: c.sport as string[],
participantCount: c._count.bookings,
maxParticipants: c.maxParticipants,
}));
},
[`dashboard-today-courses-${managerId}`],
{ tags: [`dashboard-today-courses-${managerId}`], revalidate: 60 }
);
const courses = await fn();
return { success: true, courses };
} catch {
return { success: false, error: "Heutige Kurse konnten nicht geladen werden." };
}
}
export async function getDashboardRecentBookings(): Promise<
{ success: true; bookings: RecentBooking[] } | { success: false; error: string }
> {
try {
const manager = await requireManager();
const managerId = manager.id;
const fn = unstable_cache(
async () => {
const bookings = await prisma.courseBooking.findMany({
where: {
course: { createdBy: managerId },
paymentStatus: { in: ["paid", "partially_refunded", "pending", "refunded"] },
},
include: {
course: { select: { name: true } },
user: { select: { name: true, email: true } },
guestLead: { select: { name: true, email: true } },
},
orderBy: { createdAt: "desc" },
take: 6,
});
return bookings.map((b) => ({
id: b.id,
customerName:
b.guestLead?.name ?? b.user?.name ?? b.guestLead?.email ?? b.user?.email ?? "Anonym",
courseName: b.course.name,
amountPaid: b.amountPaid,
paymentStatus: b.paymentStatus,
createdAt: b.createdAt.toISOString(),
}));
},
[`dashboard-recent-bookings-${managerId}`],
{ tags: [`dashboard-recent-bookings-${managerId}`], revalidate: 60 }
);
const bookings = await fn();
return { success: true, bookings };
} catch {
return { success: false, error: "Buchungen konnten nicht geladen werden." };
}
}

View file

@ -1,5 +1,5 @@
"use client"; "use client";
import { HelpCircle, Settings, Plus } from "lucide-react"; import { HelpCircle, Settings, Plus, LogOut, User, ChevronDown } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useState } from "react"; import { useState } from "react";
@ -8,33 +8,28 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/src/components/ui/dropdown-menu"; } from "@/src/components/ui/dropdown-menu";
import { ChevronDown } from "lucide-react";
import { Button } from "@/src/components/ui/button"; import { Button } from "@/src/components/ui/button";
import { useAuthStore } from "@/src/store/authStore";
const footerMenu = [ const footerMenu = [
{ { icon: Settings, label: "Einstellungen", href: "/settings" },
icon: Settings, { icon: HelpCircle, label: "Hilfe", href: "/help" },
label: "Einstellungen",
href: "/settings",
},
{
icon: HelpCircle,
label: "Hilfe",
href: "/help",
},
]; ];
const getDiceBearUrl = (seed: string) => const getDiceBearUrl = (seed: string) =>
`https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(seed)}`; `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(seed)}`;
const AvatarImage = ({ userData }: { userData: AuthUserData }) => { const AvatarImage = ({ userData, size = 10 }: { userData: AuthUserData; size?: number }) => {
const pbSrc = "pbSrc" in userData ? userData.pbSrc : null; const pbSrc = "pbSrc" in userData ? userData.pbSrc : null;
const src = pbSrc || getDiceBearUrl(userData.id); const src = pbSrc || getDiceBearUrl(userData.id);
return ( return (
<div className="relative w-10 h-10 rounded-full overflow-hidden shrink-0 bg-gray-100"> <div
className="relative rounded-full overflow-hidden shrink-0 bg-gray-100"
style={{ width: size * 4, height: size * 4 }}
>
<Image src={src} alt="" fill className="object-cover" unoptimized /> <Image src={src} alt="" fill className="object-cover" unoptimized />
</div> </div>
); );
@ -51,34 +46,34 @@ const DashboardFooter = ({ displayName, userData }: DashboardFooterProps) => {
const [selectedBusinessId, setSelectedBusinessId] = useState( const [selectedBusinessId, setSelectedBusinessId] = useState(
businesses.length > 0 ? businesses[0].id : "" businesses.length > 0 ? businesses[0].id : ""
); );
const signOut = useAuthStore((state) => state.signOut);
const selectedBusiness = businesses.find((b) => b.id === selectedBusinessId); const selectedBusiness = businesses.find((b) => b.id === selectedBusinessId);
return ( return (
<div> <div className="space-y-1">
{footerMenu.map((item) => ( {footerMenu.map((item) => (
<div key={item.href}> <Link
<Link href={item.href} className="flex items-center gap-3 w-full"> key={item.href}
<item.icon size={18} /> href={item.href}
<span className="text-md font-medium tracking-tight mt-1">{item.label}</span> className="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-gray-100/60 transition-colors text-gray-700"
</Link> >
</div> <item.icon size={16} />
<span className="text-sm font-medium">{item.label}</span>
</Link>
))} ))}
{/* Business Dropdown - nur für Manager */} {/* Business Dropdown - nur für Manager */}
{isManager && businesses.length > 0 && ( {isManager && businesses.length > 0 && (
<div className="mt-2 mb-2"> <div className="pt-1">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<div> <div>
<span className="text-xs text-gray-500">Business</span> <span className="text-xs text-gray-400 px-2">Business</span>
<button className="w-full flex text-left items-center justify-between p-2 rounded-lg border border-blue hover:bg-gray-100 transition-colors"> <button className="w-full flex text-left items-center justify-between px-2 py-2 rounded-lg border border-blue/30 hover:bg-gray-100/60 transition-colors mt-0.5">
<div className="flex flex-col items-start flex-1 min-w-0"> <span className="text-sm font-medium truncate flex-1">
<span className="text-sm font-medium mt-1 truncate w-full"> {selectedBusiness?.name || "Business auswählen"}
{selectedBusiness?.name || "Business auswählen"} </span>
</span> <ChevronDown size={14} className="ml-2 shrink-0 text-gray-400" />
</div>
<ChevronDown size={16} className="ml-2 shrink-0" />
</button> </button>
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -95,10 +90,10 @@ const DashboardFooter = ({ displayName, userData }: DashboardFooterProps) => {
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
<Link href="/business/add" className="block mt-2"> <Link href="/business/add" className="block mt-1">
<Button variant="outline" size="sm" className="w-full justify-start gap-2"> <Button variant="outline" size="sm" className="w-full justify-start gap-2">
<Plus size={16} /> <Plus size={14} />
<span className="mt-1">Standort hinzufügen</span> <span className="mt-0.5">Standort hinzufügen</span>
</Button> </Button>
</Link> </Link>
</DropdownMenuContent> </DropdownMenuContent>
@ -106,10 +101,42 @@ const DashboardFooter = ({ displayName, userData }: DashboardFooterProps) => {
</div> </div>
)} )}
{/* DisplayName + Avatar */} {/* Profile dropdown */}
<div className="w-full flex items-center gap-4 p-2 mt-2"> <div className="pt-2 border-t border-gray-100 mt-1">
<AvatarImage userData={userData} /> <DropdownMenu>
<p className="truncate text-sm font-medium">{displayName}</p> <DropdownMenuTrigger asChild>
<button className="w-full flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-gray-100/60 transition-colors cursor-pointer">
<AvatarImage userData={userData} size={8} />
<div className="flex-1 min-w-0 text-left">
<p className="text-sm font-semibold text-gray-900 truncate">{displayName}</p>
<p className="text-xs text-gray-400 truncate">{userData.email}</p>
</div>
<ChevronDown size={14} className="shrink-0 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-56 mb-1">
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/settings/personal" className="flex items-center gap-2">
<User size={14} />
<span>Profil bearbeiten</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/settings" className="flex items-center gap-2">
<Settings size={14} />
<span>Einstellungen</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={signOut}
className="cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
>
<LogOut size={14} />
<span>Abmelden</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
); );

View file

@ -2,12 +2,14 @@
import { Button } from "@/src/components/ui/button"; import { Button } from "@/src/components/ui/button";
import { useSidebar } from "@/src/components/ui/sidebar"; import { useSidebar } from "@/src/components/ui/sidebar";
import { PanelLeftCloseIcon, PanelLeftIcon } from "lucide-react"; import { PanelLeftCloseIcon, PanelLeftIcon, Plus } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import NotificationBell from "@/src/modules/notifications/ui/components/NotificationBell"; import NotificationBell from "@/src/modules/notifications/ui/components/NotificationBell";
import DashboardSearch from "./DashboardSearch";
const menuTitles: Record<string, string> = { const menuTitles: Record<string, string> = {
"/dashboard": "Dashboard", "/dashboard": "Übersicht",
"/courses/myCourses": "Meine Kurse", "/courses/myCourses": "Meine Kurse",
"/courses": "Kurs Übersicht", "/courses": "Kurs Übersicht",
"/courses/create": "Erstelle einen Kurs", "/courses/create": "Erstelle einen Kurs",
@ -19,47 +21,42 @@ const menuTitles: Record<string, string> = {
"/employee/create": "Mitarbeiter erstellen", "/employee/create": "Mitarbeiter erstellen",
"/settings": "Einstellungen", "/settings": "Einstellungen",
"/settings/public-page": "Einstellungen", "/settings/public-page": "Einstellungen",
"/kunden": "Kunden",
"/zahlungen": "Zahlungen",
}; };
const dynamicRoutes: Array<{ const dynamicRoutes: Array<{ prefix: string; titleKey: string }> = [
prefix: string; { prefix: "/courses/edit", titleKey: "/courses/edit" },
titleKey: string; { prefix: "/rooms/edit", titleKey: "/rooms/edit" },
}> = [ { prefix: "/kunden/", titleKey: "/kunden" },
{
prefix: "/courses/edit",
titleKey: "/courses/edit",
},
{
prefix: "/rooms/edit",
titleKey: "/rooms/edit",
},
]; ];
const getPageTitle = (pathname: string) => { const getPageTitle = (pathname: string) => {
if (menuTitles[pathname]) { if (menuTitles[pathname]) return menuTitles[pathname];
return menuTitles[pathname];
}
for (const route of dynamicRoutes) { for (const route of dynamicRoutes) {
if (pathname.startsWith(route.prefix)) { if (pathname.startsWith(route.prefix)) return menuTitles[route.titleKey];
return menuTitles[route.titleKey];
}
} }
// Fallback
return "Dashboard"; return "Dashboard";
}; };
const DashboardNavbar = () => { interface DashboardNavbarProps {
canCreateCourse?: boolean;
userRole?: string;
}
const DashboardNavbar = ({ canCreateCourse, userRole }: DashboardNavbarProps) => {
const { state, toggleSidebar, isMobile } = useSidebar(); const { state, toggleSidebar, isMobile } = useSidebar();
const pathname = usePathname(); const pathname = usePathname();
const pageTitle = getPageTitle(pathname); const pageTitle = getPageTitle(pathname);
return ( return (
<div className="sticky top-0 z-10 -mx-4 px-4 mb-8 bg-white/55 backdrop-blur-xl supports-backdrop-filter:bg-white/45 border-b border-white/30 shadow-sm"> <div className="sticky top-0 z-10 -mx-4 px-3 sm:px-4 mb-8 bg-white/55 backdrop-blur-xl supports-backdrop-filter:bg-white/45 border-b border-white/30 shadow-sm">
<div className="flex gap-x-4 items-center py-4"> <div className="flex gap-x-2 sm:gap-x-3 items-center py-3">
<Button className="size-9 cursor-pointer" variant="outline" onClick={toggleSidebar}> <Button
className="size-9 cursor-pointer shrink-0"
variant="outline"
onClick={toggleSidebar}
>
{state === "collapsed" || isMobile ? ( {state === "collapsed" || isMobile ? (
<PanelLeftIcon className="size-4" /> <PanelLeftIcon className="size-4" />
) : ( ) : (
@ -67,13 +64,28 @@ const DashboardNavbar = () => {
)} )}
</Button> </Button>
<div className="w-px h-6 bg-border" /> <div className="w-px h-6 bg-border shrink-0" />
<h1 className="text-xl font-semibold mt-1">{pageTitle}</h1> <h1 className="text-base sm:text-xl font-semibold mt-0.5 truncate min-w-0">{pageTitle}</h1>
<div className="ml-auto"> <div className="flex-1 min-w-0" />
<NotificationBell />
</div> <DashboardSearch userRole={userRole} />
{canCreateCourse && pathname === "/dashboard" && (
<Button
asChild
size="sm"
className="shrink-0 gap-1.5 bg-[#c9a84c] hover:bg-[#b8963e] text-white border-0 shadow-sm px-2 sm:px-3"
>
<Link href="/courses/create">
<Plus className="size-4" />
<span className="hidden sm:inline mt-0.5">Neuer Kurs</span>
</Link>
</Button>
)}
<NotificationBell />
</div> </div>
</div> </div>
); );

View file

@ -0,0 +1,13 @@
import { requireAuthWithData, isManager, isEmployee } from "@/src/lib/auth/getUser";
import DashboardNavbar from "./DashboardNavbar";
const DashboardNavbarWrapper = async () => {
const userData = await requireAuthWithData();
const canCreateCourse =
isManager(userData) || (isEmployee(userData) && userData.permissions.courses.create);
return <DashboardNavbar canCreateCourse={canCreateCourse} userRole={userData.role} />;
};
export default DashboardNavbarWrapper;

View file

@ -0,0 +1,77 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import type { RecentBooking } from "../../actions/dashboard-actions";
import { PAYMENT_STATUS } from "@/src/modules/zahlungen/ui/components/zahlungen-status-badge";
import { format } from "date-fns";
import { de } from "date-fns/locale";
function formatEur(cents: number | null) {
if (cents == null) return "—";
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(cents / 100);
}
interface DashboardRecentPaymentsProps {
bookings: RecentBooking[];
}
const DashboardRecentPayments = ({ bookings }: DashboardRecentPaymentsProps) => {
return (
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-6">
<div className="flex items-center justify-between mb-5">
<div>
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-0.5">
Letzte Buchungen
</p>
<h2 className="text-lg font-bold text-gray-900">Zahlungen</h2>
</div>
<Link
href="/zahlungen"
className="flex items-center gap-1 text-xs font-semibold text-gray-400 hover:text-gray-600 transition-colors"
>
Alle ansehen <ArrowRight className="size-3.5" />
</Link>
</div>
{bookings.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-8">Noch keine Buchungen vorhanden</p>
) : (
<div className="space-y-0">
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-4 px-1 pb-2 text-xs font-semibold text-gray-400 uppercase tracking-wide border-b border-gray-100">
<span>Name</span>
<span>Kurs</span>
<span>Status</span>
<span className="text-right">Betrag</span>
</div>
{bookings.map((b) => {
const statusStyle = PAYMENT_STATUS[b.paymentStatus] ?? PAYMENT_STATUS.pending;
return (
<div
key={b.id}
className="grid grid-cols-[1fr_1fr_auto_auto] gap-4 items-center px-1 py-3 border-b border-gray-50 last:border-0 hover:bg-white/40 transition-colors rounded-lg"
>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{b.customerName}</p>
<p className="text-xs text-gray-400">
{format(new Date(b.createdAt), "d. MMM", { locale: de })}
</p>
</div>
<p className="text-sm text-gray-600 truncate">{b.courseName}</p>
<span
className={`inline-block text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap ${statusStyle.bg} ${statusStyle.text}`}
>
{statusStyle.label}
</span>
<p className="text-sm font-semibold text-gray-900 text-right whitespace-nowrap">
{formatEur(b.amountPaid)}
</p>
</div>
);
})}
</div>
)}
</div>
);
};
export default DashboardRecentPayments;

View file

@ -0,0 +1,206 @@
"use client";
import { useState, useMemo } from "react";
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from "recharts";
import type { ChartPoint } from "../../actions/dashboard-actions";
type Range = 3 | 6 | 12;
type Metric = "revenue" | "bookings" | "courses";
function formatEur(cents: number) {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
}).format(cents / 100);
}
function formatYAxis(value: number, metric: Metric) {
if (metric === "revenue") {
const euros = value / 100;
if (euros >= 1000) return `${(euros / 1000).toFixed(1).replace(".", ",")} T`;
return `${euros.toFixed(0)} €`;
}
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
return String(value);
}
function formatTotal(total: number, metric: Metric) {
if (metric === "revenue") return formatEur(total);
return String(total);
}
type TooltipEntry = { value?: number };
const CustomTooltip = ({
active,
payload,
label,
metric,
color,
}: {
active?: boolean;
payload?: TooltipEntry[];
label?: string;
metric: Metric;
color: string;
}) => {
if (!active || !payload?.length) return null;
const val = payload[0].value ?? 0;
return (
<div className="bg-gray-900 text-white rounded-xl px-4 py-2.5 shadow-lg text-sm">
<p className="font-semibold mb-0.5">{label}</p>
<p className="font-bold" style={{ color }}>
{metric === "revenue"
? formatEur(val)
: `${val} ${metric === "bookings" ? "Buchungen" : "Kurse"}`}
</p>
</div>
);
};
interface DashboardChartProps {
data: ChartPoint[];
}
const RANGES: { label: string; value: Range }[] = [
{ label: "12 M", value: 12 },
{ label: "6 M", value: 6 },
{ label: "3 M", value: 3 },
];
const METRICS: { label: string; value: Metric; color: string }[] = [
{ label: "Umsatz", value: "revenue", color: "#c9a84c" },
{ label: "Buchungen", value: "bookings", color: "#3b82f6" },
{ label: "Kurse", value: "courses", color: "#10b981" },
];
const DashboardRevenueChart = ({ data }: DashboardChartProps) => {
const [range, setRange] = useState<Range>(12);
const [metric, setMetric] = useState<Metric>("revenue");
const sliced = useMemo(() => data.slice(-range), [data, range]);
const activeMetric = METRICS.find((m) => m.value === metric)!;
const total = sliced.reduce((sum, p) => sum + p[metric], 0);
const prevSlice = data.slice(-(range * 2), -range);
const prevTotal = prevSlice.reduce((sum, p) => sum + p[metric], 0);
const pctChange = prevTotal === 0 ? null : ((total - prevTotal) / prevTotal) * 100;
const metricLabel: Record<Metric, string> = {
revenue: `Umsatz – letzte ${range} Monate`,
bookings: `Buchungen – letzte ${range} Monate`,
courses: `Kurse – letzte ${range} Monate`,
};
return (
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-4 sm:p-6">
{/* Header row */}
<div className="flex flex-col gap-3 mb-5">
{/* Metric tabs */}
<div className="flex items-center gap-1 bg-gray-100/80 rounded-xl p-1 self-start">
{METRICS.map((m) => (
<button
key={m.value}
onClick={() => setMetric(m.value)}
className={`px-3 py-1.5 text-xs font-semibold rounded-lg transition-all whitespace-nowrap ${
metric === m.value
? "bg-white text-gray-900 shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
style={metric === m.value ? { color: m.color } : {}}
>
{m.label}
</button>
))}
</div>
{/* Value + range row */}
<div className="flex items-end justify-between gap-3">
<div>
<div className="flex items-baseline gap-2 flex-wrap">
<p className="text-2xl sm:text-3xl font-black text-gray-900">
{formatTotal(total, metric)}
</p>
{pctChange !== null && (
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
pctChange >= 0 ? "bg-green-100 text-green-700" : "bg-red-100 text-red-600"
}`}
>
{pctChange >= 0 ? "+" : ""}
{pctChange.toFixed(1)} %
</span>
)}
</div>
<p className="text-xs text-gray-400 mt-0.5">{metricLabel[metric]}</p>
</div>
<div className="flex items-center gap-1 bg-gray-100/80 rounded-xl p-1 shrink-0">
{RANGES.map((r) => (
<button
key={r.value}
onClick={() => setRange(r.value)}
className={`px-2.5 py-1.5 text-xs font-semibold rounded-lg transition-all ${
range === r.value
? "bg-gray-900 text-white shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
{r.label}
</button>
))}
</div>
</div>
</div>
{/* Chart */}
<div className="h-48 sm:h-56">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={sliced} margin={{ top: 5, right: 5, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "#9ca3af" }}
axisLine={false}
tickLine={false}
dy={8}
/>
<YAxis
tickFormatter={(v) => formatYAxis(v, metric)}
tick={{ fontSize: 11, fill: "#9ca3af" }}
axisLine={false}
tickLine={false}
width={metric === "revenue" ? 56 : 36}
/>
<Tooltip
content={<CustomTooltip metric={metric} color={activeMetric.color} />}
cursor={{ stroke: "#e5e7eb", strokeWidth: 1 }}
/>
<Line
key={metric}
type="monotone"
dataKey={metric}
stroke={activeMetric.color}
strokeWidth={2.5}
dot={false}
activeDot={{ r: 5, fill: activeMetric.color, stroke: "#fff", strokeWidth: 2 }}
isAnimationActive={true}
animationDuration={400}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default DashboardRevenueChart;

View file

@ -0,0 +1,416 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import {
LayoutDashboard,
Dumbbell,
IdCardLanyard,
User,
CreditCard,
DoorOpen,
Settings,
Plus,
Search,
ArrowRight,
Palette,
Globe,
Bell,
XCircle,
Tag,
UserCircle,
} from "lucide-react";
import { Dialog, DialogContent } from "@/src/components/ui/dialog";
type NavItem = {
label: string;
description: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
category: string;
roles: string[];
keywords?: string[];
};
const navItems: NavItem[] = [
// ── Navigation ──────────────────────────────────────────────────
{
label: "Dashboard",
description: "Statistiken, Umsatz & heutige Kurse",
href: "/dashboard",
icon: LayoutDashboard,
category: "Navigation",
roles: ["manager", "employee", "user"],
keywords: ["übersicht", "home", "start"],
},
{
label: "Kurse",
description: "Alle Kurse ansehen, filtern & verwalten",
href: "/courses",
icon: Dumbbell,
category: "Navigation",
roles: ["manager", "employee"],
keywords: ["kurs", "training", "sport"],
},
{
label: "Meine Kurse",
description: "Gebuchte Trainings anzeigen",
href: "/courses/myCourses",
icon: Dumbbell,
category: "Navigation",
roles: ["user"],
keywords: ["buchung", "training"],
},
{
label: "Kunden",
description: "Kundenliste, Buchungshistorie & Details",
href: "/kunden",
icon: User,
category: "Navigation",
roles: ["manager"],
keywords: ["kunde", "teilnehmer", "mitglied", "user"],
},
{
label: "Zahlungen",
description: "Transaktionen, Rechnungen & Erstattungen",
href: "/zahlungen",
icon: CreditCard,
category: "Navigation",
roles: ["manager"],
keywords: ["zahlung", "rechnung", "stripe", "geld", "umsatz", "erstattung", "refund"],
},
{
label: "Räume",
description: "Räume & Locations verwalten",
href: "/rooms",
icon: DoorOpen,
category: "Navigation",
roles: ["manager", "employee"],
keywords: ["raum", "location", "studio", "halle"],
},
{
label: "Mitarbeiter",
description: "Team & Berechtigungen verwalten",
href: "/employee",
icon: IdCardLanyard,
category: "Navigation",
roles: ["manager", "employee"],
keywords: ["mitarbeiter", "team", "trainer", "angestellter"],
},
// ── Schnellaktionen ─────────────────────────────────────────────
{
label: "Neuen Kurs erstellen",
description: "Einzeltermin oder Dauerauftrag anlegen",
href: "/courses/create",
icon: Plus,
category: "Schnellaktionen",
roles: ["manager", "employee"],
keywords: ["neu", "erstellen", "kurs", "anlegen"],
},
{
label: "Neuen Raum erstellen",
description: "Raum oder Location hinzufügen",
href: "/rooms/create",
icon: Plus,
category: "Schnellaktionen",
roles: ["manager"],
keywords: ["neu", "raum", "erstellen"],
},
{
label: "Mitarbeiter einladen",
description: "Neues Teammitglied per E-Mail einladen",
href: "/employee/create",
icon: Plus,
category: "Schnellaktionen",
roles: ["manager"],
keywords: ["einladen", "mitarbeiter", "neu", "trainer"],
},
// ── Einstellungen ────────────────────────────────────────────────
{
label: "Persönliches Profil",
description: "Name, Avatar & Passwort ändern",
href: "/settings/personal",
icon: UserCircle,
category: "Einstellungen",
roles: ["manager", "employee", "user"],
keywords: ["profil", "account", "passwort", "avatar", "bild"],
},
{
label: "Business-Einstellungen",
description: "Firmenname, Adresse & Kontaktdaten",
href: "/settings/business",
icon: Settings,
category: "Einstellungen",
roles: ["manager"],
keywords: ["firma", "business", "adresse", "kontakt", "einstellungen"],
},
{
label: "Branding & Design",
description: "Logo, Farben & E-Mail-Gestaltung",
href: "/settings/branding",
icon: Palette,
category: "Einstellungen",
roles: ["manager"],
keywords: ["logo", "farbe", "design", "email", "brand", "ci"],
},
{
label: "Öffentliche Buchungsseite",
description: "Buchungsseite aktivieren & anpassen",
href: "/settings/public-page",
icon: Globe,
category: "Einstellungen",
roles: ["manager"],
keywords: ["öffentlich", "buchung", "seite", "link", "public"],
},
{
label: "Zahlungseinstellungen",
description: "Stripe Connect & Bankkonto verknüpfen",
href: "/settings/payments",
icon: CreditCard,
category: "Einstellungen",
roles: ["manager"],
keywords: ["stripe", "zahlung", "bank", "auszahlung", "connect"],
},
{
label: "Benachrichtigungen",
description: "E-Mail-Benachrichtigungen konfigurieren",
href: "/settings/notifications",
icon: Bell,
category: "Einstellungen",
roles: ["manager", "employee", "user"],
keywords: ["benachrichtigung", "email", "notification", "push"],
},
{
label: "Stornierungsrichtlinien",
description: "Fristen, Erstattungsregeln & Kulanzoptionen",
href: "/settings/cancellation",
icon: XCircle,
category: "Einstellungen",
roles: ["manager"],
keywords: ["storno", "erstattung", "refund", "frist", "richtlinie", "policy"],
},
{
label: "Rabatte & Gutscheine",
description: "Aktionscodes erstellen & verwalten",
href: "/settings/discounts",
icon: Tag,
category: "Einstellungen",
roles: ["manager"],
keywords: ["rabatt", "gutschein", "code", "discount", "aktions"],
},
];
interface DashboardSearchProps {
userRole?: string;
}
const CATEGORY_ORDER = ["Navigation", "Schnellaktionen", "Einstellungen"];
const DashboardSearch = ({ userRole = "manager" }: DashboardSearchProps) => {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const visibleItems = navItems.filter((item) => item.roles.includes(userRole));
const filtered =
query.trim() === ""
? visibleItems
: visibleItems.filter((item) => {
const q = query.toLowerCase();
return (
item.label.toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q) ||
item.category.toLowerCase().includes(q) ||
item.keywords?.some((k) => k.includes(q))
);
});
const grouped = CATEGORY_ORDER.reduce<{ category: string; items: NavItem[] }[]>((acc, cat) => {
const items = filtered.filter((i) => i.category === cat);
if (items.length > 0) acc.push({ category: cat, items });
return acc;
}, []);
// Flat index for keyboard nav
const flat = grouped.flatMap((g) => g.items);
const openSearch = () => {
setQuery("");
setActiveIndex(0);
setOpen(true);
};
useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((prev) => {
if (!prev) {
setQuery("");
setActiveIndex(0);
}
return !prev;
});
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, flat.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
const item = flat[activeIndex];
if (item) navigate(item.href);
}
};
const navigate = (href: string) => {
setOpen(false);
router.push(href);
};
let flatCounter = 0;
return (
<>
{/* Mobile: icon only */}
<button
onClick={openSearch}
className="sm:hidden flex items-center justify-center size-9 bg-white/60 border border-white/60 rounded-lg hover:bg-white/80 transition-colors cursor-pointer"
aria-label="Suche öffnen"
>
<Search className="size-4 text-gray-400" />
</button>
{/* Desktop: full bar */}
<button
onClick={openSearch}
className="hidden sm:flex items-center gap-2.5 px-3 py-1.5 text-sm text-gray-400 bg-white/60 border border-white/60 rounded-lg hover:bg-white/80 hover:text-gray-600 transition-colors cursor-pointer backdrop-blur-sm min-w-52"
>
<Search className="size-3.5 shrink-0 text-gray-400" />
<span className="flex-1 text-left">Suchen oder navigieren…</span>
<kbd className="inline-flex items-center gap-0.5 text-[10px] font-mono bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded border border-gray-200 shrink-0">
<span className="text-xs">⌘</span>K
</kbd>
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="p-0 max-w-xl overflow-hidden gap-0 bg-white/95 backdrop-blur-xl border-white/60">
{/* Search input */}
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-gray-100">
<Search className="size-4 text-gray-400 shrink-0" />
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActiveIndex(0);
}}
onKeyDown={handleKeyDown}
placeholder="Suchen oder navigieren…"
className="flex-1 text-sm bg-transparent outline-none placeholder:text-gray-400"
/>
{query && (
<button
onClick={() => setQuery("")}
className="text-xs text-gray-400 hover:text-gray-600 transition-colors px-1"
>
Löschen
</button>
)}
</div>
{/* Results */}
<div className="py-2 max-h-96 overflow-y-auto">
{flat.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-10">
Keine Ergebnisse für &bdquo;{query}&ldquo;
</p>
) : (
grouped.map(({ category, items }) => (
<div key={category}>
<p className="px-4 pt-3 pb-1 text-[10px] font-bold uppercase tracking-widest text-gray-400">
{category}
</p>
{items.map((item) => {
const globalIdx = flatCounter++;
const Icon = item.icon;
const isActive = globalIdx === activeIndex;
return (
<button
key={item.href + item.label}
onClick={() => navigate(item.href)}
onMouseEnter={() => setActiveIndex(globalIdx)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
isActive ? "bg-gray-50" : "hover:bg-gray-50/60"
}`}
>
<div
className={`flex items-center justify-center size-8 rounded-lg shrink-0 ${
category === "Schnellaktionen"
? "bg-[#c9a84c]/10 text-[#c9a84c]"
: category === "Einstellungen"
? "bg-blue-50 text-blue-500"
: "bg-gray-100 text-gray-500"
}`}
>
<Icon className="size-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">{item.label}</p>
<p className="text-xs text-gray-400 truncate">{item.description}</p>
</div>
{isActive && <ArrowRight className="size-3.5 text-gray-300 shrink-0" />}
</button>
);
})}
</div>
))
)}
</div>
{/* Footer hints */}
<div className="flex items-center gap-4 px-4 py-2.5 border-t border-gray-100 text-[11px] text-gray-400">
<span className="flex items-center gap-1">
<kbd className="bg-gray-100 border border-gray-200 px-1 py-0.5 rounded text-[10px] font-mono">
↑↓
</kbd>
navigieren
</span>
<span className="flex items-center gap-1">
<kbd className="bg-gray-100 border border-gray-200 px-1 py-0.5 rounded text-[10px] font-mono">
↵
</kbd>
öffnen
</span>
<span className="flex items-center gap-1">
<kbd className="bg-gray-100 border border-gray-200 px-1 py-0.5 rounded text-[10px] font-mono">
Esc
</kbd>
schließen
</span>
</div>
</DialogContent>
</Dialog>
</>
);
};
export default DashboardSearch;

View file

@ -0,0 +1,133 @@
import { TrendingUp, TrendingDown, Minus, Users, CalendarDays, Dumbbell } from "lucide-react";
import type { DashboardStats } from "../../actions/dashboard-actions";
function formatEur(cents: number) {
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(cents / 100);
}
function ChangeChip({ current, previous }: { current: number; previous: number }) {
if (previous === 0 && current === 0) {
return (
<span className="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500">
<Minus className="size-3" /> –
</span>
);
}
if (previous === 0) {
return (
<span className="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-700">
<TrendingUp className="size-3" /> Neu
</span>
);
}
const pct = ((current - previous) / previous) * 100;
const up = pct >= 0;
return (
<span
className={`inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full ${
up ? "bg-green-100 text-green-700" : "bg-red-100 text-red-600"
}`}
>
{up ? <TrendingUp className="size-3" /> : <TrendingDown className="size-3" />}
{up ? "+" : ""}
{Math.abs(pct).toFixed(1)} %
</span>
);
}
interface StatTileProps {
label: string;
subLabel: string;
value: string;
current: number;
previous: number;
icon: React.ComponentType<{ className?: string }>;
accent?: string;
}
function StatTile({
label,
subLabel,
value,
current,
previous,
icon: Icon,
accent,
}: StatTileProps) {
return (
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-5 relative overflow-hidden">
<div
className="absolute inset-0 pointer-events-none opacity-40"
style={{
background: accent
? `radial-gradient(ellipse 80% 80% at 110% -10%, ${accent} 0%, transparent 70%)`
: undefined,
}}
/>
<div className="flex items-start justify-between mb-3 relative">
<div className="flex items-center gap-2 text-gray-500">
<Icon className="size-3.5" />
<span className="text-xs font-semibold tracking-wide uppercase">{label}</span>
</div>
<ChangeChip current={current} previous={previous} />
</div>
<p className="text-3xl font-black text-gray-900 relative">{value}</p>
<p className="text-xs text-gray-400 mt-1 relative">{subLabel}</p>
</div>
);
}
interface DashboardStatsGridProps {
stats: DashboardStats;
}
const DashboardStatsGrid = ({ stats }: DashboardStatsGridProps) => {
const tiles: StatTileProps[] = [
{
label: "Kurse",
subLabel: "Kurse heute · vs. gestern",
value: String(stats.coursesToday),
current: stats.coursesToday,
previous: stats.coursesTodayYesterday,
icon: Dumbbell,
accent: "oklch(0.88 0.09 85 / 0.5)",
},
{
label: "Teilnehmer",
subLabel: "Heute · vs. gestern",
value: String(stats.participantsToday),
current: stats.participantsToday,
previous: stats.participantsTodayYesterday,
icon: Users,
accent: "oklch(0.88 0.09 85 / 0.5)",
},
{
label: "Umsatz",
subLabel: "Diesen Monat · vs. letzten Monat",
value: formatEur(stats.revenueThisMonth),
current: stats.revenueThisMonth,
previous: stats.revenueLastMonth,
icon: TrendingUp,
accent: "oklch(0.82 0.06 240 / 0.4)",
},
{
label: "Kurse",
subLabel: "Diesen Monat · vs. letzten Monat",
value: String(stats.coursesThisMonth),
current: stats.coursesThisMonth,
previous: stats.coursesLastMonth,
icon: CalendarDays,
accent: "oklch(0.82 0.06 240 / 0.4)",
},
];
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{tiles.map((tile) => (
<StatTile key={tile.label + tile.subLabel} {...tile} />
))}
</div>
);
};
export default DashboardStatsGrid;

View file

@ -0,0 +1,130 @@
import Link from "next/link";
import { Clock, Users, ArrowRight, CalendarDays } from "lucide-react";
import type { TodayCourse } from "../../actions/dashboard-actions";
import { format } from "date-fns";
import { de } from "date-fns/locale";
interface DashboardTodayScheduleProps {
courses: TodayCourse[];
}
const DashboardTodaySchedule = ({ courses }: DashboardTodayScheduleProps) => {
const today = format(new Date(), "EEEE, d. MMMM", { locale: de });
return (
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-6">
<div className="flex items-center justify-between mb-5">
<div>
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-0.5">
Heute
</p>
<h2 className="text-lg font-bold text-gray-900 capitalize">{today}</h2>
</div>
<div className="flex items-center gap-2 text-xs font-semibold text-gray-500 bg-gray-100/80 px-2.5 py-1.5 rounded-lg">
<CalendarDays className="size-3.5" />
{courses.length} {courses.length === 1 ? "Kurs" : "Kurse"}
</div>
</div>
{courses.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<CalendarDays className="size-10 text-gray-200 mb-3" />
<p className="text-sm font-medium text-gray-500">Keine Kurse heute</p>
<p className="text-xs text-gray-400 mt-1">Genieß den freien Tag!</p>
<Link
href="/courses/create"
className="mt-4 text-xs font-semibold text-[#c9a84c] hover:underline"
>
Kurs erstellen →
</Link>
</div>
) : (
<div className="space-y-2.5">
{courses.map((course) => {
const occupancy =
course.maxParticipants > 0
? Math.round((course.participantCount / course.maxParticipants) * 100)
: 0;
const isFull =
course.maxParticipants > 0 && course.participantCount >= course.maxParticipants;
return (
<Link
key={course.id}
href={`/courses/edit/${course.id}`}
className="flex items-center gap-4 p-3.5 rounded-xl border border-white/50 bg-white/40 hover:bg-white/70 hover:border-white/80 transition-all group"
>
{/* Time column */}
<div className="flex flex-col items-center justify-center min-w-[52px] text-center">
<div className="flex items-center gap-1 text-gray-900">
<Clock className="size-3 text-gray-400 shrink-0" />
<span className="text-xs font-bold">{course.timeFrom}</span>
</div>
<div className="w-px h-4 bg-gray-200 my-0.5" />
<span className="text-[10px] text-gray-400 font-medium">{course.timeTo}</span>
</div>
<div className="w-px h-10 bg-gray-100" />
{/* Course info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">{course.name}</p>
<div className="flex items-center gap-2 mt-0.5">
{course.room && (
<span className="text-xs text-gray-400 truncate">{course.room}</span>
)}
{course.sport.length > 0 && (
<>
{course.room && <span className="text-gray-300">·</span>}
<span className="text-xs text-gray-400 capitalize truncate">
{course.sport[0]}
</span>
</>
)}
</div>
</div>
{/* Occupancy */}
<div className="flex flex-col items-end gap-1 shrink-0">
<div className="flex items-center gap-1 text-xs text-gray-500">
<Users className="size-3" />
<span className="font-semibold">
{course.participantCount}
{course.maxParticipants > 0 && `/${course.maxParticipants}`}
</span>
</div>
{course.maxParticipants > 0 && (
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
isFull ? "bg-red-400" : occupancy > 75 ? "bg-amber-400" : "bg-green-400"
}`}
style={{ width: `${Math.min(occupancy, 100)}%` }}
/>
</div>
)}
{isFull && (
<span className="text-[10px] font-semibold text-red-500">Ausgebucht</span>
)}
</div>
<ArrowRight className="size-4 text-gray-300 group-hover:text-gray-400 transition-colors shrink-0" />
</Link>
);
})}
</div>
)}
{courses.length > 0 && (
<Link
href="/courses"
className="flex items-center justify-center gap-1.5 mt-4 text-xs font-semibold text-gray-400 hover:text-gray-600 transition-colors"
>
Alle Kurse ansehen <ArrowRight className="size-3.5" />
</Link>
)}
</div>
);
};
export default DashboardTodaySchedule;

View file

@ -1,57 +1,129 @@
import { LogoutButton } from "@/src/components/layout/LogoutButton"; import { isManager, isEmployee, type AuthUserData } from "@/src/lib/auth/getUser";
import { isManager, isEmployee, isUser, type AuthUserData } from "@/src/lib/auth/getUser"; import DashboardStatsGrid from "../components/DashboardStatsGrid";
import DashboardRevenueChart from "../components/DashboardRevenueChart";
import DashboardTodaySchedule from "../components/DashboardTodaySchedule";
import DashboardRecentPayments from "../components/DashboardRecentPayments";
import OnboardingChecklist from "@/src/modules/onboarding/ui/components/OnboardingChecklist";
import EmployeeFirstSteps from "@/src/modules/onboarding/ui/components/EmployeeFirstSteps";
import {
getOnboardingProgress,
isEmployeeFirstStepsVisible,
} from "@/src/modules/onboarding/actions/onboarding-actions";
import {
getDashboardStats,
getDashboardRevenueChart,
getDashboardTodayCourses,
getDashboardRecentBookings,
} from "../../actions/dashboard-actions";
interface DashboardViewProps { interface DashboardViewProps {
userData: AuthUserData; userData: AuthUserData;
} }
const DashboardView = ({ userData }: DashboardViewProps) => { const DashboardView = async ({ userData }: DashboardViewProps) => {
const displayName = isManager(userData) const displayName = isManager(userData)
? `${userData.firstName} ${userData.lastName}` ? userData.firstName
: isEmployee(userData) : isEmployee(userData)
? `${userData.firstName ?? ""} ${userData.lastName ?? ""}`.trim() || userData.email ? (userData.firstName ?? userData.email.split("@")[0])
: userData.name || userData.email; : userData.name || userData.email.split("@")[0];
return ( if (isManager(userData)) {
<div className="space-y-6"> const [statsResult, chartResult, todayResult, bookingsResult, onboardingResult] =
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-8 relative overflow-hidden"> await Promise.all([
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_80%_at_100%_0%,oklch(0.88_0.09_85/_0.25)_0%,transparent_70%)] pointer-events-none" /> getDashboardStats(),
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-2"> getDashboardRevenueChart(),
Dashboard getDashboardTodayCourses(),
</p> getDashboardRecentBookings(),
<h1 className="text-4xl font-black text-gray-900 mb-1">Willkommen zurück!</h1> getOnboardingProgress(),
<p className="text-gray-500 font-light">{displayName}</p> ]);
<div className="mt-4 flex gap-2">
{isManager(userData) && ( const stats = statsResult.success ? statsResult.stats : null;
<span className="inline-flex items-center px-3 py-1 bg-blue/10 text-blue text-xs font-semibold rounded-full border border-blue/10"> const chartData = chartResult.success ? chartResult.data : [];
Manager const todayCourses = todayResult.success ? todayResult.courses : [];
</span> const recentBookings = bookingsResult.success ? bookingsResult.bookings : [];
)}
{isEmployee(userData) && ( // Checkliste nur solange etwas offen und sie nicht weggeklickt ist. Der
// Fortschritt ist abgeleitet: bricht ein Schritt später weg (z.B. Stripe wird
// "restricted"), taucht die Karte von selbst wieder auf - es sei denn, der
// Manager hat sie bewusst ausgeblendet.
const onboarding =
onboardingResult.success &&
!onboardingResult.progress.isComplete &&
onboardingResult.progress.dismissedAt === null
? onboardingResult.progress
: null;
return (
<div className="space-y-6">
{/* Welcome header */}
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm px-8 py-6 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_80%_at_100%_0%,oklch(0.88_0.09_85/_0.25)_0%,transparent_70%)] pointer-events-none" />
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-1">
Dashboard
</p>
<h1 className="text-3xl font-black text-gray-900">Hallo {displayName} 👋</h1>
<p className="text-gray-400 text-sm mt-1 font-light">
Hier ist ein Überblick über dein Business.
</p>
</div>
{/* Setup-Checkliste, solange das Onboarding offen ist */}
{onboarding && (
<OnboardingChecklist
steps={onboarding.steps}
completedCount={onboarding.completedCount}
totalCount={onboarding.totalCount}
/>
)}
{/* Stats tiles */}
{stats && <DashboardStatsGrid stats={stats} />}
{/* Revenue chart */}
{chartData.length > 0 && <DashboardRevenueChart data={chartData} />}
{/* Today's schedule + recent payments */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<DashboardTodaySchedule courses={todayCourses} />
<DashboardRecentPayments bookings={recentBookings} />
</div>
</div>
);
}
if (isEmployee(userData)) {
const [todayResult, showFirstSteps] = await Promise.all([
getDashboardTodayCourses(),
isEmployeeFirstStepsVisible(userData.id),
]);
const todayCourses = todayResult.success ? todayResult.courses : [];
return (
<div className="space-y-6">
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm px-8 py-6 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_80%_at_100%_0%,oklch(0.88_0.09_85/_0.25)_0%,transparent_70%)] pointer-events-none" />
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-1">
Dashboard
</p>
<h1 className="text-3xl font-black text-gray-900">Hallo {displayName} 👋</h1>
<div className="mt-3">
<span className="inline-flex items-center px-3 py-1 bg-green-500/10 text-green-700 text-xs font-semibold rounded-full border border-green-500/10"> <span className="inline-flex items-center px-3 py-1 bg-green-500/10 text-green-700 text-xs font-semibold rounded-full border border-green-500/10">
Mitarbeiter Mitarbeiter
</span> </span>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6">
<h2 className="text-base font-semibold text-gray-900 mb-1">Profil</h2>
<p className="text-gray-400 text-sm font-light">Verwalte deine persönlichen Daten</p>
</div>
{isManager(userData) ? (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6">
<h2 className="text-base font-semibold text-gray-900 mb-1">Meine Businesses</h2>
<p className="text-gray-400 text-sm font-light">
{userData.businesses.length}{" "}
{userData.businesses.length === 1 ? "Business" : "Businesses"}
</p>
</div> </div>
) : isEmployee(userData) ? ( </div>
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6">
<h2 className="text-base font-semibold text-gray-900 mb-1">Meine Berechtigungen</h2> {/* Erste Schritte für frisch eingeladene Mitarbeiter */}
{showFirstSteps && <EmployeeFirstSteps permissions={userData.permissions} />}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<DashboardTodaySchedule courses={todayCourses} />
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-6">
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-1">
Berechtigungen
</p>
<h2 className="text-lg font-bold text-gray-900 mb-4">Meine Zugänge</h2>
{(() => { {(() => {
const p = userData.permissions; const p = userData.permissions;
const RESOURCE_LABELS: Record<string, string> = { const RESOURCE_LABELS: Record<string, string> = {
@ -71,146 +143,77 @@ const DashboardView = ({ userData }: DashboardViewProps) => {
.filter(([, v]) => v) .filter(([, v]) => v)
.map(([a]) => ACTION_LABELS[a] ?? a); .map(([a]) => ACTION_LABELS[a] ?? a);
return active.length > 0 return active.length > 0
? `${RESOURCE_LABELS[res] ?? res}: ${active.join(", ")}` ? { resource: RESOURCE_LABELS[res] ?? res, actions: active }
: null; : null;
}) })
.filter(Boolean); .filter(Boolean) as { resource: string; actions: string[] }[];
return rows.length > 0 ? ( return rows.length > 0 ? (
<ul className="text-gray-600 text-sm space-y-1 mt-2"> <div className="space-y-3">
{rows.map((row) => ( {rows.map((row) => (
<li key={row}>• {row}</li> <div key={row.resource} className="flex items-start gap-3">
<span className="text-sm font-semibold text-gray-700 min-w-24">
{row.resource}
</span>
<div className="flex flex-wrap gap-1">
{row.actions.map((a) => (
<span
key={a}
className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full"
>
{a}
</span>
))}
</div>
</div>
))} ))}
</ul> </div>
) : ( ) : (
<p className="text-gray-400 text-sm mt-2">Keine erweiterten Berechtigungen</p> <p className="text-gray-400 text-sm">Keine erweiterten Berechtigungen</p>
); );
})()} })()}
</div> </div>
) : (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6">
<h2 className="text-base font-semibold text-gray-900 mb-1">Trainings</h2>
<p className="text-gray-400 text-sm font-light">Deine gebuchten Trainingseinheiten</p>
</div>
)}
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6">
<h2 className="text-base font-semibold text-gray-900 mb-1">Einstellungen</h2>
<p className="text-gray-400 text-sm font-light">Passe deine Einstellungen an</p>
</div> </div>
</div> </div>
);
}
{isUser(userData) && ( // Regular user
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6"> return (
<h2 className="text-base font-semibold text-gray-900 mb-4">Benutzerdaten</h2> <div className="space-y-6">
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm px-8 py-6 relative overflow-hidden">
<div> <div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_80%_at_100%_0%,oklch(0.88_0.09_85/_0.25)_0%,transparent_70%)] pointer-events-none" />
<dt className="text-sm font-medium text-gray-500">Name</dt> <p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-1">
<dd className="mt-1 text-sm text-gray-900">{userData.name || "—"}</dd> Dashboard
</div> </p>
<div> <h1 className="text-3xl font-black text-gray-900">Hallo {displayName} 👋</h1>
<dt className="text-sm font-medium text-gray-500">E-Mail</dt> <p className="text-gray-400 text-sm mt-1 font-light">Willkommen zurück!</p>
<dd className="mt-1 text-sm text-gray-900">{userData.email}</dd> </div>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Geburtsdatum</dt>
<dd className="mt-1 text-sm text-gray-900">
{userData.birthday ? new Date(userData.birthday).toLocaleDateString("de-DE") : "—"}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Wohnort</dt>
<dd className="mt-1 text-sm text-gray-900">
{userData.plz && userData.city ? `${userData.plz} ${userData.city}` : "—"}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Geschlecht</dt>
<dd className="mt-1 text-sm text-gray-900">{userData.gender || "—"}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Registriert seit</dt>
<dd className="mt-1 text-sm text-gray-900">
{userData.createdAt
? new Date(userData.createdAt).toLocaleDateString("de-DE")
: "—"}
</dd>
</div>
</dl>
</div>
)}
{isManager(userData) && ( <div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6">
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6"> <h2 className="text-base font-semibold text-gray-900 mb-4">Meine Daten</h2>
<h2 className="text-base font-semibold text-gray-900 mb-4">Manager-Daten</h2> <dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4"> {[
<div> { label: "Name", value: userData.name || "—" },
<dt className="text-sm font-medium text-gray-500">Vorname</dt> { label: "E-Mail", value: userData.email },
<dd className="mt-1 text-sm text-gray-900">{userData.firstName}</dd> {
label: "Geburtsdatum",
value: userData.birthday
? new Date(userData.birthday).toLocaleDateString("de-DE")
: "—",
},
{
label: "Wohnort",
value: userData.plz && userData.city ? `${userData.plz} ${userData.city}` : "—",
},
].map(({ label, value }) => (
<div key={label}>
<dt className="text-sm font-medium text-gray-500">{label}</dt>
<dd className="mt-1 text-sm text-gray-900">{value}</dd>
</div> </div>
<div> ))}
<dt className="text-sm font-medium text-gray-500">Nachname</dt> </dl>
<dd className="mt-1 text-sm text-gray-900">{userData.lastName}</dd> </div>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">E-Mail</dt>
<dd className="mt-1 text-sm text-gray-900">{userData.email}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Telefon</dt>
<dd className="mt-1 text-sm text-gray-900">{userData.tel || "—"}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Registriert seit</dt>
<dd className="mt-1 text-sm text-gray-900">
{userData.createdAt
? new Date(userData.createdAt).toLocaleDateString("de-DE")
: "—"}
</dd>
</div>
</dl>
</div>
)}
{isManager(userData) && userData.businesses.length > 0 && (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6">
<h2 className="text-base font-semibold text-gray-900 mb-4">Meine Businesses</h2>
<div className="space-y-4">
{userData.businesses.map((business) => (
<div key={business.id} className="border-l-2 border-white/60 pl-4 py-1">
<h3 className="text-lg font-semibold text-gray-900">{business.name}</h3>
<p className="text-sm text-gray-600 mt-1">{business.address}</p>
<p className="text-sm text-gray-600">{business.email}</p>
</div>
))}
</div>
</div>
)}
{isEmployee(userData) && (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6">
<h2 className="text-base font-semibold text-gray-900 mb-4">Mitarbeiter-Daten</h2>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Vorname</dt>
<dd className="mt-1 text-sm text-gray-900">{userData.firstName || "—"}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Nachname</dt>
<dd className="mt-1 text-sm text-gray-900">{userData.lastName || "—"}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">E-Mail</dt>
<dd className="mt-1 text-sm text-gray-900">{userData.email}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Qualifikation</dt>
<dd className="mt-1 text-sm text-gray-900">{userData.qualification || "—"}</dd>
</div>
</dl>
</div>
)}
<LogoutButton />
</div> </div>
); );
}; };

View file

@ -0,0 +1,239 @@
/**
* Onboarding Actions - Fortschritt und Meta-Zustand des Studio-Onboardings.
*
* Der Fortschritt wird bei jedem Aufruf frisch aus den Fachdaten abgeleitet
* (siehe onboarding-steps.ts) und bewusst NICHT gecacht: die Zahlen sind ein
* findFirst plus drei count-Queries, und ein gecachter Wert würde nach jedem
* erledigten Schritt für die Cache-Dauer weiter "offen" anzeigen.
*/
"use server";
import { prisma } from "@/src/lib/prisma";
import { requireManager, requireAuthWithData, isEmployee } from "@/src/lib/auth/getUser";
import { revalidatePath } from "next/cache";
import {
countCompletedSteps,
getStepStates,
isOnboardingComplete,
type OnboardingContext,
type OnboardingStepId,
} from "../lib/onboarding-steps";
export interface OnboardingProgress {
businessId: string;
businessName: string;
steps: Array<{ id: OnboardingStepId; done: boolean }>;
completedCount: number;
totalCount: number;
isComplete: boolean;
startedAt: Date | null;
dismissedAt: Date | null;
completedAt: Date | null;
}
/**
* Ermittelt das primäre Business des Managers. Das Schema erlaubt mehrere
* Businesses pro Manager, die Registrierung legt aber genau eines an - das
* älteste ist damit das des laufenden Onboardings.
*/
async function getPrimaryBusiness(managerId: string) {
return prisma.business.findFirst({
where: { managerId },
orderBy: { createdAt: "asc" },
select: {
id: true,
name: true,
slug: true,
isPublic: true,
branding: true,
cancellationPolicy: true,
stripeAccountStatus: true,
onboardingStartedAt: true,
onboardingDismissedAt: true,
onboardingCompletedAt: true,
},
});
}
export async function getOnboardingProgress(): Promise<
{ success: true; progress: OnboardingProgress } | { success: false; error: string }
> {
// requireManager() löst bei fehlender Session/Rolle einen next/navigation-Redirect
// aus, der als spezieller Error implementiert ist - darf nicht im try/catch landen.
const manager = await requireManager();
try {
const business = await getPrimaryBusiness(manager.id);
if (!business) {
return { success: false, error: "Kein Business gefunden." };
}
const [roomCount, employeeCount, courseCount] = await Promise.all([
prisma.room.count({ where: { createdBy: manager.id } }),
prisma.employee.count({ where: { createdBy: manager.id } }),
prisma.course.count({ where: { businessId: business.id } }),
]);
const ctx: OnboardingContext = {
stripeAccountStatus: business.stripeAccountStatus,
slug: business.slug,
isPublic: business.isPublic,
hasBranding: business.branding !== null,
hasCancellationPolicy: business.cancellationPolicy !== null,
roomCount,
employeeCount,
courseCount,
};
const states = getStepStates(ctx);
const complete = isOnboardingComplete(ctx);
// completedAt einmalig festschreiben, sobald alle Pflichtschritte erledigt
// sind. Der Zeitstempel ist reine Historie (Erfolgsscreen, später Auswertung);
// sichtbar bleibt die Checkliste weiterhin über die abgeleiteten Schritte,
// damit ein später wegbrechender Schritt wieder auftaucht.
let completedAt = business.onboardingCompletedAt;
if (complete && completedAt === null) {
completedAt = new Date();
await prisma.business.update({
where: { id: business.id },
data: { onboardingCompletedAt: completedAt },
});
}
return {
success: true,
progress: {
businessId: business.id,
businessName: business.name,
steps: states.map(({ id, done }) => ({ id, done })),
completedCount: countCompletedSteps(ctx),
totalCount: states.length,
isComplete: complete,
startedAt: business.onboardingStartedAt,
dismissedAt: business.onboardingDismissedAt,
completedAt,
},
};
} catch (error) {
console.error("Error fetching onboarding progress:", error);
return { success: false, error: "Onboarding-Fortschritt konnte nicht geladen werden." };
}
}
/**
* Weiches Gate fürs Dashboard: true nur, solange der Manager den Wizard noch nie
* geöffnet hat. Bewusst eine eigene, schlanke Abfrage statt getOnboardingProgress() -
* sie läuft bei JEDEM Dashboard-Aufruf und darf keine counts auslösen.
*
* Bestehende Studios wurden in der Migration auf onboardingStartedAt = createdAt
* gesetzt und werden deshalb nie umgeleitet.
*/
export async function shouldStartOnboarding(managerId: string): Promise<boolean> {
try {
const business = await prisma.business.findFirst({
where: { managerId },
orderBy: { createdAt: "asc" },
select: { onboardingStartedAt: true },
});
return business !== null && business.onboardingStartedAt === null;
} catch (error) {
// Ein Fehler hier darf das Dashboard nicht blockieren - im Zweifel nicht umleiten.
console.error("Error checking onboarding start state:", error);
return false;
}
}
/**
* Markiert den Wizard als gesehen. Ab dann leitet das Dashboard nicht mehr
* automatisch dorthin um - der Manager erreicht ihn nur noch über die Checkliste.
*/
export async function markOnboardingStarted(): Promise<
{ success: true } | { success: false; error: string }
> {
const manager = await requireManager();
try {
// updateMany + startedAt: null statt update: setzt den Zeitstempel nur beim
// allerersten Öffnen und überschreibt ihn bei jedem weiteren Besuch nicht.
await prisma.business.updateMany({
where: { managerId: manager.id, onboardingStartedAt: null },
data: { onboardingStartedAt: new Date() },
});
return { success: true };
} catch (error) {
console.error("Error marking onboarding as started:", error);
return { success: false, error: "Onboarding-Status konnte nicht gespeichert werden." };
}
}
/** Blendet die Setup-Checkliste im Dashboard aus. */
export async function dismissOnboardingChecklist(): Promise<
{ success: true } | { success: false; error: string }
> {
const manager = await requireManager();
try {
const business = await getPrimaryBusiness(manager.id);
if (!business) {
return { success: false, error: "Kein Business gefunden." };
}
await prisma.business.update({
where: { id: business.id },
data: { onboardingDismissedAt: new Date() },
});
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Error dismissing onboarding checklist:", error);
return { success: false, error: "Checkliste konnte nicht ausgeblendet werden." };
}
}
/**
* Ob die "Erste Schritte"-Karte für den eingeloggten Mitarbeiter noch sichtbar ist.
*
* Bewusst eine eigene Abfrage statt eines Feldes in getUserData(): dessen Ergebnis
* liegt 60s im Data Cache, das Wegklicken würde also bis zu eine Minute lang wieder
* zurückspringen.
*/
export async function isEmployeeFirstStepsVisible(employeeId: string): Promise<boolean> {
try {
const employee = await prisma.employee.findUnique({
where: { id: employeeId },
select: { firstStepsDismissedAt: true },
});
return employee !== null && employee.firstStepsDismissedAt === null;
} catch (error) {
// Ein Fehler hier darf das Dashboard nicht blockieren - im Zweifel nichts anzeigen.
console.error("Error checking employee first steps state:", error);
return false;
}
}
/** Blendet die "Erste Schritte"-Karte eines Mitarbeiters aus. */
export async function dismissEmployeeFirstSteps(): Promise<
{ success: true } | { success: false; error: string }
> {
const userData = await requireAuthWithData();
if (!isEmployee(userData)) {
return { success: false, error: "Nur für Mitarbeiter verfügbar." };
}
try {
await prisma.employee.update({
where: { id: userData.id },
data: { firstStepsDismissedAt: new Date() },
});
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Error dismissing employee first steps:", error);
return { success: false, error: "Karte konnte nicht ausgeblendet werden." };
}
}

View file

@ -0,0 +1,190 @@
/**
* Registry aller Onboarding-Schritte für Studios (Manager).
*
* Single Source of Truth für:
* - welche Schritte es gibt und in welcher Reihenfolge
* - Label, Beschreibung, Icon und Ziel-Link
* - required: ob der Schritt für "Onboarding abgeschlossen" zählt
* - mode: "wizard" = eigener Screen unter /onboarding, "link" = nur Punkt in der Checkliste
* - isDone: wie sich der Status aus den vorhandenen Daten ableitet
*
* Bewusst KEINE eigenen DB-Spalten pro Schritt: jeder Status steht bereits in den
* Fachdaten (Stripe-Status, Slug, Räume, Kurse ...). Würde man ihn zusätzlich als
* Flag speichern, driften beide auseinander, sobald sich die Fachdaten ändern -
* z.B. wenn Stripe einen Account nachträglich auf "restricted" setzt oder der
* letzte Raum gelöscht wird. Ein neuer Schritt wird ausschließlich hier ergänzt.
*/
/** Zustand, aus dem sich der Fortschritt ableitet (siehe getOnboardingProgress). */
export interface OnboardingContext {
stripeAccountStatus: string | null;
slug: string | null;
isPublic: boolean;
hasBranding: boolean;
hasCancellationPolicy: boolean;
roomCount: number;
employeeCount: number;
courseCount: number;
}
export interface OnboardingStepDef {
label: string;
description: string;
/** lucide-react Icon-Komponentenname, gemappt in OnboardingStepIcon */
icon: string;
/** Zählt der Schritt für "Onboarding abgeschlossen"? */
required: boolean;
/** "wizard" = eigener Screen im Wizard, "link" = nur Checklisten-Punkt mit Deep-Link */
mode: "wizard" | "link";
/** Ziel für den Checklisten-Link bzw. den Absprung aus dem Wizard */
href: string;
/** Beschriftung des Buttons, der zu href führt */
cta: string;
isDone: (ctx: OnboardingContext) => boolean;
}
export const ONBOARDING_STEPS = {
branding: {
label: "Branding festlegen",
description:
"Logo und Farben erscheinen auf deiner öffentlichen Seite, in E-Mails und auf Rechnungen.",
icon: "Megaphone",
required: false,
mode: "wizard",
href: "/settings/branding",
cta: "Branding bearbeiten",
isDone: (ctx) => ctx.hasBranding,
},
payments: {
label: "Zahlungen aktivieren",
description:
"Verbinde dein Stripe-Konto. Ohne aktives Konto können deine Kunden keine Kurse bezahlen.",
icon: "CreditCard",
required: true,
mode: "wizard",
href: "/settings/payments",
cta: "Stripe verbinden",
isDone: (ctx) => ctx.stripeAccountStatus === "active",
},
cancellation: {
label: "Stornoregeln festlegen",
description:
"Lege fest, wie viel Prozent deine Kunden je nach Zeitpunkt der Stornierung zurückbekommen.",
icon: "TicketX",
required: false,
mode: "wizard",
href: "/settings/cancellation",
cta: "Regeln festlegen",
isDone: (ctx) => ctx.hasCancellationPolicy,
},
room: {
label: "Ersten Raum anlegen",
description: "Jeder Kurs braucht einen Raum. Du kannst später beliebig viele ergänzen.",
icon: "DoorOpen",
required: true,
mode: "wizard",
href: "/rooms/create",
cta: "Raum anlegen",
isDone: (ctx) => ctx.roomCount > 0,
},
publicPage: {
label: "Seite veröffentlichen",
description:
"Schalte deine öffentliche Kursseite frei, damit Kunden deine Kurse finden und buchen können.",
icon: "PanelTop",
required: true,
mode: "wizard",
href: "/settings/public-page",
cta: "Seite veröffentlichen",
isDone: (ctx) => ctx.isPublic && ctx.slug !== null,
},
team: {
label: "Team einladen",
description:
"Lade Trainer und Mitarbeiter ein. Sie bekommen eine E-Mail und richten ihr Konto selbst ein.",
icon: "Users",
required: false,
mode: "link",
href: "/employee/create",
cta: "Mitarbeiter einladen",
isDone: (ctx) => ctx.employeeCount > 0,
},
course: {
label: "Ersten Kurs erstellen",
description: "Der eigentliche Start: leg deinen ersten Kurs an und veröffentliche ihn.",
icon: "CalendarPlus",
required: true,
mode: "link",
href: "/courses/create",
cta: "Kurs erstellen",
isDone: (ctx) => ctx.courseCount > 0,
},
} as const satisfies Record<string, OnboardingStepDef>;
export type OnboardingStepId = keyof typeof ONBOARDING_STEPS;
/** Reihenfolge in Checkliste und Wizard = Reihenfolge der Registry-Einträge. */
export const ONBOARDING_STEP_IDS = Object.keys(ONBOARDING_STEPS) as OnboardingStepId[];
/**
* IDs nach mode aufgeteilt - als abgeleitete Typen, damit z.B. der Wizard beim
* Rendern seiner Screens vom Compiler auf Vollständigkeit geprüft wird und ein
* neuer Registry-Eintrag nicht stillschweigend ohne Screen bleibt.
*/
type StepIdsWithMode<M extends OnboardingStepDef["mode"]> = {
[K in OnboardingStepId]: (typeof ONBOARDING_STEPS)[K]["mode"] extends M ? K : never;
}[OnboardingStepId];
export type WizardStepId = StepIdsWithMode<"wizard">;
export type LinkStepId = StepIdsWithMode<"link">;
/** Nur die Schritte, die einen eigenen Screen im Wizard haben. */
export const WIZARD_STEP_IDS = ONBOARDING_STEP_IDS.filter(
(id): id is WizardStepId => ONBOARDING_STEPS[id].mode === "wizard"
);
/** Schritte, die der Wizard nur verlinkt und die in der Checkliste stehen bleiben. */
export const LINK_STEP_IDS = ONBOARDING_STEP_IDS.filter(
(id): id is LinkStepId => ONBOARDING_STEPS[id].mode === "link"
);
export function isOnboardingStepId(value: string): value is OnboardingStepId {
// Object.hasOwn statt `in`: schließt geerbte Namen wie "__proto__" oder
// "constructor" aus, die sonst als gültige Schritt-IDs durchrutschen würden.
return Object.hasOwn(ONBOARDING_STEPS, value);
}
export interface OnboardingStepState {
id: OnboardingStepId;
def: OnboardingStepDef;
done: boolean;
}
export function getStepStates(ctx: OnboardingContext): OnboardingStepState[] {
return ONBOARDING_STEP_IDS.map((id) => ({
id,
def: ONBOARDING_STEPS[id],
done: ONBOARDING_STEPS[id].isDone(ctx),
}));
}
/**
* Abgeschlossen ist das Onboarding, sobald alle PFLICHT-Schritte erledigt sind.
* Optionale Schritte (Branding, Storno, Team) haben brauchbare Defaults und
* dürfen den Abschluss nicht blockieren.
*/
export function isOnboardingComplete(ctx: OnboardingContext): boolean {
return ONBOARDING_STEP_IDS.every(
(id) => !ONBOARDING_STEPS[id].required || ONBOARDING_STEPS[id].isDone(ctx)
);
}
/** Anzahl erledigter Schritte über ALLE Schritte (für "3 von 7 erledigt"). */
export function countCompletedSteps(ctx: OnboardingContext): number {
return ONBOARDING_STEP_IDS.filter((id) => ONBOARDING_STEPS[id].isDone(ctx)).length;
}
/** Erster noch offener Wizard-Schritt, sonst null (= Wizard durchgelaufen). */
export function firstOpenWizardStep(ctx: OnboardingContext): OnboardingStepId | null {
return WIZARD_STEP_IDS.find((id) => !ONBOARDING_STEPS[id].isDone(ctx)) ?? null;
}

View file

@ -0,0 +1,118 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { ArrowRight, CalendarDays, DoorOpen, User, Users, X } from "lucide-react";
import { toast } from "sonner";
import type { EmployeeData } from "@/src/lib/auth/getUser";
import { dismissEmployeeFirstSteps } from "../../actions/onboarding-actions";
interface EmployeeFirstStepsProps {
permissions: EmployeeData["permissions"];
}
/**
* "Erste Schritte" für frisch eingeladene Mitarbeiter. Bewusst kein Wizard und kein
* Gate: der Invite-Flow (Token + Profil) ist bereits abgeschlossen, hier geht es nur
* noch um Orientierung. Gezeigt werden ausschließlich Punkte, für die der Mitarbeiter
* auch Rechte hat - sonst verlinkt die Karte auf Seiten, die ihn abweisen.
*/
const EmployeeFirstSteps = ({ permissions }: EmployeeFirstStepsProps) => {
const [isHidden, setIsHidden] = useState(false);
const [isPending, startTransition] = useTransition();
// Icons als fertige Elemente statt als Komponenten-Referenz: <item.icon /> wertet
// der React-Compiler-Linter als "Komponente während Render erzeugt".
const items = [
{
show: true,
href: "/settings/personal",
icon: <User className="size-4 text-gray-500" />,
label: "Profil vervollständigen",
description: "Profilbild und Kontaktdaten ergänzen.",
},
{
show: permissions.courses.view,
href: "/courses",
icon: <CalendarDays className="size-4 text-gray-500" />,
label: "Kurse ansehen",
description: "Verschaff dir einen Überblick über den Kursplan.",
},
{
show: permissions.rooms.view,
href: "/rooms",
icon: <DoorOpen className="size-4 text-gray-500" />,
label: "Räume ansehen",
description: "Sieh dir an, welche Räume zur Verfügung stehen.",
},
{
show: permissions.employees.view,
href: "/employee",
icon: <Users className="size-4 text-gray-500" />,
label: "Team kennenlernen",
description: "Wer arbeitet sonst noch im Studio?",
},
// Bewusst KEIN Link auf /settings/notifications: die Seite ist über
// requireManager() manager-only und würde Mitarbeiter aufs Dashboard zurückwerfen.
].filter((item) => item.show);
const handleDismiss = () => {
setIsHidden(true);
startTransition(async () => {
const result = await dismissEmployeeFirstSteps();
if (!result.success) {
setIsHidden(false);
toast.error(result.error);
}
});
};
if (isHidden) return null;
return (
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-6 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_70%_at_0%_0%,oklch(0.88_0.09_85/_0.22)_0%,transparent_70%)] pointer-events-none" />
<div className="relative">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-0.5">
Willkommen
</p>
<h2 className="text-lg font-bold text-gray-900">Deine ersten Schritte</h2>
</div>
<button
type="button"
onClick={handleDismiss}
disabled={isPending}
aria-label="Erste Schritte ausblenden"
className="text-gray-300 hover:text-gray-500 transition-colors cursor-pointer disabled:opacity-50"
>
<X className="size-4" />
</button>
</div>
<div className="space-y-2">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-3 p-3 rounded-xl border border-white/50 bg-white/40 hover:bg-white/70 hover:border-white/80 transition-all group"
>
<div className="flex items-center justify-center size-8 rounded-lg bg-gray-100/80 shrink-0">
{item.icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-gray-900">{item.label}</p>
<p className="text-xs text-gray-400 font-light truncate">{item.description}</p>
</div>
<ArrowRight className="size-4 text-gray-300 group-hover:text-[#c9a84c] group-hover:translate-x-0.5 transition-all shrink-0" />
</Link>
))}
</div>
</div>
</div>
);
};
export default EmployeeFirstSteps;

View file

@ -0,0 +1,128 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { ArrowRight, Check, Rocket, X } from "lucide-react";
import { toast } from "sonner";
import { Progress } from "@/src/components/ui/progress";
import { Button } from "@/src/components/ui/button";
import { dismissOnboardingChecklist } from "../../actions/onboarding-actions";
import { ONBOARDING_STEPS, type OnboardingStepId } from "../../lib/onboarding-steps";
import OnboardingStepIcon from "./OnboardingStepIcon";
interface OnboardingChecklistProps {
steps: Array<{ id: OnboardingStepId; done: boolean }>;
completedCount: number;
totalCount: number;
}
const OnboardingChecklist = ({ steps, completedCount, totalCount }: OnboardingChecklistProps) => {
// Optimistisch ausblenden, damit die Karte nicht bis zum Ende der Server
// Action stehen bleibt; bei Fehler kommt sie mit einem Toast zurück.
const [isHidden, setIsHidden] = useState(false);
const [isPending, startTransition] = useTransition();
const percent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
const hasOpenWizardStep = steps.some(
({ id, done }) => !done && ONBOARDING_STEPS[id].mode === "wizard"
);
const handleDismiss = () => {
setIsHidden(true);
startTransition(async () => {
const result = await dismissOnboardingChecklist();
if (!result.success) {
setIsHidden(false);
toast.error(result.error);
}
});
};
if (isHidden) return null;
return (
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-6 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_70%_at_0%_0%,oklch(0.88_0.09_85/_0.22)_0%,transparent_70%)] pointer-events-none" />
<div className="relative">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-0.5">
Einrichtung
</p>
<h2 className="text-lg font-bold text-gray-900">Richte dein Studio ein</h2>
<p className="text-sm text-gray-400 font-light mt-0.5">
{completedCount} von {totalCount} Schritten erledigt
</p>
</div>
<button
type="button"
onClick={handleDismiss}
disabled={isPending}
aria-label="Checkliste ausblenden"
className="text-gray-300 hover:text-gray-500 transition-colors cursor-pointer disabled:opacity-50"
>
<X className="size-4" />
</button>
</div>
<Progress value={percent} className="mb-5" />
<div className="space-y-2">
{steps.map(({ id, done }) => {
const step = ONBOARDING_STEPS[id];
if (done) {
return (
<div
key={id}
className="flex items-center gap-3 p-3 rounded-xl border border-transparent"
>
<div className="flex items-center justify-center size-8 rounded-lg bg-green-100/80 shrink-0">
<Check className="size-4 text-green-600" />
</div>
<p className="text-sm font-medium text-gray-400 line-through">{step.label}</p>
</div>
);
}
return (
<Link
key={id}
href={step.href}
className="flex items-center gap-3 p-3 rounded-xl border border-white/50 bg-white/40 hover:bg-white/70 hover:border-white/80 transition-all group"
>
<div className="flex items-center justify-center size-8 rounded-lg bg-gray-100/80 shrink-0">
<OnboardingStepIcon step={id} className="size-4 text-gray-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-gray-900">
{step.label}
{!step.required && (
<span className="ml-2 text-[10px] font-medium text-gray-400 uppercase tracking-wide">
optional
</span>
)}
</p>
<p className="text-xs text-gray-400 font-light truncate">{step.description}</p>
</div>
<ArrowRight className="size-4 text-gray-300 group-hover:text-[#c9a84c] group-hover:translate-x-0.5 transition-all shrink-0" />
</Link>
);
})}
</div>
{hasOpenWizardStep && (
<Button asChild size="sm" className="mt-5">
<Link href="/onboarding">
<Rocket className="size-4" />
Einrichtung fortsetzen
</Link>
</Button>
)}
</div>
</div>
);
};
export default OnboardingChecklist;

View file

@ -0,0 +1,41 @@
import {
CalendarPlus,
Circle,
CreditCard,
DoorOpen,
Megaphone,
PanelTop,
TicketX,
Users,
type LucideProps,
} from "lucide-react";
import { ONBOARDING_STEPS, type OnboardingStepId } from "../../lib/onboarding-steps";
/**
* Rendert das zu einem Onboarding-Schritt gehörende lucide-Icon.
*
* Bewusst als eigene Komponente statt `const Icon = lookup(id)` im Aufrufer:
* Letzteres wertet der React-Compiler-Linter als "Komponente während Render
* erzeugt". Hier sind alle Icon-Referenzen statisch.
*/
const ICONS = {
CalendarPlus,
CreditCard,
DoorOpen,
Megaphone,
PanelTop,
TicketX,
Users,
} as const;
interface OnboardingStepIconProps extends LucideProps {
step: OnboardingStepId;
}
const OnboardingStepIcon = ({ step, ...props }: OnboardingStepIconProps) => {
const name = ONBOARDING_STEPS[step]?.icon as keyof typeof ICONS | undefined;
const Icon = (name && ICONS[name]) || Circle;
return <Icon {...props} />;
};
export default OnboardingStepIcon;

View file

@ -0,0 +1,115 @@
"use client";
import Link from "next/link";
import { ArrowLeft, ArrowRight, Check } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { Progress } from "@/src/components/ui/progress";
import { cn } from "@/src/lib/utils";
interface OnboardingStepShellProps {
eyebrow: string;
title: string;
description: string;
/** 1-basiert, nur für die Anzeige "Schritt 2 von 5" */
stepNumber: number;
totalSteps: number;
/** Punkte-Navigation: erledigte Schritte bekommen einen Haken */
markers: Array<{ id: string; label: string; done: boolean; active: boolean }>;
onMarkerClick: (index: number) => void;
onBack?: () => void;
onNext: () => void;
nextLabel: string;
children: React.ReactNode;
}
const OnboardingStepShell = ({
eyebrow,
title,
description,
stepNumber,
totalSteps,
markers,
onMarkerClick,
onBack,
onNext,
nextLabel,
children,
}: OnboardingStepShellProps) => {
const percent = totalSteps > 0 ? Math.round(((stepNumber - 1) / totalSteps) * 100) : 0;
return (
<div className="mx-auto w-full max-w-3xl space-y-6 py-6">
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-6 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_70%_at_100%_0%,oklch(0.88_0.09_85/_0.22)_0%,transparent_70%)] pointer-events-none" />
<div className="relative space-y-5">
<div className="flex items-center justify-between gap-4">
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase">
{eyebrow}
</p>
<p className="text-xs font-medium text-gray-400">
Schritt {stepNumber} von {totalSteps}
</p>
</div>
<Progress value={percent} />
{/* Punkte-Navigation: erlaubt das Springen zwischen bereits gesehenen Schritten */}
<div className="flex flex-wrap items-center gap-2">
{markers.map((marker, index) => (
<button
key={marker.id}
type="button"
onClick={() => onMarkerClick(index)}
className={cn(
"flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors cursor-pointer",
marker.active
? "bg-gray-900 text-white"
: marker.done
? "bg-green-100/80 text-green-700 hover:bg-green-100"
: "bg-gray-100/80 text-gray-500 hover:bg-gray-200/80"
)}
>
{marker.done && <Check className="size-3" />}
{marker.label}
</button>
))}
</div>
<div>
<h1 className="text-2xl font-black text-gray-900">{title}</h1>
<p className="text-sm text-gray-400 font-light mt-1">{description}</p>
</div>
</div>
</div>
<div>{children}</div>
<div className="flex items-center justify-between gap-4">
{onBack ? (
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="size-4" />
Zurück
</Button>
) : (
<span />
)}
<div className="flex items-center gap-4">
<Link
href="/dashboard"
className="text-sm font-medium text-gray-400 hover:text-gray-700 transition-colors"
>
Später erledigen
</Link>
<Button onClick={onNext}>
{nextLabel}
<ArrowRight className="size-4" />
</Button>
</div>
</div>
</div>
);
};
export default OnboardingStepShell;

View file

@ -0,0 +1,51 @@
"use client";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import BusinessBrandingEditor from "@/src/modules/settings/ui/components/BusinessBrandingEditor";
import { saveBusinessBranding } from "@/src/modules/settings/actions/business-branding-actions";
import type { BusinessBranding } from "@/src/modules/settings/schemas/business-branding-schema";
interface StepBrandingProps {
businessId: string;
initialBranding: BusinessBranding;
onSaved: () => void;
}
/** Nutzt denselben Editor wie /settings/branding - nur die Speicher-Rückmeldung ist anders. */
const StepBranding = ({ businessId, initialBranding, onSaved }: StepBrandingProps) => {
const [branding] = useState(initialBranding);
const [isSaving, startSaving] = useTransition();
const handleSave = (newBranding: BusinessBranding): Promise<boolean> => {
return new Promise((resolve) => {
startSaving(async () => {
try {
const result = await saveBusinessBranding(businessId, newBranding);
if (result.success) {
toast.success("Branding gespeichert.");
onSaved();
resolve(true);
} else {
toast.error(result.error);
resolve(false);
}
} catch {
toast.error("Branding konnte nicht gespeichert werden.");
resolve(false);
}
});
});
};
return (
<BusinessBrandingEditor
businessId={businessId}
initialBranding={branding}
onSave={handleSave}
isSaving={isSaving}
/>
);
};
export default StepBranding;

View file

@ -0,0 +1,46 @@
"use client";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import CancellationPolicyEditor from "@/src/modules/settings/ui/components/CancellationPolicyEditor";
import { saveCancellationPolicy } from "@/src/modules/settings/actions/cancellation-settings-actions";
import type { CancellationPolicy } from "@/src/modules/settings/schemas/cancellation-policy-schema";
interface StepCancellationProps {
businessId: string;
initialPolicy: CancellationPolicy;
onSaved: () => void;
}
/** Nutzt denselben Editor wie /settings/cancellation. */
const StepCancellation = ({ businessId, initialPolicy, onSaved }: StepCancellationProps) => {
const [policy] = useState(initialPolicy);
const [isSaving, startSaving] = useTransition();
const handleSave = (newPolicy: CancellationPolicy): Promise<boolean> => {
return new Promise((resolve) => {
startSaving(async () => {
try {
const result = await saveCancellationPolicy(businessId, newPolicy);
if (result.success) {
toast.success("Stornierungsregeln gespeichert.");
onSaved();
resolve(true);
} else {
toast.error(result.error);
resolve(false);
}
} catch {
toast.error("Stornierungsregeln konnten nicht gespeichert werden.");
resolve(false);
}
});
});
};
return (
<CancellationPolicyEditor initialPolicy={policy} onSave={handleSave} isSaving={isSaving} />
);
};
export default StepCancellation;

View file

@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
import { AlertCircle, CheckCircle2, CreditCard, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/src/components/ui/button";
import { createConnectOnboardingLink } from "@/src/modules/settings/actions/stripe-connect-actions";
interface StepPaymentsProps {
businessId: string;
stripeAccountStatus: string | null;
}
/**
* Stripe-Connect-Schritt. Der Manager verlässt hier die App und kommt über
* /api/stripe/connect/return zurück - returnTo sorgt dafür, dass er wieder im
* Wizard und nicht in den Zahlungseinstellungen landet.
*/
const StepPayments = ({ businessId, stripeAccountStatus }: StepPaymentsProps) => {
const [isPending, setIsPending] = useState(false);
const handleConnect = async () => {
setIsPending(true);
try {
const result = await createConnectOnboardingLink(businessId, "/onboarding?step=payments");
if (result.success && result.url) {
window.location.assign(result.url);
return;
}
toast.error(result.error || "Fehler beim Verbinden");
} catch {
toast.error("Fehler beim Verbinden");
} finally {
setIsPending(false);
}
};
if (stripeAccountStatus === "active") {
return (
<div className="flex items-start gap-3 rounded-xl border border-green-200 bg-green-50 p-4">
<CheckCircle2 className="size-5 text-green-600 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-green-900">Zahlungen sind aktiv</p>
<p className="text-sm text-green-700 mt-0.5">
Dein Stripe-Konto ist verbunden. Buchungen werden direkt an dich ausgezahlt.
</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
{stripeAccountStatus === "pending" && (
<div className="flex items-start gap-3 rounded-xl border border-amber-200 bg-amber-50 p-4">
<AlertCircle className="size-5 text-amber-600 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-amber-900">Prüfung läuft noch</p>
<p className="text-sm text-amber-700 mt-0.5">
Stripe hat noch nicht alle Angaben. Setz das Konto-Onboarding fort, um Zahlungen
freizuschalten.
</p>
</div>
</div>
)}
<div className="rounded-xl border border-white/60 bg-white/50 p-5">
<p className="text-sm text-gray-600">
Kumasy wickelt Zahlungen über Stripe ab. Du legst in wenigen Minuten ein Konto an, danach
gehen die Kursgebühren direkt auf dein Bankkonto — abzüglich der Servicegebühr.
</p>
<Button onClick={handleConnect} disabled={isPending} className="mt-4">
{isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<CreditCard className="size-4" />
)}
{stripeAccountStatus === "pending" ? "Onboarding fortsetzen" : "Mit Stripe verbinden"}
</Button>
</div>
</div>
);
};
export default StepPayments;

View file

@ -0,0 +1,34 @@
"use client";
import BusinessPublicToggle from "@/src/modules/settings/ui/components/BusinessPublicToggle";
interface StepPublicPageProps {
businessId: string;
businessName: string;
initialIsPublic: boolean;
initialSlug: string | null;
onPublicChange: (isPublic: boolean) => void;
}
/**
* Letzter Wizard-Schritt: das "Live gehen". Der Toggle ist derselbe wie unter
* /settings/public-page und meldet den neuen Zustand per Callback zurück, damit
* der Wizard den Schritt sofort abhaken kann.
*/
const StepPublicPage = ({
businessId,
businessName,
initialIsPublic,
initialSlug,
onPublicChange,
}: StepPublicPageProps) => (
<BusinessPublicToggle
businessId={businessId}
businessName={businessName}
initialIsPublic={initialIsPublic}
initialSlug={initialSlug}
onPublicChange={onPublicChange}
/>
);
export default StepPublicPage;

View file

@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { CheckCircle2, Loader2, Plus } from "lucide-react";
import { toast } from "sonner";
import InputComponent from "@/src/components/layout/InputComponent";
import { Button } from "@/src/components/ui/button";
import { createRoom } from "@/src/modules/rooms/actions/room-actions";
interface StepRoomProps {
hasRoom: boolean;
onCreated: () => void;
}
/**
* Legt den ersten Raum an. Bewusst ein eigenes Mini-Formular statt RoomsCreateView:
* die View bringt eine eigene Seitenüberschrift, den Löschen-Dialog und ein festes
* router.push("/rooms") mit - im Wizard wäre das mehr Umbau als Wiederverwendung.
*/
const StepRoom = ({ hasRoom, onCreated }: StepRoomProps) => {
const [name, setName] = useState("");
const [error, setError] = useState<string | undefined>();
const [isPending, setIsPending] = useState(false);
const handleCreate = async () => {
if (!name.trim()) {
setError("Raumname ist erforderlich");
return;
}
setError(undefined);
setIsPending(true);
try {
const result = await createRoom({ name: name.trim() });
if (result.success) {
toast.success(result.message ?? "Raum angelegt");
setName("");
onCreated();
} else {
toast.error(result.error || "Raum konnte nicht angelegt werden");
}
} catch {
toast.error("Raum konnte nicht angelegt werden");
} finally {
setIsPending(false);
}
};
return (
<div className="space-y-4">
{hasRoom && (
<div className="flex items-start gap-3 rounded-xl border border-green-200 bg-green-50 p-4">
<CheckCircle2 className="size-5 text-green-600 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-green-900">Raum angelegt</p>
<p className="text-sm text-green-700 mt-0.5">
Du kannst hier weitere ergänzen oder später unter „Räume“ verwalten.
</p>
</div>
</div>
)}
<div className="rounded-xl border border-white/60 bg-white/50 p-5 space-y-4">
<InputComponent
label="Raumname"
isLabel
id="onboarding-room-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z.B. Studio 1"
error={error}
/>
<Button onClick={handleCreate} disabled={isPending}>
{isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
Raum anlegen
</Button>
</div>
</div>
);
};
export default StepRoom;

View file

@ -0,0 +1,271 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, PartyPopper, Rocket } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import type { BusinessBranding } from "@/src/modules/settings/schemas/business-branding-schema";
import type { CancellationPolicy } from "@/src/modules/settings/schemas/cancellation-policy-schema";
import {
LINK_STEP_IDS,
ONBOARDING_STEPS,
WIZARD_STEP_IDS,
type OnboardingStepId,
type WizardStepId,
} from "../../lib/onboarding-steps";
import OnboardingStepShell from "../components/OnboardingStepShell";
import OnboardingStepIcon from "../components/OnboardingStepIcon";
import StepBranding from "../components/steps/StepBranding";
import StepCancellation from "../components/steps/StepCancellation";
import StepPayments from "../components/steps/StepPayments";
import StepPublicPage from "../components/steps/StepPublicPage";
import StepRoom from "../components/steps/StepRoom";
interface OnboardingWizardViewProps {
businessId: string;
businessName: string;
managerFirstName: string;
done: Record<OnboardingStepId, boolean>;
branding: BusinessBranding;
policy: CancellationPolicy;
isPublic: boolean;
slug: string | null;
stripeAccountStatus: string | null;
/** Aus ?step=... - z.B. beim Rücksprung von Stripe */
initialStep: WizardStepId | null;
}
// Screen-Index: 0 = Willkommen, 1..n = Wizard-Schritte, n+1 = Abschluss
const WELCOME_INDEX = 0;
const FIRST_STEP_INDEX = 1;
const DONE_INDEX = WIZARD_STEP_IDS.length + 1;
const OnboardingWizardView = ({
businessId,
businessName,
managerFirstName,
done: initialDone,
branding,
policy,
isPublic: initialIsPublic,
slug,
stripeAccountStatus,
initialStep,
}: OnboardingWizardViewProps) => {
const router = useRouter();
// Lokale Kopie des Fortschritts: erledigte Schritte sollen sofort abgehakt
// erscheinen, ohne auf ein Neuladen der Server-Daten zu warten.
const [done, setDone] = useState(initialDone);
const [screen, setScreen] = useState(() =>
initialStep ? FIRST_STEP_INDEX + WIZARD_STEP_IDS.indexOf(initialStep) : WELCOME_INDEX
);
const markStepDone = (id: OnboardingStepId) => setDone((prev) => ({ ...prev, [id]: true }));
const markers = WIZARD_STEP_IDS.map((id, index) => ({
id,
label: ONBOARDING_STEPS[id].label,
done: done[id],
active: screen === FIRST_STEP_INDEX + index,
}));
const goTo = (index: number) => {
setScreen(index);
// Der Wizard scrollt nicht automatisch mit - ohne das startet der nächste
// Schritt je nach Länge des vorherigen mitten im Inhalt.
if (typeof window !== "undefined") window.scrollTo({ top: 0, behavior: "smooth" });
};
const finish = () => {
// refresh(), damit Dashboard und Checkliste den im Wizard erzeugten
// Fortschritt sofort und nicht erst nach einem harten Reload sehen.
router.refresh();
router.push("/dashboard");
};
// ---------- Willkommen ----------
if (screen === WELCOME_INDEX) {
return (
<div className="mx-auto w-full max-w-3xl py-6">
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-8 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_80%_at_100%_0%,oklch(0.88_0.09_85/_0.25)_0%,transparent_70%)] pointer-events-none" />
<div className="relative">
<div className="flex items-center justify-center size-12 rounded-xl bg-gray-900 mb-5">
<Rocket className="size-6 text-white" />
</div>
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-1">
Willkommen bei Kumasy
</p>
<h1 className="text-3xl font-black text-gray-900">Hallo {managerFirstName} 👋</h1>
<p className="text-gray-500 mt-2 max-w-xl">
Lass uns {businessName} in ein paar Minuten startklar machen. Du kannst jeden Schritt
überspringen und später in den Einstellungen nachholen.
</p>
<div className="mt-6 space-y-2">
{WIZARD_STEP_IDS.map((id) => (
<div
key={id}
className="flex items-center gap-3 p-3 rounded-xl border border-white/50 bg-white/40"
>
<div className="flex items-center justify-center size-8 rounded-lg bg-gray-100/80 shrink-0">
<OnboardingStepIcon step={id} className="size-4 text-gray-500" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-900">
{ONBOARDING_STEPS[id].label}
{!ONBOARDING_STEPS[id].required && (
<span className="ml-2 text-[10px] font-medium text-gray-400 uppercase tracking-wide">
optional
</span>
)}
</p>
<p className="text-xs text-gray-400 font-light">
{ONBOARDING_STEPS[id].description}
</p>
</div>
</div>
))}
</div>
<div className="flex items-center gap-4 mt-7">
<Button onClick={() => goTo(FIRST_STEP_INDEX)}>
Los geht&apos;s
<ArrowRight className="size-4" />
</Button>
<Link
href="/dashboard"
className="text-sm font-medium text-gray-400 hover:text-gray-700 transition-colors"
>
Später erledigen
</Link>
</div>
</div>
</div>
</div>
);
}
// ---------- Abschluss ----------
if (screen >= DONE_INDEX) {
const openLinkSteps = LINK_STEP_IDS.filter((id) => !done[id]);
return (
<div className="mx-auto w-full max-w-3xl py-6">
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-8 relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_80%_at_100%_0%,oklch(0.88_0.09_85/_0.25)_0%,transparent_70%)] pointer-events-none" />
<div className="relative">
<div className="flex items-center justify-center size-12 rounded-xl bg-green-600 mb-5">
<PartyPopper className="size-6 text-white" />
</div>
<h1 className="text-3xl font-black text-gray-900">Grundeinrichtung erledigt</h1>
<p className="text-gray-500 mt-2 max-w-xl">
{openLinkSteps.length > 0
? "Zwei Dinge fehlen noch, die etwas mehr Zeit brauchen — du findest sie jederzeit in der Checkliste auf deinem Dashboard."
: "Alles steht. Deine Kurse sind buchbar."}
</p>
{openLinkSteps.length > 0 && (
<div className="mt-6 space-y-2">
{openLinkSteps.map((id) => (
<Link
key={id}
href={ONBOARDING_STEPS[id].href}
className="flex items-center gap-3 p-3.5 rounded-xl border border-white/50 bg-white/40 hover:bg-white/70 hover:border-white/80 transition-all group"
>
<div className="flex items-center justify-center size-8 rounded-lg bg-gray-100/80 shrink-0">
<OnboardingStepIcon step={id} className="size-4 text-gray-500" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-gray-900">
{ONBOARDING_STEPS[id].cta}
</p>
<p className="text-xs text-gray-400 font-light">
{ONBOARDING_STEPS[id].description}
</p>
</div>
<ArrowRight className="size-4 text-gray-300 group-hover:text-[#c9a84c] group-hover:translate-x-0.5 transition-all shrink-0" />
</Link>
))}
</div>
)}
<div className="flex items-center gap-4 mt-7">
<Button onClick={finish}>
Zum Dashboard
<ArrowRight className="size-4" />
</Button>
<button
type="button"
onClick={() => goTo(DONE_INDEX - 1)}
className="text-sm font-medium text-gray-400 hover:text-gray-700 transition-colors cursor-pointer"
>
Zurück
</button>
</div>
</div>
</div>
</div>
);
}
// ---------- Wizard-Schritte ----------
const stepIndex = screen - FIRST_STEP_INDEX;
const stepId = WIZARD_STEP_IDS[stepIndex];
const step = ONBOARDING_STEPS[stepId];
const isLastStep = screen === DONE_INDEX - 1;
// Record<WizardStepId, ...>: fehlt für einen Wizard-Schritt der Screen, schlägt
// der Build fehl, statt zur Laufzeit eine leere Seite zu rendern.
const stepContent: Record<WizardStepId, React.ReactNode> = {
branding: (
<StepBranding
businessId={businessId}
initialBranding={branding}
onSaved={() => markStepDone("branding")}
/>
),
payments: <StepPayments businessId={businessId} stripeAccountStatus={stripeAccountStatus} />,
cancellation: (
<StepCancellation
businessId={businessId}
initialPolicy={policy}
onSaved={() => markStepDone("cancellation")}
/>
),
room: <StepRoom hasRoom={done.room} onCreated={() => markStepDone("room")} />,
publicPage: (
<StepPublicPage
businessId={businessId}
businessName={businessName}
initialIsPublic={initialIsPublic}
initialSlug={slug}
onPublicChange={(nowPublic) => setDone((prev) => ({ ...prev, publicPage: nowPublic }))}
/>
),
};
return (
<OnboardingStepShell
eyebrow="Einrichtung"
title={step.label}
description={step.description}
stepNumber={stepIndex + 1}
totalSteps={WIZARD_STEP_IDS.length}
markers={markers}
onMarkerClick={(index) => goTo(FIRST_STEP_INDEX + index)}
onBack={() => goTo(screen - 1)}
onNext={() => goTo(screen + 1)}
nextLabel={
isLastStep ? "Fertig" : done[stepId] ? "Weiter" : step.required ? "Überspringen" : "Weiter"
}
>
{stepContent[stepId]}
</OnboardingStepShell>
);
};
export default OnboardingWizardView;

View file

@ -11,6 +11,7 @@ import { prisma } from "@/src/lib/prisma";
import { stripe } from "@/src/lib/stripe"; import { stripe } from "@/src/lib/stripe";
import { requireAuthWithData, isManager } from "@/src/lib/auth/getUser"; import { requireAuthWithData, isManager } from "@/src/lib/auth/getUser";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { sanitizeReturnTo } from "../lib/return-to";
/** /**
* Erstellt einen Stripe Connect Express Account für ein Business und liefert * Erstellt einen Stripe Connect Express Account für ein Business und liefert
@ -18,9 +19,11 @@ import { revalidatePath } from "next/cache";
* ein neuer Onboarding-Link generiert. * ein neuer Onboarding-Link generiert.
* *
* @param businessId - Die ID des Business * @param businessId - Die ID des Business
* @param returnTo - App-interner Pfad, auf dem der Manager nach dem Stripe-Onboarding
* landen soll (z.B. "/onboarding"). Ohne Angabe: /settings/payments.
* @returns Onboarding-URL oder Fehler * @returns Onboarding-URL oder Fehler
*/ */
export async function createConnectOnboardingLink(businessId: string) { export async function createConnectOnboardingLink(businessId: string, returnTo?: string) {
try { try {
const userData = await requireAuthWithData(); const userData = await requireAuthWithData();
@ -74,10 +77,12 @@ export async function createConnectOnboardingLink(businessId: string) {
} }
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const safeReturnTo = sanitizeReturnTo(returnTo);
const returnToParam = safeReturnTo ? `&returnTo=${encodeURIComponent(safeReturnTo)}` : "";
const accountLink = await stripe.accountLinks.create({ const accountLink = await stripe.accountLinks.create({
account: stripeAccountId, account: stripeAccountId,
refresh_url: `${baseUrl}/api/stripe/connect/refresh?businessId=${businessId}`, refresh_url: `${baseUrl}/api/stripe/connect/refresh?businessId=${businessId}${returnToParam}`,
return_url: `${baseUrl}/api/stripe/connect/return?businessId=${businessId}`, return_url: `${baseUrl}/api/stripe/connect/return?businessId=${businessId}${returnToParam}`,
type: "account_onboarding", type: "account_onboarding",
}); });

View file

@ -0,0 +1,18 @@
/**
* Prüft, ob ein returnTo-Wert ein sicherer, app-interner Pfad ist.
*
* Der Wert landet in einer Stripe-Redirect-URL und damit in einem Location-Header -
* ohne Prüfung wäre das ein offener Redirect. Erlaubt ist deshalb nur ein einzelner
* führender Slash: "//host" und "/\host" interpretieren Browser als protokoll-
* relative URL auf eine fremde Domain.
*
* Bewusst in einer eigenen lib-Datei statt in stripe-connect-actions.ts: dort gilt
* "use server", und aus einer Server-Actions-Datei dürfen nur async Funktionen
* exportiert werden.
*/
export function sanitizeReturnTo(returnTo: string | null | undefined): string | null {
if (!returnTo) return null;
if (!returnTo.startsWith("/")) return null;
if (returnTo.startsWith("//") || returnTo.startsWith("/\\")) return null;
return returnTo;
}

View file

@ -12,6 +12,8 @@ interface BusinessPublicToggleProps {
businessName: string; businessName: string;
initialIsPublic: boolean; initialIsPublic: boolean;
initialSlug: string | null; initialSlug: string | null;
/** Optional: meldet den neuen Zustand nach oben, z.B. damit der Setup-Wizard den Schritt abhaken kann. */
onPublicChange?: (isPublic: boolean) => void;
} }
const BusinessPublicToggle = ({ const BusinessPublicToggle = ({
@ -19,6 +21,7 @@ const BusinessPublicToggle = ({
businessName, businessName,
initialIsPublic, initialIsPublic,
initialSlug, initialSlug,
onPublicChange,
}: BusinessPublicToggleProps) => { }: BusinessPublicToggleProps) => {
const [isPublic, setIsPublic] = useState(initialIsPublic); const [isPublic, setIsPublic] = useState(initialIsPublic);
const [slug, setSlug] = useState(initialSlug); const [slug, setSlug] = useState(initialSlug);
@ -35,6 +38,7 @@ const BusinessPublicToggle = ({
if (result.success) { if (result.success) {
setIsPublic(checked); setIsPublic(checked);
if (result.slug) setSlug(result.slug); if (result.slug) setSlug(result.slug);
onPublicChange?.(checked);
toast.success(checked ? "Seite veröffentlicht" : "Seite versteckt"); toast.success(checked ? "Seite veröffentlicht" : "Seite versteckt");
} else { } else {
toast.error(result.error ?? "Fehler beim Aktualisieren"); toast.error(result.error ?? "Fehler beim Aktualisieren");

View file

@ -0,0 +1,155 @@
import { describe, it, expect } from "vitest";
import {
ONBOARDING_STEPS,
ONBOARDING_STEP_IDS,
WIZARD_STEP_IDS,
LINK_STEP_IDS,
countCompletedSteps,
firstOpenWizardStep,
getStepStates,
isOnboardingComplete,
isOnboardingStepId,
type OnboardingContext,
} from "@/src/modules/onboarding/lib/onboarding-steps";
/** Frisch registriertes Studio: nichts eingerichtet. */
const emptyContext: OnboardingContext = {
stripeAccountStatus: null,
slug: null,
isPublic: false,
hasBranding: false,
hasCancellationPolicy: false,
roomCount: 0,
employeeCount: 0,
courseCount: 0,
};
/** Alle Pflichtschritte erledigt, optionale Schritte offen. */
const requiredDoneContext: OnboardingContext = {
...emptyContext,
stripeAccountStatus: "active",
slug: "mein-studio",
isPublic: true,
roomCount: 1,
courseCount: 1,
};
describe("isOnboardingStepId", () => {
it("erkennt gültige Registry-Keys", () => {
expect(isOnboardingStepId("payments")).toBe(true);
expect(isOnboardingStepId("course")).toBe(true);
});
it("lehnt unbekannte Keys ab", () => {
expect(isOnboardingStepId("zahlungen")).toBe(false);
expect(isOnboardingStepId("")).toBe(false);
expect(isOnboardingStepId("__proto__")).toBe(false);
});
});
describe("Registry-Aufteilung", () => {
it("teilt alle Schritte überschneidungsfrei in Wizard- und Link-Schritte", () => {
expect([...WIZARD_STEP_IDS, ...LINK_STEP_IDS].sort()).toEqual([...ONBOARDING_STEP_IDS].sort());
expect(WIZARD_STEP_IDS.filter((id) => (LINK_STEP_IDS as string[]).includes(id))).toEqual([]);
});
it("hat mindestens einen Pflichtschritt", () => {
expect(ONBOARDING_STEP_IDS.some((id) => ONBOARDING_STEPS[id].required)).toBe(true);
});
it("verlinkt jeden Schritt auf einen app-internen Pfad", () => {
for (const id of ONBOARDING_STEP_IDS) {
expect(ONBOARDING_STEPS[id].href.startsWith("/")).toBe(true);
expect(ONBOARDING_STEPS[id].href.startsWith("//")).toBe(false);
}
});
});
describe("getStepStates", () => {
it("meldet im leeren Studio jeden Schritt als offen", () => {
const states = getStepStates(emptyContext);
expect(states).toHaveLength(ONBOARDING_STEP_IDS.length);
expect(states.every((s) => !s.done)).toBe(true);
});
it("behält die Registry-Reihenfolge bei", () => {
expect(getStepStates(emptyContext).map((s) => s.id)).toEqual(ONBOARDING_STEP_IDS);
});
});
describe("Ableitung der einzelnen Schritte", () => {
it("hakt Zahlungen nur bei aktivem Stripe-Konto ab", () => {
expect(
ONBOARDING_STEPS.payments.isDone({ ...emptyContext, stripeAccountStatus: "active" })
).toBe(true);
// "pending" und "restricted" reichen NICHT: in beiden Fällen kann das Studio
// kein Geld empfangen, der Schritt muss also offen bleiben.
expect(
ONBOARDING_STEPS.payments.isDone({ ...emptyContext, stripeAccountStatus: "pending" })
).toBe(false);
expect(
ONBOARDING_STEPS.payments.isDone({ ...emptyContext, stripeAccountStatus: "restricted" })
).toBe(false);
});
it("verlangt für die öffentliche Seite Slug UND Sichtbarkeit", () => {
expect(
ONBOARDING_STEPS.publicPage.isDone({ ...emptyContext, isPublic: true, slug: null })
).toBe(false);
expect(
ONBOARDING_STEPS.publicPage.isDone({ ...emptyContext, isPublic: false, slug: "s" })
).toBe(false);
expect(ONBOARDING_STEPS.publicPage.isDone({ ...emptyContext, isPublic: true, slug: "s" })).toBe(
true
);
});
});
describe("isOnboardingComplete", () => {
it("ist im leeren Studio nicht erfüllt", () => {
expect(isOnboardingComplete(emptyContext)).toBe(false);
});
it("ist erfüllt, sobald alle Pflichtschritte stehen - auch ohne die optionalen", () => {
expect(isOnboardingComplete(requiredDoneContext)).toBe(true);
expect(requiredDoneContext.hasBranding).toBe(false);
expect(requiredDoneContext.employeeCount).toBe(0);
});
it("fällt zurück, wenn ein Pflichtschritt wegbricht (z.B. Stripe wird restricted)", () => {
expect(
isOnboardingComplete({ ...requiredDoneContext, stripeAccountStatus: "restricted" })
).toBe(false);
});
});
describe("countCompletedSteps", () => {
it("zählt über ALLE Schritte, nicht nur die Pflichtschritte", () => {
expect(countCompletedSteps(emptyContext)).toBe(0);
expect(countCompletedSteps(requiredDoneContext)).toBe(
ONBOARDING_STEP_IDS.filter((id) => ONBOARDING_STEPS[id].required).length
);
expect(
countCompletedSteps({ ...requiredDoneContext, hasBranding: true, employeeCount: 2 })
).toBe(ONBOARDING_STEP_IDS.length - 1); // nur die Storno-Policy fehlt noch
});
});
describe("firstOpenWizardStep", () => {
it("liefert den ersten offenen Wizard-Schritt in Registry-Reihenfolge", () => {
expect(firstOpenWizardStep(emptyContext)).toBe(WIZARD_STEP_IDS[0]);
});
it("überspringt bereits erledigte Schritte", () => {
expect(firstOpenWizardStep({ ...emptyContext, hasBranding: true })).toBe(WIZARD_STEP_IDS[1]);
});
it("liefert null, wenn alle Wizard-Schritte erledigt sind", () => {
const allWizardDone: OnboardingContext = {
...requiredDoneContext,
hasBranding: true,
hasCancellationPolicy: true,
};
expect(firstOpenWizardStep(allWizardDone)).toBeNull();
});
});

View file

@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { sanitizeReturnTo } from "@/src/modules/settings/lib/return-to";
describe("sanitizeReturnTo", () => {
it("lässt app-interne Pfade durch", () => {
expect(sanitizeReturnTo("/onboarding")).toBe("/onboarding");
expect(sanitizeReturnTo("/onboarding?step=payments")).toBe("/onboarding?step=payments");
});
it("lehnt absolute URLs auf fremde Hosts ab", () => {
expect(sanitizeReturnTo("https://evil.example/pwn")).toBeNull();
expect(sanitizeReturnTo("http://evil.example")).toBeNull();
});
it("lehnt protokollrelative URLs ab", () => {
// "//host" und "/\host" interpretieren Browser als fremde Domain - das wäre
// ein offener Redirect über den Stripe-Rücksprung.
expect(sanitizeReturnTo("//evil.example")).toBeNull();
expect(sanitizeReturnTo("/\\evil.example")).toBeNull();
});
it("behandelt leere und fehlende Werte als 'kein returnTo'", () => {
expect(sanitizeReturnTo(null)).toBeNull();
expect(sanitizeReturnTo(undefined)).toBeNull();
expect(sanitizeReturnTo("")).toBeNull();
});
});