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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user