From 3300045d9e3c2eeb001d7e9ccb84fed7b33421bd Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Wed, 5 Aug 2026 16:35:52 +0100 Subject: [PATCH] Staff / Owner Merged staff and owner settings --- e2e/global-setup.ts | 15 +- e2e/helpers.ts | 20 +- e2e/merged-nav.spec.ts | 195 ++++++++++++++++++ e2e/owner-journeys.spec.ts | 31 ++- src/app/(customer)/home/page.tsx | 4 +- src/app/(owner)/layout.tsx | 17 +- src/app/(owner)/owner/page.tsx | 84 ++++++-- src/app/(owner)/owner/settings/page.tsx | 76 ++----- src/app/(staff)/layout.tsx | 22 +- src/app/(staff)/staff/clients/[id]/page.tsx | 26 ++- src/app/(staff)/staff/clients/page.tsx | 12 +- .../staff/settings/notifications/page.tsx | 8 +- src/app/(staff)/staff/settings/page.tsx | 109 ++++++---- src/components/navigation/bottom-nav.tsx | 7 +- src/components/navigation/staff-shell.tsx | 18 +- .../navigation/staff-sidebar-nav.tsx | 23 +-- src/features/staff/actions/profile.ts | 13 +- .../__tests__/nav-role.integration.test.ts | 60 ++++++ src/lib/auth/permissions.ts | 19 +- .../business/get-acting-business-context.ts | 8 +- .../navigation/__tests__/nav-config.test.ts | 79 +++++++ src/lib/navigation/nav-config.ts | 50 +++++ src/lib/navigation/staff-nav-items.ts | 33 --- 23 files changed, 693 insertions(+), 236 deletions(-) create mode 100644 e2e/merged-nav.spec.ts create mode 100644 src/lib/auth/__tests__/nav-role.integration.test.ts create mode 100644 src/lib/navigation/__tests__/nav-config.test.ts create mode 100644 src/lib/navigation/nav-config.ts delete mode 100644 src/lib/navigation/staff-nav-items.ts diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 18e751f..aceff7e 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -15,7 +15,20 @@ export default function globalSetup() { ); } - const env = { ...process.env }; + const env = { + ...process.env, + // A Super Administrator is provisioned separately from prisma/seed.ts + // (see scripts/ensure-super-admin.ts's own doc comment — it deliberately + // stays out of the dev/demo seed) — the merged-nav E2E scenarios need + // one to sign in as, so this fixes deterministic test-only credentials + // when the caller hasn't already set their own (same fallback pattern + // as AUTH_SECRET in playwright.config.ts). + SUPER_ADMIN_EMAIL: process.env.SUPER_ADMIN_EMAIL || "superadmin@blakeheritage.co.uk", + SUPER_ADMIN_PASSWORD: process.env.SUPER_ADMIN_PASSWORD || "password123", + SUPER_ADMIN_FIRST_NAME: process.env.SUPER_ADMIN_FIRST_NAME || "Super", + SUPER_ADMIN_LAST_NAME: process.env.SUPER_ADMIN_LAST_NAME || "Admin", + }; execFileSync("npx", ["prisma", "migrate", "deploy"], { stdio: "inherit", env }); execFileSync("npx", ["tsx", "prisma/seed.ts"], { stdio: "inherit", env }); + execFileSync("npx", ["tsx", "scripts/ensure-super-admin.ts"], { stdio: "inherit", env }); } diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 36a216d..b2f1d30 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -11,6 +11,10 @@ export const SEEDED = { // first in the suite, so this is safe there). secondCustomer: { email: "sam.osei@example.com", password: "password123", name: "Sam Osei" }, owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" }, + // Provisioned by global-setup.ts via scripts/ensure-super-admin.ts, not + // prisma/seed.ts — a Super Administrator deliberately has no StaffProfile + // (see isSuperAdmin) and stays out of the dev/demo seed data on purpose. + superAdmin: { email: "superadmin@blakeheritage.co.uk", password: "password123", name: "Super Admin" }, barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" }, barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" }, services: { @@ -98,9 +102,21 @@ export async function completeBookingFlow( await expect(page.getByRole("heading", { name: "You're all set!" })).toBeVisible(); } -/** Locates a specific appointment card on /appointments by its rendered service + barber text, scoping later actions (Reschedule/Cancel/Book Again) to that one card so they can't accidentally hit a different appointment. */ +/** + * Locates a specific appointment card on /appointments by its rendered + * service + barber text, scoping later actions (Reschedule/Cancel/Book + * Again) to that one card so they can't accidentally hit a different + * appointment. Cards there render via AppointmentCard -> Card (see + * components/ui/card.tsx), whose root class is `rounded-md` — not + * `rounded-jumbo`, which was never an actual class in this app (confirmed: + * zero matches anywhere in the rendered page). Every test using this + * helper (2, 3, 4, 5, 6 in customer-journeys.spec.ts) was therefore always + * timing out on a selector that could never match, not on any real + * booking/query/rendering bug — same root cause already found and fixed + * in owner-journeys.spec.ts's own loyalty-redemption test. + */ export function findAppointmentCard(page: Page, serviceName: string, staffName: string) { - return page.locator(".rounded-jumbo").filter({ hasText: serviceName }).filter({ hasText: `with ${staffName}` }); + return page.locator(".rounded-md").filter({ hasText: serviceName }).filter({ hasText: `with ${staffName}` }); } export function uniqueEmail(prefix: string): string { diff --git a/e2e/merged-nav.spec.ts b/e2e/merged-nav.spec.ts new file mode 100644 index 0000000..7eda530 --- /dev/null +++ b/e2e/merged-nav.spec.ts @@ -0,0 +1,195 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { SEEDED, signIn } from "./helpers"; + +/** + * The 12 merged-navigation scenarios required by the Phase 11 brief + * ("Merge Staff and Owner Navigation into One Role-Aware Menu"), run at + * both mobile and desktop widths. See lib/navigation/nav-config.ts for the + * single source of truth these assertions are checking against, and + * (staff)/layout.tsx / (owner)/layout.tsx for where it's wired in. + * + * The bottom bar and desktop sidebar are both always present in the DOM + * (see components/navigation/bottom-nav.tsx and staff-sidebar-nav.tsx) — + * only one is visually shown per viewport width via CSS (`lg:hidden` / + * `lg:flex`), so tests select by a class fragment unique to each rather + * than by role/label alone, and independently verify the *other* one stays + * hidden. + * + * The owner-role, mobile-viewport tests (3–10, 12) share one signed-in + * page instead of each calling signIn() fresh — /sign-in rate-limits + * repeated attempts for the same account (see + * lib/security/rate-limit.ts's `signin:` bucket, 30/15min), and this file + * plus owner-journeys.spec.ts plus staff-journeys.spec.ts all authenticate + * as the same seeded owner throughout a single `npm run test:e2e` + * process — the combined count was tipping over that limit before this + * consolidation. Test 11 (desktop) and 13 (Super Administrator) still get + * their own page, since they need a different viewport/account. + */ + +const MOBILE = { width: 390, height: 844 }; +const DESKTOP = { width: 1280, height: 800 }; + +function bottomBar(page: Page) { + return page.locator('nav[aria-label="Primary"][class*="bottom-0"]'); +} + +function sidebar(page: Page) { + return page.locator('nav[aria-label="Primary"][class*="inset-y-0"]'); +} + +test.describe.serial("merged role-aware navigation", () => { + test("1. barber sees exactly the 4 merged bottom-nav items (mobile)", async ({ page }) => { + await page.setViewportSize(MOBILE); + await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); + await page.goto("/staff"); + + const bar = bottomBar(page); + await expect(bar).toBeVisible(); + await expect(sidebar(page)).toBeHidden(); + await expect(bar.getByRole("link")).toHaveText(["Calendar", "Now", "Clients", "Settings"]); + }); + + test("2. barber does not see Manage and cannot reach owner routes directly", async ({ page }) => { + await page.setViewportSize(MOBILE); + await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); + await page.goto("/staff"); + + await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveCount(0); + + // Route-level authorization must hold independent of what nav is shown + // (see (owner)/layout.tsx's hasPermission(MANAGE_STAFF) gate) — a + // barber hitting /owner directly is bounced to /staff, not merely kept + // off a nav link. + await page.goto("/owner"); + await expect(page).toHaveURL(/\/staff$/); + }); + + test.describe("owner, mobile — one shared session for tests 3–10, 12", () => { + let page: Page; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + await page.setViewportSize(MOBILE); + await signIn(page, SEEDED.owner.email, "password123"); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test("3. owner sees exactly the 5 merged bottom-nav items (mobile)", async () => { + await page.goto("/owner"); + + const bar = bottomBar(page); + await expect(bar).toBeVisible(); + await expect(bar.getByRole("link")).toHaveText(["Calendar", "Now", "Clients", "Manage", "Settings"]); + }); + + test("4. owner opens Now directly from the merged nav — no mode switch", async () => { + await page.goto("/owner"); + + await bottomBar(page).getByRole("link", { name: "Now" }).click(); + await expect(page).toHaveURL(/\/staff\/current$/); + }); + + test("5. owner opens Manage and lands on the Dashboard overview", async () => { + await page.goto("/staff"); + + await bottomBar(page).getByRole("link", { name: "Manage" }).click(); + await expect(page).toHaveURL(/\/owner$/); + await expect(page.getByRole("heading", { name: "Manage" })).toBeVisible(); + await expect(page.getByText("Today's appointments")).toBeVisible(); + }); + + test("6. owner reaches Team from Manage", async () => { + await page.goto("/owner"); + + await page.getByRole("link", { name: "Team Management" }).click(); + await expect(page).toHaveURL(/\/owner\/team$/); + }); + + test("7. owner reaches Services from Manage", async () => { + await page.goto("/owner"); + + await page.getByRole("link", { name: "Services", exact: true }).click(); + await expect(page).toHaveURL(/\/owner\/services$/); + }); + + test("8. owner reaches Business Details from Manage", async () => { + await page.goto("/owner"); + + await page.getByRole("link", { name: "Business Details" }).click(); + await expect(page).toHaveURL(/\/owner\/settings\/business$/); + }); + + test("9. owner returns to Calendar from Manage without switching modes", async () => { + await page.goto("/owner"); + + await bottomBar(page).getByRole("link", { name: "Calendar" }).click(); + await expect(page).toHaveURL(/\/owner\/calendar$/); + }); + + test("10. nested routes keep the correct primary tab active", async () => { + await page.goto("/staff/current"); + await expect(bottomBar(page).getByRole("link", { name: "Now" })).toHaveAttribute("aria-current", "page"); + + await page.goto("/owner/team"); + await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveAttribute("aria-current", "page"); + + await page.goto("/owner/services"); + await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveAttribute("aria-current", "page"); + + await page.goto("/owner/settings/loyalty"); + await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveAttribute("aria-current", "page"); + + await page.goto("/staff/settings/calendar"); + await expect(bottomBar(page).getByRole("link", { name: "Settings" })).toHaveAttribute("aria-current", "page"); + }); + + test("12. no switch-mode controls remain anywhere in the app", async () => { + for (const path of ["/staff/settings", "/owner", "/owner/settings"]) { + await page.goto(path); + await expect(page.getByText("Switch to Owner Admin")).toHaveCount(0); + await expect(page.getByText("Switch to Barber Tools")).toHaveCount(0); + } + // /owner/settings is a compatibility redirect to /staff/settings (see + // (owner)/owner/settings/page.tsx) — the old bookmark still works. + await expect(page).toHaveURL(/\/staff\/settings$/); + }); + }); + + test("11. desktop sidebar uses the same merged structure as mobile", async ({ page }) => { + await page.setViewportSize(DESKTOP); + await signIn(page, SEEDED.owner.email, "password123"); + await page.goto("/owner"); + + const side = sidebar(page); + await expect(side).toBeVisible(); + await expect(bottomBar(page)).toBeHidden(); + await expect(side.getByRole("link")).toHaveText(["Calendar", "Now", "Clients", "Manage", "Settings"]); + + await page.goto("/owner/team"); + await expect(side.getByRole("link", { name: "Manage" })).toHaveAttribute("aria-current", "page"); + }); + + test("13. a Super Administrator sees the business-wide merged nav with no personal schedule tab", async ({ page }) => { + await page.setViewportSize(MOBILE); + await signIn(page, SEEDED.superAdmin.email, SEEDED.superAdmin.password); + await page.goto("/owner"); + + const bar = bottomBar(page); + await expect(bar).toBeVisible(); + await expect(bar.getByRole("link")).toHaveText(["Calendar", "Clients", "Manage", "Settings"]); + await expect(bar.getByRole("link", { name: "Now" })).toHaveCount(0); + + await bar.getByRole("link", { name: "Settings" }).click(); + await expect(page).toHaveURL(/\/staff\/settings$/); + // Two "Notifications" links exist on this page — the fixed notification + // bell (aria-label="Notifications") and the Settings hub's own + // Notifications row — so scope to the settings list by href, not the bell. + await expect(page.locator('a[href="/staff/settings/notifications"]')).toBeVisible(); + await expect(page.getByRole("link", { name: "Working Hours" })).toHaveCount(0); + await expect(page.getByRole("link", { name: "Google Calendar" })).toHaveCount(0); + }); +}); diff --git a/e2e/owner-journeys.spec.ts b/e2e/owner-journeys.spec.ts index 665e37a..0fae67e 100644 --- a/e2e/owner-journeys.spec.ts +++ b/e2e/owner-journeys.spec.ts @@ -35,7 +35,10 @@ test.describe.serial("owner administration journeys", () => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner"); - await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + // The Manage hub's H1 (see (owner)/owner/page.tsx) — Dashboard content + // is shown first/most prominently inside it rather than on its own + // page, per the merged-nav brief's "Dashboard placement" requirement. + await expect(page.getByRole("heading", { name: "Manage" })).toBeVisible(); // Seeded fixtures from prisma/seed.ts: Owen Clarke has no working hours, // and "Kids Haircut (Under 12)" has no eligible active staff member. await expect(page.getByText("Owen Clarke has no working hours set")).toBeVisible(); @@ -294,7 +297,11 @@ test.describe.serial("owner administration journeys", () => { // Seeded: Sam Osei has a CHECKED_IN appointment with Leo today, and sits // exactly at the loyalty reward threshold with one AVAILABLE reward. - const card = page.locator(".rounded-jumbo").filter({ hasText: "Sam Osei" }).filter({ hasText: "Arrived" }); + // This page renders agenda cards via AppointmentCard -> Card (see + // components/ui/card.tsx), whose root class is `rounded-md` — not + // `rounded-jumbo`, which was never an actual class anywhere in this + // app (see findAppointmentCard in helpers.ts, which had the same bug). + const card = page.locator(".rounded-md").filter({ hasText: "Sam Osei" }).filter({ hasText: "Arrived" }); await card.getByRole("link", { name: "View", exact: true }).click(); await expect(page.getByText(/Subtotal/)).toBeVisible(); @@ -315,23 +322,29 @@ test.describe.serial("owner administration journeys", () => { await page.goto("/owner"); await expect(page).toHaveURL(/\/staff$/); await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Dashboard" })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Manage" })).toHaveCount(0); }); - test("12. the owner switches back to barber tools without losing the barber nav", async ({ page }) => { + test("12. the owner reaches barber tools straight from the merged nav — no mode switch", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner"); - await page.getByRole("link", { name: "Barber Tools", exact: true }).click(); - await expect(page).toHaveURL(/\/staff$/); - await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Current", exact: true })).toBeVisible(); + // The old "Switch to Owner Admin"/"Switch to Barber Tools" links are + // gone (see the merged-nav brief) — an owner reaches their own barber + // tools via the same "Now" nav item a barber uses, not a mode switch. + await expect(page.getByText("Barber Tools")).toHaveCount(0); + await expect(page.getByText("Switch to Barber Tools")).toHaveCount(0); + await page.getByRole("link", { name: "Now", exact: true }).first().click(); + await expect(page).toHaveURL(/\/staff\/current$/); + await expect(page.getByRole("link", { name: "Settings", exact: true }).first()).toBeVisible(); }); test("13. owner reports show real, data-backed appointment/service/staff/customer/loyalty/notification figures", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner"); - await page.getByRole("link", { name: "View Reports" }).click(); + // "View Reports" was the old Dashboard footer's button label; Reports is + // now one of the Manage hub's grouped sections (see (owner)/owner/page.tsx). + await page.getByRole("link", { name: "Reports", exact: true }).click(); await expect(page).toHaveURL(/\/owner\/reports/); await expect(page.getByRole("heading", { name: "Reports", exact: true })).toBeVisible(); diff --git a/src/app/(customer)/home/page.tsx b/src/app/(customer)/home/page.tsx index d5854eb..4f67b63 100644 --- a/src/app/(customer)/home/page.tsx +++ b/src/app/(customer)/home/page.tsx @@ -258,11 +258,11 @@ export default async function CustomerHomePage() {

{preferredBarberName}

)} - {lastCompletedAppointment && ( + {/* {lastCompletedAppointment && ( - )} + )} */} )} diff --git a/src/app/(owner)/layout.tsx b/src/app/(owner)/layout.tsx index 107921e..357f129 100644 --- a/src/app/(owner)/layout.tsx +++ b/src/app/(owner)/layout.tsx @@ -1,16 +1,19 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { StaffShell } from "@/components/navigation/staff-shell"; -import { hasPermission } from "@/lib/auth/permissions"; +import { getNavRole, hasPermission } from "@/lib/auth/permissions"; import { Permission } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db/prisma"; -import { OWNER_NAV_ITEMS } from "@/lib/navigation/staff-nav-items"; +import { getNavItems } from "@/lib/navigation/nav-config"; import { getUnreadNotificationCount } from "@/services/notifications/notification.service"; /** - * Owner is a permission on a staff account, not a separate account type - * (see docs/implementation-plan.md §3, §11) — this layout reuses the exact - * same shell/nav as (staff), and only adds a permission check on top. + * Owner and Super Administrator are permissions, not separate account + * types (see docs/implementation-plan.md §3, §11) — this layout reuses the + * exact same shell/nav as (staff), and only adds a permission check on + * top. There is no "switch to owner tools" control anywhere in the app — + * a user who can manage staff simply sees "Manage" in their one merged + * nav (see lib/navigation/nav-config.ts). */ export default async function OwnerLayout({ children }: { children: React.ReactNode }) { const session = await auth(); @@ -23,12 +26,12 @@ export default async function OwnerLayout({ children }: { children: React.ReactN redirect("/staff"); } + const role = (await getNavRole(session.user.id)) ?? "owner"; const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id); return ( diff --git a/src/app/(owner)/owner/page.tsx b/src/app/(owner)/owner/page.tsx index 6fc9783..6a21f5f 100644 --- a/src/app/(owner)/owner/page.tsx +++ b/src/app/(owner)/owner/page.tsx @@ -2,11 +2,19 @@ import type { Metadata } from "next"; import Link from "next/link"; import { AlertTriangle, + BarChart3, + Building2, Calendar, + CalendarClock, CalendarX2, CheckCheck, + ClipboardList, + Crown, Gift, + Images, + ScissorsSquare, Stamp, + Star, TrendingUp, UserX, Users, @@ -37,6 +45,55 @@ function StatTile({ icon: Icon, label, value, hint }: { icon: typeof Calendar; l ); } +const MANAGE_SECTIONS = [ + { + title: "Business", + links: [ + { href: "/owner/settings/business", label: "Business Details", icon: Building2 }, + { href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock }, + { href: "/owner/settings/gallery", label: "Gallery", icon: Images }, + ], + }, + { + title: "Booking", + links: [ + { href: "/owner/services", label: "Services", icon: ScissorsSquare }, + { href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList }, + { href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown }, + ], + }, + { + title: "Customers", + links: [{ href: "/owner/settings/reviews", label: "Reviews", icon: Star }], + }, + { + title: "Team", + links: [{ href: "/owner/team", label: "Team Management", icon: Users }], + }, + { + title: "Reports", + links: [{ href: "/owner/reports", label: "Reports", icon: BarChart3 }], + }, +] as const; + +function ManageSection({ title, links }: (typeof MANAGE_SECTIONS)[number]) { + return ( +
+

