changed useEffect Fetching to RSC + Streaming

This commit is contained in:
Rajpreet0 2026-06-24 21:21:02 +02:00
parent d8c02d398e
commit 9011c4699e
38 changed files with 661 additions and 619 deletions

262
package-lock.json generated
View file

@ -302,7 +302,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -312,7 +312,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -346,7 +346,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
@ -438,7 +438,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
@ -996,9 +996,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -1015,9 +1012,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -1034,9 +1028,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -1053,9 +1044,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -1072,9 +1060,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -1091,9 +1076,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -1110,9 +1092,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -1129,9 +1108,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -1148,9 +1124,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1173,9 +1146,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1198,9 +1168,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1223,9 +1190,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1248,9 +1212,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1273,9 +1234,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1298,9 +1256,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1323,9 +1278,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1653,9 +1605,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1672,9 +1621,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1691,9 +1637,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1710,9 +1653,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1911,18 +1851,6 @@
"url": "https://dotenvx.com"
}
},
"node_modules/@prisma/config/node_modules/magicast": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
"extraneous": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.4",
"@babel/types": "^7.25.4",
"source-map-js": "^1.2.0"
}
},
"node_modules/@prisma/debug": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
@ -3554,9 +3482,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3574,9 +3499,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -3594,9 +3516,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3614,9 +3533,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3634,9 +3550,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3654,9 +3567,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -3776,66 +3686,6 @@
"node": ">=12.16"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz",
"integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz",
"integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
"integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
"license": "MIT",
"peer": true
},
"node_modules/@supabase/postgrest-js": {
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz",
"integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz",
"integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@supabase/phoenix": "^0.4.2",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/ssr": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.7.0.tgz",
@ -3848,37 +3698,6 @@
"@supabase/supabase-js": "^2.43.4"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz",
"integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==",
"license": "MIT",
"peer": true,
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz",
"integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@supabase/auth-js": "2.108.2",
"@supabase/functions-js": "2.108.2",
"@supabase/postgrest-js": "2.108.2",
"@supabase/realtime-js": "2.108.2",
"@supabase/storage-js": "2.108.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -4030,9 +3849,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4050,9 +3866,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4070,9 +3883,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -4090,9 +3900,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -4827,7 +4634,7 @@
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@ -4847,6 +4654,7 @@
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -4856,6 +4664,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@ -5253,9 +5062,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5270,9 +5076,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -5287,9 +5090,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5304,9 +5104,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -5321,9 +5118,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5338,9 +5132,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5355,9 +5146,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -5372,9 +5160,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5389,9 +5174,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -5406,9 +5188,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -6550,6 +6329,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@ -8248,16 +8028,6 @@
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -9207,9 +8977,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -9231,9 +8998,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -9255,9 +9019,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -9279,9 +9040,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -12046,7 +11804,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -12103,7 +11861,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {

View file

@ -0,0 +1,32 @@
import { Skeleton } from "@/src/components/ui/skeleton";
export default function CoursesLoading() {
return (
<div className="space-y-6 pt-4">
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-6 flex items-center justify-between">
<div>
<Skeleton className="h-3 w-16 mb-2" />
<Skeleton className="h-7 w-28" />
</div>
<Skeleton className="h-9 w-32 rounded-xl" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-5 space-y-3"
>
<Skeleton className="h-32 w-full rounded-xl" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<div className="flex justify-between pt-1">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -1,5 +1,10 @@
import { Suspense } from "react";
import { requireManagerOrPermission, isManager, isEmployee } from "@/src/lib/auth/getUser";
import { getMyCourses } from "@/src/modules/courses/actions/course-actions";
import { getMyRooms } from "@/src/modules/rooms/actions/room-actions";
import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions";
import CourseView from "@/src/modules/courses/ui/views/course-view";
import CoursesLoading from "./loading";
const Course = async () => {
const userData = await requireManagerOrPermission((p) => p.courses.view);
@ -9,7 +14,50 @@ const Course = async () => {
isManager(userData) || (isEmployee(userData) && userData.permissions.courses.edit);
const canDelete =
isManager(userData) || (isEmployee(userData) && userData.permissions.courses.delete);
return <CourseView canCreate={canCreate} canEdit={canEdit} canDelete={canDelete} />;
const today = new Date();
today.setHours(0, 0, 0, 0);
const twoWeeksAgo = new Date(today);
twoWeeksAgo.setDate(today.getDate() - 14);
const sixWeeksLater = new Date(today);
sixWeeksLater.setDate(today.getDate() + 42);
const [coursesResult, roomsResult, trainersResult] = await Promise.all([
getMyCourses({ dateFrom: twoWeeksAgo, dateTo: sixWeeksLater }),
getMyRooms(),
getMyTrainers(),
]);
const initialCourses = coursesResult.success ? coursesResult.courses : [];
const roomsMap: Record<string, string> = {};
if (roomsResult.success) {
roomsResult.rooms.forEach((r) => {
roomsMap[r.id] = r.name;
});
}
const trainersMap: Record<string, { label: string; pbSrc?: string }> = {};
if (trainersResult.success) {
trainersResult.trainers.forEach((t) => {
trainersMap[t.value] = { label: t.label, pbSrc: t.pbSrc ?? undefined };
});
}
return (
<Suspense fallback={<CoursesLoading />}>
<CourseView
initialCourses={initialCourses}
initialDateFrom={twoWeeksAgo}
initialDateTo={sixWeeksLater}
roomsMap={roomsMap}
trainersMap={trainersMap}
canCreate={canCreate}
canEdit={canEdit}
canDelete={canDelete}
/>
</Suspense>
);
};
export default Course;

View file

@ -0,0 +1,28 @@
import { Skeleton } from "@/src/components/ui/skeleton";
export default function DashboardLoading() {
return (
<div className="space-y-6 pt-4">
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-8 relative overflow-hidden">
<Skeleton className="h-3 w-24 mb-3" />
<Skeleton className="h-10 w-72 mb-2" />
<Skeleton className="h-4 w-48" />
<div className="mt-4">
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6"
>
<Skeleton className="h-4 w-36 mb-2" />
<Skeleton className="h-3 w-52" />
</div>
))}
</div>
</div>
);
}

View file

@ -1,5 +1,14 @@
import { Suspense } from "react";
import { requireAuthWithData } from "@/src/lib/auth/getUser";
import DashboardView from "@/src/modules/dashboard/ui/views/dashboard-view";
import DashboardLoading from "./loading";
export default function DashboardPage() {
return <DashboardView />;
export default async function DashboardPage() {
const userData = await requireAuthWithData();
return (
<Suspense fallback={<DashboardLoading />}>
<DashboardView userData={userData} />
</Suspense>
);
}

View file

@ -0,0 +1,37 @@
import { Skeleton } from "@/src/components/ui/skeleton";
export default function EmployeeLoading() {
return (
<div className="space-y-6 pt-4">
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-6 flex items-center justify-between">
<div>
<Skeleton className="h-3 w-20 mb-2" />
<Skeleton className="h-7 w-40" />
</div>
<Skeleton className="h-9 w-36 rounded-xl" />
</div>
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm overflow-hidden">
<div className="p-4 border-b border-white/40">
<Skeleton className="h-9 w-64 rounded-xl" />
</div>
<div className="divide-y divide-white/30">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-4">
<Skeleton className="h-10 w-10 rounded-full shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-6 w-20 rounded-full" />
<div className="flex gap-2">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -1,5 +1,8 @@
import { Suspense } from "react";
import { requireManagerOrPermission, isManager, isEmployee } from "@/src/lib/auth/getUser";
import { getMyEmployees } from "@/src/modules/employee/actions/employee-actions";
import EmployeeView from "@/src/modules/employee/ui/views/employee-view";
import EmployeeLoading from "./loading";
const Employee = async () => {
const userData = await requireManagerOrPermission((p) => p.employees.view);
@ -9,7 +12,20 @@ const Employee = async () => {
isManager(userData) || (isEmployee(userData) && userData.permissions.employees.edit);
const canDelete =
isManager(userData) || (isEmployee(userData) && userData.permissions.employees.delete);
return <EmployeeView canCreate={canCreate} canEdit={canEdit} canDelete={canDelete} />;
const result = await getMyEmployees();
const employees = result.success ? result.employees : [];
return (
<Suspense fallback={<EmployeeLoading />}>
<EmployeeView
employees={employees}
canCreate={canCreate}
canEdit={canEdit}
canDelete={canDelete}
/>
</Suspense>
);
};
export default Employee;

View file

@ -0,0 +1,33 @@
import { Skeleton } from "@/src/components/ui/skeleton";
export default function KundenLoading() {
return (
<div className="space-y-6 pt-4">
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-6 flex items-center justify-between">
<div>
<Skeleton className="h-3 w-16 mb-2" />
<Skeleton className="h-7 w-28" />
</div>
<Skeleton className="h-9 w-32 rounded-xl" />
</div>
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm overflow-hidden">
<div className="p-4 border-b border-white/40">
<Skeleton className="h-9 w-64 rounded-xl" />
</div>
<div className="divide-y divide-white/30">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-4">
<Skeleton className="h-10 w-10 rounded-full shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-52" />
</div>
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -1,6 +1,8 @@
import { Suspense } from "react";
import { requireManager } from "@/src/lib/auth/getUser";
import { getKunden } from "@/src/modules/kunden/actions/kunden-actions";
import KundenView from "@/src/modules/kunden/ui/views/kunden-view";
import KundenLoading from "./loading";
const KundenPage = async () => {
await requireManager();
@ -9,7 +11,11 @@ const KundenPage = async () => {
throw new Error(result.error);
}
return <KundenView initialKunden={result.kunden} />;
return (
<Suspense fallback={<KundenLoading />}>
<KundenView initialKunden={result.kunden} />
</Suspense>
);
};
export default KundenPage;

View file

@ -0,0 +1,30 @@
import { Skeleton } from "@/src/components/ui/skeleton";
export default function MainLoading() {
return (
<div className="space-y-6 pt-4">
{/* Page header */}
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-8">
<Skeleton className="h-3 w-20 mb-3" />
<Skeleton className="h-9 w-64 mb-2" />
<Skeleton className="h-4 w-40" />
<div className="mt-4">
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</div>
{/* Content grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6"
>
<Skeleton className="h-4 w-32 mb-2" />
<Skeleton className="h-3 w-48" />
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,34 @@
import { Skeleton } from "@/src/components/ui/skeleton";
export default function RoomsLoading() {
return (
<div className="space-y-6 pt-4">
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-6 flex items-center justify-between">
<div>
<Skeleton className="h-3 w-16 mb-2" />
<Skeleton className="h-7 w-32" />
</div>
<Skeleton className="h-9 w-32 rounded-xl" />
</div>
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm overflow-hidden">
<div className="p-4 border-b border-white/40">
<Skeleton className="h-9 w-64 rounded-xl" />
</div>
<div className="divide-y divide-white/30">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-4">
<Skeleton className="h-10 w-10 rounded-xl shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</div>
<Skeleton className="h-7 w-16 rounded-full" />
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -1,5 +1,8 @@
import { Suspense } from "react";
import { requireManagerOrPermission, isManager, isEmployee } from "@/src/lib/auth/getUser";
import { getMyRooms } from "@/src/modules/rooms/actions/room-actions";
import RoomsView from "@/src/modules/rooms/ui/views/rooms-view";
import RoomsLoading from "./loading";
const Rooms = async () => {
const userData = await requireManagerOrPermission((p) => p.rooms.view);
@ -8,7 +11,15 @@ const Rooms = async () => {
const canEdit = isManager(userData) || (isEmployee(userData) && userData.permissions.rooms.edit);
const canDelete =
isManager(userData) || (isEmployee(userData) && userData.permissions.rooms.delete);
return <RoomsView canCreate={canCreate} canEdit={canEdit} canDelete={canDelete} />;
const result = await getMyRooms();
const rooms = result.success ? result.rooms : [];
return (
<Suspense fallback={<RoomsLoading />}>
<RoomsView rooms={rooms} canCreate={canCreate} canEdit={canEdit} canDelete={canDelete} />
</Suspense>
);
};
export default Rooms;

View file

@ -1,7 +1,13 @@
import { Suspense } from "react";
import { requireManager } from "@/src/lib/auth/getUser";
import SettingsBrandingPageView from "@/src/modules/settings/ui/views/settings-branding-page-view";
import SettingsLoading from "../loading";
export default async function SettingsBrandingPage() {
await requireManager();
return <SettingsBrandingPageView />;
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsBrandingPageView />
</Suspense>
);
}

View file

@ -1,7 +1,13 @@
import { Suspense } from "react";
import { requireManager } from "@/src/lib/auth/getUser";
import SettingsBusinessPageView from "@/src/modules/settings/ui/views/settings-business-page-view";
import SettingsLoading from "../loading";
export default async function SettingsBusinessPage() {
await requireManager();
return <SettingsBusinessPageView />;
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsBusinessPageView />
</Suspense>
);
}

View file

@ -1,7 +1,13 @@
import { Suspense } from "react";
import { requireManager } from "@/src/lib/auth/getUser";
import SettingsCancellationPageView from "@/src/modules/settings/ui/views/settings-cancellation-page-view";
import SettingsLoading from "../loading";
export default async function SettingsCancellationPage() {
await requireManager();
return <SettingsCancellationPageView />;
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsCancellationPageView />
</Suspense>
);
}

View file

@ -1,7 +1,13 @@
import { Suspense } from "react";
import { requireManager } from "@/src/lib/auth/getUser";
import SettingsDiscountsPageView from "@/src/modules/settings/ui/views/settings-discounts-page-view";
import SettingsLoading from "../loading";
export default async function SettingsDiscountPage() {
await requireManager();
return <SettingsDiscountsPageView />;
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsDiscountsPageView />
</Suspense>
);
}

View file

@ -0,0 +1,23 @@
import { Skeleton } from "@/src/components/ui/skeleton";
export default function SettingsLoading() {
return (
<div className="space-y-6 pt-4">
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-8">
<Skeleton className="h-3 w-20 mb-3" />
<Skeleton className="h-8 w-48 mb-2" />
<Skeleton className="h-4 w-64" />
</div>
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6 space-y-6">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-10 w-full rounded-xl" />
</div>
))}
<Skeleton className="h-10 w-32 rounded-xl mt-4" />
</div>
</div>
);
}

View file

@ -1,7 +1,13 @@
import { Suspense } from "react";
import { requireManager } from "@/src/lib/auth/getUser";
import SettingsNotificationPageView from "@/src/modules/settings/ui/views/settings-notification-page-view";
import SettingsLoading from "../loading";
export default async function SettingsNotificationPage() {
await requireManager();
return <SettingsNotificationPageView />;
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsNotificationPageView />
</Suspense>
);
}

View file

@ -1,5 +1,13 @@
import { Suspense } from "react";
import { requireAuthWithData } from "@/src/lib/auth/getUser";
import SettingsOverviewView from "@/src/modules/settings/ui/views/settings-overview-view";
import SettingsLoading from "./loading";
export default function SettingsPage() {
return <SettingsOverviewView />;
export default async function SettingsPage() {
await requireAuthWithData();
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsOverviewView />
</Suspense>
);
}

View file

@ -1,7 +1,17 @@
import { Suspense } from "react";
import { requireManager } from "@/src/lib/auth/getUser";
import { getBusinessesWithStripeStatus } from "@/src/modules/settings/actions/stripe-connect-actions";
import SettingsPaymentsView from "@/src/modules/settings/ui/views/settings-payments-view";
import SettingsLoading from "../loading";
export default async function SettingsPaymentsPage() {
await requireManager();
return <SettingsPaymentsView />;
const result = await getBusinessesWithStripeStatus();
const businesses = result.success ? result.businesses : [];
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsPaymentsView businesses={businesses} />
</Suspense>
);
}

View file

@ -1,5 +1,13 @@
import { Suspense } from "react";
import { requireAuthWithData } from "@/src/lib/auth/getUser";
import SettingsPersonalPageView from "@/src/modules/settings/ui/views/settings-personal-page-view";
import SettingsLoading from "../loading";
export default async function SettingsPersonalPage() {
return <SettingsPersonalPageView />;
await requireAuthWithData();
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsPersonalPageView />
</Suspense>
);
}

View file

@ -1,7 +1,13 @@
import { Suspense } from "react";
import { requireManager } from "@/src/lib/auth/getUser";
import SettingsPublicPageView from "@/src/modules/settings/ui/views/settings-public-page-view";
import SettingsLoading from "../loading";
export default async function SettingsPublicPage() {
await requireManager();
return <SettingsPublicPageView />;
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsPublicPageView />
</Suspense>
);
}

View file

@ -54,6 +54,9 @@ export async function middleware(request: NextRequest) {
}
);
// IMPORTANT: do not destroy supabaseResponse between createServerClient and supabase.auth.getUser()
// Any logic between these two calls that creates a new NextResponse will break session refresh.
const {
data: { user },
} = await supabase.auth.getUser();

View file

@ -4,7 +4,7 @@ import { deleteCourse } from "../actions/course-actions";
import { toast } from "sonner";
interface UseDeleteCourseOptions {
onSuccess?: (deletedId: string) => void;
onSuccess?: () => void;
}
export function useDeleteCourse({ onSuccess }: UseDeleteCourseOptions = {}) {
@ -27,9 +27,8 @@ export function useDeleteCourse({ onSuccess }: UseDeleteCourseOptions = {}) {
if (result.success) {
toast.success(result.message);
const deletedId = courseToDelete.id;
setCourseToDelete(null);
onSuccess?.(deletedId);
onSuccess?.();
} else {
setCourseToDelete(null);
toast.error(result.error || "Fehler beim Löschen des Kurses");

View file

@ -2,6 +2,7 @@
import { useState, useEffect, useSyncExternalStore } from "react";
import Image from "next/image";
import Link from "next/link";
import { Clock, MapPin, Users, Mail, Phone, DoorOpen, Info } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/src/components/ui/dialog";
@ -409,9 +410,9 @@ export const BookingDialog = ({
<div className="border-t border-gray-100 pt-3 flex flex-col gap-2">
<p className="text-xs text-gray-500">
Ohne Konto buchen – oder{" "}
<a href="/login" className="underline text-gray-700 hover:text-gray-900">
<Link href="/login" className="underline text-gray-700 hover:text-gray-900">
anmelden
</a>
</Link>
.
</p>
<input

View file

@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Skeleton } from "@/src/components/ui/skeleton";
import { getCourseById } from "../../actions/course-actions";
import { toast } from "sonner";
import CourseCreateView from "./course-create-view";
@ -40,8 +41,15 @@ const CourseEditView = ({ courseId, customSports }: CourseEditViewProps) => {
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<p className="text-xl">Kurs wird geladen...</p>
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-8 space-y-6 mt-4">
<Skeleton className="h-7 w-48" />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-10 w-full rounded-xl" />
</div>
))}
<Skeleton className="h-10 w-32 rounded-xl" />
</div>
);
}

View file

@ -1,9 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { Skeleton } from "@/src/components/ui/skeleton";
import { getMyCourses } from "../../actions/course-actions";
import { useDeleteCourse } from "../../hooks/useDeleteCourse";
import { getMyRooms } from "@/src/modules/rooms/actions/room-actions";
import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions";
import { Button } from "@/src/components/ui/button";
import { toast } from "sonner";
import { useRouter, useSearchParams } from "next/navigation";
@ -33,22 +33,38 @@ type Course = {
createdAt: Date;
updatedAt: Date;
coverImage: string | null;
_count?: {
bookings: number;
};
_count?: { bookings: number };
};
interface CourseListViewProps {
initialCourses: Course[];
initialDateFrom: Date;
initialDateTo: Date;
roomsMap: Record<string, string>;
trainersMap: Record<string, { label: string; pbSrc?: string }>;
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
}
const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps) => {
const CourseListView = ({
initialCourses,
initialDateFrom,
initialDateTo,
roomsMap,
trainersMap,
canCreate,
canEdit,
canDelete,
}: CourseListViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [courses, setCourses] = useState<Course[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [courses, setCourses] = useState<Course[]>(initialCourses);
const [isLoading, setIsLoading] = useState(false);
const [loadedDateFrom, setLoadedDateFrom] = useState<Date>(initialDateFrom);
const [loadedDateTo, setLoadedDateTo] = useState<Date>(initialDateTo);
const [filterStatus, setFilterStatus] = useState<"all" | "draft" | "published">("all");
const [filterSport, setFilterSport] = useState<string>("all");
const [filterTrainer, setFilterTrainer] = useState<string>(searchParams.get("trainer") ?? "all");
@ -61,10 +77,6 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
const [priceMin, setPriceMin] = useState<number | null>(null);
const [priceMax, setPriceMax] = useState<number | null>(null);
const [deleteMode, setDeleteMode] = useState(false);
const [roomsMap, setRoomsMap] = useState<Record<string, string>>({});
const [trainersMap, setTrainersMap] = useState<Record<string, { label: string; pbSrc?: string }>>(
{}
);
const {
deleteDialogOpen,
@ -75,47 +87,10 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
handleDeleteClick,
handleDeleteConfirm,
} = useDeleteCourse({
onSuccess: async () => {
if (loadedDateFrom && loadedDateTo) {
await loadCourses({ dateFrom: loadedDateFrom, dateTo: loadedDateTo });
}
},
onSuccess: () => router.refresh(),
});
const [loadedDateFrom, setLoadedDateFrom] = useState<Date | null>(null);
const [loadedDateTo, setLoadedDateTo] = useState<Date | null>(null);
// Load rooms once on mount
useEffect(() => {
const loadRooms = async () => {
const result = await getMyRooms();
if (result.success) {
const map: Record<string, string> = {};
result.rooms.forEach((room) => {
map[room.id] = room.name;
});
setRoomsMap(map);
}
};
loadRooms();
}, []);
// Load trainers once on mount
useEffect(() => {
const loadTrainers = async () => {
const result = await getMyTrainers();
if (result.success) {
const map: Record<string, { label: string; pbSrc?: string }> = {};
result.trainers.forEach((t) => {
map[t.value] = { label: t.label, pbSrc: t.pbSrc ?? undefined };
});
setTrainersMap(map);
}
};
loadTrainers();
}, []);
const loadCourses = async (options?: { dateFrom?: Date; dateTo?: Date }) => {
const loadCourses = async (options: { dateFrom: Date; dateTo: Date }) => {
const result = await getMyCourses(options);
if (result.success) {
setCourses(result.courses);
@ -124,45 +99,15 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
}
};
/**
* Initial load: Load courses from -2 weeks to +6 weeks from today
*/
// Extend loaded date range only when filter dates go outside the server-fetched range
useEffect(() => {
const fetchCourses = async () => {
setIsLoading(true);
// Calculate default date range: -2 weeks to +6 weeks from today
const today = new Date();
today.setHours(0, 0, 0, 0);
const twoWeeksAgo = new Date(today);
twoWeeksAgo.setDate(today.getDate() - 14); // -2 weeks
const sixWeeksLater = new Date(today);
sixWeeksLater.setDate(today.getDate() + 42); // +6 weeks
// Store the loaded date range
setLoadedDateFrom(twoWeeksAgo);
setLoadedDateTo(sixWeeksLater);
await loadCourses({ dateFrom: twoWeeksAgo, dateTo: sixWeeksLater });
setIsLoading(false);
};
fetchCourses();
}, []);
/**
* Extend loaded date range when filter dates are outside the current range
*/
useEffect(() => {
if (!loadedDateFrom || !loadedDateTo || isLoading) return;
if (isLoading) return;
const extendRange = async () => {
let needsReload = false;
let newDateFrom = loadedDateFrom;
let newDateTo = loadedDateTo;
// Check if dateFrom filter is before loaded range
if (dateFrom) {
const filterFromDate = new Date(dateFrom);
filterFromDate.setHours(0, 0, 0, 0);
@ -172,7 +117,6 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
}
}
// Check if dateTo filter is after loaded range
if (dateTo) {
const filterToDate = new Date(dateTo);
filterToDate.setHours(0, 0, 0, 0);
@ -192,22 +136,18 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
};
extendRange();
}, [dateFrom, dateTo, loadedDateFrom, loadedDateTo, isLoading]);
}, [dateFrom, dateTo]);
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("de-DE", {
const formatDate = (date: Date) =>
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");
};
const formatDateKey = (date: Date) => 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);
@ -216,22 +156,15 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
return course < today;
};
// Filter courses (client-side filtering for status, sport, etc.)
const filteredCourses = courses.filter((course) => {
if (filterStatus !== "all" && course.status !== filterStatus) return false;
if (filterSport !== "all" && !course.sport.includes(filterSport)) return false;
if (filterLevel !== "all" && course.level !== filterLevel) return false;
// Trainer filter
if (filterTrainer !== "all" && !course.trainers.includes(filterTrainer)) return false;
// Room filter (compare by room name, not ID)
if (filterRoom !== "all") {
const roomName = roomsMap[course.room] || course.room;
if (roomName !== filterRoom) return false;
}
// Date range filter (additional client-side filter on top of server-side)
if (dateFrom) {
const fromDate = new Date(dateFrom);
fromDate.setHours(0, 0, 0, 0);
@ -239,14 +172,12 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
courseDate.setHours(0, 0, 0, 0);
if (courseDate < fromDate) return false;
} else {
// If no dateFrom filter is set, only show courses from today onwards
const today = new Date();
today.setHours(0, 0, 0, 0);
const courseDate = new Date(course.date);
courseDate.setHours(0, 0, 0, 0);
if (courseDate < today) return false;
}
if (dateTo) {
const toDate = new Date(dateTo);
toDate.setHours(0, 0, 0, 0);
@ -254,66 +185,40 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
courseDate.setHours(0, 0, 0, 0);
if (courseDate > toDate) return false;
}
// Time range filter
if (timeFrom && course.timeFrom < timeFrom) return false;
if (timeTo && course.timeTo > timeTo) return false;
// Price range filter
if (priceMin !== null && course.price < priceMin) return false;
if (priceMax !== null && course.price > priceMax) return false;
return true;
});
// Group courses by date
const groupedCourses = filteredCourses.reduce(
(acc, course) => {
const dateKey = formatDateKey(course.date);
if (!acc[dateKey]) {
acc[dateKey] = [];
}
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 (
const sortedDates = Object.keys(groupedCourses).sort(
(a, b) =>
new Date(a.split(".").reverse().join("-")).getTime() -
new Date(b.split(".").reverse().join("-")).getTime()
);
});
);
// Calculate price range from courses
const priceRangeMin =
courses.length > 0 ? Math.floor(Math.min(...courses.map((c) => c.price))) : 0;
const priceRangeMax =
courses.length > 0 ? Math.ceil(Math.max(...courses.map((c) => c.price))) : 0;
// Get unique sports for filter
const uniqueSports = Array.from(new Set(courses.flatMap((c) => c.sport)));
// Get unique trainers for filter (flatten trainer UIDs, map to labels)
const uniqueTrainers = Array.from(new Set(courses.flatMap((c) => c.trainers))).map((uid) => ({
value: uid,
label: trainersMap[uid]?.label ?? uid,
}));
// Get unique rooms for filter (using room names from roomsMap)
const uniqueRooms = Array.from(new Set(courses.map((c) => roomsMap[c.room] || c.room)));
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<p className="text-xl">Kurse werden geladen...</p>
</div>
);
}
// Count active filters
const activeFiltersCount = [
filterStatus !== "all",
filterSport !== "all",
@ -328,6 +233,84 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
priceMax !== null && priceMax !== priceRangeMax,
].filter(Boolean).length;
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mt-6">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-5 space-y-3"
>
<Skeleton className="h-32 w-full rounded-xl" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<div className="flex justify-between pt-1">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</div>
))}
</div>
);
}
const filterProps = {
filterStatus,
filterSport,
filterTrainer,
filterRoom,
filterLevel,
dateFrom,
dateTo,
timeFrom,
timeTo,
priceMin: priceMin ?? priceRangeMin,
priceMax: priceMax ?? priceRangeMax,
priceRangeMin,
priceRangeMax,
uniqueSports,
uniqueTrainers,
uniqueRooms,
onApplyFilters: (filters: {
filterStatus: "all" | "draft" | "published";
filterSport: string;
filterTrainer: string;
filterRoom: string;
filterLevel: string;
dateFrom: string;
dateTo: string;
timeFrom: string;
timeTo: string;
priceMin: number;
priceMax: number;
}) => {
setFilterStatus(filters.filterStatus);
setFilterSport(filters.filterSport);
setFilterTrainer(filters.filterTrainer);
setFilterRoom(filters.filterRoom);
setFilterLevel(filters.filterLevel);
setDateFrom(filters.dateFrom);
setDateTo(filters.dateTo);
setTimeFrom(filters.timeFrom);
setTimeTo(filters.timeTo);
setPriceMin(filters.priceMin === priceRangeMin ? null : filters.priceMin);
setPriceMax(filters.priceMax === priceRangeMax ? null : filters.priceMax);
},
onReset: () => {
setFilterStatus("all");
setFilterSport("all");
setFilterTrainer("all");
setFilterRoom("all");
setFilterLevel("all");
setDateFrom("");
setDateTo("");
setTimeFrom("");
setTimeTo("");
setPriceMin(null);
setPriceMax(null);
},
};
return (
<div>
<div className="flex justify-between items-center mb-6">
@ -348,53 +331,8 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
</Button>
)}
</div>
{/* Mobile: Filter button below the two action buttons */}
<div className="sm:hidden">
<CourseFilter
filterStatus={filterStatus}
filterSport={filterSport}
filterTrainer={filterTrainer}
filterRoom={filterRoom}
filterLevel={filterLevel}
dateFrom={dateFrom}
dateTo={dateTo}
timeFrom={timeFrom}
timeTo={timeTo}
priceMin={priceMin ?? priceRangeMin}
priceMax={priceMax ?? priceRangeMax}
priceRangeMin={priceRangeMin}
priceRangeMax={priceRangeMax}
uniqueSports={uniqueSports}
uniqueTrainers={uniqueTrainers}
uniqueRooms={uniqueRooms}
onApplyFilters={(filters) => {
setFilterStatus(filters.filterStatus);
setFilterSport(filters.filterSport);
setFilterTrainer(filters.filterTrainer);
setFilterRoom(filters.filterRoom);
setFilterLevel(filters.filterLevel);
setDateFrom(filters.dateFrom);
setDateTo(filters.dateTo);
setTimeFrom(filters.timeFrom);
setTimeTo(filters.timeTo);
setPriceMin(filters.priceMin === priceRangeMin ? null : filters.priceMin);
setPriceMax(filters.priceMax === priceRangeMax ? null : filters.priceMax);
}}
onReset={() => {
setFilterStatus("all");
setFilterSport("all");
setFilterTrainer("all");
setFilterRoom("all");
setFilterLevel("all");
setDateFrom("");
setDateTo("");
setTimeFrom("");
setTimeTo("");
setPriceMin(null);
setPriceMax(null);
}}
>
<CourseFilter {...filterProps}>
<Button variant="outline" className="flex w-full items-center gap-2 relative">
<Filter size={18} />
Filter
@ -406,52 +344,7 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
</Button>
</CourseFilter>
</div>
{/* Desktop: Filter + Kurs löschen nebeneinander */}
<CourseFilter
filterStatus={filterStatus}
filterSport={filterSport}
filterTrainer={filterTrainer}
filterRoom={filterRoom}
filterLevel={filterLevel}
dateFrom={dateFrom}
dateTo={dateTo}
timeFrom={timeFrom}
timeTo={timeTo}
priceMin={priceMin ?? priceRangeMin}
priceMax={priceMax ?? priceRangeMax}
priceRangeMin={priceRangeMin}
priceRangeMax={priceRangeMax}
uniqueSports={uniqueSports}
uniqueTrainers={uniqueTrainers}
uniqueRooms={uniqueRooms}
onApplyFilters={(filters) => {
setFilterStatus(filters.filterStatus);
setFilterSport(filters.filterSport);
setFilterTrainer(filters.filterTrainer);
setFilterRoom(filters.filterRoom);
setFilterLevel(filters.filterLevel);
setDateFrom(filters.dateFrom);
setDateTo(filters.dateTo);
setTimeFrom(filters.timeFrom);
setTimeTo(filters.timeTo);
setPriceMin(filters.priceMin === priceRangeMin ? null : filters.priceMin);
setPriceMax(filters.priceMax === priceRangeMax ? null : filters.priceMax);
}}
onReset={() => {
setFilterStatus("all");
setFilterSport("all");
setFilterTrainer("all");
setFilterRoom("all");
setFilterLevel("all");
setDateFrom("");
setDateTo("");
setTimeFrom("");
setTimeTo("");
setPriceMin(null);
setPriceMax(null);
}}
>
<CourseFilter {...filterProps}>
<Button variant="outline" className="hidden sm:flex items-center gap-2 relative">
<Filter size={18} />
Filter
@ -495,7 +388,6 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
{sortedDates.map((dateKey) => {
const coursesForDate = groupedCourses[dateKey];
const firstCourse = coursesForDate[0];
return (
<div key={dateKey}>
<div className="flex items-center gap-3 mb-4">

View file

@ -5,13 +5,51 @@ import { Button } from "@/src/components/ui/button";
import { useRouter } from "next/navigation";
import CourseListView from "./course-list-view";
type Course = {
id: string;
name: string;
sport: string[];
level: string;
date: Date;
timeFrom: string;
timeTo: string;
trainers: string[];
room: string;
description: string;
maxParticipants: number;
price: number;
isStandingOrder: boolean;
frequency: string | null;
weekdays: string[];
status: string;
createdBy: string;
createdAt: Date;
updatedAt: Date;
coverImage: string | null;
_count?: { bookings: number };
};
interface CourseViewProps {
initialCourses: Course[];
initialDateFrom: Date;
initialDateTo: Date;
roomsMap: Record<string, string>;
trainersMap: Record<string, { label: string; pbSrc?: string }>;
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
}
const CourseView = ({ canCreate, canEdit, canDelete }: CourseViewProps) => {
const CourseView = ({
initialCourses,
initialDateFrom,
initialDateTo,
roomsMap,
trainersMap,
canCreate,
canEdit,
canDelete,
}: CourseViewProps) => {
const router = useRouter();
return (
@ -30,7 +68,16 @@ const CourseView = ({ canCreate, canEdit, canDelete }: CourseViewProps) => {
</div>
</div>
<Suspense>
<CourseListView canCreate={canCreate} canEdit={canEdit} canDelete={canDelete} />
<CourseListView
initialCourses={initialCourses}
initialDateFrom={initialDateFrom}
initialDateTo={initialDateTo}
roomsMap={roomsMap}
trainersMap={trainersMap}
canCreate={canCreate}
canEdit={canEdit}
canDelete={canDelete}
/>
</Suspense>
</div>
);

View file

@ -1,9 +1,11 @@
import { LogoutButton } from "@/src/components/layout/LogoutButton";
import { isManager, isEmployee, requireAuthWithData, isUser } from "@/src/lib/auth/getUser";
import { isManager, isEmployee, isUser, type AuthUserData } from "@/src/lib/auth/getUser";
const DashboardView = async () => {
const userData = await requireAuthWithData();
interface DashboardViewProps {
userData: AuthUserData;
}
const DashboardView = ({ userData }: DashboardViewProps) => {
const displayName = isManager(userData)
? `${userData.firstName} ${userData.lastName}`
: isEmployee(userData)

View file

@ -4,7 +4,7 @@ import { deleteEmployee, getActiveCoursesCountByTrainer } from "../actions/emplo
import { toast } from "sonner";
interface UseDeleteEmployeeOptions {
onSuccess?: (deletedId: string) => void;
onSuccess?: () => void;
}
export function useDeleteEmployee({ onSuccess }: UseDeleteEmployeeOptions = {}) {
@ -32,10 +32,9 @@ export function useDeleteEmployee({ onSuccess }: UseDeleteEmployeeOptions = {})
if (result.success) {
toast.success(result.message);
const deletedId = employeeToDelete.id;
setEmployeeToDelete(null);
setActiveCourseCount(0);
onSuccess?.(deletedId);
onSuccess?.();
} else {
setEmployeeToDelete(null);
setActiveCourseCount(0);

View file

@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Skeleton } from "@/src/components/ui/skeleton";
import { getEmployeeById } from "../../actions/employee-actions";
import { toast } from "sonner";
import EmployeeCreateView from "./employee-create-view";
@ -58,8 +59,15 @@ const EmployeeEditView = ({
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<p className="text-xl">Mitarbeiter wird geladen...</p>
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-8 space-y-6 mt-4">
<Skeleton className="h-7 w-56" />
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-10 w-full rounded-xl" />
</div>
))}
<Skeleton className="h-10 w-36 rounded-xl" />
</div>
);
}

View file

@ -1,7 +1,9 @@
"use client";
import { useState } from "react";
import { Button } from "@/src/components/ui/button";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { getMyEmployees, resendOnboardingEmail } from "../../actions/employee-actions";
import { resendOnboardingEmail } from "../../actions/employee-actions";
import EmployeeListItem from "../components/EmployeeListItem";
import { toast } from "sonner";
import DeleteDialog from "@/src/components/layout/DeleteDialog";
@ -23,15 +25,14 @@ type Employee = {
};
interface EmployeeListViewProps {
employees: Employee[];
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
}
const EmployeeListView = ({ canCreate, canEdit, canDelete }: EmployeeListViewProps) => {
const EmployeeListView = ({ employees, canCreate, canEdit, canDelete }: EmployeeListViewProps) => {
const router = useRouter();
const [employees, setEmployees] = useState<Employee[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [deleteMode, setDeleteMode] = useState(false);
const [resendingId, setResendingId] = useState<string | null>(null);
@ -45,24 +46,9 @@ const EmployeeListView = ({ canCreate, canEdit, canDelete }: EmployeeListViewPro
handleDeleteClick,
handleDeleteConfirm,
} = useDeleteEmployee({
onSuccess: (deletedId) => setEmployees((prev) => prev.filter((e) => e.id !== deletedId)),
onSuccess: () => router.refresh(),
});
useEffect(() => {
const loadEmployees = async () => {
setIsLoading(true);
const result = await getMyEmployees();
if (result.success) {
setEmployees(result.employees);
} else {
toast.error(result.error || "Fehler beim Laden der Mitarbeiter");
}
setIsLoading(false);
};
loadEmployees();
}, []);
const handleResendEmail = async (employeeId: string) => {
setResendingId(employeeId);
try {
@ -72,8 +58,7 @@ const EmployeeListView = ({ canCreate, canEdit, canDelete }: EmployeeListViewPro
} else {
toast.error(result.error || "Fehler beim Versenden der E-Mail");
}
} catch (error) {
console.error("Error resending onboarding email:", error);
} catch {
toast.error("Fehler beim Versenden der E-Mail");
} finally {
setResendingId(null);
@ -87,14 +72,6 @@ const EmployeeListView = ({ canCreate, canEdit, canDelete }: EmployeeListViewPro
return employee.email;
};
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<p className="text-xl">Mitarbeiter wird geladen</p>
</div>
);
}
return (
<div>
<div className="flex justify-between items-center mb-6">

View file

@ -4,13 +4,28 @@ import { Button } from "@/src/components/ui/button";
import { useRouter } from "next/navigation";
import EmployeeListView from "./employee-list-view";
type Employee = {
id: string;
firstName: string | null;
lastName: string | null;
email: string;
roles: string[];
locations: string[];
permissions: unknown;
status: string | null;
isOnboarded: boolean;
createdAt: Date | null;
updatedAt: Date | null;
};
interface EmployeeViewProps {
employees: Employee[];
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
}
const EmployeeView = ({ canCreate, canEdit, canDelete }: EmployeeViewProps) => {
const EmployeeView = ({ employees, canCreate, canEdit, canDelete }: EmployeeViewProps) => {
const router = useRouter();
return (
@ -28,7 +43,12 @@ const EmployeeView = ({ canCreate, canEdit, canDelete }: EmployeeViewProps) => {
)}
</div>
</div>
<EmployeeListView canCreate={canCreate} canEdit={canEdit} canDelete={canDelete} />
<EmployeeListView
employees={employees}
canCreate={canCreate}
canEdit={canEdit}
canDelete={canDelete}
/>
</div>
);
};

View file

@ -5,7 +5,7 @@ import { deleteRoom } from "../actions/room-actions";
import { toast } from "sonner";
interface UseDeleteRoomOptions {
onSuccess?: (deletedId: string) => void;
onSuccess?: () => void;
}
export function useDeleteRoom({ onSuccess }: UseDeleteRoomOptions = {}) {
@ -28,9 +28,8 @@ export function useDeleteRoom({ onSuccess }: UseDeleteRoomOptions = {}) {
if (result.success) {
toast.success(result.message);
setDeleteDialogOpen(false);
const deletedId = roomToDelete.id;
setRoomToDelete(null);
onSuccess?.(deletedId);
onSuccess?.();
} else if (result.hasActiveCourses) {
setDeleteDialogOpen(false);
setRoomToDelete(null);

View file

@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Skeleton } from "@/src/components/ui/skeleton";
import { getRoomById } from "../../actions/room-actions";
import { toast } from "sonner";
import RoomsCreateView from "./rooms-create-view";
@ -39,8 +40,13 @@ const RoomsEditView = ({ roomId }: RoomsEditViewProps) => {
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<p className="text-xl">Raum wird geladen...</p>
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-8 space-y-6 mt-4">
<Skeleton className="h-7 w-48" />
<div className="space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-10 w-full rounded-xl" />
</div>
<Skeleton className="h-10 w-32 rounded-xl" />
</div>
);
}

View file

@ -1,7 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import { getMyRooms } from "../../actions/room-actions";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import RoomsListItem from "../components/RoomsListItem";
import DeleteDialog from "@/src/components/layout/DeleteDialog";
@ -16,15 +14,14 @@ type Room = {
};
interface RoomsListViewProps {
rooms: Room[];
deleteMode: boolean;
canEdit: boolean;
canDelete: boolean;
}
const RoomsListView = ({ deleteMode, canEdit, canDelete }: RoomsListViewProps) => {
const RoomsListView = ({ rooms, deleteMode, canEdit, canDelete }: RoomsListViewProps) => {
const router = useRouter();
const [rooms, setRooms] = useState<Room[]>([]);
const [isLoading, setIsLoading] = useState(true);
const {
deleteDialogOpen,
@ -35,45 +32,15 @@ const RoomsListView = ({ deleteMode, canEdit, canDelete }: RoomsListViewProps) =
handleDeleteClick,
handleDeleteConfirm,
} = useDeleteRoom({
onSuccess: (deletedId) => setRooms((prev) => prev.filter((room) => room.id !== deletedId)),
onSuccess: () => router.refresh(),
});
// Load rooms
const loadRooms = async () => {
const result = await getMyRooms();
if (result.success) {
setRooms(result.rooms);
} else {
toast.error(result.error || "Fehler beim Laden der Räume");
}
};
// Initial load
useEffect(() => {
const fetchRooms = async () => {
setIsLoading(true);
await loadRooms();
setIsLoading(false);
};
fetchRooms();
}, []);
// Handle edit click
const handleEditClick = (id: string) => {
router.push(`/rooms/edit/${id}`);
};
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<p className="text-xl">Räume werden geladen...</p>
</div>
);
}
return (
<div className="mt-6">
{/* Rooms List */}
{rooms.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">Keine Räume gefunden</p>
@ -92,7 +59,6 @@ const RoomsListView = ({ deleteMode, canEdit, canDelete }: RoomsListViewProps) =
</div>
)}
{/* Delete Confirmation Dialog */}
<DeleteDialog
open={deleteDialogOpen}
onOpenChange={(open) => {

View file

@ -5,13 +5,22 @@ import { Button } from "@/src/components/ui/button";
import { useRouter } from "next/navigation";
import RoomsListView from "./rooms-list-view";
type Room = {
id: string;
name: string;
createdBy: string;
createdAt: Date;
updatedAt: Date;
};
interface RoomsViewProps {
rooms: Room[];
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
}
const RoomsView = ({ canCreate, canEdit, canDelete }: RoomsViewProps) => {
const RoomsView = ({ rooms, canCreate, canEdit, canDelete }: RoomsViewProps) => {
const router = useRouter();
const [deleteMode, setDeleteMode] = useState(false);
@ -39,7 +48,12 @@ const RoomsView = ({ canCreate, canEdit, canDelete }: RoomsViewProps) => {
)}
</div>
</div>
<RoomsListView deleteMode={deleteMode && canDelete} canEdit={canEdit} canDelete={canDelete} />
<RoomsListView
rooms={rooms}
deleteMode={deleteMode && canDelete}
canEdit={canEdit}
canDelete={canDelete}
/>
</div>
);
};

View file

@ -1,11 +1,10 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Button } from "@/src/components/ui/button";
import {
createConnectOnboardingLink,
createStripeExpressDashboardLink,
getBusinessesWithStripeStatus,
} from "@/src/modules/settings/actions/stripe-connect-actions";
import { toast } from "sonner";
import { CheckCircle2, AlertCircle, CreditCard, ExternalLink, Loader2 } from "lucide-react";
@ -17,29 +16,12 @@ type BusinessWithStatus = {
stripeAccountStatus: string | null;
};
const SettingsPaymentsView = () => {
const [businesses, setBusinesses] = useState<BusinessWithStatus[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [pendingId, setPendingId] = useState<string | null>(null);
interface SettingsPaymentsViewProps {
businesses: BusinessWithStatus[];
}
useEffect(() => {
const loadBusinesses = async () => {
setIsLoading(true);
try {
const result = await getBusinessesWithStripeStatus();
if (result.success) {
setBusinesses(result.businesses);
} else {
toast.error(result.error || "Fehler beim Laden");
}
} catch {
toast.error("Fehler beim Laden");
} finally {
setIsLoading(false);
}
};
void loadBusinesses();
}, []);
const SettingsPaymentsView = ({ businesses }: SettingsPaymentsViewProps) => {
const [pendingId, setPendingId] = useState<string | null>(null);
const handleConnect = async (businessId: string) => {
setPendingId(businessId);
@ -105,14 +87,6 @@ const SettingsPaymentsView = () => {
);
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-gray-400" />
</div>
);
}
return (
<div className="space-y-6">
<div>