added dashboard and changed layout of course creation

This commit is contained in:
Rajpreet0 2026-09-10 00:44:41 +02:00
parent 285eeb1536
commit 7935d4e43b
14 changed files with 3118 additions and 598 deletions

1072
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,5 +1,5 @@
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";
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>
<DashboardSidebarWrapper />
<main className="flex flex-col h-screen w-full px-4">
<DashboardNavbar />
<main className="flex flex-col h-screen w-full px-3 sm:px-4">
<DashboardNavbarWrapper />
<div className="pb-8">{children}</div>
</main>
</SidebarProvider>

View file

@ -6,14 +6,24 @@ import GenericDropdown from "@/src/components/layout/GenericDropdown";
import MultiSelectDropdown from "@/src/components/layout/MultiSelectDropdown";
import RichTextEditor from "@/src/components/layout/RichTextEditor";
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 { getMyRooms } from "@/src/modules/rooms/actions/room-actions";
import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions";
import { toast } from "sonner";
import { Course } from "../../types/course.types";
import { Trash2, ImagePlus, X, Loader2, AlertTriangle } from "lucide-react";
import {
Trash2,
ImagePlus,
X,
Loader2,
AlertTriangle,
Info,
CalendarDays,
Settings2,
FileText,
RefreshCw,
Check,
} from "lucide-react";
import Image from "next/image";
import DeleteDialog from "@/src/components/layout/DeleteDialog";
import {
@ -454,12 +464,13 @@ const CourseCreateView = ({
return (
<div>
<div className="w-full flex items-center justify-between">
{/* Page header */}
<div className="w-full flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold">
{mode === "edit" ? "Kurs bearbeiten" : "Kurs anlegen"}
</h1>
<p className="text-xl mt-2">
<p className="text-gray-500 mt-1">
{mode === "edit"
? "Ändern Sie die Kursdetails und speichern Sie Ihre Änderungen"
: "Alle Kurse können nach dem Speichern angepasst oder gelöscht werden"}
@ -474,359 +485,431 @@ const CourseCreateView = ({
</Button>
)}
</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
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>
<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 className="max-w-3xl flex flex-col gap-5">
{/* Section 1: Grunddaten */}
<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-[#c9a84c]/10 text-[#c9a84c]">
<Info className="size-4" />
</div>
) : (
<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">
{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-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}
<h2 className="text-base font-semibold text-gray-900">Grunddaten</h2>
</div>
<div className="flex flex-col gap-4">
{businesses.length > 1 && (
<GenericDropdown
label="Standort"
selected={selectedBusinessId}
onSelect={setSelectedBusinessId}
options={businesses}
placeholder="Standort auswählen"
/>
</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("");
setSelectedWeekdays([]);
setWeekdayTimings({});
setEndDate("");
}
}}
/>
<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") {
setSelectedWeekdays([]);
setWeekdayTimings({});
}
}}
options={FREQUENCIES}
error={errors.frequency}
/>
)}
<div>
<InputComponent
isLabel
label="Enddatum des Dauerauftrags"
type="date"
id="course-create-end-date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
label="Kursname"
type="text"
id="course-create-name"
value={courseName}
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>
{selectedFrequency === "custom" && (
<>
<MultiSelectDropdown
label="An welchen Tagen soll der Kurs stattfinden?"
selected={selectedWeekdays}
onSelect={(days) => {
setSelectedWeekdays(days);
// Nicht mehr ausgewählte Tage aus weekdayTimings entfernen
setWeekdayTimings((prev) => {
const next: Record<string, WeekdayTiming> = {};
days.forEach((d) => {
next[d] = prev[d] ?? { timeFrom: "", timeTo: "" };
});
return next;
});
{/* Dauerauftrag toggle */}
<button
type="button"
onClick={() => {
const next = !isStandingOrder;
setIsStandingOrder(next);
if (!next) {
setSelectedFrequency("");
setSelectedWeekdays([]);
setWeekdayTimings({});
setEndDate("");
}
}}
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") {
setSelectedWeekdays([]);
setWeekdayTimings({});
}
}}
options={WEEKDAYS}
error={errors.weekdays}
options={FREQUENCIES}
error={errors.frequency}
/>
{selectedWeekdays.length > 0 && (
<div className="space-y-3">
<p className="text-sm font-medium">Uhrzeiten pro Tag</p>
{selectedWeekdays.map((day) => {
const dayLabel = WEEKDAYS.find((w) => w.value === day)?.label ?? day;
return (
<div key={day} className="flex items-center gap-3">
<span className="w-24 text-sm shrink-0">{dayLabel}</span>
<InputComponent
type="time"
id={`weekday-time-from-${day}`}
value={weekdayTimings[day]?.timeFrom ?? ""}
onChange={(e) =>
setWeekdayTimings((prev) => ({
...prev,
[day]: { ...prev[day], timeFrom: e.target.value },
}))
}
/>
<span className="text-sm">–</span>
<InputComponent
type="time"
id={`weekday-time-to-${day}`}
value={weekdayTimings[day]?.timeTo ?? ""}
onChange={(e) =>
setWeekdayTimings((prev) => ({
...prev,
[day]: { ...prev[day], timeTo: e.target.value },
}))
}
/>
</div>
);
})}
{errors.weekdayTimings && (
<p className="text-red-500 text-sm">{errors.weekdayTimings}</p>
<div>
<InputComponent
isLabel
label="Enddatum des Dauerauftrags"
type="date"
id="course-create-end-date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
{errors.endDate && <p className="text-red-500 text-sm mt-1">{errors.endDate}</p>}
</div>
{selectedFrequency === "custom" && (
<>
<MultiSelectDropdown
label="An welchen Tagen soll der Kurs stattfinden?"
selected={selectedWeekdays}
onSelect={(days) => {
setSelectedWeekdays(days);
setWeekdayTimings((prev) => {
const next: Record<string, WeekdayTiming> = {};
days.forEach((d) => {
next[d] = prev[d] ?? { timeFrom: "", timeTo: "" };
});
return next;
});
}}
options={WEEKDAYS}
error={errors.weekdays}
/>
{selectedWeekdays.length > 0 && (
<div className="space-y-3">
<p className="text-sm font-medium text-gray-700">Uhrzeiten pro Tag</p>
{selectedWeekdays.map((day) => {
const dayLabel = WEEKDAYS.find((w) => w.value === day)?.label ?? day;
return (
<div
key={day}
className="grid grid-cols-[7rem_1fr_auto_1fr] items-center gap-3"
>
<span className="text-sm text-gray-600">{dayLabel}</span>
<InputComponent
type="time"
id={`weekday-time-from-${day}`}
value={weekdayTimings[day]?.timeFrom ?? ""}
onChange={(e) =>
setWeekdayTimings((prev) => ({
...prev,
[day]: { ...prev[day], timeFrom: e.target.value },
}))
}
/>
<span className="text-sm text-gray-400">–</span>
<InputComponent
type="time"
id={`weekday-time-to-${day}`}
value={weekdayTimings[day]?.timeTo ?? ""}
onChange={(e) =>
setWeekdayTimings((prev) => ({
...prev,
[day]: { ...prev[day], timeTo: e.target.value },
}))
}
/>
</div>
);
})}
{errors.weekdayTimings && (
<p className="text-red-500 text-sm">{errors.weekdayTimings}</p>
)}
</div>
)}
</div>
</>
)}
</>
</div>
)}
</>
)}
<div className="flex gap-2">
</div>
</div>
{/* 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
onClick={() => handleSubmit("published")}
variant="ghost"
onClick={() => handleSubmit("draft")}
disabled={isSubmitting || isUploadingImage}
className="text-gray-500 hover:text-gray-700"
>
{isSubmitting
? "Wird gespeichert..."
: mode === "edit"
? "Änderungen speichern"
: "Veröffentlichen"}
</Button>
<Button
variant="outline"
onClick={() => router.push("/courses")}
disabled={isSubmitting || isUploadingImage}
>
Abbrechen
{mode === "edit" ? "Als Entwurf speichern" : "Entwurf speichern"}
</Button>
</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>
<DeleteDialog

View file

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

View file

@ -1,5 +1,5 @@
"use client";
import { HelpCircle, Settings, Plus } from "lucide-react";
import { HelpCircle, Settings, Plus, LogOut, User, ChevronDown } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useState } from "react";
@ -8,33 +8,28 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/src/components/ui/dropdown-menu";
import { ChevronDown } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { useAuthStore } from "@/src/store/authStore";
const footerMenu = [
{
icon: Settings,
label: "Einstellungen",
href: "/settings",
},
{
icon: HelpCircle,
label: "Hilfe",
href: "/help",
},
{ icon: Settings, label: "Einstellungen", href: "/settings" },
{ icon: HelpCircle, label: "Hilfe", href: "/help" },
];
const getDiceBearUrl = (seed: string) =>
`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 src = pbSrc || getDiceBearUrl(userData.id);
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 />
</div>
);
@ -51,34 +46,34 @@ const DashboardFooter = ({ displayName, userData }: DashboardFooterProps) => {
const [selectedBusinessId, setSelectedBusinessId] = useState(
businesses.length > 0 ? businesses[0].id : ""
);
const signOut = useAuthStore((state) => state.signOut);
const selectedBusiness = businesses.find((b) => b.id === selectedBusinessId);
return (
<div>
<div className="space-y-1">
{footerMenu.map((item) => (
<div key={item.href}>
<Link href={item.href} className="flex items-center gap-3 w-full">
<item.icon size={18} />
<span className="text-md font-medium tracking-tight mt-1">{item.label}</span>
</Link>
</div>
<Link
key={item.href}
href={item.href}
className="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-gray-100/60 transition-colors text-gray-700"
>
<item.icon size={16} />
<span className="text-sm font-medium">{item.label}</span>
</Link>
))}
{/* Business Dropdown - nur für Manager */}
{isManager && businesses.length > 0 && (
<div className="mt-2 mb-2">
<div className="pt-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div>
<span className="text-xs text-gray-500">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">
<div className="flex flex-col items-start flex-1 min-w-0">
<span className="text-sm font-medium mt-1 truncate w-full">
{selectedBusiness?.name || "Business auswählen"}
</span>
</div>
<ChevronDown size={16} className="ml-2 shrink-0" />
<span className="text-xs text-gray-400 px-2">Business</span>
<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">
<span className="text-sm font-medium truncate flex-1">
{selectedBusiness?.name || "Business auswählen"}
</span>
<ChevronDown size={14} className="ml-2 shrink-0 text-gray-400" />
</button>
</div>
</DropdownMenuTrigger>
@ -95,10 +90,10 @@ const DashboardFooter = ({ displayName, userData }: DashboardFooterProps) => {
</div>
</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">
<Plus size={16} />
<span className="mt-1">Standort hinzufügen</span>
<Plus size={14} />
<span className="mt-0.5">Standort hinzufügen</span>
</Button>
</Link>
</DropdownMenuContent>
@ -106,10 +101,42 @@ const DashboardFooter = ({ displayName, userData }: DashboardFooterProps) => {
</div>
)}
{/* DisplayName + Avatar */}
<div className="w-full flex items-center gap-4 p-2 mt-2">
<AvatarImage userData={userData} />
<p className="truncate text-sm font-medium">{displayName}</p>
{/* Profile dropdown */}
<div className="pt-2 border-t border-gray-100 mt-1">
<DropdownMenu>
<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>
);

View file

@ -2,12 +2,14 @@
import { Button } from "@/src/components/ui/button";
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 NotificationBell from "@/src/modules/notifications/ui/components/NotificationBell";
import DashboardSearch from "./DashboardSearch";
const menuTitles: Record<string, string> = {
"/dashboard": "Dashboard",
"/dashboard": "Übersicht",
"/courses/myCourses": "Meine Kurse",
"/courses": "Kurs Übersicht",
"/courses/create": "Erstelle einen Kurs",
@ -19,47 +21,42 @@ const menuTitles: Record<string, string> = {
"/employee/create": "Mitarbeiter erstellen",
"/settings": "Einstellungen",
"/settings/public-page": "Einstellungen",
"/kunden": "Kunden",
"/zahlungen": "Zahlungen",
};
const dynamicRoutes: Array<{
prefix: string;
titleKey: string;
}> = [
{
prefix: "/courses/edit",
titleKey: "/courses/edit",
},
{
prefix: "/rooms/edit",
titleKey: "/rooms/edit",
},
const dynamicRoutes: Array<{ prefix: string; titleKey: string }> = [
{ prefix: "/courses/edit", titleKey: "/courses/edit" },
{ prefix: "/rooms/edit", titleKey: "/rooms/edit" },
{ prefix: "/kunden/", titleKey: "/kunden" },
];
const getPageTitle = (pathname: string) => {
if (menuTitles[pathname]) {
return menuTitles[pathname];
}
if (menuTitles[pathname]) return menuTitles[pathname];
for (const route of dynamicRoutes) {
if (pathname.startsWith(route.prefix)) {
return menuTitles[route.titleKey];
}
if (pathname.startsWith(route.prefix)) return menuTitles[route.titleKey];
}
// Fallback
return "Dashboard";
};
const DashboardNavbar = () => {
interface DashboardNavbarProps {
canCreateCourse?: boolean;
userRole?: string;
}
const DashboardNavbar = ({ canCreateCourse, userRole }: DashboardNavbarProps) => {
const { state, toggleSidebar, isMobile } = useSidebar();
const pathname = usePathname();
const pageTitle = getPageTitle(pathname);
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="flex gap-x-4 items-center py-4">
<Button className="size-9 cursor-pointer" variant="outline" onClick={toggleSidebar}>
<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-2 sm:gap-x-3 items-center py-3">
<Button
className="size-9 cursor-pointer shrink-0"
variant="outline"
onClick={toggleSidebar}
>
{state === "collapsed" || isMobile ? (
<PanelLeftIcon className="size-4" />
) : (
@ -67,13 +64,28 @@ const DashboardNavbar = () => {
)}
</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">
<NotificationBell />
</div>
<div className="flex-1 min-w-0" />
<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>
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,57 +1,95 @@
import { LogoutButton } from "@/src/components/layout/LogoutButton";
import { isManager, isEmployee, isUser, type AuthUserData } from "@/src/lib/auth/getUser";
import { isManager, isEmployee, 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 {
getDashboardStats,
getDashboardRevenueChart,
getDashboardTodayCourses,
getDashboardRecentBookings,
} from "../../actions/dashboard-actions";
interface DashboardViewProps {
userData: AuthUserData;
}
const DashboardView = ({ userData }: DashboardViewProps) => {
const DashboardView = async ({ userData }: DashboardViewProps) => {
const displayName = isManager(userData)
? `${userData.firstName} ${userData.lastName}`
? userData.firstName
: isEmployee(userData)
? `${userData.firstName ?? ""} ${userData.lastName ?? ""}`.trim() || userData.email
: userData.name || userData.email;
? (userData.firstName ?? userData.email.split("@")[0])
: userData.name || userData.email.split("@")[0];
return (
<div className="space-y-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" />
<p className="text-xs font-semibold tracking-widest text-gray-400 uppercase mb-2">
Dashboard
</p>
<h1 className="text-4xl font-black text-gray-900 mb-1">Willkommen zurück!</h1>
<p className="text-gray-500 font-light">{displayName}</p>
<div className="mt-4 flex gap-2">
{isManager(userData) && (
<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">
Manager
</span>
)}
{isEmployee(userData) && (
if (isManager(userData)) {
const [statsResult, chartResult, todayResult, bookingsResult] = await Promise.all([
getDashboardStats(),
getDashboardRevenueChart(),
getDashboardTodayCourses(),
getDashboardRecentBookings(),
]);
const stats = statsResult.success ? statsResult.stats : null;
const chartData = chartResult.success ? chartResult.data : [];
const todayCourses = todayResult.success ? todayResult.courses : [];
const recentBookings = bookingsResult.success ? bookingsResult.bookings : [];
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>
{/* 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 = await getDashboardTodayCourses();
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">
Mitarbeiter
</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>
) : 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-1">Meine Berechtigungen</h2>
</div>
<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 RESOURCE_LABELS: Record<string, string> = {
@ -71,146 +109,77 @@ const DashboardView = ({ userData }: DashboardViewProps) => {
.filter(([, v]) => v)
.map(([a]) => ACTION_LABELS[a] ?? a);
return active.length > 0
? `${RESOURCE_LABELS[res] ?? res}: ${active.join(", ")}`
? { resource: RESOURCE_LABELS[res] ?? res, actions: active }
: null;
})
.filter(Boolean);
.filter(Boolean) as { resource: string; actions: string[] }[];
return rows.length > 0 ? (
<ul className="text-gray-600 text-sm space-y-1 mt-2">
<div className="space-y-3">
{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 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>
);
}
{isUser(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">Benutzerdaten</h2>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Name</dt>
<dd className="mt-1 text-sm text-gray-900">{userData.name || "—"}</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">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>
)}
// Regular user
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>
<p className="text-gray-400 text-sm mt-1 font-light">Willkommen zurück!</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-4">Manager-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 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>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{ label: "Name", value: userData.name || "—" },
{ label: "E-Mail", value: userData.email },
{
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>
<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">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 />
))}
</dl>
</div>
</div>
);
};