added course creation with backend and list view

This commit is contained in:
Rajpreet0 2025-12-07 17:22:25 +01:00
parent f842264557
commit 08f0d75dee
10 changed files with 992 additions and 66 deletions

106
package-lock.json generated
View file

@ -25,7 +25,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"lucide-react": "^0.552.0", "lucide-react": "^0.552.0",
"next": "16.0.1", "next": "^16.0.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"prisma": "^6.19.0", "prisma": "^6.19.0",
"react": "19.2.0", "react": "19.2.0",
@ -1767,9 +1767,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.0.1", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
"integrity": "sha512-LFvlK0TG2L3fEOX77OC35KowL8D7DlFF45C0OvKMC4hy8c/md1RC4UMNDlUGJqfCoCS2VWrZ4dSE6OjaX5+8mw==", "integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
@ -1783,9 +1783,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.0.1", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
"integrity": "sha512-R0YxRp6/4W7yG1nKbfu41bp3d96a0EalonQXiMe+1H9GTHfKxGNCGFNWUho18avRBPsO8T3RmdWuzmfurlQPbg==", "integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1799,9 +1799,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.0.1", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
"integrity": "sha512-kETZBocRux3xITiZtOtVoVvXyQLB7VBxN7L6EPqgI5paZiUlnsgYv4q8diTNYeHmF9EiehydOBo20lTttCbHAg==", "integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1815,9 +1815,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.0.1", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
"integrity": "sha512-hWg3BtsxQuSKhfe0LunJoqxjO4NEpBmKkE+P2Sroos7yB//OOX3jD5ISP2wv8QdUwtRehMdwYz6VB50mY6hqAg==", "integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1831,9 +1831,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.0.1", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
"integrity": "sha512-UPnOvYg+fjAhP3b1iQStcYPWeBFRLrugEyK/lDKGk7kLNua8t5/DvDbAEFotfV1YfcOY6bru76qN9qnjLoyHCQ==", "integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1847,9 +1847,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.0.1", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
"integrity": "sha512-Et81SdWkcRqAJziIgFtsFyJizHoWne4fzJkvjd6V4wEkWTB4MX6J0uByUb0peiJQ4WeAt6GGmMszE5KrXK6WKg==", "integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1863,9 +1863,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.0.1", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
"integrity": "sha512-qBbgYEBRrC1egcG03FZaVfVxrJm8wBl7vr8UFKplnxNRprctdP26xEv9nJ07Ggq4y1adwa0nz2mz83CELY7N6Q==", "integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1879,9 +1879,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.0.1", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
"integrity": "sha512-cPuBjYP6I699/RdbHJonb3BiRNEDm5CKEBuJ6SD8k3oLam2fDRMKAvmrli4QMDgT2ixyRJ0+DTkiODbIQhRkeQ==", "integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1894,6 +1894,22 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.0.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
"integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -8989,12 +9005,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.0.1", "version": "16.0.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.1.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
"integrity": "sha512-e9RLSssZwd35p7/vOa+hoDFggUZIUbZhIUSLZuETCwrCVvxOs87NamoUzT+vbcNAL8Ld9GobBnWOA6SbV/arOw==", "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.0.1", "@next/env": "16.0.7",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31", "postcss": "8.4.31",
@ -9007,14 +9023,14 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.0.1", "@next/swc-darwin-arm64": "16.0.7",
"@next/swc-darwin-x64": "16.0.1", "@next/swc-darwin-x64": "16.0.7",
"@next/swc-linux-arm64-gnu": "16.0.1", "@next/swc-linux-arm64-gnu": "16.0.7",
"@next/swc-linux-arm64-musl": "16.0.1", "@next/swc-linux-arm64-musl": "16.0.7",
"@next/swc-linux-x64-gnu": "16.0.1", "@next/swc-linux-x64-gnu": "16.0.7",
"@next/swc-linux-x64-musl": "16.0.1", "@next/swc-linux-x64-musl": "16.0.7",
"@next/swc-win32-arm64-msvc": "16.0.1", "@next/swc-win32-arm64-msvc": "16.0.7",
"@next/swc-win32-x64-msvc": "16.0.1", "@next/swc-win32-x64-msvc": "16.0.7",
"sharp": "^0.34.4" "sharp": "^0.34.4"
}, },
"peerDependencies": { "peerDependencies": {
@ -9050,22 +9066,6 @@
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
} }
}, },
"node_modules/next/node_modules/@next/swc-win32-x64-msvc": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.1.tgz",
"integrity": "sha512-XeEUJsE4JYtfrXe/LaJn3z1pD19fK0Q6Er8Qoufi+HqvdO4LEPyCxLUt4rxA+4RfYo6S9gMlmzCMU2F+AatFqQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",

