Staff / Owner
Merged staff and owner settings
This commit is contained in:
23 files changed
+693
-236
No files matched your search
@@ -258,11 +258,11 @@ export default async function CustomerHomePage() {
|
||||
<p className="mt-1 text-sm font-bold">{preferredBarberName}</p>
|
||||
</Card>
|
||||
)}
|
||||
{lastCompletedAppointment && (
|
||||
{/* {lastCompletedAppointment && (
|
||||
<Button asChild variant="secondary" className="h-full w-full">
|
||||
<Link href="/appointments">Book Again</Link>
|
||||
</Button>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<StaffShell
|
||||
items={OWNER_NAV_ITEMS}
|
||||
footerLink={{ href: "/staff", label: "Barber Tools" }}
|
||||
items={getNavItems(role)}
|
||||
notificationsHref="/owner/notifications"
|
||||
unreadNotificationCount={unreadNotificationCount}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<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() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in?callbackUrl=/owner");
|
||||
@@ -57,8 +114,8 @@ export default async function OwnerDashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6 px-6 pt-12 pb-12">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted text-sm">{context.businessName} — today</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Manage</h1>
|
||||
<p className="text-muted text-sm">{context.businessName} — today's overview</p>
|
||||
</div>
|
||||
|
||||
{summary.warnings.length > 0 && (
|
||||
@@ -168,25 +225,10 @@ export default async function OwnerDashboardPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/owner/reports"
|
||||
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 className="space-y-5">
|
||||
{MANAGE_SECTIONS.map((section) => (
|
||||
<ManageSection key={section.title} {...section} />
|
||||
))}
|
||||
</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 { 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 (
|
||||
<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>
|
||||
);
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
@@ -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 (
|
||||
<StaffShell notificationsHref="/staff/notifications" unreadNotificationCount={unreadNotificationCount}>
|
||||
<StaffShell
|
||||
items={getNavItems(role)}
|
||||
notificationsHref="/staff/notifications"
|
||||
unreadNotificationCount={unreadNotificationCount}
|
||||
>
|
||||
{children}
|
||||
</StaffShell>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
<Card className="p-6">
|
||||
<h2 className="text-muted mb-4 text-sm font-bold tracking-widest uppercase">Notes</h2>
|
||||
<CustomerNotes customerId={customer.id} notes={notes} actingStaffId={staff.id} />
|
||||
{/* 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>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6 px-6 pt-12">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Staff Settings</h1>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-muted mb-2 px-1 text-[10px] font-bold tracking-widest uppercase">{title}</p>
|
||||
<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}>
|
||||
<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" />
|
||||
@@ -57,13 +41,60 @@ export default async function StaffSettingsPage() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<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} />
|
||||
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 (
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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<string, LucideIcon>;
|
||||
|
||||
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 (
|
||||
<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">
|
||||
{items.map(({ href, label, icon }) => {
|
||||
const Icon = NAV_ICON_MAP[icon];
|
||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
const active = href === activeHref;
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
|
||||
@@ -3,14 +3,14 @@ import { getBranding } from "@/lib/business/branding";
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
import { StaffSidebarNav } from "./staff-sidebar-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
|
||||
* is a permission on a staff account, not a separate shell. `items`
|
||||
* defaults to the staff nav; (owner) passes OWNER_NAV_ITEMS instead (see
|
||||
* staff-nav-items.ts) — same shell, same sidebar/bottom-nav components,
|
||||
* different item set, per the Phase 6 brief's own nav requirement.
|
||||
* Shared by (staff) and (owner) — owner and super-admin are permissions on
|
||||
* top of the same app, not separate shells. `items` is the merged,
|
||||
* role-aware nav list from lib/navigation/nav-config.ts (defaults to the
|
||||
* barber nav) — same shell, same sidebar/bottom-nav components, one
|
||||
* centralized nav configuration rather than parallel staff/owner ones.
|
||||
*
|
||||
* Reads business branding itself (async Server Component) rather than
|
||||
* 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({
|
||||
children,
|
||||
items = STAFF_NAV_ITEMS,
|
||||
footerLink,
|
||||
items = getNavItems("staff"),
|
||||
notificationsHref = "/staff/notifications",
|
||||
unreadNotificationCount = 0,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
items?: BottomNavItem[];
|
||||
footerLink?: { href: string; label: string };
|
||||
notificationsHref?: string;
|
||||
unreadNotificationCount?: number;
|
||||
}) {
|
||||
const branding = await getBranding();
|
||||
return (
|
||||
<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} />
|
||||
<div id="main-content">{children}</div>
|
||||
<BottomNav items={items} variant="staff" />
|
||||
|
||||
@@ -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 (
|
||||
<nav
|
||||
@@ -46,7 +46,7 @@ export function StaffSidebarNav({
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
{items.map(({ href, label, icon }) => {
|
||||
const Icon = NAV_ICON_MAP[icon];
|
||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
const active = href === activeHref;
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
@@ -63,15 +63,6 @@ export function StaffSidebarNav({
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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<ProfileActionResult> {
|
||||
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,
|
||||
|
||||
@@ -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 { 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<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({
|
||||
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,
|
||||
|
||||
@@ -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