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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user