Staff / Owner
Merged staff and owner settings
This commit is contained in:
23 files changed
+693
-236
No files matched your search
+14
-1
@@ -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", ["prisma", "migrate", "deploy"], { stdio: "inherit", env });
|
||||||
execFileSync("npx", ["tsx", "prisma/seed.ts"], { stdio: "inherit", env });
|
execFileSync("npx", ["tsx", "prisma/seed.ts"], { stdio: "inherit", env });
|
||||||
|
execFileSync("npx", ["tsx", "scripts/ensure-super-admin.ts"], { stdio: "inherit", env });
|
||||||
}
|
}
|
||||||
+18
-2
@@ -11,6 +11,10 @@ export const SEEDED = {
|
|||||||
// first in the suite, so this is safe there).
|
// first in the suite, so this is safe there).
|
||||||
secondCustomer: { email: "sam.osei@example.com", password: "password123", name: "Sam Osei" },
|
secondCustomer: { email: "sam.osei@example.com", password: "password123", name: "Sam Osei" },
|
||||||
owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" },
|
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" },
|
barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" },
|
||||||
barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" },
|
barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" },
|
||||||
services: {
|
services: {
|
||||||
@@ -98,9 +102,21 @@ export async function completeBookingFlow(
|
|||||||
await expect(page.getByRole("heading", { name: "You're all set!" })).toBeVisible();
|
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) {
|
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 {
|
export function uniqueEmail(prefix: string): string {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,10 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
await signIn(page, SEEDED.owner.email, "password123");
|
await signIn(page, SEEDED.owner.email, "password123");
|
||||||
await page.goto("/owner");
|
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,
|
// Seeded fixtures from prisma/seed.ts: Owen Clarke has no working hours,
|
||||||
// and "Kids Haircut (Under 12)" has no eligible active staff member.
|
// and "Kids Haircut (Under 12)" has no eligible active staff member.
|
||||||
await expect(page.getByText("Owen Clarke has no working hours set")).toBeVisible();
|
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
|
// Seeded: Sam Osei has a CHECKED_IN appointment with Leo today, and sits
|
||||||
// exactly at the loyalty reward threshold with one AVAILABLE reward.
|
// 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 card.getByRole("link", { name: "View", exact: true }).click();
|
||||||
|
|
||||||
await expect(page.getByText(/Subtotal/)).toBeVisible();
|
await expect(page.getByText(/Subtotal/)).toBeVisible();
|
||||||
@@ -315,23 +322,29 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
await page.goto("/owner");
|
await page.goto("/owner");
|
||||||
await expect(page).toHaveURL(/\/staff$/);
|
await expect(page).toHaveURL(/\/staff$/);
|
||||||
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
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 signIn(page, SEEDED.owner.email, "password123");
|
||||||
await page.goto("/owner");
|
await page.goto("/owner");
|
||||||
|
|
||||||
await page.getByRole("link", { name: "Barber Tools", exact: true }).click();
|
// The old "Switch to Owner Admin"/"Switch to Barber Tools" links are
|
||||||
await expect(page).toHaveURL(/\/staff$/);
|
// gone (see the merged-nav brief) — an owner reaches their own barber
|
||||||
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
// tools via the same "Now" nav item a barber uses, not a mode switch.
|
||||||
await expect(page.getByRole("link", { name: "Current", exact: true })).toBeVisible();
|
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 }) => {
|
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 signIn(page, SEEDED.owner.email, "password123");
|
||||||
await page.goto("/owner");
|
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).toHaveURL(/\/owner\/reports/);
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Reports", exact: true })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Reports", exact: true })).toBeVisible();
|
||||||
|
|||||||
@@ -258,11 +258,11 @@ export default async function CustomerHomePage() {
|
|||||||
<p className="mt-1 text-sm font-bold">{preferredBarberName}</p>
|
<p className="mt-1 text-sm font-bold">{preferredBarberName}</p>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{lastCompletedAppointment && (
|
{/* {lastCompletedAppointment && (
|
||||||
<Button asChild variant="secondary" className="h-full w-full">
|
<Button asChild variant="secondary" className="h-full w-full">
|
||||||
<Link href="/appointments">Book Again</Link>
|
<Link href="/appointments">Book Again</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { StaffShell } from "@/components/navigation/staff-shell";
|
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 { Permission } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
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";
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owner is a permission on a staff account, not a separate account type
|
* Owner and Super Administrator are permissions, not separate account
|
||||||
* (see docs/implementation-plan.md §3, §11) — this layout reuses the exact
|
* types (see docs/implementation-plan.md §3, §11) — this layout reuses the
|
||||||
* same shell/nav as (staff), and only adds a permission check on top.
|
* 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 }) {
|
export default async function OwnerLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -23,12 +26,12 @@ export default async function OwnerLayout({ children }: { children: React.ReactN
|
|||||||
redirect("/staff");
|
redirect("/staff");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const role = (await getNavRole(session.user.id)) ?? "owner";
|
||||||
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
|
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StaffShell
|
<StaffShell
|
||||||
items={OWNER_NAV_ITEMS}
|
items={getNavItems(role)}
|
||||||
footerLink={{ href: "/staff", label: "Barber Tools" }}
|
|
||||||
notificationsHref="/owner/notifications"
|
notificationsHref="/owner/notifications"
|
||||||
unreadNotificationCount={unreadNotificationCount}
|
unreadNotificationCount={unreadNotificationCount}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,11 +2,19 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
BarChart3,
|
||||||
|
Building2,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
CalendarClock,
|
||||||
CalendarX2,
|
CalendarX2,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
ClipboardList,
|
||||||
|
Crown,
|
||||||
Gift,
|
Gift,
|
||||||
|
Images,
|
||||||
|
ScissorsSquare,
|
||||||
Stamp,
|
Stamp,
|
||||||
|
Star,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
UserX,
|
UserX,
|
||||||
Users,
|
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 (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted mb-2 px-1 text-[10px] font-bold tracking-widest uppercase">{title}</p>
|
||||||
|
<ul className="divide-line rounded-md border-line divide-y border bg-white">
|
||||||
|
{links.map(({ href, label, icon: Icon }) => (
|
||||||
|
<li key={href}>
|
||||||
|
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
|
||||||
|
<Icon className="text-muted h-5 w-5" aria-hidden="true" />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function OwnerDashboardPage() {
|
export default async function OwnerDashboardPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) redirect("/sign-in?callbackUrl=/owner");
|
if (!session?.user) redirect("/sign-in?callbackUrl=/owner");
|
||||||
@@ -57,8 +114,8 @@ export default async function OwnerDashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 px-6 pt-12 pb-12">
|
<div className="space-y-6 px-6 pt-12 pb-12">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Manage</h1>
|
||||||
<p className="text-muted text-sm">{context.businessName} — today</p>
|
<p className="text-muted text-sm">{context.businessName} — today's overview</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{summary.warnings.length > 0 && (
|
{summary.warnings.length > 0 && (
|
||||||
@@ -168,25 +225,10 @@ export default async function OwnerDashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="space-y-5">
|
||||||
<Link
|
{MANAGE_SECTIONS.map((section) => (
|
||||||
href="/owner/reports"
|
<ManageSection key={section.title} {...section} />
|
||||||
className="rounded-md border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
|
))}
|
||||||
>
|
|
||||||
View Reports
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/owner/team"
|
|
||||||
className="rounded-md border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
|
|
||||||
>
|
|
||||||
Manage Team
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/owner/settings/booking-rules"
|
|
||||||
className="rounded-md bg-ink flex-1 px-5 py-4 text-center text-sm font-bold text-white"
|
|
||||||
>
|
|
||||||
Update Booking Rules
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { 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";
|
* Kept as a redirect rather than deleted so any existing bookmark to
|
||||||
|
* `/owner/settings` still works (see the Phase 11 merged-nav brief: "avoid
|
||||||
const SETTINGS_LINKS = [
|
* unnecessary route changes... add redirects only where old nav entry
|
||||||
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
|
* routes become obsolete"). The page it used to render — a mix of personal
|
||||||
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
|
* settings (Notifications) and business settings (Business Details,
|
||||||
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
|
* Gallery, Hours, Booking Rules, Loyalty, Reviews) plus a "Switch to
|
||||||
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
|
* Barber Tools" link — is now split cleanly: personal settings live at the
|
||||||
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
|
* one shared `/staff/settings` hub, and business settings are reachable
|
||||||
{ href: "/owner/settings/reviews", label: "Reviews", icon: Star },
|
* from the "Manage" nav item's grouped links (see (owner)/owner/page.tsx).
|
||||||
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
|
*/
|
||||||
] as const;
|
export default function OwnerSettingsRedirectPage() {
|
||||||
|
redirect("/staff/settings");
|
||||||
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 (
|
|
||||||
<div className="space-y-6 px-6 pt-12 pb-12">
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Owner Settings</h1>
|
|
||||||
<ProfileCard name={session.user.name ?? "Owner"} meta={session.user.email} avatarUrl={session.user.image ?? undefined} />
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/staff/settings"
|
|
||||||
className="rounded-md border-line flex items-center gap-3 border bg-white p-5 text-sm font-bold hover:bg-stone"
|
|
||||||
>
|
|
||||||
<ScissorsSquare className="text-accent h-5 w-5" aria-hidden="true" />
|
|
||||||
Switch to Barber Tools
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<ul className="divide-line rounded-md border-line divide-y border bg-white">
|
|
||||||
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
|
|
||||||
<li key={href}>
|
|
||||||
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
|
|
||||||
<Icon className="text-muted h-5 w-5" aria-hidden="true" />
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="rounded-md border-line border bg-white p-5">
|
|
||||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
|
||||||
<InstallAppButton businessName={branding.name} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SignOutButton />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { StaffShell } from "@/components/navigation/staff-shell";
|
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 { prisma } from "@/lib/db/prisma";
|
||||||
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
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
|
// Real role gating, not just "is signed in" — a registered customer with
|
||||||
// no StaffProfile is bounced back to their own home rather than seeing
|
// no StaffProfile (and no Super Administrator flag) is bounced back to
|
||||||
// an empty staff shell. See docs/implementation-plan.md §3 (role
|
// their own home rather than seeing an empty staff shell. See
|
||||||
// conflicts) and §11 (permission enforcement).
|
// docs/implementation-plan.md §3 (role conflicts) and §11 (permission
|
||||||
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
// enforcement). A Super Administrator has no StaffProfile by design (see
|
||||||
if (!staffProfile) {
|
// 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");
|
redirect("/home");
|
||||||
}
|
}
|
||||||
|
|
||||||
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
|
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StaffShell notificationsHref="/staff/notifications" unreadNotificationCount={unreadNotificationCount}>
|
<StaffShell
|
||||||
|
items={getNavItems(role)}
|
||||||
|
notificationsHref="/staff/notifications"
|
||||||
|
unreadNotificationCount={unreadNotificationCount}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</StaffShell>
|
</StaffShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { EmptyState } from "@/components/feedback/empty-state";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
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 { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||||
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||||
import { listAppointments } from "@/services/scheduling/appointment.service";
|
import { listAppointments } from "@/services/scheduling/appointment.service";
|
||||||
@@ -38,8 +38,8 @@ export default async function StaffCustomerDetailsPage({
|
|||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const staff = await getActingStaff();
|
const context = await getActingBusinessContext();
|
||||||
if (!staff) redirect("/home");
|
if (!context) redirect("/home");
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const customer = await prisma.customerProfile.findUnique({
|
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
|
// "Belongs to this business" is via the appointment relation — see
|
||||||
// docs/phase-5-summary.md, same reasoning as the clients list query.
|
// docs/phase-5-summary.md, same reasoning as the clients list query.
|
||||||
const belongsToBusiness = await prisma.appointment.findFirst({
|
const belongsToBusiness = await prisma.appointment.findFirst({
|
||||||
where: { customerId: id, businessId: staff.businessId },
|
where: { customerId: id, businessId: context.businessId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!belongsToBusiness) notFound();
|
if (!belongsToBusiness) notFound();
|
||||||
@@ -62,17 +62,17 @@ export default async function StaffCustomerDetailsPage({
|
|||||||
const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([
|
const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([
|
||||||
listAppointments(prisma, {
|
listAppointments(prisma, {
|
||||||
customerId: id,
|
customerId: id,
|
||||||
businessId: staff.businessId,
|
businessId: context.businessId,
|
||||||
statuses: ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"],
|
statuses: ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"],
|
||||||
}),
|
}),
|
||||||
listAppointments(prisma, {
|
listAppointments(prisma, {
|
||||||
customerId: id,
|
customerId: id,
|
||||||
businessId: staff.businessId,
|
businessId: context.businessId,
|
||||||
statuses: ["COMPLETED"],
|
statuses: ["COMPLETED"],
|
||||||
}),
|
}),
|
||||||
listAppointments(prisma, {
|
listAppointments(prisma, {
|
||||||
customerId: id,
|
customerId: id,
|
||||||
businessId: staff.businessId,
|
businessId: context.businessId,
|
||||||
statuses: ["CANCELLED", "NO_SHOW"],
|
statuses: ["CANCELLED", "NO_SHOW"],
|
||||||
}),
|
}),
|
||||||
prisma.customerNote.findMany({
|
prisma.customerNote.findMany({
|
||||||
@@ -80,7 +80,7 @@ export default async function StaffCustomerDetailsPage({
|
|||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: { author: { include: { user: { select: { name: true } } } } },
|
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
|
const loyaltyTransactions = loyalty?.accountId
|
||||||
@@ -96,7 +96,7 @@ export default async function StaffCustomerDetailsPage({
|
|||||||
authorName: n.author.user.name ?? "Staff",
|
authorName: n.author.user.name ?? "Staff",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const tz = staff.business.timezone;
|
const tz = context.businessTimezone;
|
||||||
|
|
||||||
const renderList = (appointments: typeof upcoming, emptyLabel: string) => {
|
const renderList = (appointments: typeof upcoming, emptyLabel: string) => {
|
||||||
if (appointments.length === 0) {
|
if (appointments.length === 0) {
|
||||||
@@ -263,7 +263,13 @@ export default async function StaffCustomerDetailsPage({
|
|||||||
|
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h2 className="text-muted mb-4 text-sm font-bold tracking-widest uppercase">Notes</h2>
|
<h2 className="text-muted mb-4 text-sm font-bold tracking-widest uppercase">Notes</h2>
|
||||||
<CustomerNotes customerId={customer.id} notes={notes} actingStaffId={staff.id} />
|
{/* 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. */}
|
||||||
|
<CustomerNotes customerId={customer.id} notes={notes} actingStaffId={context.staffId ?? ""} />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { EmptyState } from "@/components/feedback/empty-state";
|
import { EmptyState } from "@/components/feedback/empty-state";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
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 { matchesCustomerSearch } from "@/services/customer/customer.service";
|
||||||
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
|
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { cn } from "@/lib/utils/cn";
|
||||||
@@ -19,8 +19,8 @@ export default async function StaffClientsPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ q?: string; sort?: string }>;
|
searchParams: Promise<{ q?: string; sort?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const staff = await getActingStaff();
|
const context = await getActingBusinessContext();
|
||||||
if (!staff) redirect("/home");
|
if (!context) redirect("/home");
|
||||||
|
|
||||||
const { q = "", sort } = await searchParams;
|
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
|
// every appointment of every client on every page view (see the Phase 7
|
||||||
// performance pass).
|
// performance pass).
|
||||||
const customers = await prisma.customerProfile.findMany({
|
const customers = await prisma.customerProfile.findMany({
|
||||||
where: { appointments: { some: { businessId: staff.businessId } } },
|
where: { appointments: { some: { businessId: context.businessId } } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
firstName: true,
|
firstName: true,
|
||||||
@@ -41,7 +41,7 @@ export default async function StaffClientsPage({
|
|||||||
email: true,
|
email: true,
|
||||||
phone: true,
|
phone: true,
|
||||||
appointments: {
|
appointments: {
|
||||||
where: { businessId: staff.businessId },
|
where: { businessId: context.businessId },
|
||||||
orderBy: { startAt: "desc" },
|
orderBy: { startAt: "desc" },
|
||||||
select: { status: true, startAt: true },
|
select: { status: true, startAt: true },
|
||||||
},
|
},
|
||||||
@@ -49,7 +49,7 @@ export default async function StaffClientsPage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const loyaltySummaries = await getLoyaltySummariesForCustomers(prisma, {
|
const loyaltySummaries = await getLoyaltySummariesForCustomers(prisma, {
|
||||||
businessId: staff.businessId,
|
businessId: context.businessId,
|
||||||
customerIds: customers.map((c) => c.id),
|
customerIds: customers.map((c) => c.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/auth";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
|
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
|
||||||
import { getNotificationPreferences } from "@/services/notifications/notification.service";
|
import { getNotificationPreferences } from "@/services/notifications/notification.service";
|
||||||
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form";
|
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form";
|
||||||
import type { PreferenceCellState } 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];
|
const REMINDER_CHANNELS: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
|
||||||
|
|
||||||
export default async function StaffNotificationsPage() {
|
export default async function StaffNotificationsPage() {
|
||||||
const staff = await getActingStaff();
|
const session = await auth();
|
||||||
if (!staff) redirect("/home");
|
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) => ({
|
const cells: PreferenceCellState[] = REMINDER_CHANNELS.map((channel) => ({
|
||||||
channel,
|
channel,
|
||||||
isEnabled:
|
isEnabled:
|
||||||
|
|||||||
@@ -1,54 +1,38 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
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 { auth } from "@/auth";
|
||||||
import { ProfileCard } from "@/components/ui/profile-card";
|
import { ProfileCard } from "@/components/ui/profile-card";
|
||||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||||
import { hasPermission } from "@/lib/auth/permissions";
|
import { getNavRole } from "@/lib/auth/permissions";
|
||||||
import { Permission } from "@/generated/prisma/enums";
|
|
||||||
import { getBranding } from "@/lib/business/branding";
|
import { getBranding } from "@/lib/business/branding";
|
||||||
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
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/working-hours", label: "Working Hours", icon: Clock },
|
||||||
{ href: "/staff/settings/breaks", label: "Breaks", icon: CalendarClock },
|
{ href: "/staff/settings/breaks", label: "Breaks", icon: CalendarClock },
|
||||||
{ href: "/staff/settings/time-off", label: "Time Off", icon: CalendarOff },
|
{ 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;
|
] as const;
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Staff Settings" };
|
const INTEGRATION_LINKS = [{ href: "/staff/settings/calendar", label: "Google Calendar", icon: CalendarSync }] as const;
|
||||||
|
|
||||||
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(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
function SettingsSection({
|
||||||
|
title,
|
||||||
|
links,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
links: ReadonlyArray<{ href: string; label: string; icon: typeof UserCircle }>;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 px-6 pt-12">
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Staff Settings</h1>
|
<p className="text-muted mb-2 px-1 text-[10px] font-bold tracking-widest uppercase">{title}</p>
|
||||||
<ProfileCard name={session.user.name ?? "Staff member"} meta={session.user.email} avatarUrl={session.user.image ?? undefined} />
|
|
||||||
|
|
||||||
{canManageStaff && (
|
|
||||||
<Link
|
|
||||||
href="/owner"
|
|
||||||
className="flex items-center gap-3 rounded-md border border-line bg-white p-5 text-sm font-bold hover:bg-stone"
|
|
||||||
>
|
|
||||||
<ShieldCheck className="h-5 w-5 text-accent" aria-hidden="true" />
|
|
||||||
Switch to Owner Admin
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul className="divide-y divide-line rounded-md border border-line bg-white">
|
<ul className="divide-y divide-line rounded-md border border-line bg-white">
|
||||||
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
|
{links.map(({ href, label, icon: Icon }) => (
|
||||||
<li key={href}>
|
<li key={href}>
|
||||||
<Link href={href} className="flex items-center gap-3 px-5 py-4 text-sm font-bold hover:bg-stone">
|
<Link href={href} className="flex items-center gap-3 px-5 py-4 text-sm font-bold hover:bg-stone">
|
||||||
<Icon className="h-5 w-5 text-muted" aria-hidden="true" />
|
<Icon className="h-5 w-5 text-muted" aria-hidden="true" />
|
||||||
@@ -57,13 +41,60 @@ export default async function StaffSettingsPage() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<div className="rounded-md border border-line bg-white p-5">
|
export const metadata: Metadata = { title: "Settings" };
|
||||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
|
||||||
<InstallAppButton businessName={branding.name} />
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div className="space-y-6 px-6 pt-12 pb-12">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||||
|
<ProfileCard name={session.user.name ?? "Account"} meta={session.user.email} avatarUrl={session.user.image ?? undefined} />
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SettingsSection
|
||||||
|
title="Account"
|
||||||
|
links={isSuperAdmin ? ACCOUNT_LINKS.filter((l) => l.label === "Notifications") : ACCOUNT_LINKS}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isSuperAdmin && (
|
||||||
|
<>
|
||||||
|
<SettingsSection title="Working Pattern" links={WORKING_PATTERN_LINKS} />
|
||||||
|
<SettingsSection title="Integrations" links={INTEGRATION_LINKS} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SignOutButton />
|
<div>
|
||||||
|
<p className="text-muted mb-2 px-1 text-[10px] font-bold tracking-widest uppercase">App</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-md border border-line bg-white p-5">
|
||||||
|
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||||
|
<InstallAppButton businessName={branding.name} />
|
||||||
|
</div>
|
||||||
|
<SignOutButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
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 type { LucideIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils/cn";
|
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
|
* Icons are looked up by name, not passed as component references. A
|
||||||
@@ -25,6 +26,7 @@ export const NAV_ICON_MAP = {
|
|||||||
users: Users,
|
users: Users,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
"chart-line": ChartLine,
|
"chart-line": ChartLine,
|
||||||
|
briefcase: Briefcase,
|
||||||
} satisfies Record<string, LucideIcon>;
|
} satisfies Record<string, LucideIcon>;
|
||||||
|
|
||||||
export type BottomNavIconName = keyof typeof NAV_ICON_MAP;
|
export type BottomNavIconName = keyof typeof NAV_ICON_MAP;
|
||||||
@@ -55,6 +57,7 @@ export interface BottomNavProps {
|
|||||||
export function BottomNav({ items, variant = "customer" }: BottomNavProps) {
|
export function BottomNav({ items, variant = "customer" }: BottomNavProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isStaff = variant === "staff";
|
const isStaff = variant === "staff";
|
||||||
|
const activeHref = resolveActiveHref(pathname, items);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
@@ -76,7 +79,7 @@ export function BottomNav({ items, variant = "customer" }: BottomNavProps) {
|
|||||||
<div className="mx-auto flex w-full max-w-2xl items-center justify-between">
|
<div className="mx-auto flex w-full max-w-2xl items-center justify-between">
|
||||||
{items.map(({ href, label, icon }) => {
|
{items.map(({ href, label, icon }) => {
|
||||||
const Icon = NAV_ICON_MAP[icon];
|
const Icon = NAV_ICON_MAP[icon];
|
||||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
const active = href === activeHref;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { getBranding } from "@/lib/business/branding";
|
|||||||
import { BottomNav } from "./bottom-nav";
|
import { BottomNav } from "./bottom-nav";
|
||||||
import { StaffSidebarNav } from "./staff-sidebar-nav";
|
import { StaffSidebarNav } from "./staff-sidebar-nav";
|
||||||
import type { BottomNavItem } from "./bottom-nav";
|
import type { BottomNavItem } from "./bottom-nav";
|
||||||
import { STAFF_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
|
import { getNavItems } from "@/lib/navigation/nav-config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared by (staff) and (owner) — see docs/implementation-plan.md §3: owner
|
* Shared by (staff) and (owner) — owner and super-admin are permissions on
|
||||||
* is a permission on a staff account, not a separate shell. `items`
|
* top of the same app, not separate shells. `items` is the merged,
|
||||||
* defaults to the staff nav; (owner) passes OWNER_NAV_ITEMS instead (see
|
* role-aware nav list from lib/navigation/nav-config.ts (defaults to the
|
||||||
* staff-nav-items.ts) — same shell, same sidebar/bottom-nav components,
|
* barber nav) — same shell, same sidebar/bottom-nav components, one
|
||||||
* different item set, per the Phase 6 brief's own nav requirement.
|
* centralized nav configuration rather than parallel staff/owner ones.
|
||||||
*
|
*
|
||||||
* Reads business branding itself (async Server Component) rather than
|
* Reads business branding itself (async Server Component) rather than
|
||||||
* requiring every (staff)/(owner) layout to fetch and thread it through —
|
* requiring every (staff)/(owner) layout to fetch and thread it through —
|
||||||
@@ -18,21 +18,19 @@ import { STAFF_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
|
|||||||
*/
|
*/
|
||||||
export async function StaffShell({
|
export async function StaffShell({
|
||||||
children,
|
children,
|
||||||
items = STAFF_NAV_ITEMS,
|
items = getNavItems("staff"),
|
||||||
footerLink,
|
|
||||||
notificationsHref = "/staff/notifications",
|
notificationsHref = "/staff/notifications",
|
||||||
unreadNotificationCount = 0,
|
unreadNotificationCount = 0,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
items?: BottomNavItem[];
|
items?: BottomNavItem[];
|
||||||
footerLink?: { href: string; label: string };
|
|
||||||
notificationsHref?: string;
|
notificationsHref?: string;
|
||||||
unreadNotificationCount?: number;
|
unreadNotificationCount?: number;
|
||||||
}) {
|
}) {
|
||||||
const branding = await getBranding();
|
const branding = await getBranding();
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-stone pb-24 lg:pb-0 lg:pl-56">
|
<div className="min-h-screen bg-stone pb-24 lg:pb-0 lg:pl-56">
|
||||||
<StaffSidebarNav items={items} footerLink={footerLink} businessName={branding.name} logoUrl={branding.logoUrl} />
|
<StaffSidebarNav items={items} businessName={branding.name} logoUrl={branding.logoUrl} />
|
||||||
<NotificationBell href={notificationsHref} unreadCount={unreadNotificationCount} />
|
<NotificationBell href={notificationsHref} unreadCount={unreadNotificationCount} />
|
||||||
<div id="main-content">{children}</div>
|
<div id="main-content">{children}</div>
|
||||||
<BottomNav items={items} variant="staff" />
|
<BottomNav items={items} variant="staff" />
|
||||||
|
|||||||
@@ -2,32 +2,32 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { ArrowLeftRight, Scissors } from "lucide-react";
|
import { Scissors } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { cn } from "@/lib/utils/cn";
|
||||||
import { NAV_ICON_MAP } from "./bottom-nav";
|
import { NAV_ICON_MAP } from "./bottom-nav";
|
||||||
import type { BottomNavItem } 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
|
* The desktop counterpart to BottomNav's staff variant — a persistent left
|
||||||
* rail instead of a bottom bar, shown only at lg+ (BottomNav hides itself
|
* 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,
|
* 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
|
* 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
|
* to spare. One role-aware `items` list (see lib/navigation/nav-config.ts)
|
||||||
* more than the mobile bar simply centered.
|
* covers barber, owner and super-admin — there is no separate owner
|
||||||
|
* sidebar or "switch tools" control here.
|
||||||
*/
|
*/
|
||||||
export function StaffSidebarNav({
|
export function StaffSidebarNav({
|
||||||
items,
|
items,
|
||||||
footerLink,
|
|
||||||
businessName,
|
businessName,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
}: {
|
}: {
|
||||||
items: BottomNavItem[];
|
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;
|
businessName: string;
|
||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const activeHref = resolveActiveHref(pathname, items);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
@@ -46,7 +46,7 @@ export function StaffSidebarNav({
|
|||||||
<div className="flex flex-1 flex-col gap-1">
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
{items.map(({ href, label, icon }) => {
|
{items.map(({ href, label, icon }) => {
|
||||||
const Icon = NAV_ICON_MAP[icon];
|
const Icon = NAV_ICON_MAP[icon];
|
||||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
const active = href === activeHref;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
@@ -63,15 +63,6 @@ export function StaffSidebarNav({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{footerLink && (
|
|
||||||
<Link
|
|
||||||
href={footerLink.href}
|
|
||||||
className="mt-4 flex items-center gap-3 rounded-xl border-t border-white/5 px-3 pt-4 text-sm font-bold text-gray-400 transition-colors hover:text-white"
|
|
||||||
>
|
|
||||||
<ArrowLeftRight className="h-4 w-4" aria-hidden="true" />
|
|
||||||
{footerLink.label}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
import { NotificationType } from "@/generated/prisma/enums";
|
import { NotificationType } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
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." };
|
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(
|
export async function saveNotificationPreferencesAction(
|
||||||
cells: PreferenceCellState[],
|
cells: PreferenceCellState[],
|
||||||
): Promise<ProfileActionResult> {
|
): Promise<ProfileActionResult> {
|
||||||
const staff = await getActingStaff();
|
const session = await auth();
|
||||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
|
||||||
|
|
||||||
for (const cell of cells) {
|
for (const cell of cells) {
|
||||||
await setNotificationPreference(prisma, {
|
await setNotificationPreference(prisma, {
|
||||||
userId: staff.userId,
|
userId: session.user.id,
|
||||||
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
|
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
|
||||||
channel: cell.channel,
|
channel: cell.channel,
|
||||||
isEnabled: cell.isEnabled,
|
isEnabled: cell.isEnabled,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
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.
|
* 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);
|
const permissions = await getStaffPermissions(userId);
|
||||||
return permissions.has(permission);
|
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<NavRole | null> => {
|
||||||
|
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";
|
||||||
|
});
|
||||||
@@ -26,11 +26,12 @@ export const getActingBusinessContext = cache(async () => {
|
|||||||
|
|
||||||
const staffProfile = await prisma.staffProfile.findUnique({
|
const staffProfile = await prisma.staffProfile.findUnique({
|
||||||
where: { userId: session.user.id },
|
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) {
|
if (staffProfile) {
|
||||||
return {
|
return {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
|
staffId: staffProfile.id,
|
||||||
businessId: staffProfile.businessId,
|
businessId: staffProfile.businessId,
|
||||||
businessName: staffProfile.business.name,
|
businessName: staffProfile.business.name,
|
||||||
businessTimezone: staffProfile.business.timezone,
|
businessTimezone: staffProfile.business.timezone,
|
||||||
@@ -42,6 +43,11 @@ export const getActingBusinessContext = cache(async () => {
|
|||||||
const business = await getPrimaryBusiness();
|
const business = await getPrimaryBusiness();
|
||||||
return {
|
return {
|
||||||
userId: session.user.id,
|
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,
|
businessId: business.id,
|
||||||
businessName: business.name,
|
businessName: business.name,
|
||||||
businessTimezone: business.timezone,
|
businessTimezone: business.timezone,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<NavRole, BottomNavItem[]> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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" },
|
|
||||||
];
|
|
||||||
Reference in new issue
Block a user