{title}

+
    + {links.map(({ href, label, icon: Icon }) => ( +
  • + +
  • + ))} +
+
+ ); +} + export default async function OwnerDashboardPage() { const session = await auth(); if (!session?.user) redirect("/sign-in?callbackUrl=/owner"); @@ -57,8 +114,8 @@ export default async function OwnerDashboardPage() { return (
-

Dashboard

-

{context.businessName} — today

+

Manage

+

{context.businessName} — today's overview

{summary.warnings.length > 0 && ( @@ -168,25 +225,10 @@ export default async function OwnerDashboardPage() { )} -
- - View Reports - - - Manage Team - - - Update Booking Rules - +
+ {MANAGE_SECTIONS.map((section) => ( + + ))}
); diff --git a/src/app/(owner)/owner/settings/page.tsx b/src/app/(owner)/owner/settings/page.tsx index 8a797b8..93894dc 100644 --- a/src/app/(owner)/owner/settings/page.tsx +++ b/src/app/(owner)/owner/settings/page.tsx @@ -1,66 +1,16 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { Bell, Building2, CalendarClock, ClipboardList, Crown, Images, ScissorsSquare, Star } from "lucide-react"; import { redirect } from "next/navigation"; -import { auth } from "@/auth"; -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"; -import { getBranding } from "@/lib/business/branding"; -import { InstallAppButton } from "@/features/pwa/components/install-app-button"; -export const metadata: Metadata = { title: "Owner Settings" }; -export const dynamic = "force-dynamic"; - -const SETTINGS_LINKS = [ - { href: "/owner/settings/business", label: "Business Details", icon: Building2 }, - { href: "/owner/settings/gallery", label: "Gallery", icon: Images }, - { href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock }, - { href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList }, - { href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown }, - { href: "/owner/settings/reviews", label: "Reviews", icon: Star }, - { href: "/staff/settings/notifications", label: "Notifications", icon: Bell }, -] as const; - -export default async function OwnerSettingsPage() { - const session = await auth(); - if (!session?.user) redirect("/sign-in"); - - const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF); - if (!canManage) redirect("/staff"); - const branding = await getBranding(); - - return ( -
-

Owner Settings

- - - -
- ); +/** + * Kept as a redirect rather than deleted so any existing bookmark to + * `/owner/settings` still works (see the Phase 11 merged-nav brief: "avoid + * unnecessary route changes... add redirects only where old nav entry + * routes become obsolete"). The page it used to render — a mix of personal + * settings (Notifications) and business settings (Business Details, + * Gallery, Hours, Booking Rules, Loyalty, Reviews) plus a "Switch to + * Barber Tools" link — is now split cleanly: personal settings live at the + * one shared `/staff/settings` hub, and business settings are reachable + * from the "Manage" nav item's grouped links (see (owner)/owner/page.tsx). + */ +export default function OwnerSettingsRedirectPage() { + redirect("/staff/settings"); } diff --git a/src/app/(staff)/layout.tsx b/src/app/(staff)/layout.tsx index 138eeec..cb9719b 100644 --- a/src/app/(staff)/layout.tsx +++ b/src/app/(staff)/layout.tsx @@ -1,6 +1,8 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { StaffShell } from "@/components/navigation/staff-shell"; +import { getNavRole } from "@/lib/auth/permissions"; +import { getNavItems } from "@/lib/navigation/nav-config"; import { prisma } from "@/lib/db/prisma"; import { getUnreadNotificationCount } from "@/services/notifications/notification.service"; @@ -11,18 +13,26 @@ export default async function StaffLayout({ children }: { children: React.ReactN } // Real role gating, not just "is signed in" — a registered customer with - // no StaffProfile is bounced back to their own home rather than seeing - // an empty staff shell. See docs/implementation-plan.md §3 (role - // conflicts) and §11 (permission enforcement). - const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } }); - if (!staffProfile) { + // no StaffProfile (and no Super Administrator flag) is bounced back to + // their own home rather than seeing an empty staff shell. See + // docs/implementation-plan.md §3 (role conflicts) and §11 (permission + // enforcement). A Super Administrator has no StaffProfile by design (see + // isSuperAdmin) but is still admitted here — they need this route group + // for Clients and the personal Settings hub (Profile/Notifications/Sign + // Out), even though they have no personal barber schedule. + const role = await getNavRole(session.user.id); + if (!role) { redirect("/home"); } const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id); return ( - + {children} ); diff --git a/src/app/(staff)/staff/clients/[id]/page.tsx b/src/app/(staff)/staff/clients/[id]/page.tsx index 2eedc81..0938d75 100644 --- a/src/app/(staff)/staff/clients/[id]/page.tsx +++ b/src/app/(staff)/staff/clients/[id]/page.tsx @@ -10,7 +10,7 @@ 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 { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service"; import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold"; import { listAppointments } from "@/services/scheduling/appointment.service"; @@ -38,8 +38,8 @@ export default async function StaffCustomerDetailsPage({ }: { params: Promise<{ id: string }>; }) { - const staff = await getActingStaff(); - if (!staff) redirect("/home"); + const context = await getActingBusinessContext(); + if (!context) redirect("/home"); const { id } = await params; const customer = await prisma.customerProfile.findUnique({ @@ -54,7 +54,7 @@ export default async function StaffCustomerDetailsPage({ // "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 }, + where: { customerId: id, businessId: context.businessId }, select: { id: true }, }); if (!belongsToBusiness) notFound(); @@ -62,17 +62,17 @@ export default async function StaffCustomerDetailsPage({ const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([ listAppointments(prisma, { customerId: id, - businessId: staff.businessId, + businessId: context.businessId, statuses: ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"], }), listAppointments(prisma, { customerId: id, - businessId: staff.businessId, + businessId: context.businessId, statuses: ["COMPLETED"], }), listAppointments(prisma, { customerId: id, - businessId: staff.businessId, + businessId: context.businessId, statuses: ["CANCELLED", "NO_SHOW"], }), prisma.customerNote.findMany({ @@ -80,7 +80,7 @@ export default async function StaffCustomerDetailsPage({ orderBy: { createdAt: "desc" }, include: { author: { include: { user: { select: { name: true } } } } }, }), - getLoyaltySummary(prisma, { customerId: id, businessId: staff.businessId }), + getLoyaltySummary(prisma, { customerId: id, businessId: context.businessId }), ]); const loyaltyTransactions = loyalty?.accountId @@ -96,7 +96,7 @@ export default async function StaffCustomerDetailsPage({ authorName: n.author.user.name ?? "Staff", })); - const tz = staff.business.timezone; + const tz = context.businessTimezone; const renderList = (appointments: typeof upcoming, emptyLabel: string) => { if (appointments.length === 0) { @@ -263,7 +263,13 @@ export default async function StaffCustomerDetailsPage({

Notes

- + {/* A Super Administrator has no StaffProfile/staffId — passing "" is a + deliberate never-matches sentinel (see CustomerNotes' isOwn check), + so they can view every note but own none. Adding a note as a + Super Administrator fails gracefully server-side (see + addCustomerNoteAction: "You must be signed in as staff.") rather + than fabricating a staff identity for them. */} +
diff --git a/src/app/(staff)/staff/clients/page.tsx b/src/app/(staff)/staff/clients/page.tsx index bde595d..09a36e1 100644 --- a/src/app/(staff)/staff/clients/page.tsx +++ b/src/app/(staff)/staff/clients/page.tsx @@ -4,7 +4,7 @@ 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 { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { matchesCustomerSearch } from "@/services/customer/customer.service"; import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service"; import { cn } from "@/lib/utils/cn"; @@ -19,8 +19,8 @@ export default async function StaffClientsPage({ }: { searchParams: Promise<{ q?: string; sort?: string }>; }) { - const staff = await getActingStaff(); - if (!staff) redirect("/home"); + const context = await getActingBusinessContext(); + if (!context) redirect("/home"); const { q = "", sort } = await searchParams; @@ -33,7 +33,7 @@ export default async function StaffClientsPage({ // every appointment of every client on every page view (see the Phase 7 // performance pass). const customers = await prisma.customerProfile.findMany({ - where: { appointments: { some: { businessId: staff.businessId } } }, + where: { appointments: { some: { businessId: context.businessId } } }, select: { id: true, firstName: true, @@ -41,7 +41,7 @@ export default async function StaffClientsPage({ email: true, phone: true, appointments: { - where: { businessId: staff.businessId }, + where: { businessId: context.businessId }, orderBy: { startAt: "desc" }, select: { status: true, startAt: true }, }, @@ -49,7 +49,7 @@ export default async function StaffClientsPage({ }); const loyaltySummaries = await getLoyaltySummariesForCustomers(prisma, { - businessId: staff.businessId, + businessId: context.businessId, customerIds: customers.map((c) => c.id), }); diff --git a/src/app/(staff)/staff/settings/notifications/page.tsx b/src/app/(staff)/staff/settings/notifications/page.tsx index 44e8046..f9c7892 100644 --- a/src/app/(staff)/staff/settings/notifications/page.tsx +++ b/src/app/(staff)/staff/settings/notifications/page.tsx @@ -1,9 +1,9 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import { auth } from "@/auth"; import { TopNav } from "@/components/navigation/top-nav"; import { NotificationChannel, NotificationType } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db/prisma"; -import { getActingStaff } from "@/lib/staff/get-acting-staff"; import { getNotificationPreferences } from "@/services/notifications/notification.service"; import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form"; import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form"; @@ -16,10 +16,10 @@ export const dynamic = "force-dynamic"; const REMINDER_CHANNELS: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH]; export default async function StaffNotificationsPage() { - const staff = await getActingStaff(); - if (!staff) redirect("/home"); + const session = await auth(); + if (!session?.user?.id) redirect("/sign-in?callbackUrl=/staff/settings/notifications"); - const rows = await getNotificationPreferences(prisma, staff.userId); + const rows = await getNotificationPreferences(prisma, session.user.id); const cells: PreferenceCellState[] = REMINDER_CHANNELS.map((channel) => ({ channel, isEnabled: diff --git a/src/app/(staff)/staff/settings/page.tsx b/src/app/(staff)/staff/settings/page.tsx index 128a45e..6fbff47 100644 --- a/src/app/(staff)/staff/settings/page.tsx +++ b/src/app/(staff)/staff/settings/page.tsx @@ -1,54 +1,38 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { Bell, CalendarClock, CalendarOff, CalendarSync, Clock, ShieldCheck, UserCircle } from "lucide-react"; +import { Bell, CalendarClock, CalendarOff, CalendarSync, Clock, UserCircle } from "lucide-react"; import { auth } from "@/auth"; 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"; +import { getNavRole } from "@/lib/auth/permissions"; import { getBranding } from "@/lib/business/branding"; import { InstallAppButton } from "@/features/pwa/components/install-app-button"; -const SETTINGS_LINKS = [ +const ACCOUNT_LINKS = [ + { href: "/staff/settings/profile", label: "Profile", icon: UserCircle }, + { href: "/staff/settings/notifications", label: "Notifications", icon: Bell }, +] as const; + +const WORKING_PATTERN_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 }, - { href: "/staff/settings/calendar", label: "Google Calendar", icon: CalendarSync }, ] as const; -export const metadata: Metadata = { title: "Staff Settings" }; - -export default async function StaffSettingsPage() { - const session = await auth(); - if (!session?.user) return null; - - // Real permission check, not the templates' unconditional mock "Switch to - // Admin View" button (see docs/implementation-plan.md §3) — only staff - // whose Role actually grants MANAGE_STAFF see a way into the owner area. - const [canManageStaff, branding] = await Promise.all([ - hasPermission(session.user.id, Permission.MANAGE_STAFF), - getBranding(), - ]); +const INTEGRATION_LINKS = [{ href: "/staff/settings/calendar", label: "Google Calendar", icon: CalendarSync }] as const; +function SettingsSection({ + title, + links, +}: { + title: string; + links: ReadonlyArray<{ href: string; label: string; icon: typeof UserCircle }>; +}) { return ( -
-

Staff Settings

- - - {canManageStaff && ( - -