272 lines
10 KiB
TypeScript
272 lines
10 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 { 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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
|
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
|
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 staff = await getActingStaff();
|
|
if (!staff) 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();
|
|
|
|
// "Belongs to this business" is via the appointment relation — see
|
|
// docs/phase-5-summary.md, same reasoning as the clients list query.
|
|
const belongsToBusiness = await prisma.appointment.findFirst({
|
|
where: { customerId: id, businessId: staff.businessId },
|
|
select: { id: true },
|
|
});
|
|
if (!belongsToBusiness) notFound();
|
|
|
|
const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([
|
|
listAppointments(prisma, {
|
|
customerId: id,
|
|
businessId: staff.businessId,
|
|
statuses: ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"],
|
|
}),
|
|
listAppointments(prisma, {
|
|
customerId: id,
|
|
businessId: staff.businessId,
|
|
statuses: ["COMPLETED"],
|
|
}),
|
|
listAppointments(prisma, {
|
|
customerId: id,
|
|
businessId: staff.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: staff.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 = staff.business.timezone;
|
|
|
|
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 items-center gap-2 rounded-lg border bg-white px-4 py-2 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 items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
|
>
|
|
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
|
|
</a>
|
|
<Button asChild size="sm">
|
|
<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}/{loyalty.stampsRequired} stamps
|
|
{loyalty.availableReward && (
|
|
<span className="text-accent-loyalty"> · Reward available</span>
|
|
)}
|
|
</p>
|
|
<div className="mb-3 flex flex-wrap gap-2">
|
|
<AddStampButton customerId={id} />
|
|
<CorrectStampButton loyaltyAccountId={loyalty.accountId} />
|
|
</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">
|
|
<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>
|
|
|
|
<Card className="p-6">
|
|
<h2 className="text-muted mb-4 text-sm font-bold tracking-widest uppercase">Notes</h2>
|
|
<CustomerNotes customerId={customer.id} notes={notes} actingStaffId={staff.id} />
|
|
</Card>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|