mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
added course creation with backend and list view
This commit is contained in:
parent
f842264557
commit
08f0d75dee
10 changed files with 992 additions and 66 deletions
106
package-lock.json
generated
106
package-lock.json
generated
|
|
@ -25,7 +25,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "16.0.1",
|
||||
"next": "^16.0.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"prisma": "^6.19.0",
|
||||
"react": "19.2.0",
|
||||
|
|
@ -1767,9 +1767,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.1.tgz",
|
||||
"integrity": "sha512-LFvlK0TG2L3fEOX77OC35KowL8D7DlFF45C0OvKMC4hy8c/md1RC4UMNDlUGJqfCoCS2VWrZ4dSE6OjaX5+8mw==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
|
||||
"integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
|
|
@ -1783,9 +1783,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.1.tgz",
|
||||
"integrity": "sha512-R0YxRp6/4W7yG1nKbfu41bp3d96a0EalonQXiMe+1H9GTHfKxGNCGFNWUho18avRBPsO8T3RmdWuzmfurlQPbg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
|
||||
"integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1799,9 +1799,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.1.tgz",
|
||||
"integrity": "sha512-kETZBocRux3xITiZtOtVoVvXyQLB7VBxN7L6EPqgI5paZiUlnsgYv4q8diTNYeHmF9EiehydOBo20lTttCbHAg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
|
||||
"integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1815,9 +1815,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.1.tgz",
|
||||
"integrity": "sha512-hWg3BtsxQuSKhfe0LunJoqxjO4NEpBmKkE+P2Sroos7yB//OOX3jD5ISP2wv8QdUwtRehMdwYz6VB50mY6hqAg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
|
||||
"integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1831,9 +1831,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.1.tgz",
|
||||
"integrity": "sha512-UPnOvYg+fjAhP3b1iQStcYPWeBFRLrugEyK/lDKGk7kLNua8t5/DvDbAEFotfV1YfcOY6bru76qN9qnjLoyHCQ==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
|
||||
"integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1847,9 +1847,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.1.tgz",
|
||||
"integrity": "sha512-Et81SdWkcRqAJziIgFtsFyJizHoWne4fzJkvjd6V4wEkWTB4MX6J0uByUb0peiJQ4WeAt6GGmMszE5KrXK6WKg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
|
||||
"integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1863,9 +1863,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.1.tgz",
|
||||
"integrity": "sha512-qBbgYEBRrC1egcG03FZaVfVxrJm8wBl7vr8UFKplnxNRprctdP26xEv9nJ07Ggq4y1adwa0nz2mz83CELY7N6Q==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
|
||||
"integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1879,9 +1879,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.1.tgz",
|
||||
"integrity": "sha512-cPuBjYP6I699/RdbHJonb3BiRNEDm5CKEBuJ6SD8k3oLam2fDRMKAvmrli4QMDgT2ixyRJ0+DTkiODbIQhRkeQ==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
|
||||
"integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1894,6 +1894,22 @@
|
|||
"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": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
|
@ -8989,12 +9005,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.1.tgz",
|
||||
"integrity": "sha512-e9RLSssZwd35p7/vOa+hoDFggUZIUbZhIUSLZuETCwrCVvxOs87NamoUzT+vbcNAL8Ld9GobBnWOA6SbV/arOw==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
|
||||
"integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.0.1",
|
||||
"@next/env": "16.0.7",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
|
|
@ -9007,14 +9023,14 @@
|
|||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.0.1",
|
||||
"@next/swc-darwin-x64": "16.0.1",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.1",
|
||||
"@next/swc-linux-arm64-musl": "16.0.1",
|
||||
"@next/swc-linux-x64-gnu": "16.0.1",
|
||||
"@next/swc-linux-x64-musl": "16.0.1",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.1",
|
||||
"@next/swc-win32-x64-msvc": "16.0.1",
|
||||
"@next/swc-darwin-arm64": "16.0.7",
|
||||
"@next/swc-darwin-x64": "16.0.7",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.7",
|
||||
"@next/swc-linux-arm64-musl": "16.0.7",
|
||||
"@next/swc-linux-x64-gnu": "16.0.7",
|
||||
"@next/swc-linux-x64-musl": "16.0.7",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.7",
|
||||
"@next/swc-win32-x64-msvc": "16.0.7",
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
@ -9050,22 +9066,6 @@
|
|||
"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": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "16.0.1",
|
||||
"next": "^16.0.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"prisma": "^6.19.0",
|
||||
"react": "19.2.0",
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ model Business {
|
|||
managerId String
|
||||
name String
|
||||
address String
|
||||
email String
|
||||
email String
|
||||
title String
|
||||
ustId String?
|
||||
banking String?
|
||||
|
|
@ -90,4 +90,23 @@ model Business {
|
|||
manager Manager @relation(fields: [managerId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
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
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
|||
export const prisma =
|
||||
globalForPrisma.prisma ||
|
||||
new PrismaClient({
|
||||
log: ["query", "error", "warn"],
|
||||
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
datasources: {
|
||||
db: {
|
||||
url: process.env.DATABASE_URL,
|
||||
|
|
|
|||
246
src/modules/courses/actions/course-actions.ts
Normal file
246
src/modules/courses/actions/course-actions.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
89
src/modules/courses/schemas/course-schema.ts
Normal file
89
src/modules/courses/schemas/course-schema.ts
Normal 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>;
|
||||
91
src/modules/courses/ui/components/CourseListItem.tsx
Normal file
91
src/modules/courses/ui/components/CourseListItem.tsx
Normal 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;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import InputComponent from "@/src/components/layout/InputComponent";
|
||||
import GenericDropdown from "@/src/components/layout/GenericDropdown";
|
||||
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 { Checkbox } from "@/src/components/ui/checkbox";
|
||||
import { Label } from "@/src/components/ui/label";
|
||||
import { createCourse } from "../../actions/course-actions";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Hardcoded data
|
||||
const SPORTS = [
|
||||
|
|
@ -14,7 +17,7 @@ const SPORTS = [
|
|||
{ value: "basketball", label: "Basketball" },
|
||||
{ value: "volleyball", label: "Volleyball" },
|
||||
{ value: "tennis", label: "Tennis" },
|
||||
{ value: "swimming", label: "Schwimmen" },
|
||||
{ value: "boxing", label: "Boxen" },
|
||||
{ value: "yoga", label: "Yoga" },
|
||||
];
|
||||
|
||||
|
|
@ -52,13 +55,100 @@ const WEEKDAYS = [
|
|||
];
|
||||
|
||||
const CourseCreateView = () => {
|
||||
const router = useRouter();
|
||||
const [courseName, setCourseName] = useState("");
|
||||
const [courseDate, setCourseDate] = useState("");
|
||||
const [courseTime, setCourseTime] = useState("");
|
||||
const [selectedSport, setSelectedSport] = useState("");
|
||||
const [selectedTrainers, setSelectedTrainers] = useState<string[]>([]);
|
||||
const [selectedRoom, setSelectedRoom] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [maxParticipants, setMaxParticipants] = useState("");
|
||||
const [isStandingOrder, setIsStandingOrder] = useState(false);
|
||||
const [selectedFrequency, setSelectedFrequency] = useState("");
|
||||
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 (
|
||||
<div>
|
||||
|
|
@ -67,24 +157,56 @@ const CourseCreateView = () => {
|
|||
Alle Kurse können nach dem Speichern angepasst oder gelöscht werden
|
||||
</p>
|
||||
<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
|
||||
label="Sportart"
|
||||
selected={selectedSport}
|
||||
onSelect={setSelectedSport}
|
||||
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
|
||||
label="Trainer"
|
||||
selected={selectedTrainers}
|
||||
onSelect={setSelectedTrainers}
|
||||
options={TRAINERS}
|
||||
error={errors.trainers}
|
||||
/>
|
||||
|
||||
<GenericDropdown
|
||||
|
|
@ -92,13 +214,29 @@ const CourseCreateView = () => {
|
|||
selected={selectedRoom}
|
||||
onSelect={setSelectedRoom}
|
||||
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
|
||||
label="Was man mitbringen sollte"
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Beschreibe, was die Teilnehmer mitbringen sollten..."
|
||||
error={errors.description}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -131,6 +269,7 @@ const CourseCreateView = () => {
|
|||
}
|
||||
}}
|
||||
options={FREQUENCIES}
|
||||
error={errors.frequency}
|
||||
/>
|
||||
|
||||
{selectedFrequency === "custom" && (
|
||||
|
|
@ -139,16 +278,26 @@ const CourseCreateView = () => {
|
|||
selected={selectedWeekdays}
|
||||
onSelect={setSelectedWeekdays}
|
||||
options={WEEKDAYS}
|
||||
error={errors.weekdays}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button>Veröffentlichen</Button>
|
||||
<Button variant="outline">Abbrechen</Button>
|
||||
<Button onClick={() => handleSubmit("published")} disabled={isSubmitting}>
|
||||
{isSubmitting ? "Wird gespeichert..." : "Veröffentlichen"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => router.push("/courses")} disabled={isSubmitting}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
<button className="mt-6 w-fit cursor-pointer hover:underline">Entwurf speichern</button>
|
||||
<button className="mb-6 w-fit cursor-pointer hover:underline">Als Vorlage speichern</button>
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
330
src/modules/courses/ui/views/course-list-view.tsx
Normal file
330
src/modules/courses/ui/views/course-list-view.tsx
Normal 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;
|
||||
|
|
@ -2,15 +2,16 @@
|
|||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import CourseListView from "./course-list-view";
|
||||
|
||||
const CourseView = () => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col gap-4 mt-2 md:flex-row md:mt-0">
|
||||
<Button
|
||||
onClick={() => router.push("/courses/create")}
|
||||
className="text-sm tracking-wide font-normal"
|
||||
|
|
@ -22,6 +23,7 @@ const CourseView = () => {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CourseListView />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue