diff --git a/src/app/(main)/kunden/[id]/page.tsx b/src/app/(main)/kunden/[id]/page.tsx index d488e26..aecbe12 100644 --- a/src/app/(main)/kunden/[id]/page.tsx +++ b/src/app/(main)/kunden/[id]/page.tsx @@ -1,6 +1,9 @@ import { requireManager } from "@/src/lib/auth/getUser"; +import { getKundeDetail } from "@/src/modules/kunden/actions/kunde-detail-actions"; import { ArrowLeft } from "lucide-react"; import Link from "next/link"; +import { notFound } from "next/navigation"; +import KundeDetailView from "@/src/modules/kunden/ui/views/kunde-detail-view"; interface Props { params: Promise<{ id: string }>; @@ -10,17 +13,22 @@ const KundeDetailPage = async ({ params }: Props) => { await requireManager(); const { id } = await params; + const result = await getKundeDetail(id); + + if (!result.success) { + notFound(); + } + return (
Zurück zur Übersicht -

Kundendetails

-

Kunde ID: {id}

+
); }; diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index fe4536f..4e39eac 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -187,7 +187,7 @@ function Sidebar({ data-sidebar="sidebar" data-slot="sidebar" data-mobile="true" - className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" + className="bg-white text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" style={ { "--sidebar-width": SIDEBAR_WIDTH_MOBILE, diff --git a/src/modules/courses/ui/components/PublicCourseCard.tsx b/src/modules/courses/ui/components/PublicCourseCard.tsx index 57bf38c..4c9aa90 100644 --- a/src/modules/courses/ui/components/PublicCourseCard.tsx +++ b/src/modules/courses/ui/components/PublicCourseCard.tsx @@ -39,20 +39,20 @@ const levelConfig: Record = pro: { label: "Profi", bg: "bg-red-200", text: "text-red-700" }, }; -interface TrainerProfile { +export interface TrainerProfile { id: string; firstName: string | null; lastName: string | null; pbSrc: string | null; } -interface BusinessInfo { +export interface BusinessInfo { address: string; email: string; tel?: string; } -interface PublicCourse { +export interface PublicCourse { id: string; name: string; sport: string[]; @@ -102,22 +102,34 @@ const TrainerAvatar = ({ trainer, size }: { trainer: TrainerProfile; size: "sm" ); }; -const BookingDialog = ({ +const ADMIN_PAYMENT_LABELS: Record = { + paid: { label: "Bezahlt", bg: "bg-green-100", text: "text-green-700" }, + pending: { label: "Ausstehend", bg: "bg-yellow-100", text: "text-yellow-700" }, + refunded: { label: "Storniert", bg: "bg-red-100", text: "text-red-600" }, + failed: { label: "Fehlgeschlagen", bg: "bg-red-100", text: "text-red-600" }, +}; + +export const BookingDialog = ({ course, business, open, onOpenChange, + // Wenn gesetzt → Admin-Ansicht: kein Buchen-Button, nur Status-Badge + paymentStatus, }: { course: PublicCourse; business?: BusinessInfo; open: boolean; onOpenChange: (v: boolean) => void; + paymentStatus?: string; }) => { const sanitizedDescription = useSanitizedHtml(course.description); const [isSubmitting, setIsSubmitting] = useState(false); const [isBooked, setIsBooked] = useState(false); const [isCheckingStatus, setIsCheckingStatus] = useState(true); + const isAdminView = paymentStatus !== undefined; + const safeCurrent = Math.max(0, course.currentParticipants); const safeMax = Math.max(0, course.maxParticipants); const isFull = safeCurrent >= safeMax; @@ -134,8 +146,9 @@ const BookingDialog = ({ currency: "EUR", }); + // Booking-Status nur laden wenn keine Admin-Ansicht useEffect(() => { - if (!open) return; + if (!open || isAdminView) return; let active = true; const loadStatus = async () => { @@ -156,7 +169,7 @@ const BookingDialog = ({ return () => { active = false; }; - }, [open, course.id]); + }, [open, course.id, isAdminView]); const handleBooking = async () => { setIsSubmitting(true); @@ -193,6 +206,10 @@ const BookingDialog = ({ } }; + const adminStatus = isAdminView + ? (ADMIN_PAYMENT_LABELS[paymentStatus!] ?? ADMIN_PAYMENT_LABELS.pending) + : null; + return ( @@ -315,33 +332,54 @@ const BookingDialog = ({ - {/* Sticky Footer: bleibt immer sichtbar, auf Mobile gestapelt */} + {/* Sticky Footer */}
Preis: {formattedPrice} -
- - {isCheckingStatus ? ( - - ) : isBooked ? ( - +
+ {/* Admin-Ansicht: nur Status-Badge + Schließen */} + {isAdminView ? ( + <> + + {adminStatus!.label} + + + ) : ( - + /* Public-Ansicht: Buchen-Button */ + <> + + {isCheckingStatus ? ( + + ) : isBooked ? ( + + ) : ( + + )} + )}
diff --git a/src/modules/kunden/actions/kunde-detail-actions.ts b/src/modules/kunden/actions/kunde-detail-actions.ts new file mode 100644 index 0000000..862a4cc --- /dev/null +++ b/src/modules/kunden/actions/kunde-detail-actions.ts @@ -0,0 +1,196 @@ +"use server"; + +import { prisma } from "@/src/lib/prisma"; +import { requireManager } from "@/src/lib/auth/getUser"; +import { unstable_cache } from "next/cache"; + +export type KundeBooking = { + bookingId: string; + courseId: string; + courseName: string; + date: string; // ISO + timeFrom: string; + timeTo: string; + roomName: string; + // Felder für den PublicCourseCard-Dialog + level: string; + price: number; + maxParticipants: number; + currentParticipants: number; + coverImage: string | null; + description: string; + trainerProfiles: { + id: string; + firstName: string | null; + lastName: string | null; + pbSrc: string | null; + }[]; + // Für die Card-Anzeige + trainers: string[]; + trainersMap: Record; + paymentStatus: string; + isPast: boolean; +}; + +export type KundeDetail = { + id: string; + name: string; + email: string; + telefon: string; + gender: string | null; + birthday: Date | null; + pbSrc: string | null; + createdAt: Date | null; + bookings: KundeBooking[]; +}; + +export async function getKundeDetail( + kundeId: string +): Promise<{ success: true; kunde: KundeDetail } | { success: false; error: string }> { + const manager = await requireManager(); + const businessIds = manager.businesses.map((b) => b.id); + + const fetchDetail = unstable_cache( + async () => { + // Alles parallel: User+Bookings und Räume des Managers + const [userWithBookings, allRooms] = await Promise.all([ + prisma.user.findUnique({ + where: { id: kundeId }, + select: { + id: true, + name: true, + email: true, + tel: true, + gender: true, + birthday: true, + pbSrc: true, + createdAt: true, + courseBookings: { + where: { + course: { businessId: { in: businessIds } }, + }, + select: { + id: true, + paymentStatus: true, + course: { + select: { + id: true, + name: true, + date: true, + timeFrom: true, + timeTo: true, + room: true, + trainers: true, + // Dialog-Felder + level: true, + price: true, + maxParticipants: true, + coverImage: true, + description: true, + // Anzahl aller Buchungen für diesen Kurs + _count: { select: { bookings: true } }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }, + }, + }), + prisma.room.findMany({ + where: { createdBy: manager.id }, + select: { id: true, name: true }, + }), + ]); + + if (!userWithBookings) return null; + + // Raum-ID → Name + const roomsMap: Record = {}; + for (const r of allRooms) roomsMap[r.id] = r.name; + + // Alle Trainer-IDs sammeln + const allTrainerIds = new Set(); + for (const b of userWithBookings.courseBookings) { + for (const tid of b.course.trainers) allTrainerIds.add(tid); + } + + // Trainer-Daten laden (ein Query für alle) + const trainersData = await prisma.employee.findMany({ + where: { id: { in: Array.from(allTrainerIds) } }, + select: { id: true, firstName: true, lastName: true, pbSrc: true }, + }); + + // trainersMap für die Card-Avatare (id → label+pbSrc) + const trainersMap: Record = {}; + for (const t of trainersData) { + const label = [t.firstName, t.lastName].filter(Boolean).join(" ") || "Trainer"; + trainersMap[t.id] = { label, pbSrc: t.pbSrc ?? undefined }; + } + + // trainerProfiles-Map für den Dialog (id → volles Profil) + const trainerProfilesMap: Record< + string, + { id: string; firstName: string | null; lastName: string | null; pbSrc: string | null } + > = {}; + for (const t of trainersData) { + trainerProfilesMap[t.id] = { + id: t.id, + firstName: t.firstName, + lastName: t.lastName, + pbSrc: t.pbSrc, + }; + } + + const now = new Date(); + + const bookings: KundeBooking[] = userWithBookings.courseBookings.map((b) => { + const courseDate = new Date(b.course.date); + return { + bookingId: b.id, + courseId: b.course.id, + courseName: b.course.name, + date: b.course.date.toISOString(), + timeFrom: b.course.timeFrom, + timeTo: b.course.timeTo, + roomName: roomsMap[b.course.room] ?? b.course.room, + level: b.course.level, + price: b.course.price, + maxParticipants: b.course.maxParticipants, + currentParticipants: b.course._count.bookings, + coverImage: b.course.coverImage ?? null, + description: b.course.description, + trainerProfiles: b.course.trainers.map((tid) => trainerProfilesMap[tid]).filter(Boolean), + trainers: b.course.trainers, + trainersMap, + paymentStatus: b.paymentStatus, + isPast: courseDate < now, + }; + }); + + return { + id: userWithBookings.id, + name: userWithBookings.name ?? userWithBookings.email, + email: userWithBookings.email, + telefon: userWithBookings.tel ?? "–", + gender: userWithBookings.gender, + birthday: userWithBookings.birthday, + pbSrc: userWithBookings.pbSrc, + createdAt: userWithBookings.createdAt, + bookings, + } satisfies KundeDetail; + }, + [`kunde-detail-${kundeId}-${manager.id}`], + { + tags: [`kunde-detail-${kundeId}`, `manager-${manager.id}`], + revalidate: 60, + } + ); + + try { + const kunde = await fetchDetail(); + if (!kunde) return { success: false, error: "Kunde nicht gefunden." }; + return { success: true, kunde }; + } catch { + return { success: false, error: "Kundendetails konnten nicht geladen werden." }; + } +} diff --git a/src/modules/kunden/ui/components/kunde-booking-card.tsx b/src/modules/kunden/ui/components/kunde-booking-card.tsx new file mode 100644 index 0000000..eb7d8d4 --- /dev/null +++ b/src/modules/kunden/ui/components/kunde-booking-card.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { MapPin, Clock } from "lucide-react"; +import type { KundeBooking } from "../../actions/kunde-detail-actions"; +import AbstractTooltip from "@/src/components/layout/AbstractTooltip"; +import { BookingDialog } from "@/src/modules/courses/ui/components/PublicCourseCard"; +import type { PublicCourse } from "@/src/modules/courses/ui/components/PublicCourseCard"; + +const getDiceBearUrl = (seed: string) => + `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(seed)}`; + +const PAYMENT_STATUS: Record = { + paid: { label: "Bezahlt", bg: "bg-green-100", text: "text-green-700" }, + pending: { label: "Ausstehend", bg: "bg-yellow-100", text: "text-yellow-700" }, + refunded: { label: "Storniert", bg: "bg-red-100", text: "text-red-600" }, + failed: { label: "Fehlgeschlagen", bg: "bg-red-100", text: "text-red-600" }, +}; + +/** KundeBooking → PublicCourse für den BookingDialog */ +function toPublicCourse(b: KundeBooking): PublicCourse { + return { + id: b.courseId, + name: b.courseName, + sport: [], + level: b.level, + date: new Date(b.date), + timeFrom: b.timeFrom, + timeTo: b.timeTo, + maxParticipants: b.maxParticipants, + price: b.price, + currentParticipants: b.currentParticipants, + description: b.description, + roomName: b.roomName, + coverImage: b.coverImage ?? undefined, + trainerProfiles: b.trainerProfiles, + }; +} + +interface KundeBookingCardProps { + booking: KundeBooking; +} + +const KundeBookingCard = ({ booking }: KundeBookingCardProps) => { + const [dialogOpen, setDialogOpen] = useState(false); + const status = PAYMENT_STATUS[booking.paymentStatus] ?? PAYMENT_STATUS.pending; + + const dateFormatted = new Date(booking.date).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + + return ( + <> +
setDialogOpen(true)} + className="bg-white/70 backdrop-blur-sm border border-white/50 rounded-xl p-4 shadow-sm hover:shadow-md hover:bg-white/85 transition-all cursor-pointer flex flex-col gap-3 select-none" + > + {/* Trainer Avatare + Uhrzeit */} +
+
+ {booking.trainers.length > 0 ? ( + booking.trainers.map((tid, idx) => { + const info = booking.trainersMap[tid]; + const name = info?.label ?? "Trainer"; + const src = info?.pbSrc ?? getDiceBearUrl(tid); + return ( + +
+ {name} +
+
+ ); + }) + ) : ( +
+ – +
+ )} +
+ + + {booking.timeFrom} – {booking.timeTo} + +
+ + {/* Kursname */} +

+ {booking.courseName} +

+ + {/* Datum + Raum */} +
+ {dateFormatted} + + + {booking.roomName} + +
+ + {/* Status Badge */} +
+ + {status.label} + +
+
+ + {/* Exakt derselbe Dialog wie auf der Public Page, aber in der Admin-Ansicht */} + + + ); +}; + +export default KundeBookingCard; diff --git a/src/modules/kunden/ui/views/kunde-detail-view.tsx b/src/modules/kunden/ui/views/kunde-detail-view.tsx new file mode 100644 index 0000000..a9bcd6d --- /dev/null +++ b/src/modules/kunden/ui/views/kunde-detail-view.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { + Phone, + Mail, + User, + Calendar, + Hash, + ChevronDown, + ChevronUp, + BookOpen, + History, + CreditCard, + Bell, + Scale, + CheckCircle2, +} from "lucide-react"; +import type { KundeDetail } from "../../actions/kunde-detail-actions"; +import KundeBookingCard from "../components/kunde-booking-card"; + +// ─── DiceBear Fallback ──────────────────────────────────────────────────────── +const getDiceBearUrl = (seed: string) => + `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(seed)}`; + +// ─── ID kürzen: erste 8 Zeichen + … ────────────────────────────────────────── +const shortenId = (id: string) => `${id.slice(0, 8)}…${id.slice(-4)}`; + +// ─── Kollapsible Sektion ────────────────────────────────────────────────────── +interface CollapsibleSectionProps { + icon: React.ReactNode; + title: string; + defaultOpen?: boolean; + children: React.ReactNode; + badge?: number | string; +} + +const CollapsibleSection = ({ + icon, + title, + defaultOpen = true, + children, + badge, +}: CollapsibleSectionProps) => { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+ + {open &&
{children}
} +
+ ); +}; + +// ─── Info-Zeile in der ID-Karte ─────────────────────────────────────────────── +interface InfoRowProps { + icon: React.ReactNode; + label: string; + value: React.ReactNode; +} + +const InfoRow = ({ icon, label, value }: InfoRowProps) => ( +
+ {icon} +
+

{label}

+

{value}

+
+
+); + +// ─── Haupt-View ─────────────────────────────────────────────────────────────── +interface KundeDetailViewProps { + kunde: KundeDetail; +} + +const KundeDetailView = ({ kunde }: KundeDetailViewProps) => { + const avatarSrc = kunde.pbSrc ?? getDiceBearUrl(kunde.id); + + const upcomingBookings = kunde.bookings.filter((b) => !b.isPast); + const pastBookings = kunde.bookings.filter((b) => b.isPast); + + const genderLabel: Record = { + male: "Männlich", + female: "Weiblich", + diverse: "Divers", + }; + + const birthdayFormatted = kunde.birthday + ? new Date(kunde.birthday).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }) + : "–"; + + const memberSinceFormatted = kunde.createdAt + ? new Date(kunde.createdAt).toLocaleDateString("de-DE", { + day: "2-digit", + month: "long", + year: "numeric", + }) + : "–"; + + return ( +
+ {/* Kundenname */} +

{kunde.name}

+ + {/* ── Glassmorphism ID-Karte ── */} +
+ {/* weißes Glas-Hintergrundpanel */} +
+ {/* subtiler weißer Glanz-Schein oben */} +
+ +
+ {/* Avatar – größer */} +
+ {kunde.name} +
+ + {/* Info-Grid */} +
+ } label="Telefon" value={kunde.telefon} /> + } label="E-Mail" value={kunde.email} /> + } + label="Geschlecht" + value={kunde.gender ? (genderLabel[kunde.gender] ?? kunde.gender) : "–"} + /> + } label="Geburtsdatum" value={birthdayFormatted} /> + {/* Kunden-ID über volle Breite */} +
+ + + +
+

