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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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";
|
"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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
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 { 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue