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
@@ -15,7 +15,7 @@ import { cn } from "@/lib/utils/cn";
|
||||
* to Client Components from Server Components"). A string key sidesteps
|
||||
* that entirely.
|
||||
*/
|
||||
const ICON_MAP = {
|
||||
export const NAV_ICON_MAP = {
|
||||
house: House,
|
||||
"calendar-check": CalendarCheck,
|
||||
user: User,
|
||||
@@ -25,7 +25,7 @@ const ICON_MAP = {
|
||||
settings: Settings,
|
||||
} satisfies Record<string, LucideIcon>;
|
||||
|
||||
export type BottomNavIconName = keyof typeof ICON_MAP;
|
||||
export type BottomNavIconName = keyof typeof NAV_ICON_MAP;
|
||||
|
||||
export interface BottomNavItem {
|
||||
href: string;
|
||||
@@ -61,6 +61,11 @@ export function BottomNav({ items, variant = "customer" }: BottomNavProps) {
|
||||
"fixed inset-x-0 bottom-0 z-50 border-t px-5 py-2",
|
||||
"pb-[max(0.5rem,env(safe-area-inset-bottom))]",
|
||||
isStaff ? "border-white/5 bg-[#0c0c0c] text-gray-400" : "border-line text-muted bg-white",
|
||||
// Staff/owner get a persistent left sidebar at lg+ instead (see
|
||||
// staff-sidebar-nav.tsx) — a bottom bar doesn't earn its keep once
|
||||
// there's room for one-hand-unnecessary desktop chrome. Customer
|
||||
// nav stays bottom-anchored at every size; that's Phase 4 scope.
|
||||
isStaff && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
{/* The bar itself stays edge-to-edge (matches the booking flow's fixed
|
||||
@@ -68,7 +73,7 @@ export function BottomNav({ items, variant = "customer" }: BottomNavProps) {
|
||||
they don't stretch out unreadably on a wide desktop viewport. */}
|
||||
<div className="mx-auto flex w-full max-w-2xl items-center justify-between">
|
||||
{items.map(({ href, label, icon }) => {
|
||||
const Icon = ICON_MAP[icon];
|
||||
const Icon = NAV_ICON_MAP[icon];
|
||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
import { StaffSidebarNav } from "./staff-sidebar-nav";
|
||||
import { STAFF_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
|
||||
|
||||
/** Shared by (staff) and (owner) — see docs/implementation-plan.md §3: owner is a permission on a staff account, not a separate shell. */
|
||||
export function StaffShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone pb-24 lg:pb-0 lg:pl-56">
|
||||
<StaffSidebarNav items={STAFF_NAV_ITEMS} />
|
||||
{children}
|
||||
<BottomNav items={STAFF_NAV_ITEMS} variant="staff" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Scissors } from "lucide-react";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { NAV_ICON_MAP } from "./bottom-nav";
|
||||
import type { BottomNavItem } from "./bottom-nav";
|
||||
|
||||
/**
|
||||
* The desktop counterpart to BottomNav's staff variant — a persistent left
|
||||
* rail instead of a bottom bar, shown only at lg+ (BottomNav hides itself
|
||||
* there in turn, see bottom-nav.tsx). Same dark palette, same nav items,
|
||||
* same active-route logic; just a different shape for a screen with room
|
||||
* to spare, per the brief's explicit call-out that staff desktop needs
|
||||
* more than the mobile bar simply centered.
|
||||
*/
|
||||
export function StaffSidebarNav({ items }: { items: BottomNavItem[] }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className="fixed inset-y-0 left-0 z-50 hidden w-56 flex-col border-r border-white/5 bg-[#0c0c0c] px-4 py-6 lg:flex"
|
||||
>
|
||||
<div className="mb-8 flex items-center gap-2 px-2 text-white">
|
||||
<Scissors className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-sm font-bold tracking-widest uppercase">NextChair</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map(({ href, label, icon }) => {
|
||||
const Icon = NAV_ICON_MAP[icon];
|
||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-bold transition-colors",
|
||||
active ? "bg-white/10 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { Gift, NotebookText } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { StatusPill } from "@/components/ui/status-pill";
|
||||
@@ -7,11 +8,18 @@ import type { AppointmentStatus, StaffSummary } from "@/types/domain";
|
||||
export interface AppointmentCardProps {
|
||||
status: AppointmentStatus;
|
||||
serviceName: string;
|
||||
staff: StaffSummary;
|
||||
/** Pre-formatted for display, e.g. "Sat, Oct 12". */
|
||||
formattedDate: string;
|
||||
/** The other party shown on the card — the barber on a customer-facing card, the customer on a staff-facing one. Same {id, name, avatarUrl} shape either way. */
|
||||
person: StaffSummary;
|
||||
/** Defaults to "with" — a staff card might prefer e.g. "for". */
|
||||
personLabel?: string;
|
||||
/** Pre-formatted for display, e.g. "Sat, Oct 12" — omit when the surrounding page already makes the date clear (e.g. a single day's agenda). */
|
||||
formattedDate?: string;
|
||||
/** Pre-formatted for display, e.g. "2:00 PM". */
|
||||
formattedTime: string;
|
||||
/** Small notes-exist indicator — staff calendar/agenda use, customer cards leave this unset. */
|
||||
hasNotes?: boolean;
|
||||
/** Small loyalty-reward-available indicator — same. */
|
||||
hasAvailableReward?: boolean;
|
||||
/** Reschedule/Cancel/Book again buttons — left to the caller so this
|
||||
* component stays agnostic of which actions apply to which status. */
|
||||
actions?: ReactNode;
|
||||
@@ -21,9 +29,12 @@ export interface AppointmentCardProps {
|
||||
export function AppointmentCard({
|
||||
status,
|
||||
serviceName,
|
||||
staff,
|
||||
person,
|
||||
personLabel = "with",
|
||||
formattedDate,
|
||||
formattedTime,
|
||||
hasNotes,
|
||||
hasAvailableReward,
|
||||
actions,
|
||||
}: AppointmentCardProps) {
|
||||
return (
|
||||
@@ -31,30 +42,43 @@ export function AppointmentCard({
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<StatusPill status={status} />
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold">{formattedDate}</p>
|
||||
<p className="text-xs text-muted">{formattedTime}</p>
|
||||
{formattedDate && <p className="text-sm font-bold">{formattedDate}</p>}
|
||||
<p className="text-muted text-xs">{formattedTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
{staff.avatarUrl ? (
|
||||
{person.avatarUrl ? (
|
||||
<Image
|
||||
src={staff.avatarUrl}
|
||||
src={person.avatarUrl}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-stone text-xs font-bold text-muted">
|
||||
{staff.name.charAt(0)}
|
||||
<div className="bg-stone text-muted flex h-10 w-10 items-center justify-center rounded-full text-xs font-bold">
|
||||
{person.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold">{serviceName}</h3>
|
||||
<p className="text-xs text-muted">with {staff.name}</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="truncate text-sm font-bold">{serviceName}</h3>
|
||||
{hasNotes && (
|
||||
<NotebookText className="text-muted h-3.5 w-3.5 shrink-0" aria-label="Has notes" />
|
||||
)}
|
||||
{hasAvailableReward && (
|
||||
<Gift
|
||||
className="text-accent-loyalty h-3.5 w-3.5 shrink-0"
|
||||
aria-label="Reward available"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted text-xs">
|
||||
{personLabel} {person.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex gap-2 border-t border-line pt-4">{actions}</div>}
|
||||
{actions && <div className="border-line flex gap-2 border-t pt-4">{actions}</div>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,74 @@
|
||||
import { Ban, CheckCheck, CircleDot, Clock, LogIn, UserX } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
const STATUS_CONFIG: Record<AppointmentStatus, { label: string; className: string }> = {
|
||||
/**
|
||||
* Icon + text + colour, never colour alone — the barber calendar packs
|
||||
* these into small blocks where colour alone would be genuinely hard to
|
||||
* tell apart at a glance (see docs/phase-5-summary.md, Accessibility).
|
||||
* Reused as-is on the customer side too, where the icon is a small,
|
||||
* harmless addition to a pill that already carried a text label.
|
||||
*/
|
||||
const STATUS_CONFIG: Record<
|
||||
AppointmentStatus,
|
||||
{ label: string; className: string; icon: LucideIcon }
|
||||
> = {
|
||||
pending_approval: {
|
||||
label: "Pending Approval",
|
||||
className: "bg-status-pending/10 text-status-pending",
|
||||
icon: Clock,
|
||||
},
|
||||
confirmed: {
|
||||
label: "Confirmed",
|
||||
className: "bg-status-confirmed/10 text-status-confirmed",
|
||||
icon: CheckCheck,
|
||||
},
|
||||
arrived: {
|
||||
label: "Arrived",
|
||||
className: "bg-status-confirmed/10 text-status-confirmed",
|
||||
icon: LogIn,
|
||||
},
|
||||
in_progress: {
|
||||
label: "In Progress",
|
||||
className: "bg-status-progress/10 text-status-progress",
|
||||
icon: CircleDot,
|
||||
},
|
||||
completed: { label: "Completed", className: "bg-stone text-muted", icon: CheckCheck },
|
||||
cancelled: {
|
||||
label: "Cancelled",
|
||||
className: "bg-status-cancelled/10 text-status-cancelled",
|
||||
icon: Ban,
|
||||
},
|
||||
no_show: {
|
||||
label: "No-show",
|
||||
className: "bg-status-cancelled/10 text-status-cancelled",
|
||||
icon: UserX,
|
||||
},
|
||||
confirmed: { label: "Confirmed", className: "bg-status-confirmed/10 text-status-confirmed" },
|
||||
arrived: { label: "Arrived", className: "bg-status-confirmed/10 text-status-confirmed" },
|
||||
in_progress: { label: "In Progress", className: "bg-status-progress/10 text-status-progress" },
|
||||
completed: { label: "Completed", className: "bg-stone text-muted" },
|
||||
cancelled: { label: "Cancelled", className: "bg-status-cancelled/10 text-status-cancelled" },
|
||||
no_show: { label: "No-show", className: "bg-status-cancelled/10 text-status-cancelled" },
|
||||
};
|
||||
|
||||
export function StatusPill({
|
||||
status,
|
||||
className,
|
||||
iconOnly,
|
||||
}: {
|
||||
status: AppointmentStatus;
|
||||
className?: string;
|
||||
/** Renders just the icon with a visually-hidden label — for tight spaces like calendar blocks. */
|
||||
iconOnly?: boolean;
|
||||
}) {
|
||||
const config = STATUS_CONFIG[status];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded-full px-3 py-1 text-[10px] font-bold tracking-widest uppercase",
|
||||
"inline-flex items-center gap-1 rounded-full px-3 py-1 text-[10px] font-bold tracking-widest uppercase",
|
||||
config.className,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
{iconOnly ? <span className="sr-only">{config.label}</span> : config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user