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>
+9 -1
View File
@@ -15,15 +15,23 @@ export function DateStrip({
businessTimezone,
basePath,
view,
extraParams,
}: {
selectedDate: CalendarDate;
businessTimezone: string;
basePath: string;
view?: string;
/** Preserved across date navigation — e.g. the owner calendar's `?staffId=` filter. */
extraParams?: Record<string, string>;
}) {
const today = todayIn(businessTimezone);
const viewParam = view ? `&view=${view}` : "";
const hrefFor = (date: CalendarDate) => `${basePath}?date=${date}${viewParam}`;
const extraParamsString = extraParams
? Object.entries(extraParams)
.map(([key, value]) => `&${key}=${encodeURIComponent(value)}`)
.join("")
: "";
const hrefFor = (date: CalendarDate) => `${basePath}?date=${date}${viewParam}${extraParamsString}`;
const days = Array.from({ length: 7 }, (_, i) => addDays(selectedDate, i - 3));
const label = new Date(`${selectedDate}T00:00:00`).toLocaleDateString("en-GB", {
@@ -52,6 +52,7 @@ export function DayCalendar({
blocks,
workingWindow,
currentTimeMinutes,
appointmentHrefBase = "/staff/appointments",
}: {
appointments: CalendarAppointment[];
blocks: CalendarBlock[];
@@ -59,6 +60,8 @@ export function DayCalendar({
workingWindow: { startMinutes: number; endMinutes: number } | null;
/** Minutes-from-midnight right now, only passed when the viewed day is today. */
currentTimeMinutes: number | null;
/** Owner's all-staff calendar reuses this component unchanged but links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar approach". */
appointmentHrefBase?: string;
}) {
const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR;
@@ -121,7 +124,7 @@ export function DayCalendar({
{appointments.map((appt) => (
<Link
key={appt.id}
href={`/staff/appointments/${appt.id}`}
href={`${appointmentHrefBase}/${appt.id}`}
className={cn(
"absolute inset-x-1 z-20 overflow-hidden rounded-lg border-l-4 bg-white p-2 shadow-sm transition-shadow hover:shadow-md",
"focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1",
@@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Weekday } from "@/generated/prisma/enums";
import { saveWorkingHoursAction } from "../actions/working-hours";
import type { WorkingHoursActionResult } from "../actions/working-hours";
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
@@ -52,7 +54,16 @@ export interface InitialWorkingHoursRow {
endMinutes: number | null;
}
export function WorkingHoursForm({ initialRows, businessHoursLabel }: { initialRows: InitialWorkingHoursRow[]; businessHoursLabel: Record<Weekday, string> }) {
export function WorkingHoursForm({
initialRows,
businessHoursLabel,
onSave = saveWorkingHoursAction,
}: {
initialRows: InitialWorkingHoursRow[];
businessHoursLabel: Record<Weekday, string>;
/** Owner-on-behalf-of-staff editing (src/app/(owner)/owner/team/[id]/hours) injects its own Server Action here instead of the self-service default — same pattern as AppointmentStatusActions' `actions` prop. */
onSave?: (days: WorkingHoursDayInput[]) => Promise<WorkingHoursActionResult>;
}) {
const router = useRouter();
const [days, setDays] = useState<DayState[]>(() =>
DAY_ORDER.map((dayOfWeek) => {
@@ -84,7 +95,7 @@ export function WorkingHoursForm({ initialRows, businessHoursLabel }: { initialR
const handleSave = () => {
setError(null);
startTransition(async () => {
const result = await saveWorkingHoursAction(
const result = await onSave(
days.map((d) => ({
dayOfWeek: d.dayOfWeek,
isOff: d.isOff,