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

+1 -1
View File
@@ -112,7 +112,7 @@ export default async function AppointmentsPage() {
key={appointment.id}
status={STATUS_MAP[appointment.status] ?? "confirmed"}
serviceName={appointment.services.map((s) => s.service.name).join(" + ")}
staff={{ id: appointment.staffId, name: appointment.staff.user.name ?? "Barber" }}
person={{ id: appointment.staffId, name: appointment.staff.user.name ?? "Barber" }}
formattedDate={appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
+1 -1
View File
@@ -163,7 +163,7 @@ export default async function CustomerHomePage() {
<AppointmentCard
status={STATUS_MAP[upcomingAppointment.status] ?? "confirmed"}
serviceName={upcomingAppointment.services.map((s) => s.service.name).join(" + ")}
staff={{
person={{
id: upcomingAppointment.staffId,
name: upcomingAppointment.staff.user.name ?? "Barber",
}}
+2 -8
View File
@@ -1,7 +1,6 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { BottomNav } from "@/components/navigation/bottom-nav";
import { STAFF_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
import { StaffShell } from "@/components/navigation/staff-shell";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
@@ -21,10 +20,5 @@ export default async function OwnerLayout({ children }: { children: React.ReactN
redirect("/staff");
}
return (
<div className="min-h-screen bg-stone pb-24">
{children}
<BottomNav items={STAFF_NAV_ITEMS} variant="staff" />
</div>
);
return <StaffShell>{children}</StaffShell>;
}
+2 -8
View File
@@ -1,7 +1,6 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { BottomNav } from "@/components/navigation/bottom-nav";
import { STAFF_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
import { StaffShell } from "@/components/navigation/staff-shell";
import { prisma } from "@/lib/db/prisma";
export default async function StaffLayout({ children }: { children: React.ReactNode }) {
@@ -19,10 +18,5 @@ export default async function StaffLayout({ children }: { children: React.ReactN
redirect("/home");
}
return (
<div className="min-h-screen bg-stone pb-24">
{children}
<BottomNav items={STAFF_NAV_ITEMS} variant="staff" />
</div>
);
return <StaffShell>{children}</StaffShell>;
}
@@ -0,0 +1,266 @@
import type { Metadata } from "next";
import { CalendarPlus, Mail, Phone } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Appointment 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",
};
const RESCHEDULABLE = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
export default async function StaffAppointmentDetailsPage({
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;
}
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
prisma.customerNote.findMany({
where: { customerId: appointment.customerId },
orderBy: { createdAt: "desc" },
take: 5,
include: { author: { include: { user: { select: { name: true } } } } },
}),
prisma.loyaltyTransaction.findMany({
where: { appointmentId: appointment.id },
orderBy: { createdAt: "desc" },
}),
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: staff.businessId }),
appointment.cancelledByUserId
? prisma.user.findUnique({
where: { id: appointment.cancelledByUserId },
select: { name: true },
})
: null,
]);
const tz = staff.business.timezone;
const totalDuration = appointment.services.reduce(
(sum, s) => sum + s.durationAtBookingMinutes,
0,
);
const totalPrice = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
return (
<div className="pb-12">
<TopNav title="Appointment Details" 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-center justify-between">
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
Ref: {appointment.id.slice(-8)}
</span>
</div>
<div className="border-line space-y-1 border-t pt-4">
<Link
href={`/staff/clients/${appointment.customerId}`}
className="text-lg font-bold underline"
>
{appointment.customer.firstName} {appointment.customer.lastName}
</Link>
<p className="text-muted text-sm">
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
</p>
</div>
<div className="flex flex-wrap gap-3">
{appointment.customer.phone && (
<a
href={`tel:${appointment.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:${appointment.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>
</div>
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Services
</span>
<p className="font-bold">
{appointment.services.map((s) => s.service.name).join(" + ")}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Date
</span>
<p className="font-bold">
{appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: tz,
})}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Time
</span>
<p className="font-bold">
{appointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}{" "}
–{" "}
{appointment.endAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Duration / Price
</span>
<p className="font-bold">
{totalDuration} min · £{totalPrice.toFixed(2)}
</p>
</div>
</div>
{appointment.notes && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Booking Notes
</span>
<p className="text-muted mt-1 text-sm italic">{appointment.notes}</p>
</div>
)}
{notes.length > 0 && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Customer Notes
</span>
<div className="mt-2 space-y-2">
{notes.map((n) => (
<p key={n.id} className="text-muted text-xs">
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
</p>
))}
</div>
</div>
)}
{loyalty?.isEnabled && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Loyalty
</span>
<p className="text-sm font-bold">
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
{loyalty.availableReward && (
<span className="text-accent-loyalty"> · Reward available</span>
)}
</p>
{loyaltyTransactions.length > 0 && (
<ul className="mt-2 space-y-1">
{loyaltyTransactions.map((t) => (
<li key={t.id} className="text-muted text-xs">
{t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} —{" "}
{t.type.replace("_", " ").toLowerCase()}
{t.reason ? ` (${t.reason})` : ""}
</li>
))}
</ul>
)}
</div>
)}
{appointment.status === "CANCELLED" && (
<div className="border-line border-t pt-4 text-sm">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Cancellation
</span>
<p className="text-muted mt-1">
Cancelled {appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: tz })}
{cancelledByUser ? ` by ${cancelledByUser.name}` : ""}
</p>
{appointment.cancellationReason && (
<p className="text-muted mt-1 italic">
&ldquo;{appointment.cancellationReason}&rdquo;
</p>
)}
</div>
)}
</Card>
<div className="flex flex-wrap gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics`}
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
</a>
{RESCHEDULABLE.has(appointment.status) && (
<Link
href={`/staff/appointments/${appointment.id}/reschedule`}
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
>
Reschedule
</Link>
)}
{appointment.status === "COMPLETED" && (
<BookFollowUpButton appointmentId={appointment.id} />
)}
</div>
<AppointmentStatusActions appointmentId={appointment.id} status={appointment.status} />
{loyalty?.isEnabled && (
<AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />
)}
</main>
</div>
);
}
+271
View File
@@ -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>
);
}
+153 -4
View File
@@ -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>
);
}
+221 -4
View File
@@ -1,9 +1,226 @@
import type { Metadata } from "next";
import { Scissors } from "lucide-react";
import { ComingSoon } from "@/components/feedback/coming-soon";
import { Mail, NotebookText, Phone, Scissors } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill";
import { EmptyState } from "@/components/feedback/empty-state";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { todayIn } from "@/lib/time/business-time";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import {
listAppointments,
selectCurrentAppointment,
} from "@/services/scheduling/appointment.service";
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Current Appointment" };
export const dynamic = "force-dynamic";
export default function StaffCurrentPage() {
return <ComingSoon icon={Scissors} title="Current Appointment" phase="Phase 3: Barber Tools" />;
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 StaffCurrentPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const today = todayIn(staff.business.timezone);
const todaysAppointments = await listAppointments(prisma, {
businessId: staff.businessId,
staffId: staff.id,
fromDate: today,
toDate: today,
businessTimezone: staff.business.timezone,
});
const current = selectCurrentAppointment(todaysAppointments, new Date());
if (!current) {
return (
<div className="px-6 pt-12">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Current Appointment</h1>
<EmptyState
icon={Scissors}
title="Nothing scheduled right now"
description="Your next appointment today will show up here."
/>
</div>
);
}
const [notes, lastCompleted, loyalty] = await Promise.all([
prisma.customerNote.findMany({
where: { customerId: current.customerId },
orderBy: { createdAt: "desc" },
take: 2,
include: { author: { include: { user: { select: { name: true } } } } },
}),
prisma.appointment.findFirst({
where: { customerId: current.customerId, status: "COMPLETED", id: { not: current.id } },
orderBy: { startAt: "desc" },
include: { services: { include: { service: true } } },
}),
getLoyaltySummary(prisma, { customerId: current.customerId, businessId: staff.businessId }),
]);
const totalDuration = current.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
const totalPrice = current.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
const tz = staff.business.timezone;
return (
<div className="space-y-4 px-6 pt-12 pb-8">
<h1 className="text-2xl font-bold tracking-tight">Current Appointment</h1>
<Card className="space-y-5 p-6">
<div className="flex items-start justify-between">
<div>
<p className="text-lg font-bold">
{current.customer.firstName} {current.customer.lastName}
</p>
<p className="text-muted text-xs">
{current.customer.isGuest ? "Guest customer" : "Registered customer"}
</p>
</div>
<StatusPill status={STATUS_MAP[current.status] ?? "confirmed"} />
</div>
<div className="flex flex-wrap gap-3">
{current.customer.phone && (
<a
href={`tel:${current.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:${current.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>
<Link
href={`/staff/clients/${current.customerId}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
View Customer
</Link>
</div>
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Services
</span>
<p className="font-bold">{current.services.map((s) => s.service.name).join(" + ")}</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Time
</span>
<p className="font-bold">
{current.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}{" "}
–{" "}
{current.endAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Duration
</span>
<p className="font-bold">{totalDuration} min</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Price
</span>
<p className="font-bold">£{totalPrice.toFixed(2)}</p>
</div>
</div>
{current.notes && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Booking Notes
</span>
<p className="text-muted mt-1 text-sm italic">{current.notes}</p>
</div>
)}
{notes.length > 0 && (
<div className="border-line border-t pt-4">
<span className="text-muted mb-2 flex items-center gap-1.5 text-[10px] font-bold tracking-widest uppercase">
<NotebookText className="h-3 w-3" aria-hidden="true" /> Customer Notes
</span>
<div className="space-y-2">
{notes.map((n) => (
<p key={n.id} className="text-muted text-xs">
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
</p>
))}
</div>
</div>
)}
{loyalty?.isEnabled && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Loyalty
</span>
<p className="text-sm font-bold">
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
{loyalty.availableReward && (
<span className="text-accent-loyalty"> · Reward available</span>
)}
</p>
</div>
)}
{lastCompleted && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Last Visit
</span>
<p className="text-muted text-sm">
{lastCompleted.services.map((s) => s.service.name).join(" + ")} ·{" "}
{lastCompleted.startAt.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
timeZone: tz,
})}
</p>
</div>
)}
</Card>
<AppointmentStatusActions appointmentId={current.id} status={current.status} />
<div className="flex flex-wrap gap-3">
{loyalty?.isEnabled && (
<AddStampButton customerId={current.customerId} appointmentId={current.id} />
)}
<BookFollowUpButton appointmentId={current.id} />
</div>
</div>
);
}
+271 -4
View File
@@ -1,9 +1,276 @@
import type { Metadata } from "next";
import { CalendarDays } from "lucide-react";
import { ComingSoon } from "@/components/feedback/coming-soon";
import { CalendarX2, Plus } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state";
import { AppointmentCard } from "@/components/ui/appointment-card";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import {
calendarDateToUtcMidnight,
minutesFromMidnight,
todayIn,
weekdayOf,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
import { listAppointments, selectCurrentAppointment } from "@/services/scheduling/appointment.service";
import { DateStrip } from "@/features/staff/components/date-strip";
import { DayCalendar } from "@/features/staff/components/day-calendar";
import type { CalendarAppointment, CalendarBlock } from "@/features/staff/components/day-calendar";
import { BlockTimeButton } from "@/features/staff/components/block-time-button";
import { cn } from "@/lib/utils/cn";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Calendar" };
export const dynamic = "force-dynamic";
export default function StaffCalendarPage() {
return <ComingSoon icon={CalendarDays} title="Calendar" phase="Phase 3: Barber Tools" />;
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 StaffCalendarPage({
searchParams,
}: {
searchParams: Promise<{ date?: string; view?: string }>;
}) {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const { date: dateParam, view: viewParam } = await searchParams;
const today = todayIn(staff.business.timezone);
const date: CalendarDate = dateParam ?? today;
const view = viewParam === "agenda" ? "agenda" : "day";
const weekday = weekdayOf(date);
const dateUtcMidnight = calendarDateToUtcMidnight(date);
const [workingHours, breakDays, timeOffRows, appointments] = await Promise.all([
prisma.workingHours.findUnique({
where: { staffId_dayOfWeek: { staffId: staff.id, dayOfWeek: weekday } },
}),
prisma.breakDay.findMany({
where: { dayOfWeek: weekday, break: { staffId: staff.id } },
include: { break: true },
}),
prisma.timeOff.findMany({
where: {
staffId: staff.id,
status: "APPROVED",
startDate: { lte: dateUtcMidnight },
endDate: { gte: dateUtcMidnight },
},
}),
listAppointments(prisma, {
businessId: staff.businessId,
staffId: staff.id,
fromDate: date,
toDate: date,
businessTimezone: staff.business.timezone,
}),
]);
const wholeDayOff = timeOffRows.find((t) => t.startMinutes === null);
const rewardFlags = await getAvailableRewardFlags(prisma, {
businessId: staff.businessId,
customerIds: [...new Set(appointments.map((a) => a.customerId))],
});
const workingWindow =
workingHours &&
!workingHours.isOff &&
workingHours.startMinutes !== null &&
workingHours.endMinutes !== null
? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes }
: null;
const blocks: CalendarBlock[] = [
...breakDays.map((bd) => ({
id: `break-${bd.id}`,
startMinutes: bd.break.startMinutes,
endMinutes: bd.break.endMinutes,
label: bd.break.label,
kind: "break" as const,
})),
...timeOffRows
.filter((t) => t.startMinutes !== null && t.endMinutes !== null)
.map((t) => ({
id: `timeoff-${t.id}`,
startMinutes: t.startMinutes!,
endMinutes: t.endMinutes!,
label: t.reason ?? "Blocked",
kind: "timeOff" as const,
})),
];
const calendarAppointments: CalendarAppointment[] = appointments.map((a) => ({
id: a.id,
startMinutes: minutesFromMidnight(a.startAt, staff.business.timezone),
endMinutes: minutesFromMidnight(a.endAt, staff.business.timezone),
customerName: `${a.customer.firstName} ${a.customer.lastName}`,
serviceNames: a.services.map((s) => s.service.name).join(" + "),
status: STATUS_MAP[a.status] ?? "confirmed",
hasNotes: !!a.notes,
hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
}));
const currentTimeMinutes =
date === today ? minutesFromMidnight(new Date(), staff.business.timezone) : null;
// Desktop-only: a "Right Now" panel beside the calendar so a wide screen
// does more than center the mobile layout (see docs/phase-5-summary.md,
// "Responsive Design"). Only meaningful when viewing today.
const rightNow = date === today ? selectCurrentAppointment(appointments, new Date()) : null;
return (
<div className="pb-6">
<div className="flex items-center justify-between px-6 pt-12">
<h1 className="text-2xl font-bold tracking-tight">Calendar</h1>
<div className="flex gap-2">
<BlockTimeButton date={date} />
<Button asChild size="sm">
<Link href="/staff/book">
<Plus className="h-4 w-4" aria-hidden="true" />
New
</Link>
</Button>
</div>
</div>
<DateStrip
selectedDate={date}
businessTimezone={staff.business.timezone}
basePath="/staff"
view={view}
/>
<div className="mt-4 flex gap-2 px-6">
<Link
href={`/staff?date=${date}&view=day`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
view === "day" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Day
</Link>
<Link
href={`/staff?date=${date}&view=agenda`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
view === "agenda" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Agenda
</Link>
</div>
<div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6">
<div className="lg:flex-1">
{wholeDayOff ? (
<EmptyState
icon={CalendarX2}
title="You're off this day"
description={wholeDayOff.reason ?? "Approved time off."}
/>
) : view === "day" ? (
<DayCalendar
appointments={calendarAppointments}
blocks={blocks}
workingWindow={workingWindow}
currentTimeMinutes={currentTimeMinutes}
/>
) : calendarAppointments.length === 0 ? (
<div className="px-6 lg:px-0">
<EmptyState
icon={CalendarX2}
title="No appointments today"
description="Your schedule is clear for this day."
/>
</div>
) : (
<div className="space-y-4 px-6 lg:px-0">
{calendarAppointments.map((a) => (
<AppointmentCard
key={a.id}
status={a.status}
serviceName={a.serviceNames}
person={{ id: a.id, name: a.customerName }}
personLabel="for"
formattedTime={formatRange(a.startMinutes, a.endMinutes)}
hasNotes={a.hasNotes}
hasAvailableReward={a.hasAvailableReward}
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>
)}
</div>
{/* Desktop-only "Right Now" panel — a wide screen shows the current
appointment alongside the calendar instead of requiring a
separate tab, since there's room for both at once. */}
<aside className="hidden shrink-0 lg:block lg:w-80">
<div className="rounded-jumbo border-line sticky top-6 border bg-white p-5">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Right Now
</span>
{rightNow ? (
<div className="mt-3 space-y-3">
<AppointmentCard
status={STATUS_MAP[rightNow.status] ?? "confirmed"}
serviceName={rightNow.services.map((s) => s.service.name).join(" + ")}
person={{ id: rightNow.customerId, name: `${rightNow.customer.firstName} ${rightNow.customer.lastName}` }}
personLabel="for"
formattedTime={formatRange(
minutesFromMidnight(rightNow.startAt, staff.business.timezone),
minutesFromMidnight(rightNow.endAt, staff.business.timezone),
)}
/>
<Link
href={`/staff/appointments/${rightNow.id}`}
className="border-line block rounded-lg border px-4 py-2 text-center text-xs font-bold tracking-widest uppercase"
>
Open Details
</Link>
</div>
) : (
<p className="text-muted mt-3 text-sm">Nothing in progress or up next right now.</p>
)}
<Link
href="/staff/current"
className="text-accent mt-4 block text-center text-xs font-bold tracking-widest uppercase underline"
>
Full Current Appointment View
</Link>
</div>
</aside>
</div>
</div>
);
}
function formatRange(startMinutes: number, endMinutes: number): string {
const fmt = (m: number) => {
const h = Math.floor(m / 60);
const min = m % 60;
const period = h >= 12 ? "PM" : "AM";
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(min).padStart(2, "0")} ${period}`;
};
return `${fmt(startMinutes)} – ${fmt(endMinutes)}`;
}
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { listBreaks } from "@/services/staff/staff.service";
import { BreaksList } from "@/features/staff/components/breaks-list";
export const metadata: Metadata = { title: "Breaks" };
export const dynamic = "force-dynamic";
export default async function StaffBreaksPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const breaks = await listBreaks(prisma, staff.id);
return (
<div className="pb-12">
<TopNav title="Breaks" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<BreaksList
breaks={breaks.map((b) => ({
id: b.id,
label: b.label,
startMinutes: b.startMinutes,
endMinutes: b.endMinutes,
days: b.days.map((d) => d.dayOfWeek),
}))}
/>
</main>
</div>
);
}
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getNotificationPreferences } from "@/services/staff/staff.service";
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form";
export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic";
export default async function StaffNotificationsPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const cells = await getNotificationPreferences(prisma, staff.userId);
return (
<div className="pb-12">
<TopNav title="Notifications" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<NotificationPreferencesForm initial={cells} />
</main>
</div>
);
}
+20 -5
View File
@@ -1,13 +1,20 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ShieldCheck } from "lucide-react";
import { Bell, CalendarClock, CalendarOff, Clock, ShieldCheck, UserCircle } from "lucide-react";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { ProfileCard } from "@/components/ui/profile-card";
import { SignOutButton } from "@/features/auth/components/sign-out-button";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
const SETTINGS_LINKS = [
{ href: "/staff/settings/working-hours", label: "Working Hours", icon: Clock },
{ href: "/staff/settings/breaks", label: "Breaks", icon: CalendarClock },
{ href: "/staff/settings/time-off", label: "Time Off", icon: CalendarOff },
{ href: "/staff/settings/profile", label: "Profile", icon: UserCircle },
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
] as const;
export const metadata: Metadata = { title: "Staff Settings" };
export default async function StaffSettingsPage() {
@@ -34,9 +41,17 @@ export default async function StaffSettingsPage() {
</Link>
)}
<Button variant="ghost" className="w-full" disabled>
Working Hours, Breaks &amp; Time Off — Phase 3
</Button>
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
<li key={href}>
<Link href={href} className="flex items-center gap-3 px-5 py-4 text-sm font-bold hover:bg-stone">
<Icon className="h-5 w-5 text-muted" aria-hidden="true" />
{label}
</Link>
</li>
))}
</ul>
<SignOutButton />
</div>
);
@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getStaffProfileByUserId } from "@/services/staff/staff.service";
import { ProfileForm } from "@/features/staff/components/profile-form";
export const metadata: Metadata = { title: "Profile" };
export const dynamic = "force-dynamic";
export default async function StaffProfilePage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const profile = await getStaffProfileByUserId(prisma, staff.userId);
if (!profile) redirect("/home");
return (
<div className="pb-12">
<TopNav title="Profile" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<ProfileForm
initial={{
name: profile.user.name ?? "",
phone: profile.user.phone ?? "",
image: profile.user.image ?? "",
bio: profile.bio ?? "",
isBookableOnline: profile.isBookableOnline,
}}
/>
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">
Services You Offer
</p>
{profile.services.length === 0 ? (
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
No services assigned yet. Ask the owner to add you to a service.
</p>
) : (
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
{profile.services.map((s) => (
<li key={s.service.id} className="px-5 py-3 text-sm font-bold">
{s.service.name}
</li>
))}
</ul>
)}
<p className="text-muted mt-2 text-xs">
Only the business owner can change which services you offer.
</p>
</div>
</main>
</div>
);
}
@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { listTimeOff } from "@/services/staff/staff.service";
import { TimeOffList } from "@/features/staff/components/time-off-list";
export const metadata: Metadata = { title: "Time Off" };
export const dynamic = "force-dynamic";
function toCalendarDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
export default async function StaffTimeOffPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const { upcoming, past } = await listTimeOff(prisma, staff.id);
// Whole-day rows only — partial-day "Block Time" entries are managed from the calendar (see block-time-button.tsx).
const wholeDay = (rows: typeof upcoming) => rows.filter((r) => r.startMinutes === null && r.endMinutes === null);
const toEditable = (rows: typeof upcoming) =>
wholeDay(rows).map((t) => ({
id: t.id,
startDate: toCalendarDate(t.startDate),
endDate: toCalendarDate(t.endDate),
reason: t.reason,
}));
return (
<div className="pb-12">
<TopNav title="Time Off" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<TimeOffList upcoming={toEditable(upcoming)} past={toEditable(past)} />
</main>
</div>
);
}
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getWorkingHours } from "@/services/staff/staff.service";
import { Weekday } from "@/generated/prisma/enums";
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
export const metadata: Metadata = { title: "Working Hours" };
export const dynamic = "force-dynamic";
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
function formatMinutes(m: number): string {
const h = Math.floor(m / 60);
const min = m % 60;
return `${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
}
export default async function StaffWorkingHoursPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const [workingHours, businessHours] = await Promise.all([
getWorkingHours(prisma, staff.id),
prisma.businessHours.findMany({ where: { businessId: staff.businessId } }),
]);
const businessHoursLabel = Object.fromEntries(
DAY_ORDER.map((day) => {
const hours = businessHours.find((h) => h.dayOfWeek === day);
const label = !hours || hours.isClosed || hours.opensAtMinutes === null || hours.closesAtMinutes === null ? "Closed" : `${formatMinutes(hours.opensAtMinutes)}–${formatMinutes(hours.closesAtMinutes)}`;
return [day, label];
}),
) as Record<Weekday, string>;
return (
<div className="pb-12">
<TopNav title="Working Hours" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<WorkingHoursForm initialRows={workingHours} businessHoursLabel={businessHoursLabel} />
</main>
</div>
);
}
+7 -1
View File
@@ -4,6 +4,7 @@ import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { getAvailableServices } from "@/services/scheduling/availability.service";
import { ServiceSelector } from "@/features/booking/components/service-selector";
import { saveServicesStepAction } from "@/features/booking/actions/draft";
export const metadata: Metadata = { title: "Select Services" };
export const dynamic = "force-dynamic";
@@ -22,14 +23,19 @@ export default async function BookServicesPage() {
const categoryNames = new Map(categories.map((c) => [c.id, c.name]));
const orderedCategoryIds = categories.map((c) => c.id);
async function handleContinue(serviceIds: string[]) {
"use server";
return saveServicesStepAction({ businessId: business.id, serviceIds });
}
return (
<ServiceSelector
businessId={business.id}
services={services}
categoryNames={Object.fromEntries(categoryNames)}
categoryOrder={orderedCategoryIds}
initialSelectedServiceIds={draft.serviceIds}
rebooking={!!draft.rebookFromAppointmentId}
onContinue={handleContinue}
/>
);
}
@@ -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}
/>
);
}
+1 -1
View File
@@ -120,7 +120,7 @@ export default function StyleGuidePage() {
<AppointmentCard
status="confirmed"
serviceName="Standard Cut"
staff={{
person={{
id: "1",
name: "Marcus Reed",
avatarUrl: "https://storage.googleapis.com/uxpilot-auth.appspot.com/avatars/avatar-2.jpg",