Staff / Owner

Merged staff and owner settings
This commit is contained in:
Stefan Lazic committed 2026-08-05 16:35:52 +01:00
1 parent cf3a28f3cd
commit 3300045d9e
23 files changed
+693 -236

No files matched your search

+2 -2
View File
@@ -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>
)}
+10 -7
View File
@@ -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}
>
+63 -21
View File
@@ -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&apos;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>
);
+13 -63
View File
@@ -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");
}
+16 -6
View File
@@ -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>
);
+16 -10
View File
@@ -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>
+6 -6
View File
@@ -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:
+70 -39
View File
@@ -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>
);
}