Files
NextChair/src/app/(staff)/staff/clients/[id]/page.tsx
T
Stefan Lazic 05ea83e059
CI / ci (push) Canceled after 0s
Fixes
fixed super admin showing in client list

added a reconnect button to google calendar syncying

fixed ui issue with whatsapp undo link
2026-10-17 09:35:52 +01:00

307 lines
13 KiB
TypeScript

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<string, AppointmentStatus> = {
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 <EmptyState icon={CalendarX2} title={emptyLabel} />;
}
return (
<div className="space-y-3">
{appointments.map((a) => (
<AppointmentCard
key={a.id}
status={STATUS_MAP[a.status] ?? "confirmed"}
serviceName={a.services.map((s) => 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={
<Link
href={`/staff/appointments/${a.id}`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
View
</Link>
}
/>
))}
</div>
);
};
return (
<div className="pb-12">
<TopNav title="Client Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6 lg:max-w-5xl lg:items-start lg:gap-6 lg:space-y-0 lg:[display:grid] lg:grid-cols-[360px_1fr]">
<Card className="space-y-5 p-6">
<div className="flex items-center gap-4">
{customer.user?.image ? (
<Image
src={customer.user.image}
alt=""
width={56}
height={56}
className="h-14 w-14 rounded-full object-cover"
/>
) : (
<div className="bg-stone text-muted flex h-14 w-14 items-center justify-center rounded-full text-lg font-bold">
{customer.firstName.charAt(0)}
{customer.lastName.charAt(0)}
</div>
)}
<div>
<p className="text-lg font-bold">
{customer.firstName} {customer.lastName}
</p>
<p className="text-muted text-xs">
{customer.isGuest ? "Guest customer" : "Registered customer"}
</p>
</div>
</div>
<div className="border-line space-y-1 border-t pt-4 text-sm">
<p className="text-muted">{customer.email}</p>
{customer.phone && <p className="text-muted">{customer.phone}</p>}
{customer.preferredBarber && (
<p className="text-muted">
Preferred barber: {customer.preferredBarber.user.name ?? "—"}
</p>
)}
</div>
<div className="border-line flex flex-wrap gap-3 border-t pt-4">
{customer.phone && (
<a
href={`tel:${customer.phone}`}
className="border-line flex h-12 min-w-[104px] flex-1 items-center justify-center gap-2 rounded-md border bg-white px-4 text-xs font-bold"
>
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
</a>
)}
<a
href={`mailto:${customer.email}`}
className="border-line flex h-12 min-w-[104px] flex-1 items-center justify-center gap-2 rounded-md border bg-white px-4 text-xs font-bold"
>
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
</a>
<WhatsAppButton
businessId={context.businessId}
businessName={context.businessName}
timezone={tz}
customer={customer}
appointment={
nextBooking
? {
id: nextBooking.id,
startAt: nextBooking.startAt,
staffName: nextBooking.staff.user.name ?? "your barber",
serviceNames: nextBooking.services.map((s) => s.service.name).join(" + "),
whatsappReminderSentAt: nextBooking.whatsappReminderSentAt,
}
: undefined
}
className="min-w-[104px] flex-1"
buttonClassName="h-12 w-full rounded-md"
/>
<Button asChild size="md" className="min-w-[104px] flex-1">
<Link href={`/staff/book?customerId=${customer.id}`}>Book Appointment</Link>
</Button>
</div>
{loyalty?.isEnabled && loyalty.accountId && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Loyalty
</span>
<p className="mb-3 text-sm font-bold">
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && (
<span className="text-accent-loyalty"> · Reward available</span>
)}
</p>
<div className="mb-3 flex flex-wrap gap-2">
<AddStampButton customerId={id} className="min-w-[140px] flex-1" />
<CorrectStampButton
loyaltyAccountId={loyalty.accountId}
className="min-w-[140px] flex-1"
/>
</div>
{loyaltyTransactions.length > 0 && (
<ul className="space-y-1">
{loyaltyTransactions.map((t) => (
<li key={t.id} className="text-muted text-xs">
{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}` : ""}
</li>
))}
</ul>
)}
</div>
)}
<div className="border-line grid grid-cols-3 gap-3 border-t pt-4 text-center">
<div>
<p className="text-lg font-bold">{past.length}</p>
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Visits</p>
</div>
<div>
<p className="text-lg font-bold">{upcoming.length}</p>
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Upcoming</p>
</div>
<div>
<p className="text-lg font-bold">{cancelled.length}</p>
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
Cancelled
</p>
</div>
</div>
</Card>
<div className="mt-4 space-y-4 lg:mt-0">
<Card className="p-6">
<h2 className="text-muted mb-4 text-sm font-bold tracking-widest uppercase">Notes</h2>
{/* 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. */}
<CustomerNotes customerId={customer.id} notes={notes} actingStaffId={context.staffId ?? ""} />
</Card>
<Tabs defaultValue="upcoming">
<TabsList>
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
<TabsTrigger value="past">Past</TabsTrigger>
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
</TabsList>
<TabsContent value="upcoming">
{renderList(upcoming, "No upcoming appointments")}
</TabsContent>
<TabsContent value="past">{renderList(past, "No past appointments")}</TabsContent>
<TabsContent value="cancelled">
{renderList(cancelled, "No cancelled appointments")}
</TabsContent>
</Tabs>
</div>
</main>
</div>
);
}