Owner Administration is complete. Summary of this final stretch of work:

Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB.

Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres:

Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation).
Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres.
12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively.
Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected.

Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass.
This commit is contained in:
Stefan Lazic committed 2026-08-03 12:56:55 +01:00
1 parent ffee9d6754
commit ec5583f972
73 files changed
+8400 -53

No files matched your search

@@ -0,0 +1,50 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getBookingRule } from "@/services/scheduling/booking-rule.service";
import { BookingRulesForm } from "@/features/owner/components/booking-rules-form";
export const metadata: Metadata = { title: "Booking Rules" };
export const dynamic = "force-dynamic";
export default async function OwnerBookingRulesPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!owner) redirect("/staff");
const rule = await getBookingRule(prisma, owner.businessId);
if (!rule) redirect("/owner");
return (
<div className="pb-12">
<TopNav title="Booking Rules" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<BookingRulesForm
initial={{
minNoticeMinutes: rule.minNoticeMinutes,
maxAdvanceDays: rule.maxAdvanceDays,
cancellationWindowHours: rule.cancellationWindowHours,
rescheduleWindowHours: rule.rescheduleWindowHours,
slotIntervalMinutes: rule.slotIntervalMinutes,
bufferBeforeMinutes: rule.bufferBeforeMinutes,
bufferAfterMinutes: rule.bufferAfterMinutes,
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
customerCanChooseStaff: rule.customerCanChooseStaff,
allowGuestBooking: rule.allowGuestBooking,
requireApproval: rule.requireApproval,
}}
/>
</main>
</div>
);
}
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getBusinessProfile } from "@/services/business/business.service";
import { BusinessDetailsForm } from "@/features/owner/components/business-details-form";
export const metadata: Metadata = { title: "Business Details" };
export const dynamic = "force-dynamic";
export default async function OwnerBusinessDetailsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) redirect("/staff");
const business = await getBusinessProfile(prisma, staff.businessId);
if (!business) redirect("/owner");
const location = business.locations[0];
return (
<div className="pb-12">
<TopNav title="Business Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<BusinessDetailsForm
initial={{
name: business.name,
description: business.description ?? "",
logoUrl: business.logoUrl ?? "",
phone: business.phone ?? "",
email: business.email ?? "",
website: business.website ?? "",
instagramUrl: business.instagramUrl ?? "",
facebookUrl: business.facebookUrl ?? "",
timezone: business.timezone,
currency: business.currency,
locale: business.locale,
addressLine1: location?.addressLine1 ?? "",
addressLine2: location?.addressLine2 ?? "",
city: location?.city ?? "",
region: location?.region ?? "",
postalCode: location?.postalCode ?? "",
country: location?.country ?? "GB",
cancellationPolicy: business.settings?.cancellationPolicy ?? "",
lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "",
bookingTerms: business.settings?.bookingTerms ?? "",
privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "",
}}
/>
</main>
</div>
);
}
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getOpeningHours } from "@/services/business/business.service";
import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form";
export const metadata: Metadata = { title: "Opening Hours" };
export const dynamic = "force-dynamic";
export default async function OwnerOpeningHoursPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) redirect("/staff");
const hours = await getOpeningHours(prisma, staff.businessId);
return (
<div className="pb-12">
<TopNav title="Opening Hours" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<OpeningHoursForm initialRows={hours} />
</main>
</div>
);
}
@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
export const metadata: Metadata = { title: "Loyalty Programme" };
export const dynamic = "force-dynamic";
export default async function OwnerLoyaltyConfigPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!owner) redirect("/staff");
const [programme, services, staff] = await Promise.all([
getProgrammeConfig(prisma, owner.businessId),
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
prisma.staffProfile.findMany({
where: { businessId: owner.businessId, status: "ACTIVE" },
include: { user: { select: { name: true } } },
}),
]);
const initialConfig = programme
? {
isEnabled: programme.isEnabled,
stampsRequired: programme.stampsRequired,
rewardType: programme.rewardType,
rewardValue: programme.rewardValue?.toString(),
rewardLabel: programme.rewardLabel ?? undefined,
maxRewardValue: programme.maxRewardValue?.toString(),
freeServiceId: programme.freeServiceId ?? undefined,
autoApplyReward: programme.autoApplyReward,
requiresStaffApproval: programme.requiresStaffApproval,
stampsExpireAfterDays: programme.stampsExpireAfterDays ?? undefined,
rewardExpiresAfterDays: programme.rewardExpiresAfterDays ?? undefined,
earnOnCancelled: programme.earnOnCancelled,
earnOnNoShow: programme.earnOnNoShow,
cardBackgroundColor: programme.cardBackgroundColor ?? undefined,
cardBackgroundImageUrl: programme.cardBackgroundImageUrl ?? undefined,
stampIcon: programme.stampIcon ?? undefined,
termsLine: programme.termsLine ?? undefined,
}
: {
isEnabled: false,
stampsRequired: 10,
rewardType: "PERCENTAGE" as const,
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
};
return (
<div className="pb-12">
<TopNav title="Loyalty Programme" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<LoyaltyConfigForm
initialConfig={initialConfig}
initialEligibleServiceIds={programme?.eligibleServices.map((s) => s.serviceId) ?? []}
initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []}
services={services.map((s) => ({ id: s.id, name: s.name }))}
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))}
/>
</main>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Bell, Building2, CalendarClock, ClipboardList, Crown, ScissorsSquare } 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";
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/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: "/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");
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-jumbo 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-jumbo 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>
<SignOutButton />
</div>
);
}