import type { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; import { CalendarX2, Mail, Phone } from "lucide-react"; import { WhatsAppButton } from "@/features/staff/components/whatsapp-button"; import { notFound, redirect } from "next/navigation"; import { AppointmentCard } from "@/components/ui/appointment-card"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { EmptyState } from "@/components/feedback/empty-state"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TopNav } from "@/components/navigation/top-nav"; import { prisma } from "@/lib/db/prisma"; import { clientDirectoryWhere } from "@/services/customer/client-directory"; import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service"; import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold"; import { listAppointments } from "@/services/scheduling/appointment.service"; import { AddStampButton } from "@/features/staff/components/add-stamp-button"; import { CorrectStampButton } from "@/features/staff/components/correct-stamp-button"; import { CustomerNotes } from "@/features/staff/components/customer-notes"; import type { CustomerNoteItem } from "@/features/staff/components/customer-notes"; import type { AppointmentStatus } from "@/types/domain"; export const metadata: Metadata = { title: "Client Details" }; export const dynamic = "force-dynamic"; const STATUS_MAP: Record = { PENDING: "pending_approval", CONFIRMED: "confirmed", CHECKED_IN: "arrived", IN_PROGRESS: "in_progress", COMPLETED: "completed", CANCELLED: "cancelled", NO_SHOW: "no_show", }; export default async function StaffCustomerDetailsPage({ params, }: { params: Promise<{ id: string }>; }) { const context = await getActingBusinessContext(); if (!context) redirect("/home"); const { id } = await params; const customer = await prisma.customerProfile.findUnique({ where: { id }, include: { preferredBarber: { include: { user: { select: { name: true } } } }, user: { select: { image: true } }, }, }); if (!customer) notFound(); // The same rule the Clients list uses (clientDirectoryWhere), asked about this // one person — so anyone the list shows opens, and anyone it hides (the // Super Administrator, a barber with no bookings) 404s here too. const inDirectory = (await prisma.customerProfile.count({ where: { AND: [{ id }, clientDirectoryWhere(context.businessId)] } })) > 0; if (!inDirectory) notFound(); const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([ listAppointments(prisma, { customerId: id, businessId: context.businessId, statuses: ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"], }), listAppointments(prisma, { customerId: id, businessId: context.businessId, statuses: ["COMPLETED"], }), listAppointments(prisma, { customerId: id, businessId: context.businessId, statuses: ["CANCELLED", "NO_SHOW"], }), prisma.customerNote.findMany({ where: { customerId: id }, orderBy: { createdAt: "desc" }, include: { author: { include: { user: { select: { name: true } } } } }, }), getLoyaltySummary(prisma, { customerId: id, businessId: context.businessId }), ]); const loyaltyTransactions = loyalty?.accountId ? await getLoyaltyHistory(prisma, { loyaltyAccountId: loyalty.accountId, limit: 10 }) : []; const notes: CustomerNoteItem[] = notesRaw.map((n) => ({ id: n.id, body: n.body, createdAt: n.createdAt.toISOString(), updatedAt: n.updatedAt.toISOString(), authorStaffId: n.authorStaffId, authorName: n.author.user.name ?? "Staff", })); const tz = context.businessTimezone; // The soonest upcoming booking — what a WhatsApp reminder from this page is about. const now = new Date(); const nextBooking = upcoming .filter((a) => a.startAt > now) .sort((a, b) => a.startAt.getTime() - b.startAt.getTime())[0]; const renderList = (appointments: typeof upcoming, emptyLabel: string) => { if (appointments.length === 0) { return ; } return (
{appointments.map((a) => ( s.service.name).join(" + ")} person={{ id: a.staffId, name: a.staff.user.name ?? "Barber" }} formattedDate={a.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: tz, })} formattedTime={a.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz, })} actions={ View } /> ))}
); }; return (
{customer.user?.image ? ( ) : (
{customer.firstName.charAt(0)} {customer.lastName.charAt(0)}
)}

{customer.firstName} {customer.lastName}

{customer.isGuest ? "Guest customer" : "Registered customer"}

{customer.email}

{customer.phone &&

{customer.phone}

} {customer.preferredBarber && (

Preferred barber: {customer.preferredBarber.user.name ?? "—"}

)}
{customer.phone && ( )} s.service.name).join(" + "), whatsappReminderSentAt: nextBooking.whatsappReminderSentAt, } : undefined } className="min-w-[104px] flex-1" buttonClassName="h-12 w-full rounded-md" />
{loyalty?.isEnabled && loyalty.accountId && (
Loyalty

{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps {loyalty.availableReward && ( · Reward available )}

{loyaltyTransactions.length > 0 && (
    {loyaltyTransactions.map((t) => (
  • {t.createdAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}{" "} — {t.amount > 0 ? "+" : ""} {t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} — {t.type.replace(/_/g, " ").toLowerCase()} {t.reason ? ` (${t.reason})` : ""} {t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
  • ))}
)}
)}

{past.length}

Visits

{upcoming.length}

Upcoming

{cancelled.length}

Cancelled

Notes

{/* A Super Administrator has no StaffProfile/staffId — passing "" is a deliberate never-matches sentinel (see CustomerNotes' isOwn check), so they can view every note but own none. Adding a note as a Super Administrator fails gracefully server-side (see addCustomerNoteAction: "You must be signed in as staff.") rather than fabricating a staff identity for them. */}
Upcoming Past Cancelled {renderList(upcoming, "No upcoming appointments")} {renderList(past, "No past appointments")} {renderList(cancelled, "No cancelled appointments")}
); }