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

@@ -13,11 +13,31 @@ import {
markNoShowAction,
startAppointmentAction,
} from "../actions/appointment-actions";
import type { StaffAppointmentActionResult } from "../actions/appointment-actions";
type RawStatus =
"PENDING" | "CONFIRMED" | "CHECKED_IN" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED" | "NO_SHOW";
type ConfirmKind = "complete" | "no_show" | "cancel";
/** Same shape the owner variant (src/features/owner/actions/appointments.ts) implements — see AppointmentStatusActions' `actions` prop. */
export interface AppointmentActionSet {
confirm: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
checkIn: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
start: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
complete: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
markNoShow: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
cancel: (params: { appointmentId: string; reason?: string }) => Promise<StaffAppointmentActionResult>;
}
const DEFAULT_ACTIONS: AppointmentActionSet = {
confirm: confirmAppointmentAction,
checkIn: checkInAppointmentAction,
start: startAppointmentAction,
complete: completeAppointmentAction,
markNoShow: markNoShowAction,
cancel: cancelStaffAppointmentAction,
};
/**
* The status-appropriate action set — shared by Current Appointment and
* Appointment Details, since both need exactly the same buttons for the
@@ -25,14 +45,19 @@ type ConfirmKind = "complete" | "no_show" | "cancel";
* AppointmentService's own state machine; this component never decides
* whether a transition is legal, only which buttons to *offer* for a given
* status (offering an illegal one would just get rejected server-side —
* see appointment-actions.ts).
* see appointment-actions.ts). `actions` defaults to the staff (own-only)
* Server Actions; the owner appointment details page passes the owner
* variant instead, so this one component serves both contexts without
* forking — see docs/phase-6-summary.md, "Owner appointment access".
*/
export function AppointmentStatusActions({
appointmentId,
status,
actions = DEFAULT_ACTIONS,
}: {
appointmentId: string;
status: RawStatus;
actions?: AppointmentActionSet;
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
@@ -59,10 +84,10 @@ export function AppointmentStatusActions({
startTransition(async () => {
const result =
confirmKind === "complete"
? await completeAppointmentAction(appointmentId)
? await actions.complete(appointmentId)
: confirmKind === "no_show"
? await markNoShowAction(appointmentId)
: await cancelStaffAppointmentAction({ appointmentId, reason: reason || undefined });
? await actions.markNoShow(appointmentId)
: await actions.cancel({ appointmentId, reason: reason || undefined });
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
@@ -101,7 +126,7 @@ export function AppointmentStatusActions({
<Button
size="sm"
isLoading={isPending}
onClick={() => runQuiet(() => confirmAppointmentAction(appointmentId))}
onClick={() => runQuiet(() => actions.confirm(appointmentId))}
>
Confirm Booking
</Button>
@@ -110,7 +135,7 @@ export function AppointmentStatusActions({
<Button
size="sm"
isLoading={isPending}
onClick={() => runQuiet(() => checkInAppointmentAction(appointmentId))}
onClick={() => runQuiet(() => actions.checkIn(appointmentId))}
>
Check In
</Button>
@@ -119,7 +144,7 @@ export function AppointmentStatusActions({
<Button
size="sm"
isLoading={isPending}
onClick={() => runQuiet(() => startAppointmentAction(appointmentId))}
onClick={() => runQuiet(() => actions.start(appointmentId))}
>
Start Appointment
</Button>