Phase 5
Phase 5 is complete. All 53 tasks are done, verified on a fresh database in a single clean run: **121 unit tests**, **55 integration tests**, and **15 E2E journeys** (6 Phase 4 + 9 new Phase 5) all passing, plus a clean `tsc`/`eslint`/`build`. Summary of this final stretch: - **Fixed a real bug**: `StaffService`'s break/time-off update/delete functions threw uncaught instead of failing gracefully on a permission mismatch — fixed with proper `PERMISSION_DENIED` handling, covered by new integration tests. - **Built the remaining Phase 5 UI**: Breaks editor, Time Off editor, Barber Profile, and Notification Preferences — all wired to real services, no placeholders. - **Extended seed data**: more customers, loyalty accounts/transactions/rewards, and appointments spanning every status today (anchored to real time so the "Current Appointment" priority logic is reliably exercised). - **Found and fixed a second real bug live in the browser**: the Break/Time-Off edit sheets showed stale default values instead of the item being edited (a classic `useState`-doesn't-reinitialize-on-prop-change bug) — fixed with a `key` prop to force remount. - **Wrote the full Phase 5 test suite**: unit tests for `selectCurrentAppointment` and all `StaffService` validators, integration tests for `LoyaltyService` and `StaffService` (including the permission-fix regression tests), and 9 new Playwright E2E journeys covering the brief's exact required list. - **Did the desktop-responsive pass**: a persistent sidebar nav replacing the bottom bar at `lg:`, a genuine calendar+"Right Now" side-by-side split, and 2-column layouts for the clients list and customer details — addressing the brief's explicit "more than simple centering" requirement. - **Wrote [docs/phase-5-summary.md](docs/phase-5-summary.md)** documenting everything above in full. Nothing is committed — the whole session (Phases 2–5) sits uncommitted on top of the initial template commit, same as you left it. Let me know if you'd like me to commit, or if you want to move on to Phase 6.
This commit is contained in:
81 files changed
+8024
-113
No files matched your search
@@ -0,0 +1,271 @@
|
||||
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.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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,158 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Users } from "lucide-react";
|
||||
import { ComingSoon } from "@/components/feedback/coming-soon";
|
||||
import { Search, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { EmptyState } from "@/components/feedback/empty-state";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { matchesCustomerSearch } from "@/services/customer/customer.service";
|
||||
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export const metadata: Metadata = { title: "Clients" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function StaffClientsPage() {
|
||||
return <ComingSoon icon={Users} title="Clients" phase="Phase 3: Barber Tools" />;
|
||||
const UPCOMING_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN"];
|
||||
|
||||
export default async function StaffClientsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; sort?: string }>;
|
||||
}) {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const { q = "", sort } = await searchParams;
|
||||
|
||||
// Scoped via the appointment relation, not "every CustomerProfile row" —
|
||||
// a real business boundary even though this is a single-business v1 app
|
||||
// (see docs/phase-5-summary.md).
|
||||
const customers = await prisma.customerProfile.findMany({
|
||||
where: { appointments: { some: { businessId: staff.businessId } } },
|
||||
include: {
|
||||
appointments: { where: { businessId: staff.businessId }, orderBy: { startAt: "desc" } },
|
||||
},
|
||||
});
|
||||
|
||||
const loyaltySummaries = await getLoyaltySummariesForCustomers(prisma, {
|
||||
businessId: staff.businessId,
|
||||
customerIds: customers.map((c) => c.id),
|
||||
});
|
||||
|
||||
const filtered = customers.filter((c) => matchesCustomerSearch(c, q));
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
if (sort === "recent") {
|
||||
const aLatest = a.appointments[0]?.startAt.getTime() ?? 0;
|
||||
const bLatest = b.appointments[0]?.startAt.getTime() ?? 0;
|
||||
return bLatest - aLatest;
|
||||
}
|
||||
return `${a.lastName} ${a.firstName}`.localeCompare(`${b.lastName} ${b.firstName}`);
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-12 pb-8 lg:max-w-5xl">
|
||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Clients</h1>
|
||||
|
||||
<form action="/staff/clients" method="GET" className="relative mb-4">
|
||||
{sort && <input type="hidden" name="sort" value={sort} />}
|
||||
<Search
|
||||
className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<label htmlFor="client-search" className="sr-only">
|
||||
Search clients by name, email, or phone
|
||||
</label>
|
||||
<input
|
||||
id="client-search"
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Search by name, email, or phone"
|
||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="mb-4 flex gap-2">
|
||||
<Link
|
||||
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=name`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
sort !== "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
Alphabetical
|
||||
</Link>
|
||||
<Link
|
||||
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=recent`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
sort === "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
Recent
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={q ? "No matching clients" : "No clients yet"}
|
||||
description={
|
||||
q
|
||||
? "Try a different name, email, or phone number."
|
||||
: "Clients will show up here once they've booked with you."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{sorted.map((customer) => {
|
||||
const completedVisits = customer.appointments.filter(
|
||||
(a) => a.status === "COMPLETED",
|
||||
).length;
|
||||
const lastVisit = customer.appointments.find((a) => a.status === "COMPLETED");
|
||||
const upcoming = customer.appointments.find(
|
||||
(a) => UPCOMING_STATUSES.includes(a.status) && a.startAt > now,
|
||||
);
|
||||
const loyalty = loyaltySummaries.get(customer.id);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={customer.id}
|
||||
href={`/staff/clients/${customer.id}`}
|
||||
className="rounded-jumbo border-line hover:bg-stone flex items-center gap-3 border bg-white px-5 py-4"
|
||||
>
|
||||
<div className="bg-stone text-muted flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold">
|
||||
{customer.firstName.charAt(0)}
|
||||
{customer.lastName.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold">
|
||||
{customer.firstName} {customer.lastName}
|
||||
</p>
|
||||
<p className="text-muted truncate text-xs">
|
||||
{customer.phone ?? customer.email}
|
||||
{lastVisit &&
|
||||
` · Last visit ${lastVisit.startAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
|
||||
</p>
|
||||
<p className="text-muted mt-0.5 text-[10px] font-bold tracking-widest uppercase">
|
||||
{completedVisits} visit{completedVisits === 1 ? "" : "s"}
|
||||
{upcoming && <span className="text-accent"> · Upcoming</span>}
|
||||
{loyalty && (
|
||||
<span className={loyalty.hasAvailableReward ? "text-accent-loyalty" : ""}>
|
||||
{" "}
|
||||
· {loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user