mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
fix merge conflict
This commit is contained in:
commit
d045ee1600
38 changed files with 4938 additions and 643 deletions
1072
package-lock.json
generated
1072
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Business" ADD COLUMN "dashboardDismissedRecommendations" JSONB;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
79
src/app/(main)/onboarding/page.tsx
Normal file
79
src/app/(main)/onboarding/page.tsx
Normal 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",
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
415
src/modules/dashboard/actions/dashboard-actions.ts
Normal file
415
src/modules/dashboard/actions/dashboard-actions.ts
Normal 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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
206
src/modules/dashboard/ui/components/DashboardRevenueChart.tsx
Normal file
206
src/modules/dashboard/ui/components/DashboardRevenueChart.tsx
Normal 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;
|
||||||
416
src/modules/dashboard/ui/components/DashboardSearch.tsx
Normal file
416
src/modules/dashboard/ui/components/DashboardSearch.tsx
Normal 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 „{query}“
|
||||||
|
</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;
|
||||||
133
src/modules/dashboard/ui/components/DashboardStatsGrid.tsx
Normal file
133
src/modules/dashboard/ui/components/DashboardStatsGrid.tsx
Normal 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;
|
||||||
130
src/modules/dashboard/ui/components/DashboardTodaySchedule.tsx
Normal file
130
src/modules/dashboard/ui/components/DashboardTodaySchedule.tsx
Normal 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;
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
239
src/modules/onboarding/actions/onboarding-actions.ts
Normal file
239
src/modules/onboarding/actions/onboarding-actions.ts
Normal 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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
190
src/modules/onboarding/lib/onboarding-steps.ts
Normal file
190
src/modules/onboarding/lib/onboarding-steps.ts
Normal 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;
|
||||||
|
}
|
||||||
118
src/modules/onboarding/ui/components/EmployeeFirstSteps.tsx
Normal file
118
src/modules/onboarding/ui/components/EmployeeFirstSteps.tsx
Normal 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;
|
||||||
128
src/modules/onboarding/ui/components/OnboardingChecklist.tsx
Normal file
128
src/modules/onboarding/ui/components/OnboardingChecklist.tsx
Normal 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;
|
||||||
41
src/modules/onboarding/ui/components/OnboardingStepIcon.tsx
Normal file
41
src/modules/onboarding/ui/components/OnboardingStepIcon.tsx
Normal 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;
|
||||||
115
src/modules/onboarding/ui/components/OnboardingStepShell.tsx
Normal file
115
src/modules/onboarding/ui/components/OnboardingStepShell.tsx
Normal 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;
|
||||||
51
src/modules/onboarding/ui/components/steps/StepBranding.tsx
Normal file
51
src/modules/onboarding/ui/components/steps/StepBranding.tsx
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
85
src/modules/onboarding/ui/components/steps/StepPayments.tsx
Normal file
85
src/modules/onboarding/ui/components/steps/StepPayments.tsx
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
83
src/modules/onboarding/ui/components/steps/StepRoom.tsx
Normal file
83
src/modules/onboarding/ui/components/steps/StepRoom.tsx
Normal 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;
|
||||||
271
src/modules/onboarding/ui/views/onboarding-wizard-view.tsx
Normal file
271
src/modules/onboarding/ui/views/onboarding-wizard-view.tsx
Normal 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'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;
|
||||||
|
|
@ -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",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
18
src/modules/settings/lib/return-to.ts
Normal file
18
src/modules/settings/lib/return-to.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
155
tests/onboarding/onboarding-steps.test.ts
Normal file
155
tests/onboarding/onboarding-steps.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
27
tests/settings/return-to.test.ts
Normal file
27
tests/settings/return-to.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue