mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:41:16 +00:00
added detail customer page in admin dashbaord with customer data and customer bookings
This commit is contained in:
parent
9231d9651c
commit
63d2632154
6 changed files with 710 additions and 34 deletions
|
|
@ -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 (
|
||||
<div className="p-2">
|
||||
<Link
|
||||
href="/kunden"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-6"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Zurück zur Übersicht
|
||||
</Link>
|
||||
<h1 className="text-4xl font-bold mb-2">Kundendetails</h1>
|
||||
<p className="text-muted-foreground text-sm">Kunde ID: {id}</p>
|
||||
<KundeDetailView kunde={result.kunde} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -39,20 +39,20 @@ const levelConfig: Record<string, { label: string; bg: string; text: string }> =
|
|||
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<string, { label: string; bg: string; text: string }> = {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl! w-[95vw] max-w-[calc(100%-1rem)] p-0 overflow-hidden gap-0 max-h-[90vh] sm:max-h-[85vh] flex flex-col">
|
||||
|
|
@ -315,33 +332,54 @@ const BookingDialog = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky Footer: bleibt immer sichtbar, auf Mobile gestapelt */}
|
||||
{/* Sticky Footer */}
|
||||
<div className="shrink-0 border-t border-gray-200 bg-white px-4 sm:px-6 py-3 sm:py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<span className="text-lg font-bold text-gray-900">Preis: {formattedPrice}</span>
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
{isCheckingStatus ? (
|
||||
<Button disabled className="flex-1 sm:flex-initial">
|
||||
Lade…
|
||||
</Button>
|
||||
) : isBooked ? (
|
||||
<Button disabled className="flex-1 sm:flex-initial">
|
||||
✓ Angemeldet
|
||||
</Button>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
{/* Admin-Ansicht: nur Status-Badge + Schließen */}
|
||||
{isAdminView ? (
|
||||
<>
|
||||
<span
|
||||
className={`text-sm font-semibold px-3 py-1.5 rounded-full ${adminStatus!.bg} ${adminStatus!.text}`}
|
||||
>
|
||||
{adminStatus!.label}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleBooking}
|
||||
disabled={isSubmitting || isFull}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"}
|
||||
</Button>
|
||||
/* Public-Ansicht: Buchen-Button */
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
{isCheckingStatus ? (
|
||||
<Button disabled className="flex-1 sm:flex-initial">
|
||||
Lade…
|
||||
</Button>
|
||||
) : isBooked ? (
|
||||
<Button disabled className="flex-1 sm:flex-initial">
|
||||
✓ Angemeldet
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleBooking}
|
||||
disabled={isSubmitting || isFull}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
{isSubmitting ? "Wird gebucht…" : isFull ? "Ausgebucht" : "Buchen"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
196
src/modules/kunden/actions/kunde-detail-actions.ts
Normal file
196
src/modules/kunden/actions/kunde-detail-actions.ts
Normal file
|
|
@ -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<string, { label: string; pbSrc?: string }>;
|
||||
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<string, string> = {};
|
||||
for (const r of allRooms) roomsMap[r.id] = r.name;
|
||||
|
||||
// Alle Trainer-IDs sammeln
|
||||
const allTrainerIds = new Set<string>();
|
||||
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<string, { label: string; pbSrc?: string }> = {};
|
||||
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." };
|
||||
}
|
||||
}
|
||||
129
src/modules/kunden/ui/components/kunde-booking-card.tsx
Normal file
129
src/modules/kunden/ui/components/kunde-booking-card.tsx
Normal file
|
|
@ -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<string, { label: string; bg: string; text: string }> = {
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
onClick={() => 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 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{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 (
|
||||
<AbstractTooltip key={tid} tooltipText={name}>
|
||||
<div
|
||||
className={`relative w-8 h-8 rounded-full border-2 border-white overflow-hidden bg-gray-100 ${
|
||||
idx !== 0 ? "-ml-2" : ""
|
||||
}`}
|
||||
style={{ zIndex: booking.trainers.length - idx }}
|
||||
>
|
||||
<Image src={src} alt={name} fill className="object-cover" unoptimized />
|
||||
</div>
|
||||
</AbstractTooltip>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-xs text-gray-400">
|
||||
–
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
<Clock size={13} className="shrink-0" />
|
||||
{booking.timeFrom} – {booking.timeTo}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Kursname */}
|
||||
<h3 className="font-semibold text-gray-900 text-base leading-tight">
|
||||
{booking.courseName}
|
||||
</h3>
|
||||
|
||||
{/* Datum + Raum */}
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{dateFormatted}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin size={13} className="shrink-0" />
|
||||
{booking.roomName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div>
|
||||
<span
|
||||
className={`inline-block text-xs font-semibold px-2.5 py-1 rounded-full ${status.bg} ${status.text}`}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exakt derselbe Dialog wie auf der Public Page, aber in der Admin-Ansicht */}
|
||||
<BookingDialog
|
||||
course={toPublicCourse(booking)}
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
paymentStatus={booking.paymentStatus}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default KundeBookingCard;
|
||||
305
src/modules/kunden/ui/views/kunde-detail-view.tsx
Normal file
305
src/modules/kunden/ui/views/kunde-detail-view.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="rounded-2xl border border-white/60 bg-white/55 backdrop-blur-xl shadow-sm overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 hover:bg-white/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-blue">{icon}</span>
|
||||
<span className="font-semibold text-gray-800 text-base">{title}</span>
|
||||
{badge !== undefined && (
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-blue/10 text-blue">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{open ? (
|
||||
<ChevronUp size={18} className="text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown size={18} className="text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{open && <div className="px-5 pb-5 pt-1 border-t border-white/40">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Info-Zeile in der ID-Karte ───────────────────────────────────────────────
|
||||
interface InfoRowProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}
|
||||
|
||||
const InfoRow = ({ icon, label, value }: InfoRowProps) => (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="mt-0.5 text-blue shrink-0">{icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-400 leading-none mb-0.5">{label}</p>
|
||||
<p className="font-medium text-gray-800 text-sm truncate">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── 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<string, string> = {
|
||||
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 (
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
{/* Kundenname */}
|
||||
<h1 className="text-4xl font-bold">{kunde.name}</h1>
|
||||
|
||||
{/* ── Glassmorphism ID-Karte ── */}
|
||||
<div className="relative rounded-2xl overflow-hidden border border-white/60 shadow-lg w-full">
|
||||
{/* weißes Glas-Hintergrundpanel */}
|
||||
<div className="absolute inset-0 bg-white/40 backdrop-blur-2xl" />
|
||||
{/* subtiler weißer Glanz-Schein oben */}
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-white/80" />
|
||||
|
||||
<div className="relative p-6 flex flex-col sm:flex-row items-start gap-6">
|
||||
{/* Avatar – größer */}
|
||||
<div className="relative w-28 h-28 rounded-full overflow-hidden border-4 border-white/80 shadow-lg shrink-0 bg-white/50">
|
||||
<Image src={avatarSrc} alt={kunde.name} fill className="object-cover" unoptimized />
|
||||
</div>
|
||||
|
||||
{/* Info-Grid */}
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-x-10 gap-y-4">
|
||||
<InfoRow icon={<Phone size={15} />} label="Telefon" value={kunde.telefon} />
|
||||
<InfoRow icon={<Mail size={15} />} label="E-Mail" value={kunde.email} />
|
||||
<InfoRow
|
||||
icon={<User size={15} />}
|
||||
label="Geschlecht"
|
||||
value={kunde.gender ? (genderLabel[kunde.gender] ?? kunde.gender) : "–"}
|
||||
/>
|
||||
<InfoRow icon={<Calendar size={15} />} label="Geburtsdatum" value={birthdayFormatted} />
|
||||
{/* Kunden-ID über volle Breite */}
|
||||
<div className="sm:col-span-2 flex items-start gap-2.5">
|
||||
<span className="mt-0.5 text-blue shrink-0">
|
||||
<Hash size={15} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-400 leading-none mb-0.5">Kunden-ID</p>
|
||||
<p
|
||||
className="font-mono text-sm text-gray-700 truncate cursor-default"
|
||||
title={kunde.id}
|
||||
>
|
||||
{shortenId(kunde.id)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mitglied-seit Chip – oben rechts auf Desktop */}
|
||||
<div className="sm:absolute sm:top-4 sm:right-5 shrink-0">
|
||||
<span className="text-xs text-gray-500 bg-white/70 border border-white/60 px-3 py-1 rounded-full shadow-sm whitespace-nowrap">
|
||||
Mitglied seit {memberSinceFormatted}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Gebuchte Kurse (zukünftig) ── */}
|
||||
<CollapsibleSection
|
||||
icon={<BookOpen size={18} />}
|
||||
title="Gebuchte Kurse"
|
||||
badge={upcomingBookings.length}
|
||||
defaultOpen={true}
|
||||
>
|
||||
{upcomingBookings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-3">Keine bevorstehenden Kursbuchungen.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 pt-3">
|
||||
{upcomingBookings.map((b) => (
|
||||
<KundeBookingCard key={b.bookingId} booking={b} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* ── Vergangene Kurse ── */}
|
||||
<CollapsibleSection
|
||||
icon={<History size={18} />}
|
||||
title="Vergangene Kurse"
|
||||
badge={pastBookings.length}
|
||||
defaultOpen={false}
|
||||
>
|
||||
{pastBookings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-3">Keine vergangenen Kursbuchungen.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 pt-3">
|
||||
{pastBookings.map((b) => (
|
||||
<KundeBookingCard key={b.bookingId} booking={b} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* ── Abonnements & Credits (Dummy) ── */}
|
||||
<CollapsibleSection
|
||||
icon={<CreditCard size={18} />}
|
||||
title="Abonnements & Credits"
|
||||
defaultOpen={false}
|
||||
>
|
||||
<div className="pt-3 flex flex-col gap-2.5">
|
||||
{[
|
||||
{ label: "Aktive Abonnements", value: "1 Abo" },
|
||||
{ label: "Übrige Buchungen", value: "4" },
|
||||
{ label: "Credits", value: "0" },
|
||||
].map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className="flex items-center justify-between rounded-xl bg-white/60 border border-white/50 px-4 py-3"
|
||||
>
|
||||
<span className="text-sm text-gray-600">{row.label}</span>
|
||||
<span className="font-semibold text-gray-900 text-sm">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* ── Benachrichtigungen (Dummy) ── */}
|
||||
<CollapsibleSection icon={<Bell size={18} />} title="Benachrichtigungen" defaultOpen={false}>
|
||||
<div className="pt-3 grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
{[
|
||||
{ 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) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex items-center justify-between rounded-xl bg-white/60 border border-white/50 px-4 py-3"
|
||||
>
|
||||
<span className="text-sm text-gray-600">{item.label}</span>
|
||||
<span
|
||||
className={`text-sm font-semibold ${
|
||||
item.value === "Ja" ? "text-green-600" : "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* ── Rechtliches (Dummy) ── */}
|
||||
<CollapsibleSection icon={<Scale size={18} />} title="Rechtliches" defaultOpen={false}>
|
||||
<div className="pt-3 grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
{/* AGB */}
|
||||
<div className="rounded-xl bg-white/60 border border-white/50 px-4 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 size={18} className="text-green-500 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800 text-sm">AGB</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Zustimmung: Ja</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Datum: 14.01.2025</p>
|
||||
<p className="text-xs text-gray-400">Uhrzeit: 19:51</p>
|
||||
<p className="text-xs text-gray-400 font-mono mt-1">Version 1.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datenschutzerklärung */}
|
||||
<div className="rounded-xl bg-white/60 border border-white/50 px-4 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 size={18} className="text-green-500 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800 text-sm">Datenschutzerklärung</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Zustimmung: Ja</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Datum: 14.01.2025</p>
|
||||
<p className="text-xs text-gray-400">Uhrzeit: 19:51</p>
|
||||
<button className="text-xs text-blue underline mt-2 hover:opacity-70 transition-opacity">
|
||||
Datenschutzerklärung ansehen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KundeDetailView;
|
||||
Loading…
Add table
Add a link
Reference in a new issue