Phase 5 is complete. All 53 tasks are done, verified on a fresh database in a single clean run: **121 unit tests**, **55 integration tests**, and **15 E2E journeys** (6 Phase 4 + 9 new Phase 5) all passing, plus a clean `tsc`/`eslint`/`build`.

Summary of this final stretch:

- **Fixed a real bug**: `StaffService`'s break/time-off update/delete functions threw uncaught instead of failing gracefully on a permission mismatch — fixed with proper `PERMISSION_DENIED` handling, covered by new integration tests.
- **Built the remaining Phase 5 UI**: Breaks editor, Time Off editor, Barber Profile, and Notification Preferences — all wired to real services, no placeholders.
- **Extended seed data**: more customers, loyalty accounts/transactions/rewards, and appointments spanning every status today (anchored to real time so the "Current Appointment" priority logic is reliably exercised).
- **Found and fixed a second real bug live in the browser**: the Break/Time-Off edit sheets showed stale default values instead of the item being edited (a classic `useState`-doesn't-reinitialize-on-prop-change bug) — fixed with a `key` prop to force remount.
- **Wrote the full Phase 5 test suite**: unit tests for `selectCurrentAppointment` and all `StaffService` validators, integration tests for `LoyaltyService` and `StaffService` (including the permission-fix regression tests), and 9 new Playwright E2E journeys covering the brief's exact required list.
- **Did the desktop-responsive pass**: a persistent sidebar nav replacing the bottom bar at `lg:`, a genuine calendar+"Right Now" side-by-side split, and 2-column layouts for the clients list and customer details — addressing the brief's explicit "more than simple centering" requirement.
- **Wrote [docs/phase-5-summary.md](docs/phase-5-summary.md)** documenting everything above in full.

Nothing is committed — the whole session (Phases 2–5) sits uncommitted on top of the initial template commit, same as you left it. Let me know if you'd like me to commit, or if you want to move on to Phase 6.
This commit is contained in:
Stefan Lazic committed 2026-08-02 23:44:18 +01:00
1 parent 6ae145d187
commit ffee9d6754
81 files changed
+8024 -113

No files matched your search

@@ -0,0 +1,206 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { Textarea } from "@/components/ui/textarea";
import {
cancelStaffAppointmentAction,
checkInAppointmentAction,
completeAppointmentAction,
confirmAppointmentAction,
markNoShowAction,
startAppointmentAction,
} from "../actions/appointment-actions";
type RawStatus =
"PENDING" | "CONFIRMED" | "CHECKED_IN" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED" | "NO_SHOW";
type ConfirmKind = "complete" | "no_show" | "cancel";
/**
* The status-appropriate action set — shared by Current Appointment and
* Appointment Details, since both need exactly the same buttons for the
* same status. Every button calls a Server Action that delegates to
* 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).
*/
export function AppointmentStatusActions({
appointmentId,
status,
}: {
appointmentId: string;
status: RawStatus;
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [confirmKind, setConfirmKind] = useState<ConfirmKind | null>(null);
const [reason, setReason] = useState("");
const [done, setDone] = useState<{ loyaltyStampAwarded?: boolean } | null>(null);
const runQuiet = (action: () => Promise<{ success: boolean; error?: string }>) => {
setError(null);
startTransition(async () => {
const result = await action();
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
}
router.refresh();
});
};
const runConfirmed = () => {
if (!confirmKind) return;
setError(null);
startTransition(async () => {
const result =
confirmKind === "complete"
? await completeAppointmentAction(appointmentId)
: confirmKind === "no_show"
? await markNoShowAction(appointmentId)
: await cancelStaffAppointmentAction({ appointmentId, reason: reason || undefined });
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
}
setDone(
confirmKind === "complete" ? { loyaltyStampAwarded: result.loyaltyStampAwarded } : {},
);
});
};
const closeConfirm = () => {
const shouldRefresh = !!done;
setConfirmKind(null);
setDone(null);
setReason("");
setError(null);
if (shouldRefresh) router.refresh();
};
const modalTitle =
confirmKind === "complete"
? "Complete Appointment?"
: confirmKind === "no_show"
? "Mark as No-Show?"
: "Cancel Appointment?";
return (
<div className="space-y-3">
{error && !confirmKind && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<div className="flex flex-wrap gap-2">
{status === "PENDING" && (
<Button
size="sm"
isLoading={isPending}
onClick={() => runQuiet(() => confirmAppointmentAction(appointmentId))}
>
Confirm Booking
</Button>
)}
{status === "CONFIRMED" && (
<Button
size="sm"
isLoading={isPending}
onClick={() => runQuiet(() => checkInAppointmentAction(appointmentId))}
>
Check In
</Button>
)}
{status === "CHECKED_IN" && (
<Button
size="sm"
isLoading={isPending}
onClick={() => runQuiet(() => startAppointmentAction(appointmentId))}
>
Start Appointment
</Button>
)}
{status === "IN_PROGRESS" && (
<Button size="sm" onClick={() => setConfirmKind("complete")}>
Complete Appointment
</Button>
)}
{status === "CONFIRMED" && (
<Button size="sm" variant="secondary" onClick={() => setConfirmKind("no_show")}>
Mark No-Show
</Button>
)}
{(status === "PENDING" || status === "CONFIRMED" || status === "CHECKED_IN") && (
<Button size="sm" variant="destructive" onClick={() => setConfirmKind("cancel")}>
Cancel
</Button>
)}
</div>
<Modal open={!!confirmKind} onClose={closeConfirm} title={done ? "Done" : modalTitle}>
{done ? (
<div className="space-y-4">
<p role="status" className="text-muted text-sm">
{confirmKind === "complete" && "This appointment is marked complete."}
{confirmKind === "no_show" && "This appointment is marked as a no-show."}
{confirmKind === "cancel" &&
"This appointment has been cancelled. It won't be deleted from history."}
</p>
{done.loyaltyStampAwarded && (
<p className="border-accent-loyalty/30 bg-accent-loyalty/10 rounded-lg border p-3 text-xs font-medium">
A loyalty stamp was added for this visit.
</p>
)}
<Button className="w-full" onClick={closeConfirm}>
Done
</Button>
</div>
) : (
<div className="space-y-4">
<p className="text-muted text-sm">
{confirmKind === "complete" &&
"This marks the visit as finished and (if eligible) adds a loyalty stamp."}
{confirmKind === "no_show" &&
"The customer didn't arrive for this appointment. This can't be undone."}
{confirmKind === "cancel" &&
"This can't be undone. You can add an optional reason below."}
</p>
{confirmKind === "cancel" && (
<Textarea
placeholder="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
)}
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={closeConfirm}>
Keep As Is
</Button>
<Button
variant={confirmKind === "complete" ? "primary" : "destructive"}
className="flex-1"
isLoading={isPending}
onClick={runConfirmed}
>
{confirmKind === "complete"
? "Complete"
: confirmKind === "no_show"
? "Mark No-Show"
: "Cancel It"}
</Button>
</div>
</div>
)}
</Modal>
</div>
);
}