Kunden-ID

+

+ {shortenId(kunde.id)} +

+
+
+
+ + {/* Mitglied-seit Chip – oben rechts auf Desktop */} +
+ + Mitglied seit {memberSinceFormatted} + +
+
+
+ + {/* ── Gebuchte Kurse (zukünftig) ── */} + } + title="Gebuchte Kurse" + badge={upcomingBookings.length} + defaultOpen={true} + > + {upcomingBookings.length === 0 ? ( +

Keine bevorstehenden Kursbuchungen.

+ ) : ( +
+ {upcomingBookings.map((b) => ( + + ))} +
+ )} +
+ + {/* ── Vergangene Kurse ── */} + } + title="Vergangene Kurse" + badge={pastBookings.length} + defaultOpen={false} + > + {pastBookings.length === 0 ? ( +

Keine vergangenen Kursbuchungen.

+ ) : ( +
+ {pastBookings.map((b) => ( + + ))} +
+ )} +
+ + {/* ── Abonnements & Credits (Dummy) ── */} + } + title="Abonnements & Credits" + defaultOpen={false} + > +
+ {[ + { label: "Aktive Abonnements", value: "1 Abo" }, + { label: "Übrige Buchungen", value: "4" }, + { label: "Credits", value: "0" }, + ].map((row) => ( +
+ {row.label} + {row.value} +
+ ))} +
+
+ + {/* ── Benachrichtigungen (Dummy) ── */} + } title="Benachrichtigungen" defaultOpen={false}> +
+ {[ + { label: "Newsletter", value: "Nein" }, + { label: "Marketing", value: "Nein" }, + { label: "Buchungsbestätigung", value: "Ja" }, + { label: "Warteliste", value: "Ja" }, + { label: "Erinnerungen", value: "Ja" }, + { label: "Kursbeginn", value: "Ja" }, + { label: "Kursänderungen", value: "Ja" }, + { label: "Abonnements-News", value: "Nein" }, + ].map((item) => ( +
+ {item.label} + + {item.value} + +
+ ))} +
+
+ + {/* ── Rechtliches (Dummy) ── */} + } title="Rechtliches" defaultOpen={false}> +
+ {/* AGB */} +
+
+ +
+

AGB

+

Zustimmung: Ja

+

Datum: 14.01.2025

+

Uhrzeit: 19:51

+

Version 1.0.0

+
+
+
+ + {/* Datenschutzerklärung */} +
+
+ +
+

Datenschutzerklärung

+

Zustimmung: Ja

+

Datum: 14.01.2025

+

Uhrzeit: 19:51

+ +
+
+
+
+
+
+ ); +}; + +export default KundeDetailView;