View file

@ -30,7 +30,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"lucide-react": "^0.552.0", "lucide-react": "^0.552.0",
"next": "16.0.1", "next": "^16.0.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"prisma": "^6.19.0", "prisma": "^6.19.0",
"react": "19.2.0", "react": "19.2.0",

View file

@ -81,7 +81,7 @@ model Business {
managerId String managerId String
name String name String
address String address String
email String email String
title String title String
ustId String? ustId String?
banking String? banking String?
@ -90,4 +90,23 @@ model Business {
manager Manager @relation(fields: [managerId], references: [id]) manager Manager @relation(fields: [managerId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
}
model Course {
id String @id @default(uuid())
name String
sport String
date DateTime
time String
trainers String[] // Array of trainer IDs
room String
description String // Rich text HTML content
maxParticipants Int // Maximum number of participants
isStandingOrder Boolean @default(false)
frequency String? // daily, weekly, biweekly, monthly, custom
weekdays String[] // For custom frequency
status String @default("draft") // draft, published
createdBy String // Manager ID who created the course
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }

View file

@ -5,7 +5,7 @@ const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = export const prisma =
globalForPrisma.prisma || globalForPrisma.prisma ||
new PrismaClient({ new PrismaClient({
log: ["query", "error", "warn"], log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
datasources: { datasources: {
db: { db: {
url: process.env.DATABASE_URL, url: process.env.DATABASE_URL,

View file

@ -0,0 +1,246 @@
/**
* Course Actions - Server-seitige Aktionen für Kursverwaltung
*
* Diese Datei enthält alle Server Actions für das Kurs-Management-System.
* Funktionen umfassen: Kurse erstellen, abrufen, aktualisieren und löschen.
* Alle Funktionen sind mit Authentifizierung und Autorisierung geschützt.
*/
"use server";
import { prisma } from "@/src/lib/prisma";
import { getUserData, isManager } from "@/src/lib/auth/getUser";
import { courseSchema, type CourseFormData } from "../schemas/course-schema";
import { revalidatePath } from "next/cache";
/**
* Erstellt einen neuen Kurs in der Datenbank
*
* Diese Funktion validiert die Eingabedaten, überprüft die Berechtigungen des Benutzers
* und erstellt einen neuen Kurs. Nur Manager können Kurse erstellen.
*
* @param data - Die Kursdaten aus dem Formular
* @param status - Der Status des Kurses ("draft" für Entwurf oder "published" für veröffentlicht)
* @returns Ein Objekt mit success-Flag, optionaler courseId und Nachricht oder Fehler
*/
export async function createCourse(data: CourseFormData, status: "draft" | "published") {
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 erstellen",
};
}
// Schritt 3: Überprüfen, ob Benutzer ein Manager ist (nur Manager dürfen Kurse erstellen)
if (!isManager(userData)) {
return {
success: false,
error: "Nur Manager können Kurse erstellen",
};
}
// Schritt 4: Formulardaten mit Zod-Schema validieren
const validation = courseSchema.safeParse(data);
if (!validation.success) {
return {
success: false,
error: "Validierungsfehler",
fieldErrors: validation.error.flatten().fieldErrors,
};
}
const validatedData = validation.data;
// Schritt 5: Kurs in der Datenbank erstellen
const course = await prisma.course.create({
data: {
name: validatedData.name,
sport: validatedData.sport,
date: new Date(validatedData.date), // String zu Date konvertieren
time: validatedData.time,
trainers: validatedData.trainers,
room: validatedData.room,
description: validatedData.description,
maxParticipants: validatedData.maxParticipants,
isStandingOrder: validatedData.isStandingOrder,
frequency: validatedData.frequency || null,
weekdays: validatedData.weekdays || [],
status, // "draft" oder "published"
createdBy: userData.id, // Manager-ID als Ersteller
},
});
// Schritt 6: Next.js Cache für /courses Seite invalidieren, damit neue Daten angezeigt werden
revalidatePath("/courses");
return {
success: true,
courseId: course.id,
message:
status === "published"
? "Kurs erfolgreich veröffentlicht"
: "Entwurf erfolgreich gespeichert",
};
} catch (error) {
console.error("Error creating course:", error);
return {
success: false,
error: "Ein Fehler ist beim Erstellen des Kurses aufgetreten",
};
}
}
/**
* Ruft alle veröffentlichten Kurse ab
*
* Diese Funktion gibt alle Kurse zurück, die den Status "published" haben.
* Die Kurse werden nach Datum aufsteigend sortiert.
*
* @returns Ein Objekt mit success-Flag und einem Array von Kursen
*/
export async function getAllCourses() {
try {
// Alle veröffentlichten Kurse aus der Datenbank abrufen
// Sortiert nach Datum aufsteigend (älteste zuerst)
const courses = await prisma.course.findMany({
where: {
status: "published", // Nur veröffentlichte Kurse
},
orderBy: {
date: "asc", // Aufsteigende Sortierung nach Datum
},
});
return {
success: true,
courses,
};
} catch (error) {
console.error("Error fetching courses:", error);
return {
success: false,
error: "Fehler beim Laden der Kurse",
courses: [],
};
}
}
/**
* Ruft alle Kurse des angemeldeten Managers ab
*
* Diese Funktion gibt alle Kurse zurück, die vom aktuell angemeldeten Manager
* erstellt wurden, unabhängig vom Status (Entwurf oder veröffentlicht).
* Die Kurse werden nach Erstellungsdatum absteigend sortiert (neueste zuerst).
*
* @returns Ein Objekt mit success-Flag und einem Array von Kursen des Managers
*/
export async function getMyCourses() {
try {
// Aktuellen Benutzer abrufen
const userData = await getUserData();
// Überprüfen, ob Benutzer angemeldet und ein Manager ist
if (!userData || !isManager(userData)) {
return {
success: false,
error: "Unauthorized",
courses: [],
};
}
// Alle Kurse abrufen, die von diesem Manager erstellt wurden
const courses = await prisma.course.findMany({
where: {
createdBy: userData.id, // Filtert nach Manager-ID
},
orderBy: {
createdAt: "desc", // Neueste Kurse zuerst
},
});
return {
success: true,
courses,
};
} catch (error) {
console.error("Error fetching my courses:", error);
return {
success: false,
error: "Fehler beim Laden der Kurse",
courses: [],
};
}
}
/**
* Löscht einen Kurs aus der Datenbank
*
* Diese Funktion löscht einen Kurs, überprüft aber vorher:
* - Ob der Benutzer ein Manager ist
* - Ob der Kurs existiert
* - Ob der Manager der Besitzer des Kurses ist (Manager können nur eigene Kurse löschen)
*
* @param courseId - Die eindeutige ID des zu löschenden Kurses
* @returns Ein Objekt mit success-Flag und Nachricht oder Fehler
*/
export async function deleteCourse(courseId: string) {
try {
// Schritt 1: Aktuellen Benutzer abrufen
const userData = await getUserData();
// Schritt 2: Überprüfen, ob Benutzer ein Manager ist
if (!userData || !isManager(userData)) {
return {
success: false,
error: "Nur Manager können Kurse löschen",
};
}
// Schritt 3: Kurs aus der Datenbank abrufen
const course = await prisma.course.findUnique({
where: { id: courseId },
});
// Schritt 4: Überprüfen, ob Kurs existiert
if (!course) {
return {
success: false,
error: "Kurs nicht gefunden",
};
}
// Schritt 5: Überprüfen, ob der Manager der Besitzer des Kurses ist
// (Sicherheit: Manager können nur ihre eigenen Kurse löschen)
if (course.createdBy !== userData.id) {
return {
success: false,
error: "Sie können nur Ihre eigenen Kurse löschen",
};
}
// Schritt 6: Kurs aus der Datenbank löschen
await prisma.course.delete({
where: { id: courseId },
});
// Schritt 7: Next.js Cache invalidieren, damit gelöschter Kurs nicht mehr angezeigt wird
revalidatePath("/courses");
return {
success: true,
message: "Kurs erfolgreich gelöscht",
};
} catch (error) {
console.error("Error deleting course:", error);
return {
success: false,
error: "Fehler beim Löschen des Kurses",
};
}
}

View file

@ -0,0 +1,89 @@
/**
* Course Schema - Validierungsschema für Kurs-Formulare
*
* Diese Datei definiert das Zod-Schema zur Validierung von Kursdaten.
* Es stellt sicher, dass alle erforderlichen Felder korrekt ausgefüllt werden
* und die Daten den definierten Regeln entsprechen.
*/
import { z } from "zod";
/**
* Haupt-Validierungsschema für Kurse
*
* Dieses Schema validiert:
* - Grundlegende Kursinformationen (Name, Sportart, Datum, Zeit)
* - Trainer und Raum-Zuweisungen
* - Teilnehmerbegrenzungen (1-100 Personen)
* - Dauerauftrag-Einstellungen mit Häufigkeit und Wochentagen
*/
export const courseSchema = z
.object({
// Kursname (Pflichtfeld, min. 1 Zeichen)
name: z.string().min(1, "Kursname ist erforderlich"),
// Sportart (Pflichtfeld, z.B. Fußball, Basketball)
sport: z.string().min(1, "Sportart ist erforderlich"),
// Kursdatum als String im Format YYYY-MM-DD
date: z.string().min(1, "Datum ist erforderlich"),
// Kurszeit als String im Format HH:MM
time: z.string().min(1, "Uhrzeit ist erforderlich"),
// Array von Trainer-IDs (mindestens ein Trainer erforderlich)
trainers: z.array(z.string()).min(1, "Mindestens ein Trainer muss ausgewählt werden"),
// Raum-ID für den Kurs (z.B. "Sporthalle 1")
room: z.string().min(1, "Raum ist erforderlich"),
// Rich-Text Beschreibung mit HTML-Inhalt
description: z.string().min(1, "Beschreibung ist erforderlich"),
// Maximale Anzahl der Teilnehmer (1-100)
maxParticipants: z
.number()
.min(1, "Mindestens 1 Teilnehmer erforderlich")
.max(100, "Maximal 100 Teilnehmer erlaubt"),
// Gibt an, ob der Kurs ein Dauerauftrag ist (regelmäßig wiederkehrend)
isStandingOrder: z.boolean(),
// Häufigkeit des Dauerauftrags (täglich, wöchentlich, etc.) - optional
frequency: z.string().optional(),
// Ausgewählte Wochentage für individuelle Häufigkeit - optional
weekdays: z.array(z.string()).optional(),
})
.refine(
(data) => {
// Validierungsregel: Wenn Dauerauftrag aktiviert ist, muss eine Häufigkeit ausgewählt werden
if (data.isStandingOrder && !data.frequency) {
return false;
}
return true;
},
{
message: "Häufigkeit ist erforderlich bei Daueraufträgen",
path: ["frequency"],
}
)
.refine(
(data) => {
// Validierungsregel: Bei individueller Häufigkeit müssen Wochentage ausgewählt werden
if (data.frequency === "custom" && (!data.weekdays || data.weekdays.length === 0)) {
return false;
}
return true;
},
{
message: "Mindestens ein Tag muss ausgewählt werden bei individueller Häufigkeit",
path: ["weekdays"],
}
);
/**
* TypeScript-Typ für Kursdaten
* Wird automatisch aus dem Zod-Schema generiert
*/
export type CourseFormData = z.infer<typeof courseSchema>;

View file

@ -0,0 +1,91 @@
/**
* CourseListItem - Kurs-Karten-Komponente
*
* Diese Komponente zeigt einen einzelnen Kurs als Karte an.
* Sie wird in der Kursliste verwendet und zeigt alle wichtigen Kursinformationen
* inklusive Teilnehmerzahl, Status und Bearbeitungsoption.
*/
import { Pen } from "lucide-react";
/**
* Props für die CourseListItem Komponente
*/
interface CourseListItemProps {
courseName: string; // Name des Kurses
room: string; // Raum, in dem der Kurs stattfindet
time: string; // Uhrzeit des Kurses (z.B. "14:00")
currentParticipants: number; // Aktuelle Anzahl der Teilnehmer
maxParticipants: number; // Maximale Anzahl der Teilnehmer
trainerName: string; // Name(n) des/der Trainer
onEdit?: () => void; // Callback-Funktion beim Klick auf Bearbeiten-Button
status?: string; // Status des Kurses ("draft" oder "published")
isPast?: boolean; // Ob der Kurs in der Vergangenheit liegt
}
/**
* Kurs-Karten-Komponente
* Zeigt Kursinformationen in einem visuell ansprechenden Karten-Layout
*/
const CourseListItem: React.FC<CourseListItemProps> = ({
courseName,
room,
time,
currentParticipants,
maxParticipants,
trainerName,
onEdit,
status,
isPast,
}) => {
// Prüfen, ob der Kurs voll belegt ist (für visuelle Hervorhebung)
const isFull = currentParticipants >= maxParticipants;
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 ${
isPast ? "opacity-60 bg-gray-50" : ""
}`}
>
{status && (
<span
className={`w-fit top-3 left-3 text-xs font-semibold px-2 py-1 rounded ${
status === "draft" ? "bg-yellow-100 text-yellow-700" : "bg-green-100 text-green-700"
}`}
>
{status === "draft" ? "Entwurf" : "Veröffentlicht"}
</span>
)}
<div>
<h2 className="text-xl font-semibold text-gray-900">{courseName}</h2>
<p className="text-gray-500">
{room} • {time}
</p>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Teilnehmer</span>
<span className={isFull ? "text-red-600 font-semibold" : "text-gray-800"}>
{currentParticipants}/{maxParticipants}
</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: `${(currentParticipants / maxParticipants) * 100}%` }}
/>
</div>
</div>
<div className="flex justify-between items-center pt-3 border-t border-gray-200">
<p className="text-gray-700">{trainerName}</p>
{onEdit && (
<button onClick={onEdit} className="p-2 rounded-md hover:bg-gray-100 transition">
<Pen size={20} className="text-gray-700" />
</button>
)}
</div>
</div>
);
};
export default CourseListItem;

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation";
import InputComponent from "@/src/components/layout/InputComponent"; import InputComponent from "@/src/components/layout/InputComponent";
import GenericDropdown from "@/src/components/layout/GenericDropdown"; import GenericDropdown from "@/src/components/layout/GenericDropdown";
import MultiSelectDropdown from "@/src/components/layout/MultiSelectDropdown"; import MultiSelectDropdown from "@/src/components/layout/MultiSelectDropdown";
@ -7,6 +8,8 @@ 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 } from "../../actions/course-actions";
import { toast } from "sonner";
// Hardcoded data // Hardcoded data
const SPORTS = [ const SPORTS = [
@ -14,7 +17,7 @@ const SPORTS = [
{ value: "basketball", label: "Basketball" }, { value: "basketball", label: "Basketball" },
{ value: "volleyball", label: "Volleyball" }, { value: "volleyball", label: "Volleyball" },
{ value: "tennis", label: "Tennis" }, { value: "tennis", label: "Tennis" },
{ value: "swimming", label: "Schwimmen" }, { value: "boxing", label: "Boxen" },
{ value: "yoga", label: "Yoga" }, { value: "yoga", label: "Yoga" },
]; ];
@ -52,13 +55,100 @@ const WEEKDAYS = [
]; ];
const CourseCreateView = () => { const CourseCreateView = () => {
const router = useRouter();
const [courseName, setCourseName] = useState("");
const [courseDate, setCourseDate] = useState("");
const [courseTime, setCourseTime] = useState("");
const [selectedSport, setSelectedSport] = useState(""); const [selectedSport, setSelectedSport] = useState("");
const [selectedTrainers, setSelectedTrainers] = useState<string[]>([]); const [selectedTrainers, setSelectedTrainers] = useState<string[]>([]);
const [selectedRoom, setSelectedRoom] = useState(""); const [selectedRoom, setSelectedRoom] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [maxParticipants, setMaxParticipants] = useState("");
const [isStandingOrder, setIsStandingOrder] = useState(false); const [isStandingOrder, setIsStandingOrder] = useState(false);
const [selectedFrequency, setSelectedFrequency] = useState(""); const [selectedFrequency, setSelectedFrequency] = useState("");
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>([]); const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>([]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!courseName.trim()) newErrors.name = "Kursname ist erforderlich";
if (!selectedSport) newErrors.sport = "Sportart ist erforderlich";
if (!courseDate) newErrors.date = "Datum ist erforderlich";
if (!courseTime) newErrors.time = "Uhrzeit ist erforderlich";
if (selectedTrainers.length === 0)
newErrors.trainers = "Mindestens ein Trainer muss ausgewählt werden";
if (!selectedRoom) newErrors.room = "Raum ist erforderlich";
if (!description.trim()) newErrors.description = "Beschreibung ist erforderlich";
const maxPart = parseInt(maxParticipants);
if (!maxParticipants || isNaN(maxPart) || maxPart < 1) {
newErrors.maxParticipants = "Mindestens 1 Teilnehmer erforderlich";
} else if (maxPart > 100) {
newErrors.maxParticipants = "Maximal 100 Teilnehmer erlaubt";
}
if (isStandingOrder) {
if (!selectedFrequency)
newErrors.frequency = "Häufigkeit ist erforderlich bei Daueraufträgen";
if (selectedFrequency === "custom" && selectedWeekdays.length === 0) {
newErrors.weekdays = "Mindestens ein Tag muss ausgewählt werden";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (status: "draft" | "published") => {
if (!validateForm()) {
toast.error("Bitte füllen Sie alle erforderlichen Felder aus");
return;
}
setIsSubmitting(true);
try {
const result = await createCourse(
{
name: courseName,
sport: selectedSport,
date: courseDate,
time: courseTime,
trainers: selectedTrainers,
room: selectedRoom,
description,
maxParticipants: parseInt(maxParticipants),
isStandingOrder,
frequency: selectedFrequency || undefined,
weekdays: selectedWeekdays.length > 0 ? selectedWeekdays : undefined,
},
status
);
if (result.success) {
toast.success(result.message);
router.push("/courses");
} else {
toast.error(result.error || "Ein Fehler ist aufgetreten");
if (result.fieldErrors) {
const fieldErrors: Record<string, string> = {};
Object.entries(result.fieldErrors).forEach(([key, value]) => {
if (Array.isArray(value) && value.length > 0) {
fieldErrors[key] = value[0];
}
});
setErrors(fieldErrors);
}
}
} catch (error) {
toast.error("Ein unerwarteter Fehler ist aufgetreten");
console.error(error);
} finally {
setIsSubmitting(false);
}
};
return ( return (
<div> <div>
@ -67,24 +157,56 @@ const CourseCreateView = () => {
Alle Kurse können nach dem Speichern angepasst oder gelöscht werden Alle Kurse können nach dem Speichern angepasst oder gelöscht werden
</p> </p>
<div className="mt-6 flex flex-col gap-4 max-w-xl"> <div className="mt-6 flex flex-col gap-4 max-w-xl">
<InputComponent isLabel label="Kursnamen" type="text" id="course-create-name" /> <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>
<GenericDropdown <GenericDropdown
label="Sportart" label="Sportart"
selected={selectedSport} selected={selectedSport}
onSelect={setSelectedSport} onSelect={setSelectedSport}
options={SPORTS} options={SPORTS}
error={errors.sport}
/> />
<InputComponent isLabel label="Datum" type="date" id="course-create-date" /> <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>
<InputComponent isLabel label="Uhrzeit" type="time" id="course-create-time" /> <div>
<InputComponent
isLabel
label="Uhrzeit"
type="time"
id="course-create-time"
value={courseTime}
onChange={(e) => setCourseTime(e.target.value)}
/>
{errors.time && <p className="text-red-500 text-sm mt-1">{errors.time}</p>}
</div>
<MultiSelectDropdown <MultiSelectDropdown
label="Trainer" label="Trainer"
selected={selectedTrainers} selected={selectedTrainers}
onSelect={setSelectedTrainers} onSelect={setSelectedTrainers}
options={TRAINERS} options={TRAINERS}
error={errors.trainers}
/> />
<GenericDropdown <GenericDropdown
@ -92,13 +214,29 @@ const CourseCreateView = () => {
selected={selectedRoom} selected={selectedRoom}
onSelect={setSelectedRoom} onSelect={setSelectedRoom}
options={ROOMS} options={ROOMS}
error={errors.room}
/> />
<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>
<RichTextEditor <RichTextEditor
label="Was man mitbringen sollte" label="Was man mitbringen sollte"
value={description} value={description}
onChange={setDescription} onChange={setDescription}
placeholder="Beschreibe, was die Teilnehmer mitbringen sollten..." placeholder="Beschreibe, was die Teilnehmer mitbringen sollten..."
error={errors.description}
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -131,6 +269,7 @@ const CourseCreateView = () => {
} }
}} }}
options={FREQUENCIES} options={FREQUENCIES}
error={errors.frequency}
/> />
{selectedFrequency === "custom" && ( {selectedFrequency === "custom" && (
@ -139,16 +278,26 @@ const CourseCreateView = () => {
selected={selectedWeekdays} selected={selectedWeekdays}
onSelect={setSelectedWeekdays} onSelect={setSelectedWeekdays}
options={WEEKDAYS} options={WEEKDAYS}
error={errors.weekdays}
/> />
)} )}
</> </>
)} )}
<div className="flex gap-2"> <div className="flex gap-2">
<Button>Veröffentlichen</Button> <Button onClick={() => handleSubmit("published")} disabled={isSubmitting}>
<Button variant="outline">Abbrechen</Button> {isSubmitting ? "Wird gespeichert..." : "Veröffentlichen"}
</Button>
<Button variant="outline" onClick={() => router.push("/courses")} disabled={isSubmitting}>
Abbrechen
</Button>
</div> </div>
<button className="mt-6 w-fit cursor-pointer hover:underline">Entwurf speichern</button> <button
<button className="mb-6 w-fit cursor-pointer hover:underline">Als Vorlage speichern</button> className="mt-6 w-fit cursor-pointer hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => handleSubmit("draft")}
disabled={isSubmitting}
>
Entwurf speichern
</button>
</div> </div>
</div> </div>
); );

View file

@ -0,0 +1,330 @@
"use client";
import { useEffect, useState } from "react";
import { getMyCourses, deleteCourse } from "../../actions/course-actions";
import { Button } from "@/src/components/ui/button";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import CourseListItem from "../components/CourseListItem";
import { Filter } from "lucide-react";
type Course = {
id: string;
name: string;
sport: string;
date: Date;
time: string;
trainers: string[];
room: string;
description: string;
maxParticipants: number;
isStandingOrder: boolean;
frequency: string | null;
weekdays: string[];
status: string;
createdBy: string;
createdAt: Date;
updatedAt: Date;
};
const CourseListView = () => {
const router = useRouter();
const [courses, setCourses] = useState<Course[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filterStatus, setFilterStatus] = useState<"all" | "draft" | "published">("all");
const [filterSport, setFilterSport] = useState<string>("all");
const [showFilters, setShowFilters] = useState(false);
const [showPastCourses, setShowPastCourses] = useState(false);
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const loadCourses = async () => {
const result = await getMyCourses();
if (result.success) {
setCourses(result.courses);
} else {
toast.error(result.error || "Fehler beim Laden der Kurse");
}
};
useEffect(() => {
const fetchCourses = async () => {
setIsLoading(true);
await loadCourses();
setIsLoading(false);
};
fetchCourses();
}, []);
const handleDelete = async (courseId: string, courseName: string) => {
if (!confirm(`Möchten Sie den Kurs "${courseName}" wirklich löschen?`)) {
return;
}
const result = await deleteCourse(courseId);
if (result.success) {
toast.success(result.message);
loadCourses();
} else {
toast.error(result.error || "Fehler beim Löschen");
}
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
};
const formatDateKey = (date: Date) => {
return new Date(date).toLocaleDateString("de-DE");
};
// Helper to check if a course is in the past
const isPastCourse = (courseDate: Date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const course = new Date(courseDate);
course.setHours(0, 0, 0, 0);
return course < today;
};
// Filter courses
const filteredCourses = courses.filter((course) => {
if (filterStatus !== "all" && course.status !== filterStatus) return false;
if (filterSport !== "all" && course.sport !== filterSport) return false;
// Hide past courses unless showPastCourses is enabled
if (!showPastCourses && isPastCourse(course.date)) return false;
// Date range filter
if (dateFrom) {
const fromDate = new Date(dateFrom);
fromDate.setHours(0, 0, 0, 0);
const courseDate = new Date(course.date);
courseDate.setHours(0, 0, 0, 0);
if (courseDate < fromDate) return false;
}
if (dateTo) {
const toDate = new Date(dateTo);
toDate.setHours(0, 0, 0, 0);
const courseDate = new Date(course.date);
courseDate.setHours(0, 0, 0, 0);
if (courseDate > toDate) return false;
}
return true;
});
// Group courses by date
const groupedCourses = filteredCourses.reduce(
(acc, course) => {
const dateKey = formatDateKey(course.date);
if (!acc[dateKey]) {
acc[dateKey] = [];
}
acc[dateKey].push(course);
return acc;
},
{} as Record<string, Course[]>
);
// Sort dates
const sortedDates = Object.keys(groupedCourses).sort((a, b) => {
return (
new Date(a.split(".").reverse().join("-")).getTime() -
new Date(b.split(".").reverse().join("-")).getTime()
);
});
// Get unique sports for filter
const uniqueSports = Array.from(new Set(courses.map((c) => c.sport)));
// Get first trainer name (for display)
const getTrainerName = (trainers: string[]) => {
if (trainers.length === 0) return "Kein Trainer";
// For now, using trainer ID as name - in real app, you'd fetch trainer names
return trainers.length > 1 ? `${trainers[0]} +${trainers.length - 1}` : trainers[0];
};
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<p className="text-xl">Kurse werden geladen...</p>
</div>
);
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<div className="flex w-full justify-end mr-2">
<Button
variant="outline"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
>
<Filter size={18} />
Filter
</Button>
</div>
</div>
{showFilters && (
<div className="mb-6 p-6 bg-white border border-gray-200 rounded-xl shadow-sm">
<h2 className="text-lg font-semibold mb-4 text-gray-900">Filter</h2>
{/* Filter Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Status Filter */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Status</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as "all" | "draft" | "published")}
className="h-10 px-3 rounded-lg border border-gray-300 bg-gray-50 focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-300 transition"
>
<option value="all">Alle</option>
<option value="draft">Entwurf</option>
<option value="published">Veröffentlicht</option>
</select>
</div>
{/* Sportart Filter */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Sportart</label>
<select
value={filterSport}
onChange={(e) => setFilterSport(e.target.value)}
className="h-10 px-3 rounded-lg border border-gray-300 bg-gray-50 focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-300 transition"
>
<option value="all">Alle</option>
{uniqueSports.map((sport) => (
<option key={sport} value={sport}>
{sport}
</option>
))}
</select>
</div>
{/* Date From */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Von Datum</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="h-10 px-3 rounded-lg border border-gray-300 bg-gray-50 focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-300 transition"
/>
</div>
{/* Date To */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Bis Datum</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="h-10 px-3 rounded-lg border border-gray-300 bg-gray-50 focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-300 transition"
/>
</div>
{/* Show Past Courses Toggle */}
<div className="flex flex-col justify-end">
<label className="text-sm font-medium text-gray-700 mb-1">Vergangene Kurse</label>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={showPastCourses}
onChange={(e) => setShowPastCourses(e.target.checked)}
className="w-4 h-4 rounded cursor-pointer accent-blue-500"
/>
<span className="text-sm text-gray-600">anzeigen</span>
</div>
</div>
</div>
{/* Reset Button */}
{(filterStatus !== "all" ||
filterSport !== "all" ||
dateFrom ||
dateTo ||
showPastCourses) && (
<div className="mt-6">
<Button
variant="outline"
size="sm"
onClick={() => {
setFilterStatus("all");
setFilterSport("all");
setDateFrom("");
setDateTo("");
setShowPastCourses(false);
}}
className="border-gray-300 text-gray-700 hover:bg-gray-100 transition"
>
Filter zurücksetzen
</Button>
</div>
)}
</div>
)}
{filteredCourses.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-lg">
<p className="text-xl text-gray-600">
{courses.length === 0
? "Sie haben noch keine Kurse erstellt"
: "Keine Kurse gefunden mit den ausgewählten Filtern"}
</p>
<p className="text-gray-500 mt-2">
{courses.length === 0
? "Erstellen Sie Ihren ersten Kurs!"
: "Ändern Sie die Filter oder setzen Sie sie zurück"}
</p>
{courses.length === 0 && (
<Button onClick={() => router.push("/courses/create")} className="mt-4">
Kurs anlegen
</Button>
)}
</div>
) : (
<div className="space-y-8">
{sortedDates.map((dateKey) => {
const coursesForDate = groupedCourses[dateKey];
const firstCourse = coursesForDate[0];
return (
<div key={dateKey}>
<h2 className="text-2xl font-bold text-gray-800 mb-4">
{formatDate(firstCourse.date)}
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{coursesForDate.map((course) => (
<CourseListItem
key={course.id}
courseName={course.name}
room={course.room}
time={course.time}
currentParticipants={0} // TODO: Implement participant tracking
maxParticipants={course.maxParticipants}
trainerName={getTrainerName(course.trainers)}
status={course.status}
isPast={isPastCourse(course.date)}
onEdit={() => router.push(`/courses/edit/${course.id}`)}
/>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
};
export default CourseListView;

View file

@ -2,15 +2,16 @@
import { Button } from "@/src/components/ui/button"; import { Button } from "@/src/components/ui/button";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import CourseListView from "./course-list-view";
const CourseView = () => { const CourseView = () => {
const router = useRouter(); const router = useRouter();
return ( return (
<div> <div>
<div className="w-full flex items-center justify-between p-2"> <div className="w-full flex flex-col items-left p-2 md:flex-row md:justify-between md:items-center">
<h1 className="text-4xl font-bold">Kurse</h1> <h1 className="text-4xl font-bold">Kurse</h1>
<div className="flex gap-4"> <div className="flex flex-col gap-4 mt-2 md:flex-row md:mt-0">
<Button <Button
onClick={() => router.push("/courses/create")} onClick={() => router.push("/courses/create")}
className="text-sm tracking-wide font-normal" className="text-sm tracking-wide font-normal"
@ -22,6 +23,7 @@ const CourseView = () => {
</Button> </Button>
</div> </div>
</div> </div>
<CourseListView />
</div> </div>
); );
}; };