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 && (
Book Again
- )}
+ )} */}
)}
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 }) => (
+
+
+
+ {label}
+
+
+ ))}
+
+
+ );
+}
+
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
-
-
-
-
- Switch to Barber Tools
-
-
-
- {SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
-
-
-
- {label}
-
-
- ))}
-
-
-
-
-
-
- );
+/**
+ * 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 && (
-
-
- Switch to Owner Admin
-
- )}
-
+
+
{title}
- {SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
+ {links.map(({ href, label, icon: Icon }) => (
@@ -57,13 +41,60 @@ export default async function StaffSettingsPage() {
))}
+
+ );
+}
-
-
Get the app
-
+export const metadata: Metadata = { title: "Settings" };
+
+/**
+ * The one personal Settings hub shared by every signed-in staff member,
+ * owner and Super Administrator — there is no separate owner settings hub
+ * or "Switch to Owner Admin" link here anymore (business management now
+ * lives behind the "Manage" nav item, see (owner)/owner/page.tsx). Working
+ * Pattern and Integrations only apply to a real personal barber schedule,
+ * so a Super Administrator (who deliberately has no StaffProfile — see
+ * isSuperAdmin) sees only Notifications and Sign Out; their identity is
+ * shown via the ProfileCard above rather than a "Profile" link, since the
+ * profile-edit page below is staff-bio-specific and a Super Administrator
+ * has no StaffProfile to edit.
+ */
+export default async function StaffSettingsPage() {
+ const session = await auth();
+ if (!session?.user) return null;
+
+ const [role, branding] = await Promise.all([getNavRole(session.user.id), getBranding()]);
+ const isSuperAdmin = role === "superadmin";
+
+ return (
+
+
Settings
+
+
+
+ l.label === "Notifications") : ACCOUNT_LINKS}
+ />
+
+ {!isSuperAdmin && (
+ <>
+
+
+ >
+ )}
-
+
);
}
diff --git a/src/components/navigation/bottom-nav.tsx b/src/components/navigation/bottom-nav.tsx
index f9f4b77..848d05a 100644
--- a/src/components/navigation/bottom-nav.tsx
+++ b/src/components/navigation/bottom-nav.tsx
@@ -2,9 +2,10 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
-import { CalendarCheck, CalendarDays, ChartLine, Gift, House, Scissors, Settings, User, Users } from "lucide-react";
+import { Briefcase, CalendarCheck, CalendarDays, ChartLine, Gift, House, Scissors, Settings, User, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils/cn";
+import { resolveActiveHref } from "@/lib/navigation/nav-config";
/**
* Icons are looked up by name, not passed as component references. A
@@ -25,6 +26,7 @@ export const NAV_ICON_MAP = {
users: Users,
settings: Settings,
"chart-line": ChartLine,
+ briefcase: Briefcase,
} satisfies Record
;
export type BottomNavIconName = keyof typeof NAV_ICON_MAP;
@@ -55,6 +57,7 @@ export interface BottomNavProps {
export function BottomNav({ items, variant = "customer" }: BottomNavProps) {
const pathname = usePathname();
const isStaff = variant === "staff";
+ const activeHref = resolveActiveHref(pathname, items);
return (
{items.map(({ href, label, icon }) => {
const Icon = NAV_ICON_MAP[icon];
- const active = pathname === href || pathname.startsWith(`${href}/`);
+ const active = href === activeHref;
return (
-
+
{children}
diff --git a/src/components/navigation/staff-sidebar-nav.tsx b/src/components/navigation/staff-sidebar-nav.tsx
index d6cac84..063907f 100644
--- a/src/components/navigation/staff-sidebar-nav.tsx
+++ b/src/components/navigation/staff-sidebar-nav.tsx
@@ -2,32 +2,32 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
-import { ArrowLeftRight, Scissors } from "lucide-react";
+import { Scissors } from "lucide-react";
import { cn } from "@/lib/utils/cn";
import { NAV_ICON_MAP } from "./bottom-nav";
import type { BottomNavItem } from "./bottom-nav";
+import { resolveActiveHref } from "@/lib/navigation/nav-config";
/**
* 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.
+ * to spare. One role-aware `items` list (see lib/navigation/nav-config.ts)
+ * covers barber, owner and super-admin — there is no separate owner
+ * sidebar or "switch tools" control here.
*/
export function StaffSidebarNav({
items,
- footerLink,
businessName,
logoUrl,
}: {
items: BottomNavItem[];
- /** Owner sidebar's explicit "back to barber tools" — see the brief's "clear access back to barber tools". Omitted on the staff sidebar itself. */
- footerLink?: { href: string; label: string };
businessName: string;
logoUrl: string | null;
}) {
const pathname = usePathname();
+ const activeHref = resolveActiveHref(pathname, items);
return (
{items.map(({ href, label, icon }) => {
const Icon = NAV_ICON_MAP[icon];
- const active = pathname === href || pathname.startsWith(`${href}/`);
+ const active = href === activeHref;
return (
- {footerLink && (
-
-
- {footerLink.label}
-
- )}
);
}
diff --git a/src/features/staff/actions/profile.ts b/src/features/staff/actions/profile.ts
index 7f35809..1f72199 100644
--- a/src/features/staff/actions/profile.ts
+++ b/src/features/staff/actions/profile.ts
@@ -1,5 +1,6 @@
"use server";
+import { auth } from "@/auth";
import { NotificationType } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
@@ -36,15 +37,21 @@ export async function updateProfileAction(input: {
return result.success ? { success: true } : { success: false, error: "Couldn't save your profile." };
}
+/**
+ * Keyed purely by userId, not a StaffProfile — a Super Administrator (who
+ * has no StaffProfile, see isSuperAdmin) still has their own notification
+ * preferences, so this checks session auth directly rather than requiring
+ * getActingStaff() the way the staff-specific actions above do.
+ */
export async function saveNotificationPreferencesAction(
cells: PreferenceCellState[],
): Promise {
- const staff = await getActingStaff();
- if (!staff) return { success: false, error: "You must be signed in as staff." };
+ const session = await auth();
+ if (!session?.user?.id) return { success: false, error: "You must be signed in." };
for (const cell of cells) {
await setNotificationPreference(prisma, {
- userId: staff.userId,
+ userId: session.user.id,
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
channel: cell.channel,
isEnabled: cell.isEnabled,
diff --git a/src/lib/auth/__tests__/nav-role.integration.test.ts b/src/lib/auth/__tests__/nav-role.integration.test.ts
new file mode 100644
index 0000000..e1068ea
--- /dev/null
+++ b/src/lib/auth/__tests__/nav-role.integration.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { getNavRole } from "../permissions";
+import { cleanupTestBusiness, createTestBusiness, createTestStaff, prisma } from "@/services/scheduling/__tests__/fixtures";
+import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
+
+/**
+ * getNavRole is what both (staff)/layout.tsx and (owner)/layout.tsx use to
+ * pick a merged nav item set (see lib/navigation/nav-config.ts) and to
+ * gate entry to the (staff) route group at all. These tests cover the
+ * three roles the merged-nav brief distinguishes, plus the "signed in but
+ * neither staff nor super admin" case that must still redirect a
+ * registered customer away.
+ */
+describe("getNavRole (integration)", () => {
+ let business: TestBusiness | null = null;
+ const extraUserIds: string[] = [];
+
+ afterEach(async () => {
+ if (business) {
+ await cleanupTestBusiness(business);
+ business = null;
+ }
+ if (extraUserIds.length > 0) {
+ await prisma.user.deleteMany({ where: { id: { in: extraUserIds } } }).catch(() => {});
+ extraUserIds.length = 0;
+ }
+ });
+
+ it("classifies an ordinary barber (no MANAGE_STAFF) as staff", async () => {
+ business = await createTestBusiness();
+ const { userId } = await createTestStaff(business);
+ await expect(getNavRole(userId)).resolves.toBe("staff");
+ });
+
+ it("classifies a staff member whose Role grants MANAGE_STAFF as owner", async () => {
+ business = await createTestBusiness();
+ const role = await prisma.role.create({
+ data: { businessId: business.businessId, name: "Owner", permissions: { create: [{ permission: "MANAGE_STAFF" }] } },
+ });
+ const user = await prisma.user.create({
+ data: { email: "owner-role@test.local", name: "Owner", staffProfile: { create: { businessId: business.businessId, roleId: role.id } } },
+ });
+ business.createdUserIds.push(user.id);
+ await expect(getNavRole(user.id)).resolves.toBe("owner");
+ });
+
+ it("classifies a Super Administrator as superadmin even with no StaffProfile at all", async () => {
+ const user = await prisma.user.create({
+ data: { email: "superadmin-navrole@test.local", name: "Super Admin", isSuperAdmin: true },
+ });
+ extraUserIds.push(user.id);
+ await expect(getNavRole(user.id)).resolves.toBe("superadmin");
+ });
+
+ it("returns null for a signed-in user who is neither staff nor a super admin (a registered customer)", async () => {
+ const user = await prisma.user.create({ data: { email: "customer-navrole@test.local", name: "Customer" } });
+ extraUserIds.push(user.id);
+ await expect(getNavRole(user.id)).resolves.toBeNull();
+ });
+});
diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts
index 1da6c7a..344dc2b 100644
--- a/src/lib/auth/permissions.ts
+++ b/src/lib/auth/permissions.ts
@@ -1,6 +1,7 @@
import { cache } from "react";
import { prisma } from "@/lib/db/prisma";
-import type { Permission } from "@/generated/prisma/enums";
+import { Permission } from "@/generated/prisma/enums";
+import type { NavRole } from "@/lib/navigation/nav-config";
/**
* Foundation for Phase 3+ role gating — not exercised by any UI yet.
@@ -50,3 +51,19 @@ export async function hasPermission(userId: string, permission: Permission): Pro
const permissions = await getStaffPermissions(userId);
return permissions.has(permission);
}
+
+/**
+ * The single role classification behind the merged nav (see
+ * lib/navigation/nav-config.ts) — "staff" vs "owner" is just whether the
+ * account's Role grants MANAGE_STAFF, and "superadmin" short-circuits both
+ * (a Super Administrator has no StaffProfile at all, see isSuperAdmin).
+ * Returns null for a signed-in user who is neither staff nor a super
+ * admin — callers should redirect those away, same as today.
+ */
+export const getNavRole = cache(async (userId: string): Promise => {
+ if (await isSuperAdmin(userId)) return "superadmin";
+ const staffProfile = await prisma.staffProfile.findUnique({ where: { userId }, select: { id: true } });
+ if (!staffProfile) return null;
+ const permissions = await getStaffPermissions(userId);
+ return permissions.has(Permission.MANAGE_STAFF) ? "owner" : "staff";
+});
diff --git a/src/lib/business/get-acting-business-context.ts b/src/lib/business/get-acting-business-context.ts
index 084c80b..8b6f634 100644
--- a/src/lib/business/get-acting-business-context.ts
+++ b/src/lib/business/get-acting-business-context.ts
@@ -26,11 +26,12 @@ export const getActingBusinessContext = cache(async () => {
const staffProfile = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
- select: { businessId: true, business: { select: { name: true, timezone: true } } },
+ select: { id: true, businessId: true, business: { select: { name: true, timezone: true } } },
});
if (staffProfile) {
return {
userId: session.user.id,
+ staffId: staffProfile.id,
businessId: staffProfile.businessId,
businessName: staffProfile.business.name,
businessTimezone: staffProfile.business.timezone,
@@ -42,6 +43,11 @@ export const getActingBusinessContext = cache(async () => {
const business = await getPrimaryBusiness();
return {
userId: session.user.id,
+ // A Super Administrator has no StaffProfile by design (see isSuperAdmin) —
+ // null here is what lets callers tell "acting as real staff" apart from
+ // "acting as a Super Administrator with no personal staff identity",
+ // e.g. to withhold note authorship (see CustomerNotes' actingStaffId).
+ staffId: null,
businessId: business.id,
businessName: business.name,
businessTimezone: business.timezone,
diff --git a/src/lib/navigation/__tests__/nav-config.test.ts b/src/lib/navigation/__tests__/nav-config.test.ts
new file mode 100644
index 0000000..44e23ea
--- /dev/null
+++ b/src/lib/navigation/__tests__/nav-config.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import { getNavItems, resolveActiveHref } from "../nav-config";
+
+describe("getNavItems", () => {
+ it("gives a barber exactly Calendar/Now/Clients/Settings", () => {
+ const labels = getNavItems("staff").map((i) => i.label);
+ expect(labels).toEqual(["Calendar", "Now", "Clients", "Settings"]);
+ });
+
+ it("gives an owner Calendar/Now/Clients/Manage/Settings — barber tools and business tools in one menu", () => {
+ const labels = getNavItems("owner").map((i) => i.label);
+ expect(labels).toEqual(["Calendar", "Now", "Clients", "Manage", "Settings"]);
+ });
+
+ it("gives a Super Administrator no 'Now' tab — there is no personal barber schedule to show", () => {
+ const labels = getNavItems("superadmin").map((i) => i.label);
+ expect(labels).toEqual(["Calendar", "Clients", "Manage", "Settings"]);
+ expect(labels).not.toContain("Now");
+ });
+
+ it("points owner's Now/Clients/Settings at the same routes a barber uses — no duplicate business-wide pages", () => {
+ const owner = getNavItems("owner");
+ const staff = getNavItems("staff");
+ expect(owner.find((i) => i.label === "Now")?.href).toBe(staff.find((i) => i.label === "Now")?.href);
+ expect(owner.find((i) => i.label === "Clients")?.href).toBe(staff.find((i) => i.label === "Clients")?.href);
+ expect(owner.find((i) => i.label === "Settings")?.href).toBe(staff.find((i) => i.label === "Settings")?.href);
+ });
+
+ it("gives owner and super-admin a business-wide Calendar, distinct from the barber's personal one", () => {
+ expect(getNavItems("owner").find((i) => i.label === "Calendar")?.href).toBe("/owner/calendar");
+ expect(getNavItems("superadmin").find((i) => i.label === "Calendar")?.href).toBe("/owner/calendar");
+ expect(getNavItems("staff").find((i) => i.label === "Calendar")?.href).toBe("/staff");
+ });
+
+ it("caps every role's nav at 5 items", () => {
+ for (const role of ["staff", "owner", "superadmin"] as const) {
+ expect(getNavItems(role).length).toBeLessThanOrEqual(5);
+ }
+ });
+});
+
+describe("resolveActiveHref", () => {
+ const staffItems = getNavItems("staff");
+ const ownerItems = getNavItems("owner");
+
+ it("activates Calendar for the barber's own calendar root", () => {
+ expect(resolveActiveHref("/staff", staffItems)).toBe("/staff");
+ });
+
+ it("activates Now for /staff/current, not Calendar — the classic longest-prefix trap", () => {
+ expect(resolveActiveHref("/staff/current", staffItems)).toBe("/staff/current");
+ });
+
+ it("activates Clients for a nested client detail route", () => {
+ expect(resolveActiveHref("/staff/clients/abc123", staffItems)).toBe("/staff/clients");
+ });
+
+ it("activates Settings for a nested personal-settings route", () => {
+ expect(resolveActiveHref("/staff/settings/calendar", staffItems)).toBe("/staff/settings");
+ });
+
+ it("activates Manage for every /owner/* business-management route", () => {
+ expect(resolveActiveHref("/owner/team", ownerItems)).toBe("/owner");
+ expect(resolveActiveHref("/owner/services", ownerItems)).toBe("/owner");
+ expect(resolveActiveHref("/owner/settings/loyalty", ownerItems)).toBe("/owner");
+ });
+
+ it("activates the business-wide Calendar for /owner/calendar, not Manage", () => {
+ expect(resolveActiveHref("/owner/calendar", ownerItems)).toBe("/owner/calendar");
+ });
+
+ it("activates Now for /staff/current even from the owner nav (shared personal route)", () => {
+ expect(resolveActiveHref("/staff/current", ownerItems)).toBe("/staff/current");
+ });
+
+ it("returns null for a route no item covers", () => {
+ expect(resolveActiveHref("/sign-in", staffItems)).toBeNull();
+ });
+});
diff --git a/src/lib/navigation/nav-config.ts b/src/lib/navigation/nav-config.ts
new file mode 100644
index 0000000..6e13860
--- /dev/null
+++ b/src/lib/navigation/nav-config.ts
@@ -0,0 +1,50 @@
+import type { BottomNavItem } from "@/components/navigation/bottom-nav";
+
+/**
+ * Single source of truth for the merged staff/owner/super-admin bottom nav
+ * and desktop sidebar (see docs/implementation-plan.md and the Phase 11
+ * "merged navigation" brief). Owner is a permission on a staff account, not
+ * a separate app — Owner and SuperAdmin nav reuse the exact same personal
+ * routes (`/staff/current`, `/staff/clients`, `/staff/settings`) a barber
+ * uses, rather than duplicating them under `/owner`. This is what lets
+ * owners reach barber tools and business tools from one menu with zero new
+ * routes.
+ *
+ * A Super Administrator has no StaffProfile by construction (see
+ * isSuperAdmin/getActingBusinessContext) — they get no "Now" tab, since
+ * there is no personal schedule to show, per the brief's explicit "do not
+ * fabricate a personal barber schedule for the Super Administrator".
+ */
+export type NavRole = "staff" | "owner" | "superadmin";
+
+const CALENDAR: BottomNavItem = { href: "/staff", label: "Calendar", icon: "calendar-days" };
+const OWNER_CALENDAR: BottomNavItem = { href: "/owner/calendar", label: "Calendar", icon: "calendar-days" };
+const NOW: BottomNavItem = { href: "/staff/current", label: "Now", icon: "scissors" };
+const CLIENTS: BottomNavItem = { href: "/staff/clients", label: "Clients", icon: "users" };
+const MANAGE: BottomNavItem = { href: "/owner", label: "Manage", icon: "briefcase" };
+const SETTINGS: BottomNavItem = { href: "/staff/settings", label: "Settings", icon: "settings" };
+
+const NAV_ITEMS_BY_ROLE: Record = {
+ staff: [CALENDAR, NOW, CLIENTS, SETTINGS],
+ owner: [OWNER_CALENDAR, NOW, CLIENTS, MANAGE, SETTINGS],
+ superadmin: [OWNER_CALENDAR, CLIENTS, MANAGE, SETTINGS],
+};
+
+export function getNavItems(role: NavRole): BottomNavItem[] {
+ return NAV_ITEMS_BY_ROLE[role];
+}
+
+/**
+ * Longest-matching-href wins, rather than "every item whose href is a
+ * pathname prefix" (the previous per-component logic) — that scheme let
+ * `/staff/current` mark both "Calendar" (href `/staff`) and "Now" active at
+ * once, since `/staff/current` also starts with `/staff`. Sorting by href
+ * length and taking the first match makes only the most specific item win,
+ * which is what makes nested routes (e.g. `/owner/settings/loyalty`,
+ * `/staff/clients/[id]`) activate exactly one, correct primary tab.
+ */
+export function resolveActiveHref(pathname: string, items: BottomNavItem[]): string | null {
+ const matches = items.filter((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
+ if (matches.length === 0) return null;
+ return matches.reduce((longest, item) => (item.href.length > longest.href.length ? item : longest)).href;
+}
diff --git a/src/lib/navigation/staff-nav-items.ts b/src/lib/navigation/staff-nav-items.ts
deleted file mode 100644
index 0ffe3ba..0000000
--- a/src/lib/navigation/staff-nav-items.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import type { BottomNavItem } from "@/components/navigation/bottom-nav";
-
-/**
- * Single source of truth for the staff bottom nav. The templates had two
- * competing 4-item sets for what is conceptually the same nav
- * (Calendar/Current/Clients/Settings on most staff+owner screens vs.
- * Dashboard/Staff/Calendar/Admin on the Team Management screen — see
- * docs/implementation-plan.md §5/§8). Phase 5 resolved that by having
- * (owner) reuse this same list; Phase 6 gives (owner) its own
- * OWNER_NAV_ITEMS below instead, per that phase's own explicit nav
- * requirement (Dashboard/Calendar/Team/Services/Settings) — see
- * docs/phase-6-summary.md, "Owner navigation".
- *
- * Icons are string keys, not component references — this module is
- * imported by Server Component layouts, and a Lucide icon component isn't
- * a serializable prop across the Server → Client boundary into `BottomNav`
- * (see bottom-nav.tsx).
- */
-export const STAFF_NAV_ITEMS: BottomNavItem[] = [
- { href: "/staff", label: "Calendar", icon: "calendar-days" },
- { href: "/staff/current", label: "Current", icon: "scissors" },
- { href: "/staff/clients", label: "Clients", icon: "users" },
- { href: "/staff/settings", label: "Settings", icon: "settings" },
-];
-
-/** The (owner) route group's own nav — distinct from STAFF_NAV_ITEMS per the Phase 6 brief. "Team" covers staff management (incl. individual staff detail); "Services" covers categories+services; business/booking-rules/loyalty/notifications live under Settings. */
-export const OWNER_NAV_ITEMS: BottomNavItem[] = [
- { href: "/owner", label: "Dashboard", icon: "chart-line" },
- { href: "/owner/calendar", label: "Calendar", icon: "calendar-days" },
- { href: "/owner/team", label: "Team", icon: "users" },
- { href: "/owner/services", label: "Services", icon: "scissors" },
- { href: "/owner/settings", label: "Settings", icon: "settings" },
-];