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:
Stefan Lazic committed 2026-08-02 23:44:18 +01:00
1 parent 6ae145d187
commit ffee9d6754
81 files changed
+8024 -113

No files matched your search

@@ -0,0 +1,10 @@
/**
* Deliberately outside the (staff) route group, same reasoning as
* src/app/appointments/[id]/reschedule/layout.tsx on the customer side:
* this page reuses BarberDateTimePicker, whose own fixed bottom action bar
* would otherwise sit under the persistent staff bottom nav and swallow
* clicks meant for "Confirm Reschedule".
*/
export default function StaffRescheduleLayout({ children }: { children: React.ReactNode }) {
return <div className="bg-stone min-h-screen">{children}</div>;
}
@@ -0,0 +1,93 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { rescheduleStaffAppointmentAction } from "@/features/staff/actions/appointment-actions";
import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker";
export const metadata: Metadata = { title: "Reschedule Appointment" };
export const dynamic = "force-dynamic";
const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]);
export default async function StaffRescheduleAppointmentPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const { id } = await params;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
try {
assertOwnAppointment(appointment, staff);
} catch (error) {
if (error instanceof StaffPermissionDeniedError) notFound();
throw error;
}
if (NON_RESCHEDULABLE.has(appointment.status)) {
redirect(`/staff/appointments/${id}`);
}
async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) {
"use server";
const result = await rescheduleStaffAppointmentAction({
appointmentId: id,
staffId: selection.staffId,
startAt: selection.startAt,
});
if (result.success) {
redirect(`/staff/appointments/${id}`);
}
return result;
}
return (
<div className="pb-6">
<TopNav title="Reschedule" onBack sticky className="bg-stone" />
<Card className="mx-6 mt-4 flex items-center gap-3 p-4">
<div className="flex-1">
<p className="text-sm font-bold">
{appointment.services.map((s) => s.service.name).join(" + ")} for{" "}
{appointment.customer.firstName} {appointment.customer.lastName}
</p>
<p className="text-muted text-xs">
Currently:{" "}
{appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: staff.business.timezone,
})}{" "}
·{" "}
{appointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: staff.business.timezone,
})}
</p>
</div>
</Card>
<div className="mt-4">
<BarberDateTimePicker
businessId={staff.businessId}
businessTimezone={staff.business.timezone}
serviceIds={appointment.services.map((s) => s.serviceId)}
initialStaffId={appointment.staffId}
onContinue={handleContinue}
continueLabel="Confirm Reschedule"
/>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { readStaffDraft } from "@/lib/booking-draft/cookie";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker";
import { saveStaffBarberTimeStepAction } from "@/features/staff/actions/staff-booking";
export const metadata: Metadata = { title: "Barber & Time" };
export const dynamic = "force-dynamic";
export default async function StaffBookBarberTimePage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const draft = await readStaffDraft(staff.businessId);
if (draft.serviceIds.length === 0) redirect("/staff/book/services");
async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) {
"use server";
return saveStaffBarberTimeStepAction(selection);
}
return (
<div className="pb-6">
<TopNav title="Barber & Time" onBack sticky className="bg-stone" />
<BarberDateTimePicker
businessId={staff.businessId}
businessTimezone={staff.business.timezone}
serviceIds={draft.serviceIds}
initialStaffId={draft.staffId}
initialDate={draft.date}
onContinue={handleContinue}
/>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Deliberately outside the (staff) route group — same reasoning as
* src/app/book/layout.tsx (customer) and src/app/staff/appointments/[id]/reschedule:
* this flow reuses ServiceSelector and BarberDateTimePicker, both of which
* render their own fixed bottom action bar that would otherwise sit under
* the persistent staff bottom nav.
*/
export default function StaffBookLayout({ children }: { children: React.ReactNode }) {
return <div className="bg-stone min-h-screen">{children}</div>;
}
+130
View File
@@ -0,0 +1,130 @@
import type { Metadata } from "next";
import { Search, UserPlus } from "lucide-react";
import { redirect } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { matchesCustomerSearch } from "@/services/customer/customer.service";
import { selectExistingCustomerAction } from "@/features/staff/actions/staff-booking";
import { NewGuestCustomerForm } from "@/features/staff/components/new-guest-customer-form";
export const metadata: Metadata = { title: "Book Appointment" };
export const dynamic = "force-dynamic";
export default async function StaffBookCustomerPage({
searchParams,
}: {
searchParams: Promise<{ customerId?: string; q?: string }>;
}) {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const { customerId, q } = await searchParams;
if (customerId) {
const customer = await prisma.customerProfile.findUnique({ where: { id: customerId } });
if (customer) {
const selectAction = selectExistingCustomerAction.bind(null, customer.id);
return (
<div className="pb-6">
<TopNav title="Book Appointment" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<Card className="p-6">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Booking For
</span>
<p className="mt-1 text-lg font-bold">
{customer.firstName} {customer.lastName}
</p>
<p className="text-muted text-sm">{customer.phone ?? customer.email}</p>
</Card>
<form action={selectAction}>
<Button type="submit" className="w-full">
Continue
</Button>
</form>
</main>
</div>
);
}
}
const candidates = q
? await prisma.customerProfile.findMany({
where: { appointments: { some: { businessId: staff.businessId } } },
})
: [];
const results = candidates.filter((c) => matchesCustomerSearch(c, q ?? "")).slice(0, 20);
return (
<div className="pb-6">
<TopNav title="Book Appointment" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<section>
<h2 className="mb-3 text-sm font-bold">Find an Existing Customer</h2>
<form action="/staff/book" method="GET" className="relative mb-3">
<Search
className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<label htmlFor="customer-search" className="sr-only">
Search customers
</label>
<input
id="customer-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>
{q && (
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
{results.length === 0 ? (
<p className="text-muted px-5 py-4 text-sm">
No customers match &ldquo;{q}&rdquo;.
</p>
) : (
results.map((customer) => {
const action = selectExistingCustomerAction.bind(null, customer.id);
return (
<form key={customer.id} action={action}>
<button
type="submit"
className="hover:bg-stone flex w-full items-center gap-3 px-5 py-4 text-left"
>
<div className="bg-stone text-muted flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-bold">
{customer.firstName.charAt(0)}
{customer.lastName.charAt(0)}
</div>
<div>
<p className="text-sm font-bold">
{customer.firstName} {customer.lastName}
</p>
<p className="text-muted text-xs">{customer.phone ?? customer.email}</p>
</div>
</button>
</form>
);
})
)}
</div>
)}
</section>
<section>
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<UserPlus className="text-accent h-4 w-4" aria-hidden="true" /> Or Add a New Customer
</h2>
<Card className="p-5">
<NewGuestCustomerForm />
</Card>
</section>
</main>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { readStaffDraft } from "@/lib/booking-draft/cookie";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { ConfirmStaffBooking } from "@/features/staff/components/confirm-staff-booking";
export const metadata: Metadata = { title: "Review Booking" };
export const dynamic = "force-dynamic";
export default async function StaffBookReviewPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const draft = await readStaffDraft(staff.businessId);
if (draft.serviceIds.length === 0) redirect("/staff/book/services");
if (!draft.startAt) redirect("/staff/book/barber-time");
if (!draft.customerId && !draft.guest) redirect("/staff/book");
const [services, staffMember, customerProfile] = await Promise.all([
prisma.service.findMany({ where: { id: { in: draft.serviceIds } } }),
draft.staffId
? prisma.staffProfile.findUnique({
where: { id: draft.staffId },
include: { user: { select: { name: true } } },
})
: null,
draft.customerId
? prisma.customerProfile.findUnique({ where: { id: draft.customerId } })
: null,
]);
const totalDuration = services.reduce((sum, s) => sum + s.durationMinutes, 0);
const totalPrice = services.reduce((sum, s) => sum + Number(s.price), 0);
const startAt = new Date(draft.startAt);
const endAt = new Date(startAt.getTime() + totalDuration * 60_000);
const tz = staff.business.timezone;
const customerName = draft.guest
? `${draft.guest.firstName} ${draft.guest.lastName}`
: `${customerProfile?.firstName ?? ""} ${customerProfile?.lastName ?? ""}`.trim();
const customerContact = draft.guest
? `${draft.guest.email} · ${draft.guest.phone}`
: (customerProfile?.phone ?? customerProfile?.email ?? "");
return (
<div className="pb-12">
<TopNav title="Review Booking" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<Card className="space-y-6 p-6">
<div className="flex items-start justify-between">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Customer
</span>
<p className="text-sm font-bold">{customerName}</p>
<p className="text-muted text-xs">{customerContact}</p>
</div>
<Link
href="/staff/book"
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
Change
</Link>
</div>
<div className="border-line space-y-4 border-t pt-6">
<div className="flex items-center justify-between">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Services
</span>
<ul className="mt-1 text-sm font-bold">
{services.map((s) => (
<li key={s.id}>{s.name}</li>
))}
</ul>
</div>
<Link
href="/staff/book/services"
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
Edit
</Link>
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Barber
</span>
<p className="text-sm font-bold">
{staffMember?.user.name ?? "Any available barber"}
</p>
</div>
<Link
href="/staff/book/barber-time"
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
Edit
</Link>
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Date &amp; Time
</span>
<p className="text-sm font-bold">
{startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: tz,
})}
{" · "}
{startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}
–
{endAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}
</p>
</div>
<Link
href="/staff/book/barber-time"
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
Edit
</Link>
</div>
</div>
<div className="border-line grid grid-cols-2 gap-6 border-t pt-6">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Duration
</span>
<p className="text-sm font-bold">{totalDuration} min</p>
</div>
<div className="text-right">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Total
</span>
<p className="text-sm font-bold">£{totalPrice.toFixed(2)}</p>
</div>
</div>
</Card>
<ConfirmStaffBooking />
</main>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { readStaffDraft } from "@/lib/booking-draft/cookie";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getAvailableServices } from "@/services/scheduling/availability.service";
import { ServiceSelector } from "@/features/booking/components/service-selector";
import { saveStaffServicesStepAction } from "@/features/staff/actions/staff-booking";
export const metadata: Metadata = { title: "Select Services" };
export const dynamic = "force-dynamic";
export default async function StaffBookServicesPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const draft = await readStaffDraft(staff.businessId);
if (!draft.customerId && !draft.guest) redirect("/staff/book");
const [services, categories] = await Promise.all([
getAvailableServices(prisma, { businessId: staff.businessId }),
prisma.serviceCategory.findMany({
where: { businessId: staff.businessId, isActive: true },
orderBy: { displayOrder: "asc" },
}),
]);
const categoryNames = Object.fromEntries(categories.map((c) => [c.id, c.name]));
const categoryOrder = categories.map((c) => c.id);
async function handleContinue(serviceIds: string[]) {
"use server";
return saveStaffServicesStepAction({ serviceIds });
}
return (
<ServiceSelector
services={services}
categoryNames={categoryNames}
categoryOrder={categoryOrder}
initialSelectedServiceIds={draft.serviceIds}
rebooking={!!draft.followUpFromAppointmentId}
onContinue={handleContinue}
/>
);
}