Phase 6
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:
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>
|
||||
|
||||
Reference in new issue
Block a user