Merge pull request #24 from Since-3/feature/mvp-booking-proces

added booking process for a specific course via Link
This commit is contained in:
Rajpreet0 2026-01-06 18:04:20 +01:00 • committed by GitHub
commit 6a4a9a3867
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 467 additions and 7 deletions

View file

@ -1,5 +0,0 @@
{
"permissions": {
"allow": ["Bash(npx prisma migrate:*)"]
}
}

View file

@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "CourseBooking" (
"id" TEXT NOT NULL,
"courseId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CourseBooking_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CourseBooking_courseId_idx" ON "CourseBooking"("courseId");
-- CreateIndex
CREATE INDEX "CourseBooking_userId_idx" ON "CourseBooking"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "CourseBooking_courseId_userId_key" ON "CourseBooking"("courseId", "userId");
-- AddForeignKey
ALTER TABLE "CourseBooking" ADD CONSTRAINT "CourseBooking_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CourseBooking" ADD CONSTRAINT "CourseBooking_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -32,6 +32,7 @@ model User {
pbSrc String?
isValid Boolean @default(false)
trainings UserTraining[]
courseBookings CourseBooking[]
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
}
@ -108,6 +109,7 @@ model Course {
weekdays String[] // For custom frequency
status String @default("draft") // draft, published
createdBy String // Manager ID who created the course
bookings CourseBooking[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@ -118,4 +120,17 @@ model Room {
createdBy String // Manager ID who created the room
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CourseBooking {
id String @id @default(uuid())
courseId String
userId String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([courseId, userId]) // Ein User kann sich nur einmal pro Kurs anmelden
@@index([courseId])
@@index([userId])
}

View file

@ -0,0 +1,20 @@
import { getCourseForBooking } from "@/src/modules/courses/actions/booking-actions";
import CourseBookingView from "@/src/modules/courses/ui/views/course-booking-view";
import { notFound } from "next/navigation";
interface CourseBookingPageProps {
params: Promise<{
courseId: string;
}>;
}
export default async function CourseBookingPage({ params }: CourseBookingPageProps) {
const { courseId } = await params;
const result = await getCourseForBooking(courseId);
if (!result.success || !result.course) {
notFound();
}
return <CourseBookingView course={result.course} />;
}

View file

@ -0,0 +1,203 @@
/**
* Booking Actions - Server-seitige Aktionen für Kursbuchungen
*
* Diese Datei enthält alle Server Actions für Kursbuchungen.
* Funktionen umfassen: Kurs buchen, Buchungen abrufen und Status prüfen.
*/
"use server";
import { prisma } from "@/src/lib/prisma";
import { getUserData } from "@/src/lib/auth/getUser";
import { revalidatePath } from "next/cache";
/**
* Bucht einen User in einen Kurs ein
*
* Diese Funktion überprüft:
* - Ob der Kurs existiert und veröffentlicht ist
* - Ob noch Plätze verfügbar sind
* - Ob der User bereits gebucht hat
* - Ob der User angemeldet ist
*
* @param courseId - Die eindeutige ID des Kurses
* @returns Ein Objekt mit success-Flag und Nachricht oder Fehler
*/
export async function bookCourse(courseId: string) {
try {
// Schritt 1: Aktuellen Benutzer abrufen
const userData = await getUserData();
// Schritt 2: Überprüfen, ob Benutzer angemeldet ist
if (!userData) {
return {
success: false,
error: "Sie müssen angemeldet sein, um einen Kurs zu buchen",
};
}
// Schritt 3: Kurs mit Buchungszähler abrufen
const course = await prisma.course.findUnique({
where: { id: courseId },
include: {
_count: {
select: { bookings: true },
},
},
});
// Schritt 4: Überprüfen, ob Kurs existiert
if (!course) {
return {
success: false,
error: "Kurs nicht gefunden",
};
}
// Schritt 5: Überprüfen, ob Kurs veröffentlicht ist
if (course.status !== "published") {
return {
success: false,
error: "Dieser Kurs ist nicht verfügbar",
};
}
// Schritt 6: Überprüfen, ob noch Plätze verfügbar sind
if (course._count.bookings >= course.maxParticipants) {
return {
success: false,
error: "Dieser Kurs ist bereits ausgebucht",
};
}
// Schritt 7: Überprüfen, ob User bereits gebucht hat
const existingBooking = await prisma.courseBooking.findUnique({
where: {
courseId_userId: {
courseId,
userId: userData.id,
},
},
});
if (existingBooking) {
return {
success: false,
error: "Sie haben sich bereits für diesen Kurs angemeldet",
};
}
// Schritt 8: Buchung erstellen
await prisma.courseBooking.create({
data: {
courseId,
userId: userData.id,
},
});
// Schritt 9: Cache invalidieren
revalidatePath("/courses");
revalidatePath(`/courses/book/${courseId}`);
return {
success: true,
message: "Sie wurden erfolgreich für den Kurs angemeldet",
};
} catch (error) {
console.error("Error booking course:", error);
return {
success: false,
error: "Ein Fehler ist bei der Buchung aufgetreten",
};
}
}
/**
* Ruft einen Kurs mit Buchungsinformationen ab (öffentlich)
*
* Diese Funktion gibt Kursinformationen inkl. aktueller Teilnehmerzahl zurück.
* Sie ist öffentlich zugänglich für die Buchungsseite.
*
* @param courseId - Die eindeutige ID des Kurses
* @returns Ein Objekt mit success-Flag und Kursdaten
*/
export async function getCourseForBooking(courseId: string) {
try {
const course = await prisma.course.findUnique({
where: { id: courseId },
include: {
_count: {
select: { bookings: true },
},
},
});
if (!course) {
return {
success: false,
error: "Kurs nicht gefunden",
};
}
if (course.status !== "published") {
return {
success: false,
error: "Dieser Kurs ist nicht verfügbar",
};
}
return {
success: true,
course: {
...course,
currentParticipants: course._count.bookings,
},
};
} catch (error) {
console.error("Error fetching course for booking:", error);
return {
success: false,
error: "Fehler beim Laden des Kurses",
};
}
}
/**
* Überprüft, ob ein User bereits für einen Kurs gebucht hat
*
* @param courseId - Die eindeutige ID des Kurses
* @returns Ein Objekt mit success-Flag und isBooked Status
*/
export async function checkUserBookingStatus(courseId: string) {
try {
const userData = await getUserData();
if (!userData) {
return {
success: true,
isBooked: false,
};
}
const booking = await prisma.courseBooking.findUnique({
where: {
courseId_userId: {
courseId,
userId: userData.id,
},
},
});
return {
success: true,
isBooked: !!booking,
};
} catch (error) {
console.error("Error checking booking status:", error);
return {
success: false,
error: "Fehler beim Prüfen des Buchungsstatus",
isBooked: false,
};
}
}

View file

@ -182,6 +182,11 @@ export async function getMyCourses(options?: { dateFrom?: Date; dateTo?: Date })
// Step 4: Fetch courses created by this manager with optional date filtering
const courses = await prisma.course.findMany({
where: whereClause,
include: {
_count: {
select: { bookings: true },
},
},
orderBy: {
date: "asc", // Sort by course date ascending (oldest first)
},

View file

@ -0,0 +1,29 @@
export type CourseBooking = {
id: string;
courseId: string;
userId: string;
createdAt: Date;
};
export type CourseWithBookingCount = {
id: string;
name: string;
sport: string;
date: Date;
timeFrom: string;
timeTo: string;
trainers: string[];
room: string;
description: string;
maxParticipants: number;
isStandingOrder: boolean;
frequency: string | null;
weekdays: string[];
status: string;
createdBy: string;
createdAt: Date;
updatedAt: Date;
_count?: {
bookings: number;
};
};

View file

@ -6,12 +6,14 @@
* inklusive Teilnehmerzahl, Status und Bearbeitungsoption.
*/
import { Pen, Trash2 } from "lucide-react";
import { Pen, Trash2, Link } from "lucide-react";
import { toast } from "sonner";
/**
* Props für die CourseListItem Komponente
*/
interface CourseListItemProps {
courseId?: string; // ID des Kurses für den Buchungslink
courseName: string; // Name des Kurses
room: string; // Raum, in dem der Kurs stattfindet
timeFrom: string; // Uhrzeit am Start des Kurses
@ -31,6 +33,7 @@ interface CourseListItemProps {
* Zeigt Kursinformationen in einem visuell ansprechenden Karten-Layout
*/
const CourseListItem: React.FC<CourseListItemProps> = ({
courseId,
courseName,
room,
timeFrom,
@ -47,6 +50,15 @@ const CourseListItem: React.FC<CourseListItemProps> = ({
// Prüfen, ob der Kurs voll belegt ist (für visuelle Hervorhebung)
const isFull = currentParticipants >= maxParticipants;
// Funktion zum Kopieren des Buchungslinks
const handleCopyLink = () => {
if (!courseId) return;
const bookingUrl = `${window.location.origin}/courses/book/${courseId}`;
navigator.clipboard.writeText(bookingUrl);
toast.success("Link wurde in die Zwischenablage kopiert");
};
return (
<div
className={`bg-white border border-gray-200 rounded-xl p-5 shadow-sm hover:shadow-md transition flex flex-col gap-4 relative ${
@ -85,6 +97,15 @@ const CourseListItem: React.FC<CourseListItemProps> = ({
<div className="flex justify-between items-center pt-3 border-t border-gray-200">
<p className="text-gray-700">{trainerName}</p>
<div className="flex gap-2">
{courseId && status === "published" && (
<button
onClick={handleCopyLink}
className="p-2 rounded-md hover:bg-blue-100 transition"
title="Buchungslink kopieren"
>
<Link size={20} className="text-blue-600" />
</button>
)}
{showDeleteIcon && onDelete && (
<button onClick={onDelete} className="p-2 rounded-md hover:bg-red-100 transition">
<Trash2 size={20} className="text-red-600" />

View file

@ -0,0 +1,144 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/src/components/ui/button";
import { bookCourse, checkUserBookingStatus } from "../../actions/booking-actions";
import { toast } from "sonner";
interface CourseBookingViewProps {
course: {
id: string;
name: string;
sport: string;
date: Date;
timeFrom: string;
timeTo: string;
description: string;
maxParticipants: number;
currentParticipants: number;
room: string;
};
}
const CourseBookingView = ({ course }: CourseBookingViewProps) => {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isAlreadyBooked, setIsAlreadyBooked] = useState(false);
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
const spotsAvailable = course.maxParticipants - course.currentParticipants;
const isFull = spotsAvailable <= 0;
// Format date for display
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
// Check if user already booked this course
useEffect(() => {
const checkStatus = async () => {
const result = await checkUserBookingStatus(course.id);
if (result.success) {
setIsAlreadyBooked(result.isBooked);
}
setIsCheckingStatus(false);
};
checkStatus();
}, [course.id]);
const handleBooking = async () => {
setIsSubmitting(true);
try {
const result = await bookCourse(course.id);
if (result.success) {
toast.success(result.message);
setIsAlreadyBooked(true);
router.refresh();
} else {
toast.error(result.error || "Ein Fehler ist aufgetreten");
}
} catch (error) {
toast.error("Ein unerwarteter Fehler ist aufgetreten");
console.error(error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-2xl mx-auto p-6">
<div className="bg-white rounded-lg shadow-md p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2">{course.name}</h1>
<p className="text-lg text-gray-600">{course.sport}</p>
</div>
<div className="space-y-4 mb-6">
<div>
<h2 className="font-semibold text-gray-700">Datum</h2>
<p className="text-gray-900">{formattedDate}</p>
</div>
<div>
<h2 className="font-semibold text-gray-700">Uhrzeit</h2>
<p className="text-gray-900">
{course.timeFrom} - {course.timeTo}
</p>
</div>
<div>
<h2 className="font-semibold text-gray-700">Raum</h2>
<p className="text-gray-900">{course.room}</p>
</div>
<div>
<h2 className="font-semibold text-gray-700">Verfügbare Plätze</h2>
<p className="text-gray-900">
{course.currentParticipants} / {course.maxParticipants} Teilnehmer
{isFull && <span className="text-red-500 ml-2">(Ausgebucht)</span>}
{!isFull && (
<span className="text-green-600 ml-2">({spotsAvailable} Plätze frei)</span>
)}
</p>
</div>
<div>
<h2 className="font-semibold text-gray-700 mb-2">Was Sie mitbringen sollten</h2>
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: course.description }}
/>
</div>
</div>
<div className="flex gap-4">
{isCheckingStatus ? (
<Button disabled className="flex-1">
Lade Status...
</Button>
) : isAlreadyBooked ? (
<Button disabled className="flex-1">
Bereits angemeldet
</Button>
) : (
<Button onClick={handleBooking} disabled={isSubmitting || isFull} className="flex-1">
{isSubmitting ? "Wird gebucht..." : isFull ? "Ausgebucht" : "Jetzt anmelden"}
</Button>
)}
</div>
{isAlreadyBooked && (
<p className="text-green-600 text-center mt-4">✓ Sie sind für diesen Kurs angemeldet</p>
)}
</div>
</div>
);
};
export default CourseBookingView;

View file

@ -27,6 +27,9 @@ type Course = {
createdBy: string;
createdAt: Date;
updatedAt: Date;
_count?: {
bookings: number;
};
};
const CourseListView = () => {
@ -459,11 +462,12 @@ const CourseListView = () => {
{coursesForDate.map((course) => (
<CourseListItem
key={course.id}
courseId={course.id}
courseName={course.name}
room={roomsMap[course.room] || course.room}
timeFrom={course.timeFrom}
timeTo={course.timeTo}
currentParticipants={0} // TODO: Implement participant tracking
currentParticipants={course._count?.bookings || 0}
maxParticipants={course.maxParticipants}
trainerName={getTrainerName(course.trainers)}
status={course.status}