mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
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:
commit
6a4a9a3867
10 changed files with 467 additions and 7 deletions
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(npx prisma migrate:*)"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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])
|
||||
}
|
||||
20
src/app/courses/book/[courseId]/page.tsx
Normal file
20
src/app/courses/book/[courseId]/page.tsx
Normal 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} />;
|
||||
}
|
||||
203
src/modules/courses/actions/booking-actions.ts
Normal file
203
src/modules/courses/actions/booking-actions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
29
src/modules/courses/types/booking.types.ts
Normal file
29
src/modules/courses/types/booking.types.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
144
src/modules/courses/ui/views/course-booking-view.tsx
Normal file
144
src/modules/courses/ui/views/course-booking-view.tsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue