Merge pull request #89 from Since-3/feature/slug-public-site

Feature/slug public site
This commit is contained in:
Rajpreet0 2026-05-05 11:05:07 +02:00 • committed by GitHub
commit 5a94b63b23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2916 additions and 23 deletions

1699
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -34,13 +34,17 @@
"@tiptap/starter-kit": "^3.13.0", "@tiptap/starter-kit": "^3.13.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.4.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"lucide-react": "^0.552.0", "lucide-react": "^0.552.0",
"next": "16.1.6", "next": "16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.4", "nodemailer": "^8.0.4",
"prisma": "^6.19.0", "prisma": "^6.19.0",
"radix-ui": "^1.4.3",
"react": "19.2.0", "react": "19.2.0",
"react-day-picker": "^9.14.0",
"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",
@ -55,6 +59,7 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.0.5",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^7.0.4", "@types/nodemailer": "^7.0.4",
"@types/react": "^19", "@types/react": "^19",

View file

@ -97,7 +97,10 @@ model Business {
banking String? banking String?
plan String? plan String?
subscription Json? subscription Json?
slug String? @unique
isPublic Boolean @default(false)
manager Manager @relation(fields: [managerId], references: [id]) manager Manager @relation(fields: [managerId], references: [id])
courses Course[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -122,6 +125,8 @@ model Course {
weekdays String[] // For custom frequency weekdays String[] // For custom frequency
status String @default("draft") // draft, published status String @default("draft") // draft, published
createdBy String // Manager ID who created the course createdBy String // Manager ID who created the course
businessId String?
business Business? @relation(fields: [businessId], references: [id], onDelete: SetNull)
bookings CourseBooking[] bookings CourseBooking[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -129,6 +134,7 @@ model Course {
@@index([createdBy]) @@index([createdBy])
@@index([date]) @@index([date])
@@index([status]) @@index([status])
@@index([businessId])
} }
model Room { model Room {

View file

@ -0,0 +1,5 @@
import SettingsView from "@/src/modules/settings/ui/views/settings-view";
export default function SettingsPage() {
return <SettingsView />;
}

View file

@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import {
getBusinessBySlug,
getPublishedCoursesForBusiness,
} from "@/src/modules/courses/actions/booking-actions";
import BusinessPublicView from "@/src/modules/courses/ui/views/business-public-view";
interface BusinessPageProps {
params: Promise<{ slug: string }>;
}
export default async function BusinessPage({ params }: BusinessPageProps) {
const { slug } = await params;
const businessResult = await getBusinessBySlug(slug);
if (!businessResult.success || !businessResult.business || !businessResult.business.slug) {
notFound();
}
const business = {
...businessResult.business,
slug: businessResult.business.slug,
};
const from = new Date();
from.setHours(0, 0, 0, 0);
const to = new Date(from);
to.setDate(to.getDate() + 30);
const coursesResult = await getPublishedCoursesForBusiness(businessResult.business.id, {
from,
to,
});
return (
<BusinessPublicView
business={business}
initialCourses={
(coursesResult.courses ?? []) as Parameters<typeof BusinessPublicView>[0]["initialCourses"]
}
initialWindow={{ from, to }}
/>
);
}

View file

@ -0,0 +1,220 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/src/lib/utils"
import { Button, buttonVariants } from "@/src/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View file

@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/src/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View file

@ -145,7 +145,12 @@ export const getUserData = cache(async (): Promise<AuthUserData | null> => {
let permissions: EmployeeData["permissions"]; let permissions: EmployeeData["permissions"];
if (raw && ("canCreateCourses" in raw || "canCreateEmployees" in raw)) { if (
raw &&
typeof raw === "object" &&
!Array.isArray(raw) &&
("canCreateCourses" in raw || "canCreateEmployees" in raw)
) {
// Backward-Kompatibilität: altes Format migrieren // Backward-Kompatibilität: altes Format migrieren
const old = raw as { canCreateCourses?: boolean; canCreateEmployees?: boolean }; const old = raw as { canCreateCourses?: boolean; canCreateEmployees?: boolean };
permissions = { permissions = {
@ -210,6 +215,14 @@ export const getUserData = cache(async (): Promise<AuthUserData | null> => {
)(user.email, user.id); )(user.email, user.id);
}); });
// Returns the effective manager ID for any user type.
// For Employees, this is createdBy (the manager who created them).
// Returns null if the employee is orphaned (no manager mapping) — callers must handle this.
export function getEffectiveManagerId(userData: AuthUserData): string | null {
if (isEmployee(userData)) return userData.createdBy;
return userData.id;
}
export async function requireAuth() { export async function requireAuth() {
const user = await getUser(); const user = await getUser();
return user!; return user!;

16
src/lib/slug.ts Normal file
View file

@ -0,0 +1,16 @@
import { PrismaClient } from "@prisma/client";
export async function generateUniqueSlug(name: string, prisma: PrismaClient): Promise<string> {
const normalized = name
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.slice(0, 60);
const base = normalized || "business";
let slug = base;
let counter = 2;
while (await prisma.business.findUnique({ where: { slug } })) {
slug = `${base}-${counter++}`;
}
return slug;
}

View file

@ -4,7 +4,15 @@ import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
// Protected routes // Protected routes
const protectedRoutes = ["/dashboard", "/profile", "/trainings", "/courses"]; const protectedRoutes = [
"/dashboard",
"/profile",
"/trainings",
"/courses",
"/settings",
"/employee",
"/rooms",
];
const authRoutes = ["/login", "/register", "/forgot-password"]; const authRoutes = ["/login", "/register", "/forgot-password"];
const isProtectedRoute = protectedRoutes.some((route) => const isProtectedRoute = protectedRoutes.some((route) =>

View file

@ -201,3 +201,56 @@ export async function checkUserBookingStatus(courseId: string) {
}; };
} }
} }
export async function getBusinessBySlug(slug: string) {
try {
const business = await prisma.business.findUnique({
where: { slug },
select: {
id: true,
name: true,
address: true,
email: true,
title: true,
slug: true,
isPublic: true,
},
});
if (!business || !business.isPublic) {
return { success: false, error: "Seite nicht gefunden" };
}
return { success: true, business };
} catch (error) {
console.error("Error fetching business by slug:", error);
return { success: false, error: "Fehler beim Laden" };
}
}
export async function getPublishedCoursesForBusiness(
businessId: string,
options: { from: Date; to: Date }
) {
try {
const courses = await prisma.course.findMany({
where: {
businessId,
status: "published",
date: { gte: options.from, lte: options.to },
},
include: {
_count: { select: { bookings: true } },
},
orderBy: { date: "asc" },
});
return {
success: true,
courses: courses.map((c) => ({ ...c, currentParticipants: c._count.bookings })),
};
} catch (error) {
console.error("Error fetching courses for business:", error);
return { success: false, error: "Fehler beim Laden der Kurse", courses: [] };
}
}

View file

@ -9,10 +9,11 @@
"use server"; "use server";
import { prisma } from "@/src/lib/prisma"; import { prisma } from "@/src/lib/prisma";
import { getUserData, isManager, isEmployee } from "@/src/lib/auth/getUser"; import { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser";
import { courseSchema, publishedCourseSchema, type CourseFormData } from "../schemas/course-schema"; import { courseSchema, publishedCourseSchema, type CourseFormData } from "../schemas/course-schema";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { generateUniqueSlug } from "@/src/lib/slug";
/** /**
* Erstellt einen neuen Kurs in der Datenbank * Erstellt einen neuen Kurs in der Datenbank
@ -62,7 +63,9 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
const validatedData = validation.data; const validatedData = validation.data;
// Schritt 5: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz) // Schritt 5: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz)
const creatorId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const creatorId = getEffectiveManagerId(userData);
if (!creatorId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
// Schritt 6: Kurs in der Datenbank erstellen // Schritt 6: Kurs in der Datenbank erstellen
const course = await prisma.course.create({ const course = await prisma.course.create({
@ -83,6 +86,7 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
weekdays: validatedData.weekdays || [], weekdays: validatedData.weekdays || [],
status, status,
createdBy: creatorId, createdBy: creatorId,
businessId: validatedData.businessId ?? null,
}, },
}); });
@ -171,7 +175,13 @@ export async function getMyCourses(options?: { dateFrom?: Date; dateTo?: Date })
} }
// Step 3: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz) // Step 3: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz)
const managerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const managerId = getEffectiveManagerId(userData);
if (!managerId)
return {
success: false,
error: "Mitarbeiter-Account ist keinem Manager zugeordnet",
courses: [],
};
// Step 4: Build the where clause with optional date filtering // Step 4: Build the where clause with optional date filtering
const whereClause: Prisma.CourseWhereInput = { const whereClause: Prisma.CourseWhereInput = {
@ -246,7 +256,9 @@ export async function getCourseById(courseId: string) {
} }
// Schritt 3: Effektive Manager-ID ermitteln // Schritt 3: Effektive Manager-ID ermitteln
const managerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const managerId = getEffectiveManagerId(userData);
if (!managerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
// Schritt 4: Kurs aus der Datenbank abrufen // Schritt 4: Kurs aus der Datenbank abrufen
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
@ -322,7 +334,9 @@ export async function updateCourse(
} }
// Schritt 4: Effektive Manager-ID ermitteln // Schritt 4: Effektive Manager-ID ermitteln
const managerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const managerId = getEffectiveManagerId(userData);
if (!managerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
// Schritt 5: Kurs aus der Datenbank abrufen // Schritt 5: Kurs aus der Datenbank abrufen
const existingCourse = await prisma.course.findUnique({ const existingCourse = await prisma.course.findUnique({
@ -378,6 +392,9 @@ export async function updateCourse(
frequency: validatedData.frequency || null, frequency: validatedData.frequency || null,
weekdays: validatedData.weekdays || [], weekdays: validatedData.weekdays || [],
status, status,
...(validatedData.businessId !== undefined
? { businessId: validatedData.businessId || null }
: {}),
}, },
}); });
@ -429,7 +446,9 @@ export async function deleteCourse(courseId: string) {
} }
// Schritt 3: Effektive Manager-ID ermitteln // Schritt 3: Effektive Manager-ID ermitteln
const managerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const managerId = getEffectiveManagerId(userData);
if (!managerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
// Schritt 4: Kurs aus der Datenbank abrufen // Schritt 4: Kurs aus der Datenbank abrufen
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
@ -487,7 +506,13 @@ export async function getMySportTypes() {
return { success: false, error: "Unauthorized", sports: [] }; return { success: false, error: "Unauthorized", sports: [] };
} }
const managerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const managerId = getEffectiveManagerId(userData);
if (!managerId)
return {
success: false,
error: "Mitarbeiter-Account ist keinem Manager zugeordnet",
sports: [],
};
const courses = await prisma.course.findMany({ const courses = await prisma.course.findMany({
where: { createdBy: managerId }, where: { createdBy: managerId },
@ -508,3 +533,93 @@ export async function getMySportTypes() {
return { success: false, error: "Fehler beim Laden der Sportarten", sports: [] }; return { success: false, error: "Fehler beim Laden der Sportarten", sports: [] };
} }
} }
export async function getMyBusinesses() {
try {
const userData = await getUserData();
if (!userData || (!isManager(userData) && !isEmployee(userData))) {
return { success: false, error: "Unauthorized", businesses: [] };
}
const managerId = getEffectiveManagerId(userData);
if (!managerId)
return {
success: false,
error: "Mitarbeiter-Account ist keinem Manager zugeordnet",
businesses: [],
};
const businesses = await prisma.business.findMany({
where: { managerId },
select: { id: true, name: true, slug: true, isPublic: true },
orderBy: { name: "asc" },
});
return { success: true, businesses };
} catch (error) {
console.error("Error fetching businesses:", error);
return { success: false, error: "Fehler beim Laden der Businesses", businesses: [] };
}
}
export async function toggleBusinessPublic(businessId: string, isPublic: boolean) {
try {
const userData = await getUserData();
if (!userData || !isManager(userData)) {
return { success: false, error: "Keine Berechtigung" };
}
const business = await prisma.business.findUnique({
where: { id: businessId },
select: { managerId: true, slug: true, name: true },
});
if (!business || business.managerId !== userData.id) {
return { success: false, error: "Business nicht gefunden" };
}
// Generate slug on first publish if missing, retry on P2002 race condition
let slug = business.slug;
let updated = false;
while (!updated) {
if (!slug) {
slug = await generateUniqueSlug(business.name, prisma);
}
try {
await prisma.business.update({
where: { id: businessId },
data: { isPublic, slug },
});
updated = true;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
// Another request claimed this slug concurrently — generate a new one and retry
slug = null;
} else {
throw err;
}
}
}
// Auto-assign courses that belong to this manager but have no businessId yet.
// Only do this when there is exactly one business — if there are multiple,
// the manager must assign courses manually via the course edit form.
const totalBusinesses = await prisma.business.count({ where: { managerId: userData.id } });
if (totalBusinesses === 1) {
await prisma.course.updateMany({
where: { createdBy: userData.id, businessId: null },
data: { businessId },
});
}
revalidatePath("/settings");
revalidatePath(`/business/${slug}`);
return { success: true, slug };
} catch (error) {
console.error("Error toggling business public:", error);
return { success: false, error: "Fehler beim Aktualisieren" };
}
}

View file

@ -17,6 +17,7 @@ export const courseSchema = z
isStandingOrder: z.boolean(), isStandingOrder: z.boolean(),
frequency: z.string().optional(), frequency: z.string().optional(),
weekdays: z.array(z.string()).optional(), weekdays: z.array(z.string()).optional(),
businessId: z.string().optional(),
}) })
.refine( .refine(
(data) => { (data) => {
@ -54,6 +55,7 @@ export const publishedCourseSchema = z
isStandingOrder: z.boolean(), isStandingOrder: z.boolean(),
frequency: z.string().optional(), frequency: z.string().optional(),
weekdays: z.array(z.string()).optional(), weekdays: z.array(z.string()).optional(),
businessId: z.string().optional(),
}) })
.refine( .refine(
(data) => { (data) => {

View file

@ -16,6 +16,7 @@ export type Course = {
weekdays: string[]; weekdays: string[];
status: string; status: string;
createdBy: string; createdBy: string;
businessId: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}; };

View file

@ -0,0 +1,255 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/src/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/src/components/ui/dialog";
import DOMPurify from "dompurify";
import { bookCourse, checkUserBookingStatus } from "../../actions/booking-actions";
import { toast } from "sonner";
const levelConfig: Record<string, { label: string; bg: string; text: string }> = {
any: { label: "Jedes Niveau", bg: "bg-gray-200", text: "text-gray-700" },
beginner: { label: "Anfänger", bg: "bg-blue-200", text: "text-blue-700" },
advanced: { label: "Fortgeschrittene", bg: "bg-orange-200", text: "text-orange-700" },
pro: { label: "Profi", bg: "bg-red-200", text: "text-red-700" },
};
interface PublicCourse {
id: string;
name: string;
sport: string[];
level: string;
date: Date;
timeFrom: string;
timeTo: string;
maxParticipants: number;
price: number;
currentParticipants: number;
description?: string;
room?: string;
}
interface PublicCourseCardProps {
course: PublicCourse;
}
// Inner dialog content — only mounts when dialog opens, so booking status
// is checked lazily rather than on every card render.
const BookingDialog = ({
course,
open,
onOpenChange,
}: {
course: PublicCourse;
open: boolean;
onOpenChange: (v: boolean) => void;
}) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isBooked, setIsBooked] = useState(false);
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
const safeCurrent = Math.max(0, course.currentParticipants);
const safeMax = Math.max(0, course.maxParticipants);
const isFull = safeCurrent >= safeMax;
const spotsLeft = Math.max(0, safeMax - safeCurrent);
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
const formattedPrice = course.price.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
});
useEffect(() => {
if (!open) return;
let active = true;
const loadStatus = async () => {
setIsCheckingStatus(true);
try {
const result = await checkUserBookingStatus(course.id);
if (!active) return;
setIsBooked(result.success ? result.isBooked : false);
} catch {
if (!active) return;
setIsBooked(false);
} finally {
if (active) setIsCheckingStatus(false);
}
};
void loadStatus();
return () => {
active = false;
};
}, [open, course.id]);
const handleBooking = async () => {
setIsSubmitting(true);
try {
const result = await bookCourse(course.id);
if (result.success) {
toast.success(result.message);
setIsBooked(true);
} else if (result.error?.includes("angemeldet sein")) {
window.open("/login", "_blank");
} else {
toast.error(result.error || "Ein Fehler ist aufgetreten");
}
} catch {
toast.error("Ein unerwarteter Fehler ist aufgetreten");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{course.name}</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<div className="flex justify-between text-gray-500">
<span>{formattedDate}</span>
<span>
{course.timeFrom} – {course.timeTo}
</span>
</div>
{course.sport.length > 0 && <p className="text-gray-600">{course.sport.join(", ")}</p>}
<div className="flex items-center justify-between">
<span
className={`text-xs font-semibold px-2 py-1 rounded-lg ${levelConfig[course.level]?.bg ?? "bg-gray-200"} ${levelConfig[course.level]?.text ?? "text-gray-700"}`}
>
{levelConfig[course.level]?.label ?? "Jedes Niveau"}
</span>
<span className="font-semibold">{formattedPrice}</span>
</div>
<div className={`text-sm font-medium ${isFull ? "text-red-600" : "text-gray-700"}`}>
{isFull
? "Ausgebucht"
: `${spotsLeft} ${spotsLeft === 1 ? "Platz" : "Plätze"} verfügbar`}
</div>
{/* Participant bar */}
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${isFull ? "bg-red-500" : "bg-blue-500"}`}
style={{
width: `${safeMax > 0 ? Math.min(100, (safeCurrent / safeMax) * 100) : 0}%`,
}}
/>
</div>
{course.description && (
<div
className="prose prose-sm max-w-none text-gray-600 pt-1"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(course.description) }}
/>
)}
</div>
<div className="pt-2">
{isCheckingStatus ? (
<Button disabled className="w-full">
Lade Status…
</Button>
) : isBooked ? (
<Button disabled className="w-full">
✓ Bereits angemeldet
</Button>
) : (
<Button onClick={handleBooking} disabled={isSubmitting || isFull} className="w-full">
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Jetzt anmelden"}
</Button>
)}
</div>
</DialogContent>
</Dialog>
);
};
const PublicCourseCard = ({ course }: PublicCourseCardProps) => {
const [dialogOpen, setDialogOpen] = useState(false);
const cardSafeCurrent = Math.max(0, course.currentParticipants);
const cardSafeMax = Math.max(0, course.maxParticipants);
const isFull = cardSafeCurrent >= cardSafeMax;
const currentLevel = levelConfig[course.level] ?? levelConfig.any;
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
const formattedPrice = course.price.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
});
const fillPercent = cardSafeMax > 0 ? Math.min(100, (cardSafeCurrent / cardSafeMax) * 100) : 0;
return (
<>
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm hover:shadow-md transition flex flex-col gap-4">
<div className="flex items-center justify-between">
<p className="text-gray-500 text-sm">{formattedDate}</p>
<p className="text-gray-500 text-sm">
{course.timeFrom} – {course.timeTo}
</p>
</div>
<h2 className="text-xl font-semibold text-gray-900">{course.name}</h2>
<p className="text-gray-500 text-sm">{course.sport.join(", ")}</p>
<div className="flex items-center justify-between">
<span
className={`text-xs font-semibold px-2 py-1 rounded-lg ${currentLevel.bg} ${currentLevel.text}`}
>
{currentLevel.label}
</span>
<span className="text-lg font-semibold">{formattedPrice}</span>
</div>
<div className="flex items-center justify-between">
<span className={isFull ? "text-red-600 font-semibold text-sm" : "text-gray-700 text-sm"}>
{course.currentParticipants}/{course.maxParticipants} Teilnehmer
</span>
</div>
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${isFull ? "bg-red-500" : "bg-blue-500"}`}
style={{ width: `${fillPercent}%` }}
/>
</div>
{isFull ? (
<Button disabled variant="outline" className="w-full">
Ausgebucht
</Button>
) : (
<Button className="w-full" onClick={() => setDialogOpen(true)}>
Jetzt anmelden
</Button>
)}
</div>
<BookingDialog course={course} open={dialogOpen} onOpenChange={setDialogOpen} />
</>
);
};
export default PublicCourseCard;

View file

@ -0,0 +1,197 @@
"use client";
import { useState, useTransition } from "react";
import { CalendarIcon, X } from "lucide-react";
import PublicCourseCard from "../components/PublicCourseCard";
import { getPublishedCoursesForBusiness } from "../../actions/booking-actions";
import { Calendar } from "@/src/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/src/components/ui/popover";
import { Button } from "@/src/components/ui/button";
interface Business {
id: string;
name: string;
address: string;
email: string;
title: string;
slug: string;
}
interface PublicCourse {
id: string;
name: string;
sport: string[];
level: string;
date: Date;
timeFrom: string;
timeTo: string;
maxParticipants: number;
price: number;
currentParticipants: number;
description?: string;
room?: string;
}
interface BusinessPublicViewProps {
business: Business;
initialCourses: PublicCourse[];
initialWindow: { from: Date; to: Date };
}
const BusinessPublicView = ({
business,
initialCourses,
initialWindow,
}: BusinessPublicViewProps) => {
const [loadedCourses, setLoadedCourses] = useState<PublicCourse[]>(initialCourses);
const [loadedWindow, setLoadedWindow] = useState(initialWindow);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [calendarOpen, setCalendarOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const handleDateSelect = (date: Date | undefined) => {
setCalendarOpen(false);
setSelectedDate(date);
if (!date) return;
const dayStart = new Date(date);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(date);
dayEnd.setHours(23, 59, 59, 999);
// If within loaded window — filter client-side, no fetch needed
if (dayStart >= loadedWindow.from && dayEnd <= loadedWindow.to) {
return;
}
// Outside window — fetch ±15 day window around chosen date
const newFrom = new Date(date);
newFrom.setDate(newFrom.getDate() - 15);
newFrom.setHours(0, 0, 0, 0);
const newTo = new Date(date);
newTo.setDate(newTo.getDate() + 15);
newTo.setHours(23, 59, 59, 999);
startTransition(async () => {
const result = await getPublishedCoursesForBusiness(business.id, {
from: newFrom,
to: newTo,
});
if (result.success && result.courses) {
setLoadedCourses(result.courses as PublicCourse[]);
setLoadedWindow({ from: newFrom, to: newTo });
}
});
};
const handleReset = () => {
setSelectedDate(undefined);
if (
loadedWindow.from.getTime() !== initialWindow.from.getTime() ||
loadedWindow.to.getTime() !== initialWindow.to.getTime()
) {
startTransition(async () => {
const result = await getPublishedCoursesForBusiness(business.id, initialWindow);
if (result.success && result.courses) {
setLoadedCourses(result.courses as PublicCourse[]);
setLoadedWindow(initialWindow);
}
});
}
};
const displayedCourses = selectedDate
? loadedCourses.filter((c) => {
const d = new Date(c.date);
const dayStart = new Date(selectedDate);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(selectedDate);
dayEnd.setHours(23, 59, 59, 999);
return d >= dayStart && d <= dayEnd;
})
: loadedCourses;
const formattedSelectedDate = selectedDate
? selectedDate.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" })
: null;
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b border-gray-200">
<div className="max-w-5xl mx-auto px-6 py-10">
<h1 className="text-4xl font-bold text-gray-900">{business.name}</h1>
{business.title && <p className="text-xl text-gray-500 mt-1">{business.title}</p>}
<div className="mt-4 flex flex-col sm:flex-row gap-2 text-sm text-gray-600">
<span>{business.address}</span>
{business.email && (
<>
<span className="hidden sm:inline">·</span>
<a href={`mailto:${business.email}`} className="hover:underline text-blue-600">
{business.email}
</a>
</>
)}
</div>
</div>
</div>
{/* Courses Section */}
<div className="max-w-5xl mx-auto px-6 py-10">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<h2 className="text-2xl font-semibold text-gray-900">Unsere Kurse</h2>
<div className="flex items-center gap-2">
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="gap-2">
<CalendarIcon size={16} />
{formattedSelectedDate ?? "Datum filtern"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
initialFocus
/>
</PopoverContent>
</Popover>
{selectedDate && (
<button
onClick={handleReset}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-800"
title="Filter zurücksetzen"
>
<X size={14} />
</button>
)}
</div>
</div>
{isPending ? (
<div className="flex justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
) : displayedCourses.length === 0 ? (
<div className="text-center py-16 text-gray-500">
{selectedDate
? "Für diesen Tag sind keine Kurse verfügbar."
: "Aktuell sind keine Kurse verfügbar."}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{displayedCourses.map((course) => (
<PublicCourseCard key={course.id} course={course} />
))}
</div>
)}
</div>
</div>
);
};
export default BusinessPublicView;

View file

@ -8,7 +8,7 @@ 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 { Checkbox } from "@/src/components/ui/checkbox";
import { Label } from "@/src/components/ui/label"; import { Label } from "@/src/components/ui/label";
import { createCourse, updateCourse } 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";
@ -80,6 +80,8 @@ const CourseCreateView = ({
const [isLoadingRooms, setIsLoadingRooms] = useState(true); const [isLoadingRooms, setIsLoadingRooms] = useState(true);
const [trainers, setTrainers] = useState<Array<{ value: string; label: string }>>([]); const [trainers, setTrainers] = useState<Array<{ value: string; label: string }>>([]);
const [isLoadingTrainers, setIsLoadingTrainers] = useState(true); const [isLoadingTrainers, setIsLoadingTrainers] = useState(true);
const [selectedBusinessId, setSelectedBusinessId] = useState("");
const [businesses, setBusinesses] = useState<Array<{ value: string; label: string }>>([]);
const { const {
deleteDialogOpen, deleteDialogOpen,
@ -93,6 +95,21 @@ const CourseCreateView = ({
onSuccess: () => router.push("/courses"), onSuccess: () => router.push("/courses"),
}); });
// Load businesses from database
useEffect(() => {
const loadBusinesses = async () => {
const result = await getMyBusinesses();
if (result.success) {
const opts = result.businesses.map((b) => ({ value: b.id, label: b.name }));
setBusinesses(opts);
if (opts.length === 1) setSelectedBusinessId(opts[0].value);
} else {
toast.error("Fehler beim Laden der Standorte. Bitte laden Sie die Seite neu.");
}
};
loadBusinesses();
}, []);
// Load rooms from database // Load rooms from database
useEffect(() => { useEffect(() => {
const loadRooms = async () => { const loadRooms = async () => {
@ -160,6 +177,7 @@ const CourseCreateView = ({
setIsStandingOrder(initialData.isStandingOrder || false); setIsStandingOrder(initialData.isStandingOrder || false);
setSelectedFrequency(initialData.frequency || ""); setSelectedFrequency(initialData.frequency || "");
setSelectedWeekdays(initialData.weekdays || []); setSelectedWeekdays(initialData.weekdays || []);
setSelectedBusinessId(initialData.businessId ?? "");
} }
}, [mode, initialData, customSports]); }, [mode, initialData, customSports]);
@ -244,6 +262,7 @@ const CourseCreateView = ({
isStandingOrder, isStandingOrder,
frequency: selectedFrequency || undefined, frequency: selectedFrequency || undefined,
weekdays: selectedWeekdays.length > 0 ? selectedWeekdays : undefined, weekdays: selectedWeekdays.length > 0 ? selectedWeekdays : undefined,
businessId: selectedBusinessId || undefined,
}; };
if (mode === "edit" && !courseId) { if (mode === "edit" && !courseId) {
@ -298,6 +317,15 @@ const CourseCreateView = ({
)} )}
</div> </div>
<div className="mt-6 flex flex-col gap-4 max-w-xl"> <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> <div>
<InputComponent <InputComponent
isLabel isLabel

View file

@ -15,6 +15,7 @@ const menuTitles: Record<string, string> = {
"/rooms/edit": "Bearbeite den Raum", "/rooms/edit": "Bearbeite den Raum",
"/employee": "Mitarbeiter Übersicht", "/employee": "Mitarbeiter Übersicht",
"/employee/create": "Mitarbeiter erstellen", "/employee/create": "Mitarbeiter erstellen",
"/settings": "Einstellungen",
}; };
const dynamicRoutes: Array<{ const dynamicRoutes: Array<{

View file

@ -9,7 +9,7 @@
"use server"; "use server";
import { prisma } from "@/src/lib/prisma"; import { prisma } from "@/src/lib/prisma";
import { getUserData, isManager, isEmployee } from "@/src/lib/auth/getUser"; import { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser";
import { createAdminClient } from "@/src/lib/supabase/admin"; import { createAdminClient } from "@/src/lib/supabase/admin";
import { employeeSchema, type EmployeeFormData } from "../schemas/employee-schema"; import { employeeSchema, type EmployeeFormData } from "../schemas/employee-schema";
import { revalidatePath, revalidateTag } from "next/cache"; import { revalidatePath, revalidateTag } from "next/cache";
@ -129,7 +129,9 @@ export async function createEmployee(data: EmployeeFormData, status: "draft" | "
} }
// Schritt 7: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz) // Schritt 7: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz)
const managerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const managerId = getEffectiveManagerId(userData);
if (!managerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
// Schritt 8: Berechtigungen auf Caller-Ceiling begrenzen (Privilege-Escalation verhindern) // Schritt 8: Berechtigungen auf Caller-Ceiling begrenzen (Privilege-Escalation verhindern)
const finalPermissions = isEmployee(userData) const finalPermissions = isEmployee(userData)
@ -216,7 +218,14 @@ export async function getMyTrainers() {
}; };
} }
const managerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const managerId = getEffectiveManagerId(userData);
if (!managerId)
return {
success: false,
error: "Mitarbeiter-Account ist keinem Manager zugeordnet",
employees: [],
trainers: [],
};
const employees = await prisma.employee.findMany({ const employees = await prisma.employee.findMany({
where: { where: {
@ -282,8 +291,12 @@ export async function getMyEmployees() {
// Manager sieht alle Mitarbeiter unter seiner ID // Manager sieht alle Mitarbeiter unter seiner ID
whereClause = { createdBy: userData.id }; whereClause = { createdBy: userData.id };
} else { } else {
// Employee: eigene Manager-ID ermitteln, dann alle unter diesem Manager anzeigen (außer sich selbst) // Employee: alle unter demselben Manager anzeigen (außer sich selbst)
// createdBy kann null sein (verwaister Account) — in dem Fall nichts zurückgeben
const managerId = userData.createdBy; const managerId = userData.createdBy;
if (!managerId) {
return { success: true, employees: [] };
}
whereClause = { createdBy: managerId, NOT: { email: userData.email } }; whereClause = { createdBy: managerId, NOT: { email: userData.email } };
} }
const employees = await prisma.employee.findMany({ const employees = await prisma.employee.findMany({
@ -435,7 +448,9 @@ export async function updateEmployee(
} }
// Schritt 6: Überprüfen, ob der Benutzer der Besitzer des Mitarbeiters ist // Schritt 6: Überprüfen, ob der Benutzer der Besitzer des Mitarbeiters ist
const ownerManagerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const ownerManagerId = getEffectiveManagerId(userData);
if (!ownerManagerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
if (existingEmployee.createdBy !== ownerManagerId) { if (existingEmployee.createdBy !== ownerManagerId) {
return { return {
success: false, success: false,
@ -518,6 +533,10 @@ export async function updateEmployee(
revalidatePath("/employee"); revalidatePath("/employee");
revalidatePath(`/employee/edit/${employeeId}`); revalidatePath(`/employee/edit/${employeeId}`);
invalidateUserDataCache(validatedData.email); invalidateUserDataCache(validatedData.email);
// If email changed, also invalidate the old email's cache entry
if (existingEmployee.email !== validatedData.email) {
invalidateUserDataCache(existingEmployee.email);
}
return { return {
success: true, success: true,
@ -564,7 +583,13 @@ export async function getMyEmployeeRoles() {
} }
// Schritt 3: Effektive Manager-ID ermitteln // Schritt 3: Effektive Manager-ID ermitteln
const managerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const managerId = getEffectiveManagerId(userData);
if (!managerId)
return {
success: false,
error: "Mitarbeiter-Account ist keinem Manager zugeordnet",
roles: [],
};
// Schritt 4: Alle Mitarbeiter des Managers abrufen (nur roles Feld) // Schritt 4: Alle Mitarbeiter des Managers abrufen (nur roles Feld)
const employees = await prisma.employee.findMany({ const employees = await prisma.employee.findMany({
@ -649,7 +674,9 @@ export async function deleteEmployee(employeeId: string) {
} }
// Schritt 5: Überprüfen, ob der Benutzer der Besitzer des Mitarbeiters ist // Schritt 5: Überprüfen, ob der Benutzer der Besitzer des Mitarbeiters ist
const ownerManagerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const ownerManagerId = getEffectiveManagerId(userData);
if (!ownerManagerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
if (employee.createdBy !== ownerManagerId) { if (employee.createdBy !== ownerManagerId) {
return { return {
success: false, success: false,

View file

@ -9,7 +9,7 @@
"use server"; "use server";
import { prisma } from "@/src/lib/prisma"; import { prisma } from "@/src/lib/prisma";
import { getUserData, isManager, isEmployee } from "@/src/lib/auth/getUser"; import { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser";
import { roomSchema, type RoomFormData } from "../schemas/room-schema"; import { roomSchema, type RoomFormData } from "../schemas/room-schema";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
@ -59,7 +59,9 @@ export async function createRoom(data: RoomFormData) {
const validatedData = validation.data; const validatedData = validation.data;
// Schritt 5: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz) // Schritt 5: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz)
const creatorId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const creatorId = getEffectiveManagerId(userData);
if (!creatorId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
// Schritt 6: Raum in der Datenbank erstellen // Schritt 6: Raum in der Datenbank erstellen
const room = await prisma.room.create({ const room = await prisma.room.create({
@ -143,7 +145,13 @@ export async function getMyRooms() {
} }
// Schritt 3: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz) // Schritt 3: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz)
const managerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const managerId = getEffectiveManagerId(userData);
if (!managerId)
return {
success: false,
error: "Mitarbeiter-Account ist keinem Manager zugeordnet",
rooms: [],
};
// Schritt 4: Räume abrufen, die von diesem Manager erstellt wurden // Schritt 4: Räume abrufen, die von diesem Manager erstellt wurden
const rooms = await prisma.room.findMany({ const rooms = await prisma.room.findMany({
@ -195,7 +203,9 @@ export async function getRoomById(roomId: string) {
} }
// Schritt 3: Effektive Manager-ID ermitteln // Schritt 3: Effektive Manager-ID ermitteln
const managerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const managerId = getEffectiveManagerId(userData);
if (!managerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
// Schritt 4: Raum aus der Datenbank abrufen // Schritt 4: Raum aus der Datenbank abrufen
const room = await prisma.room.findUnique({ const room = await prisma.room.findUnique({
@ -267,7 +277,9 @@ export async function updateRoom(roomId: string, data: RoomFormData) {
} }
// Schritt 4: Effektive Manager-ID ermitteln // Schritt 4: Effektive Manager-ID ermitteln
const managerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const managerId = getEffectiveManagerId(userData);
if (!managerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
// Schritt 5: Raum aus der Datenbank abrufen // Schritt 5: Raum aus der Datenbank abrufen
const existingRoom = await prisma.room.findUnique({ const existingRoom = await prisma.room.findUnique({
@ -356,7 +368,9 @@ export async function deleteRoom(roomId: string) {
} }
// Schritt 3: Effektive Manager-ID ermitteln // Schritt 3: Effektive Manager-ID ermitteln
const managerId = isEmployee(userData) ? (userData.createdBy ?? userData.id) : userData.id; const managerId = getEffectiveManagerId(userData);
if (!managerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
// Schritt 4: Raum aus der Datenbank abrufen // Schritt 4: Raum aus der Datenbank abrufen
const room = await prisma.room.findUnique({ const room = await prisma.room.findUnique({

View file

@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { Switch } from "@/src/components/ui/switch";
import { Label } from "@/src/components/ui/label";
import { Link, Copy, Check } from "lucide-react";
import { toast } from "sonner";
import { toggleBusinessPublic } from "@/src/modules/courses/actions/course-actions";
interface BusinessPublicToggleProps {
businessId: string;
businessName: string;
initialIsPublic: boolean;
initialSlug: string | null;
}
const BusinessPublicToggle = ({
businessId,
businessName,
initialIsPublic,
initialSlug,
}: BusinessPublicToggleProps) => {
const [isPublic, setIsPublic] = useState(initialIsPublic);
const [slug, setSlug] = useState(initialSlug);
const [isLoading, setIsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const publicUrl = slug
? `${typeof window !== "undefined" ? window.location.origin : ""}/business/${slug}`
: null;
const handleToggle = async (checked: boolean) => {
setIsLoading(true);
const result = await toggleBusinessPublic(businessId, checked);
if (result.success) {
setIsPublic(checked);
if (result.slug) setSlug(result.slug);
toast.success(checked ? "Seite veröffentlicht" : "Seite versteckt");
} else {
toast.error(result.error ?? "Fehler beim Aktualisieren");
}
setIsLoading(false);
};
const handleCopy = async () => {
if (!publicUrl) return;
try {
await navigator.clipboard.writeText(publicUrl);
setCopied(true);
toast.success("Link kopiert");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Link konnte nicht kopiert werden");
}
};
return (
<div className="border border-gray-200 rounded-xl p-5 bg-white flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">{businessName}</h3>
<p className="text-sm text-gray-500 mt-0.5">Öffentliche Kursseite</p>
</div>
<div className="flex items-center gap-3">
<Label htmlFor={`toggle-${businessId}`} className="text-sm text-gray-600 cursor-pointer">
{isPublic ? "Veröffentlicht" : "Nicht öffentlich"}
</Label>
<Switch
id={`toggle-${businessId}`}
checked={isPublic}
onCheckedChange={handleToggle}
disabled={isLoading}
/>
</div>
</div>
{isPublic && publicUrl && (
<div className="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-lg px-3 py-2">
<Link size={15} className="text-gray-400 shrink-0" />
<span className="text-sm text-gray-700 truncate flex-1">{publicUrl}</span>
<button
onClick={handleCopy}
className="shrink-0 p-1 rounded hover:bg-gray-200 transition"
title="Link kopieren"
>
{copied ? (
<Check size={15} className="text-green-600" />
) : (
<Copy size={15} className="text-gray-500" />
)}
</button>
</div>
)}
</div>
);
};
export default BusinessPublicToggle;

View file

@ -0,0 +1,50 @@
import { requireAuthWithData, isManager } from "@/src/lib/auth/getUser";
import { prisma } from "@/src/lib/prisma";
import BusinessPublicToggle from "../components/BusinessPublicToggle";
const SettingsView = async () => {
const userData = await requireAuthWithData();
if (!isManager(userData)) {
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-2">Einstellungen</h1>
<p className="text-gray-500">Keine weiteren Einstellungen verfügbar.</p>
</div>
);
}
const businesses = await prisma.business.findMany({
where: { managerId: userData.id },
select: { id: true, name: true, isPublic: true, slug: true },
orderBy: { name: "asc" },
});
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Einstellungen</h1>
<p className="text-gray-500 mt-1">Verwalte die öffentlichen Seiten deiner Businesses</p>
</div>
{businesses.length === 0 ? (
<p className="text-gray-400">Noch kein Business angelegt.</p>
) : (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Öffentliche Seiten</h2>
{businesses.map((business) => (
<BusinessPublicToggle
key={business.id}
businessId={business.id}
businessName={business.name}
initialIsPublic={business.isPublic}
initialSlug={business.slug}
/>
))}
</div>
)}
</div>
);
};
export default SettingsView;