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,10 @@
/**
* Deliberately outside the (owner) route group, same reasoning as
* src/app/staff/appointments/[id]/reschedule/layout.tsx: this page reuses
* BarberDateTimePicker, whose own fixed bottom action bar would otherwise
* sit under the persistent owner sidebar/bottom-nav and swallow clicks
* meant for "Confirm Reschedule".
*/
export default function OwnerRescheduleLayout({ children }: { children: React.ReactNode }) {
return <div className="bg-stone min-h-screen">{children}</div>;
}
@@ -0,0 +1,111 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
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 { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { ownerRescheduleAppointmentAction } from "@/features/owner/actions/appointments";
import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker";
export const metadata: Metadata = { title: "Reschedule / Reassign" };
export const dynamic = "force-dynamic";
const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]);
/**
* Also the owner's appointment-reassignment surface — BarberDateTimePicker
* already lets the caller choose a different eligible barber as part of
* picking a new slot, so "reassign to another barber" and "reschedule to a
* new time" are the same underlying operation here, not two separate
* flows. See docs/phase-6-summary.md, "Appointment reassignment approach".
*/
export default async function OwnerRescheduleAppointmentPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_ALL_APPOINTMENTS);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { id: true, timezone: true } } },
});
if (!owner) redirect("/staff");
const { id } = await params;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
try {
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
} catch (error) {
if (error instanceof StaffPermissionDeniedError) notFound();
throw error;
}
if (NON_RESCHEDULABLE.has(appointment.status)) {
redirect(`/owner/appointments/${id}`);
}
async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) {
"use server";
const result = await ownerRescheduleAppointmentAction({
appointmentId: id,
staffId: selection.staffId,
startAt: selection.startAt,
});
if (result.success) {
redirect(`/owner/appointments/${id}`);
}
return result;
}
return (
<div className="pb-6">
<TopNav title="Reschedule / Reassign" onBack sticky className="bg-stone" />
<Card className="mx-6 mt-4 flex items-center gap-3 p-4">
<div className="flex-1">
<p className="text-sm font-bold">
{appointment.services.map((s) => s.service.name).join(" + ")} for {appointment.customer.firstName}{" "}
{appointment.customer.lastName}
</p>
<p className="text-muted text-xs">
Currently with {appointment.staff.user.name} —{" "}
{appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: owner.business.timezone,
})}{" "}
·{" "}
{appointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: owner.business.timezone,
})}
</p>
</div>
</Card>
<div className="mt-4">
<BarberDateTimePicker
businessId={owner.businessId}
businessTimezone={owner.business.timezone}
serviceIds={appointment.services.map((s) => s.serviceId)}
initialStaffId={appointment.staffId}
onContinue={handleContinue}
continueLabel="Confirm"
/>
</div>
</div>
);
}