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,107 @@
"use client";
import { Stamp } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { Input } from "@/components/ui/input";
import { addLoyaltyStampAction } from "../actions/loyalty-actions";
export function AddStampButton({
customerId,
appointmentId,
}: {
customerId: string;
appointmentId?: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [amount, setAmount] = useState("1");
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{ rewardCreated?: boolean } | null>(null);
const [isPending, startTransition] = useTransition();
const close = () => {
const shouldRefresh = !!result;
setOpen(false);
setResult(null);
setError(null);
setAmount("1");
setReason("");
if (shouldRefresh) router.refresh();
};
const submit = () => {
const parsed = Number(amount);
if (!Number.isInteger(parsed) || parsed <= 0) {
setError("Enter a positive number of stamps.");
return;
}
setError(null);
startTransition(async () => {
const res = await addLoyaltyStampAction({
customerId,
amount: parsed,
appointmentId,
reason: reason || undefined,
});
if (!res.success) {
setError(res.error ?? "Couldn't add a stamp.");
return;
}
setResult({ rewardCreated: res.rewardCreated });
});
};
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
<Stamp className="h-4 w-4" aria-hidden="true" />
Add Loyalty Stamp
</Button>
<Modal open={open} onClose={close} title={result ? "Stamp Added" : "Add Loyalty Stamp"}>
{result ? (
<div className="space-y-4">
<p role="status" className="text-muted text-sm">
The stamp has been recorded on this customer&apos;s loyalty account.
</p>
{result.rewardCreated && (
<p className="border-accent-loyalty/30 bg-accent-loyalty/10 rounded-lg border p-3 text-xs font-medium">
They&apos;ve now reached the stamp threshold — a reward is available.
</p>
)}
<Button className="w-full" onClick={close}>
Done
</Button>
</div>
) : (
<div className="space-y-4">
<Input
label="Stamps to add"
type="number"
min={1}
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<Input
label="Reason (optional)"
placeholder="e.g. Referral bonus"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={submit}>
Add Stamp
</Button>
</div>
)}
</Modal>
</>
);
}
@@ -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>
);
}
@@ -0,0 +1,147 @@
"use client";
import { CalendarOff } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Input } from "@/components/ui/input";
import type { BlockTimeConflict } from "../actions/block-time";
import { blockTimeAction } from "../actions/block-time";
function toMinutes(hhmm: string): number | null {
const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
export function BlockTimeButton({ date }: { date: string }) {
const [open, setOpen] = useState(false);
const [startTime, setStartTime] = useState("12:00");
const [endTime, setEndTime] = useState("13:00");
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<BlockTimeConflict[] | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleClose = () => {
setOpen(false);
setError(null);
setConflicts(null);
setReason("");
};
const submit = (acknowledgeConflicts: boolean) => {
const startMinutes = toMinutes(startTime);
const endMinutes = toMinutes(endTime);
if (startMinutes === null || endMinutes === null || startMinutes >= endMinutes) {
setError("Start time must be before end time.");
return;
}
setError(null);
startTransition(async () => {
const result = await blockTimeAction({
date,
startMinutes,
endMinutes,
reason,
acknowledgeConflicts,
});
if (!result.success) {
setError(result.error ?? "Something went wrong.");
setConflicts(result.conflicts ?? null);
return;
}
setOpen(false);
setConflicts(null);
router.refresh();
});
};
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
<CalendarOff className="h-4 w-4" aria-hidden="true" />
Block Time
</Button>
<BottomSheet open={open} onClose={handleClose} title="Block Time">
<div className="space-y-4 pb-2">
<p className="text-muted text-sm">
Reserve part of{" "}
{new Date(`${date}T00:00:00`).toLocaleDateString("en-GB", {
weekday: "long",
day: "numeric",
month: "short",
})}{" "}
for something other than appointments — a meeting, admin, a personal break.
</p>
{conflicts && conflicts.length > 0 ? (
<div className="space-y-3">
<p role="alert" className="text-danger text-sm font-medium">
This overlaps {conflicts.length} existing appointment
{conflicts.length > 1 ? "s" : ""}:
</p>
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/staff/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={handleClose}>
Cancel
</Button>
<Button
variant="destructive"
className="flex-1"
isLoading={isPending}
onClick={() => submit(true)}
>
Block Anyway
</Button>
</div>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<Input
label="Start"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
<Input
label="End"
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
<Input
label="Reason (optional)"
placeholder="e.g. Team meeting"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
Block This Time
</Button>
</>
)}
</div>
</BottomSheet>
</>
);
}
@@ -0,0 +1,20 @@
"use client";
import { useTransition } from "react";
import { Button } from "@/components/ui/button";
import { startStaffFollowUpAction } from "../actions/staff-booking";
export function BookFollowUpButton({ appointmentId }: { appointmentId: string }) {
const [isPending, startTransition] = useTransition();
return (
<Button
variant="secondary"
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
isLoading={isPending}
onClick={() => startTransition(() => startStaffFollowUpAction(appointmentId))}
>
Book Follow-Up
</Button>
);
}
@@ -0,0 +1,212 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Input } from "@/components/ui/input";
import { Weekday } from "@/generated/prisma/enums";
import type { BreakActionResult, BreakConflictItem } from "../actions/breaks";
import { createBreakAction, deleteBreakAction, updateBreakAction } from "../actions/breaks";
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
const DAY_SHORT: Record<Weekday, string> = {
MONDAY: "Mon",
TUESDAY: "Tue",
WEDNESDAY: "Wed",
THURSDAY: "Thu",
FRIDAY: "Fri",
SATURDAY: "Sat",
SUNDAY: "Sun",
};
function toMinutes(hhmm: string): number | null {
const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
function toTimeString(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
export interface EditableBreak {
id: string;
label: string;
startMinutes: number;
endMinutes: number;
days: Weekday[];
}
export function BreakFormSheet({
open,
onClose,
existing,
}: {
open: boolean;
onClose: () => void;
existing?: EditableBreak | null;
}) {
const router = useRouter();
const [label, setLabel] = useState(existing?.label ?? "Break");
const [startTime, setStartTime] = useState(existing ? toTimeString(existing.startMinutes) : "12:00");
const [endTime, setEndTime] = useState(existing ? toTimeString(existing.endMinutes) : "13:00");
const [days, setDays] = useState<Weekday[]>(existing?.days ?? []);
const [error, setError] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<BreakConflictItem[] | null>(null);
const [isPending, startTransition] = useTransition();
const [isDeleting, startDeleteTransition] = useTransition();
const reset = () => {
setLabel(existing?.label ?? "Break");
setStartTime(existing ? toTimeString(existing.startMinutes) : "12:00");
setEndTime(existing ? toTimeString(existing.endMinutes) : "13:00");
setDays(existing?.days ?? []);
setError(null);
setConflicts(null);
};
const handleClose = () => {
onClose();
reset();
};
const toggleDay = (day: Weekday) => {
setDays((prev) => (prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day]));
};
const submit = (acknowledgeConflicts: boolean) => {
const startMinutes = toMinutes(startTime);
const endMinutes = toMinutes(endTime);
if (startMinutes === null || endMinutes === null || startMinutes >= endMinutes) {
setError("Start time must be before end time.");
return;
}
if (days.length === 0) {
setError("Select at least one day.");
return;
}
if (!label.trim()) {
setError("Give this break a label.");
return;
}
setError(null);
startTransition(async () => {
const input = { label, startMinutes, endMinutes, days };
const result: BreakActionResult = existing
? await updateBreakAction(existing.id, input, acknowledgeConflicts)
: await createBreakAction(input, acknowledgeConflicts);
if (!result.success) {
setError(result.error ?? "Something went wrong.");
setConflicts(result.conflicts ?? null);
return;
}
handleClose();
router.refresh();
});
};
const handleDelete = () => {
if (!existing) return;
startDeleteTransition(async () => {
const result = await deleteBreakAction(existing.id);
if (!result.success) {
setError(result.error ?? "Couldn't delete this break.");
return;
}
handleClose();
router.refresh();
});
};
return (
<BottomSheet open={open} onClose={handleClose} title={existing ? "Edit Break" : "New Break"}>
<div className="space-y-4 pb-2">
{conflicts && conflicts.length > 0 ? (
<div className="space-y-3">
<p role="alert" className="text-danger text-sm font-medium">
This overlaps {conflicts.length} upcoming appointment
{conflicts.length > 1 ? "s" : ""}:
</p>
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/staff/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={handleClose}>
Cancel
</Button>
<Button
variant="destructive"
className="flex-1"
isLoading={isPending}
onClick={() => submit(true)}
>
Save Anyway
</Button>
</div>
</div>
) : (
<>
<Input label="Label" placeholder="e.g. Lunch" value={label} onChange={(e) => setLabel(e.target.value)} />
<div className="grid grid-cols-2 gap-4">
<Input label="Start" type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
<Input label="End" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
</div>
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Repeats on</p>
<div className="flex flex-wrap gap-2">
{DAY_ORDER.map((day) => {
const active = days.includes(day);
return (
<button
key={day}
type="button"
onClick={() => toggleDay(day)}
aria-pressed={active}
className={`rounded-full border px-3 py-1.5 text-xs font-bold transition-colors ${
active ? "border-accent bg-accent text-accent-foreground" : "border-line bg-white text-ink"
}`}
>
{DAY_SHORT[day]}
</button>
);
})}
</div>
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
{existing ? "Save Changes" : "Add Break"}
</Button>
{existing && (
<Button variant="ghost" className="w-full text-danger" isLoading={isDeleting} onClick={handleDelete}>
Delete Break
</Button>
)}
</>
)}
</div>
</BottomSheet>
);
}
@@ -0,0 +1,96 @@
"use client";
import { Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Weekday } from "@/generated/prisma/enums";
import { BreakFormSheet } from "./break-form-sheet";
import type { EditableBreak } from "./break-form-sheet";
const DAY_SHORT: Record<Weekday, string> = {
MONDAY: "Mon",
TUESDAY: "Tue",
WEDNESDAY: "Wed",
THURSDAY: "Thu",
FRIDAY: "Fri",
SATURDAY: "Sat",
SUNDAY: "Sun",
};
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
function formatDays(days: Weekday[]): string {
return DAY_ORDER.filter((d) => days.includes(d))
.map((d) => DAY_SHORT[d])
.join(", ");
}
function toTimeString(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
const period = h >= 12 ? "PM" : "AM";
const h12 = h % 12 === 0 ? 12 : h % 12;
return `${h12}:${String(m).padStart(2, "0")} ${period}`;
}
export function BreaksList({ breaks }: { breaks: EditableBreak[] }) {
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<EditableBreak | null>(null);
const openNew = () => {
setEditing(null);
setSheetOpen(true);
};
const openEdit = (b: EditableBreak) => {
setEditing(b);
setSheetOpen(true);
};
return (
<div className="space-y-4">
<Button variant="secondary" className="w-full" onClick={openNew}>
<Plus className="h-4 w-4" aria-hidden="true" />
Add Break
</Button>
{breaks.length === 0 ? (
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
No recurring breaks yet.
</p>
) : (
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
{breaks.map((b) => (
<li key={b.id}>
<button
type="button"
onClick={() => openEdit(b)}
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left hover:bg-stone"
>
<div>
<p className="text-sm font-bold">{b.label}</p>
<p className="text-xs text-muted">
{toTimeString(b.startMinutes)}–{toTimeString(b.endMinutes)} · {formatDays(b.days)}
</p>
</div>
</button>
</li>
))}
</ul>
)}
<BreakFormSheet
key={editing?.id ?? "new"}
open={sheetOpen}
onClose={() => setSheetOpen(false)}
existing={editing}
/>
</div>
);
}
@@ -0,0 +1,39 @@
"use client";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { confirmStaffBookingAction } from "../actions/staff-booking";
export function ConfirmStaffBooking() {
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleConfirm = () => {
setError(null);
startTransition(async () => {
const result = await confirmStaffBookingAction(notes || undefined);
if (!result.success) setError(result.error ?? "Something went wrong. Please try again.");
});
};
return (
<div className="space-y-4">
<Textarea
label="Booking Notes (optional)"
placeholder="e.g. requested style, allergies…"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" onClick={handleConfirm} isLoading={isPending}>
Confirm Booking
</Button>
</div>
);
}
@@ -0,0 +1,101 @@
"use client";
import { Undo2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { correctLoyaltyStampAction } from "../actions/loyalty-actions";
/** Removes stamps via a compensating ledger entry — never edits or deletes the original transaction. A reason is required (enforced both here and server-side in LoyaltyService.correctStamp). */
export function CorrectStampButton({ loyaltyAccountId }: { loyaltyAccountId: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [amount, setAmount] = useState("1");
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [isPending, startTransition] = useTransition();
const close = () => {
const shouldRefresh = done;
setOpen(false);
setDone(false);
setError(null);
setAmount("1");
setReason("");
if (shouldRefresh) router.refresh();
};
const submit = () => {
const parsed = Number(amount);
if (!Number.isInteger(parsed) || parsed <= 0) {
setError("Enter a positive number of stamps to remove.");
return;
}
if (!reason.trim()) {
setError("A reason is required to correct a stamp.");
return;
}
setError(null);
startTransition(async () => {
const res = await correctLoyaltyStampAction({ loyaltyAccountId, amount: parsed, reason });
if (!res.success) {
setError(res.error ?? "Couldn't correct that stamp.");
return;
}
setDone(true);
});
};
return (
<>
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
<Undo2 className="h-4 w-4" aria-hidden="true" />
Correct Stamp
</Button>
<Modal open={open} onClose={close} title={done ? "Stamp Corrected" : "Correct a Stamp"}>
{done ? (
<div className="space-y-4">
<p role="status" className="text-muted text-sm">
A compensating entry has been recorded — the original stamp stays in the
customer&apos;s history.
</p>
<Button className="w-full" onClick={close}>
Done
</Button>
</div>
) : (
<div className="space-y-4">
<p className="text-muted text-sm">
This removes stamps by recording a correction — it never deletes the original entry.
</p>
<Input
label="Stamps to remove"
type="number"
min={1}
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<Textarea
label="Reason (required)"
placeholder="e.g. Added by mistake"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" variant="destructive" isLoading={isPending} onClick={submit}>
Correct Stamp
</Button>
</div>
)}
</Modal>
</>
);
}
@@ -0,0 +1,187 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
addCustomerNoteAction,
deleteCustomerNoteAction,
updateCustomerNoteAction,
} from "../actions/customer-notes";
export interface CustomerNoteItem {
id: string;
body: string;
createdAt: string;
updatedAt: string;
authorStaffId: string;
authorName: string;
}
export function CustomerNotes({
customerId,
notes,
actingStaffId,
}: {
customerId: string;
notes: CustomerNoteItem[];
actingStaffId: string;
}) {
const router = useRouter();
const [newBody, setNewBody] = useState("");
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editBody, setEditBody] = useState("");
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const submitNew = () => {
if (!newBody.trim()) return;
setError(null);
startTransition(async () => {
const result = await addCustomerNoteAction({ customerId, body: newBody });
if (!result.success) {
setError(result.error ?? "Couldn't add note.");
return;
}
setNewBody("");
router.refresh();
});
};
const startEdit = (note: CustomerNoteItem) => {
setEditingId(note.id);
setEditBody(note.body);
};
const saveEdit = (noteId: string) => {
setError(null);
startTransition(async () => {
const result = await updateCustomerNoteAction({ noteId, body: editBody });
if (!result.success) {
setError(result.error ?? "Couldn't save note.");
return;
}
setEditingId(null);
router.refresh();
});
};
const confirmDelete = (noteId: string) => {
setError(null);
startTransition(async () => {
const result = await deleteCustomerNoteAction({ noteId });
if (!result.success) {
setError(result.error ?? "Couldn't delete note.");
return;
}
setConfirmDeleteId(null);
router.refresh();
});
};
return (
<div className="space-y-4">
<div className="space-y-2">
<Textarea
placeholder="Add a note about this customer…"
value={newBody}
onChange={(e) => setNewBody(e.target.value)}
/>
<Button size="sm" isLoading={isPending} disabled={!newBody.trim()} onClick={submitNew}>
Add Note
</Button>
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{notes.length === 0 ? (
<p className="text-muted text-sm">No notes yet.</p>
) : (
<ul className="space-y-3">
{notes.map((note) => {
const isOwn = note.authorStaffId === actingStaffId;
const isEditing = editingId === note.id;
const edited = note.updatedAt !== note.createdAt;
return (
<li key={note.id} className="border-line rounded-lg border bg-white p-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
{note.authorName} ·{" "}
{new Date(note.createdAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
})}
{edited && " (edited)"}
</span>
</div>
{isEditing ? (
<div className="space-y-2">
<Textarea value={editBody} onChange={(e) => setEditBody(e.target.value)} />
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditingId(null)}>
Cancel
</Button>
<Button size="sm" isLoading={isPending} onClick={() => saveEdit(note.id)}>
Save
</Button>
</div>
</div>
) : (
<>
<p className="text-muted text-sm">{note.body}</p>
{isOwn && (
<div className="mt-2 flex gap-3">
<button
type="button"
onClick={() => startEdit(note)}
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
Edit
</button>
{confirmDeleteId === note.id ? (
<>
<span className="text-muted text-[10px]">Delete this note?</span>
<button
type="button"
onClick={() => confirmDelete(note.id)}
className="text-danger text-[10px] font-bold tracking-widest uppercase underline"
>
Confirm
</button>
<button
type="button"
onClick={() => setConfirmDeleteId(null)}
className="text-muted text-[10px] font-bold tracking-widest uppercase underline"
>
Cancel
</button>
</>
) : (
<button
type="button"
onClick={() => setConfirmDeleteId(note.id)}
className="text-danger text-[10px] font-bold tracking-widest uppercase underline"
>
Delete
</button>
)}
</div>
)}
</>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -0,0 +1,100 @@
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { addDays, todayIn } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { cn } from "@/lib/utils/cn";
/**
* Today/previous/next/date-selection, all as plain navigation — every
* control here is a Link that changes the `?date=` search param, so the
* whole calendar page stays a Server Component with no client JS needed
* just to pick a day.
*/
export function DateStrip({
selectedDate,
businessTimezone,
basePath,
view,
}: {
selectedDate: CalendarDate;
businessTimezone: string;
basePath: string;
view?: string;
}) {
const today = todayIn(businessTimezone);
const viewParam = view ? `&view=${view}` : "";
const hrefFor = (date: CalendarDate) => `${basePath}?date=${date}${viewParam}`;
const days = Array.from({ length: 7 }, (_, i) => addDays(selectedDate, i - 3));
const label = new Date(`${selectedDate}T00:00:00`).toLocaleDateString("en-GB", {
weekday: "long",
day: "numeric",
month: "long",
});
return (
<div className="space-y-3 px-6 pt-4">
<div className="flex items-center justify-between">
<Link
href={hrefFor(addDays(selectedDate, -1))}
aria-label="Previous day"
className="border-line flex h-9 w-9 items-center justify-center rounded-full border bg-white text-white"
>
<ChevronLeft className="text-ink h-4 w-4" aria-hidden="true" />
</Link>
<div className="text-center">
<p className="text-sm font-bold">{selectedDate === today ? "Today" : label}</p>
{selectedDate === today && <p className="text-muted text-xs">{label}</p>}
{selectedDate !== today && (
<Link
href={hrefFor(today)}
className="text-accent text-[10px] font-bold tracking-widest uppercase underline"
>
Jump to today
</Link>
)}
</div>
<Link
href={hrefFor(addDays(selectedDate, 1))}
aria-label="Next day"
className="border-line flex h-9 w-9 items-center justify-center rounded-full border bg-white"
>
<ChevronRight className="text-ink h-4 w-4" aria-hidden="true" />
</Link>
</div>
<div className="flex justify-between gap-1">
{days.map((date) => {
const isSelected = date === selectedDate;
const isToday = date === today;
const d = new Date(`${date}T00:00:00`);
return (
<Link
key={date}
href={hrefFor(date)}
aria-current={isSelected ? "date" : undefined}
className={cn(
"flex flex-1 flex-col items-center gap-1 rounded-xl border py-2 text-xs",
isSelected
? "border-ink bg-ink text-white"
: "border-line text-ink hover:bg-stone bg-white",
)}
>
<span
className={cn(
"text-[9px] font-bold tracking-widest uppercase",
isSelected ? "text-white/70" : "text-muted",
)}
>
{d.toLocaleDateString("en-GB", { weekday: "short" })}
</span>
<span className={cn("font-bold", isToday && !isSelected && "text-accent")}>
{d.getDate()}
</span>
</Link>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,185 @@
import Link from "next/link";
import { Gift, NotebookText } from "lucide-react";
import { StatusPill } from "@/components/ui/status-pill";
import { cn } from "@/lib/utils/cn";
import type { AppointmentStatus } from "@/types/domain";
const START_HOUR = 7;
const END_HOUR = 21;
const PX_PER_HOUR = 64;
const RANGE_START_MINUTES = START_HOUR * 60;
const RANGE_END_MINUTES = END_HOUR * 60;
function topFor(minutes: number): number {
return ((clampToRange(minutes) - RANGE_START_MINUTES) / 60) * PX_PER_HOUR;
}
function clampToRange(minutes: number): number {
return Math.min(RANGE_END_MINUTES, Math.max(RANGE_START_MINUTES, minutes));
}
function heightFor(startMinutes: number, endMinutes: number): number {
return Math.max(20, topFor(endMinutes) - topFor(startMinutes));
}
export interface CalendarAppointment {
id: string;
startMinutes: number;
endMinutes: number;
customerName: string;
serviceNames: string;
status: AppointmentStatus;
hasNotes: boolean;
hasAvailableReward: boolean;
}
export interface CalendarBlock {
id: string;
startMinutes: number;
endMinutes: number;
label: string;
kind: "break" | "timeOff";
}
/**
* A single day's grid for one staff member. Deliberately no drag-and-drop —
* every interactive element is a plain Link (open details / start a
* reschedule from there), per the brief's preference for a reliable
* tap-to-act flow over an unreliable drag interaction. Pure presentation:
* every minute value here is already computed by the page from real
* WorkingHours/Break/TimeOff/Appointment rows, nothing is derived here.
*/
export function DayCalendar({
appointments,
blocks,
workingWindow,
currentTimeMinutes,
}: {
appointments: CalendarAppointment[];
blocks: CalendarBlock[];
/** The staff member's working window that day, for shading outside-hours space — null if they're off entirely. */
workingWindow: { startMinutes: number; endMinutes: number } | null;
/** Minutes-from-midnight right now, only passed when the viewed day is today. */
currentTimeMinutes: number | null;
}) {
const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR;
return (
<div className="overflow-x-auto px-6 pb-8">
<div className="relative ml-14" style={{ height: gridHeight }}>
{/* Hour gridlines + labels */}
{hourMarks.map((hour) => (
<div
key={hour}
className="border-line absolute inset-x-0 border-t"
style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}
>
<span className="text-muted absolute -top-2.5 -left-14 w-12 text-right text-[10px] font-medium">
{hour === 12 ? "12pm" : hour > 12 ? `${hour - 12}pm` : `${hour}am`}
</span>
</div>
))}
{/* Outside-working-hours shading */}
{workingWindow ? (
<>
{workingWindow.startMinutes > RANGE_START_MINUTES && (
<div
className="bg-line/20 absolute inset-x-0"
style={{ top: 0, height: topFor(workingWindow.startMinutes) }}
aria-hidden="true"
/>
)}
{workingWindow.endMinutes < RANGE_END_MINUTES && (
<div
className="bg-line/20 absolute inset-x-0"
style={{ top: topFor(workingWindow.endMinutes), bottom: 0 }}
aria-hidden="true"
/>
)}
</>
) : (
<div className="bg-line/20 absolute inset-0" aria-hidden="true" />
)}
{/* Breaks / time off */}
{blocks.map((block) => (
<div
key={block.id}
className="border-warning/40 bg-warning/10 absolute inset-x-1 z-10 overflow-hidden rounded-lg border bg-[repeating-linear-gradient(135deg,transparent,transparent_6px,rgba(0,0,0,0.04)_6px,rgba(0,0,0,0.04)_12px)] px-2 py-1"
style={{
top: topFor(block.startMinutes),
height: heightFor(block.startMinutes, block.endMinutes),
}}
>
<p className="text-warning-foreground truncate text-[10px] font-bold tracking-widest uppercase">
{block.label}
</p>
</div>
))}
{/* Appointments */}
{appointments.map((appt) => (
<Link
key={appt.id}
href={`/staff/appointments/${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",
statusBorderClass(appt.status),
)}
style={{
top: topFor(appt.startMinutes),
height: heightFor(appt.startMinutes, appt.endMinutes),
}}
>
<div className="flex items-center gap-1">
<StatusPill status={appt.status} iconOnly className="px-1.5 py-0.5" />
{appt.hasNotes && (
<NotebookText className="text-muted h-3 w-3 shrink-0" aria-label="Has notes" />
)}
{appt.hasAvailableReward && (
<Gift
className="text-accent-loyalty h-3 w-3 shrink-0"
aria-label="Reward available"
/>
)}
</div>
<p className="truncate text-xs font-bold">{appt.customerName}</p>
<p className="text-muted truncate text-[10px]">{appt.serviceNames}</p>
</Link>
))}
{/* Current-time indicator */}
{currentTimeMinutes !== null &&
currentTimeMinutes >= RANGE_START_MINUTES &&
currentTimeMinutes <= RANGE_END_MINUTES && (
<div
className="border-danger absolute inset-x-0 z-30 border-t-2"
style={{ top: topFor(currentTimeMinutes) }}
aria-hidden="true"
>
<span className="bg-danger absolute -top-1.5 -left-1.5 h-3 w-3 rounded-full" />
</div>
)}
</div>
</div>
);
}
function statusBorderClass(status: AppointmentStatus): string {
switch (status) {
case "cancelled":
case "no_show":
return "border-status-cancelled opacity-60";
case "completed":
return "border-line opacity-70";
case "in_progress":
return "border-status-progress";
case "arrived":
return "border-status-confirmed";
case "pending_approval":
return "border-status-pending";
default:
return "border-status-confirmed";
}
}
@@ -0,0 +1,61 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { guestDetailsSchema } from "@/lib/booking-draft/schema";
import type { GuestDetails } from "@/lib/booking-draft/schema";
import { selectNewGuestCustomerAction } from "../actions/staff-booking";
/** Same guestDetailsSchema the customer app's own guest checkout uses — a new customer entered by staff is resolved through the exact same CustomerService matching rules, see staff-booking.ts. */
export function NewGuestCustomerForm() {
const [serverError, setServerError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm<GuestDetails>({ resolver: zodResolver(guestDetailsSchema) });
const onSubmit = (data: GuestDetails) => {
setServerError(null);
startTransition(async () => {
const result = await selectNewGuestCustomerAction(data);
if (!result.success) {
setServerError(result.error ?? "Please check the highlighted fields.");
if (result.fieldErrors) {
for (const [field, messages] of Object.entries(result.fieldErrors)) {
if (messages?.[0]) setError(field as keyof GuestDetails, { message: messages[0] });
}
}
}
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input label="First Name" error={errors.firstName?.message} {...register("firstName")} />
<Input label="Last Name" error={errors.lastName?.message} {...register("lastName")} />
</div>
<Input
label="Email Address"
type="email"
error={errors.email?.message}
{...register("email")}
/>
<Input label="Phone Number" type="tel" error={errors.phone?.message} {...register("phone")} />
{serverError && (
<p role="alert" className="text-danger text-sm font-medium">
{serverError}
</p>
)}
<Button type="submit" className="w-full" isLoading={isPending}>
Continue With This Customer
</Button>
</form>
);
}
@@ -0,0 +1,121 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { NotificationCategory, NotificationChannel } from "@/generated/prisma/enums";
import { saveNotificationPreferencesAction } from "../actions/profile";
const CATEGORY_LABEL: Record<NotificationCategory, string> = {
BOOKING_CONFIRMATION: "New booking confirmed",
BOOKING_REMINDER: "Upcoming appointment reminders",
BOOKING_CHANGED: "Appointment rescheduled",
BOOKING_CANCELLED: "Appointment cancelled",
LOYALTY_REWARD_EARNED: "Customer earns a loyalty stamp",
LOYALTY_REWARD_AVAILABLE: "Customer reward becomes available",
MARKETING: "Product news and tips",
};
const CATEGORY_ORDER: NotificationCategory[] = [
NotificationCategory.BOOKING_CONFIRMATION,
NotificationCategory.BOOKING_REMINDER,
NotificationCategory.BOOKING_CHANGED,
NotificationCategory.BOOKING_CANCELLED,
NotificationCategory.LOYALTY_REWARD_EARNED,
NotificationCategory.LOYALTY_REWARD_AVAILABLE,
NotificationCategory.MARKETING,
];
const CHANNEL_ORDER: NotificationChannel[] = [
NotificationChannel.EMAIL,
NotificationChannel.SMS,
NotificationChannel.PUSH,
];
const CHANNEL_LABEL: Record<NotificationChannel, string> = { EMAIL: "Email", SMS: "SMS", PUSH: "Push" };
export interface PreferenceCellState {
category: NotificationCategory;
channel: NotificationChannel;
isEnabled: boolean;
}
export function NotificationPreferencesForm({ initial }: { initial: PreferenceCellState[] }) {
const router = useRouter();
const [cells, setCells] = useState(initial);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const isEnabled = (category: NotificationCategory, channel: NotificationChannel) =>
cells.find((c) => c.category === category && c.channel === channel)?.isEnabled ?? true;
const toggle = (category: NotificationCategory, channel: NotificationChannel) => {
setSaved(false);
setCells((prev) =>
prev.map((c) => (c.category === category && c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c)),
);
};
const handleSave = () => {
startTransition(async () => {
await saveNotificationPreferencesAction(cells);
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-4">
<p className="text-muted text-sm">
Choose which of these you want to hear about, and how. This controls what you receive —
delivery isn&apos;t wired up yet.
</p>
<div className="overflow-hidden rounded-jumbo border border-line bg-white">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-line">
<th scope="col" className="px-4 py-3 font-bold">
Notification
</th>
{CHANNEL_ORDER.map((channel) => (
<th key={channel} scope="col" className="px-2 py-3 text-center text-xs font-bold">
{CHANNEL_LABEL[channel]}
</th>
))}
</tr>
</thead>
<tbody>
{CATEGORY_ORDER.map((category) => (
<tr key={category} className="border-b border-line last:border-b-0">
<th scope="row" className="px-4 py-3 text-xs font-medium">
{CATEGORY_LABEL[category]}
</th>
{CHANNEL_ORDER.map((channel) => (
<td key={channel} className="px-2 py-3 text-center">
<button
type="button"
role="switch"
aria-checked={isEnabled(category, channel)}
aria-label={`${CATEGORY_LABEL[category]} via ${CHANNEL_LABEL[channel]}`}
onClick={() => toggle(category, channel)}
className={`h-5 w-5 rounded-md border transition-colors ${
isEnabled(category, channel) ? "border-accent bg-accent" : "border-line bg-white"
}`}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{saved && (
<p role="status" className="text-accent text-sm font-medium">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { updateProfileAction } from "../actions/profile";
export interface ProfileFormInitial {
name: string;
phone: string;
image: string;
bio: string;
isBookableOnline: boolean;
}
export function ProfileForm({ initial }: { initial: ProfileFormInitial }) {
const router = useRouter();
const [name, setName] = useState(initial.name);
const [phone, setPhone] = useState(initial.phone);
const [image, setImage] = useState(initial.image);
const [bio, setBio] = useState(initial.bio);
const [isBookableOnline, setIsBookableOnline] = useState(initial.isBookableOnline);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const handleSave = () => {
setError(null);
setSaved(false);
startTransition(async () => {
const result = await updateProfileAction({ name, phone, image, bio, isBookableOnline });
if (!result.success) {
setError(result.error ?? "Couldn't save your profile.");
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-4">
<Input label="Display name" value={name} onChange={(e) => setName(e.target.value)} />
<Input
label="Phone"
type="tel"
placeholder="+1 555 123 4567"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
<Input
label="Photo URL"
placeholder="https://…"
value={image}
onChange={(e) => setImage(e.target.value)}
/>
<Textarea
label="Bio"
placeholder="A short introduction customers will see on your profile"
value={bio}
onChange={(e) => setBio(e.target.value)}
/>
<div className="rounded-jumbo border border-line bg-white p-5">
<Switch
label="Visible for online booking"
description="Customers can find and book you when this is on."
checked={isBookableOnline}
onCheckedChange={setIsBookableOnline}
/>
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
);
}
@@ -0,0 +1,152 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Input } from "@/components/ui/input";
import type { TimeOffActionResult, TimeOffConflictItem } from "../actions/time-off";
import { createTimeOffAction, deleteTimeOffAction, updateTimeOffAction } from "../actions/time-off";
export interface EditableTimeOff {
id: string;
startDate: string;
endDate: string;
reason: string | null;
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export function TimeOffFormSheet({
open,
onClose,
existing,
}: {
open: boolean;
onClose: () => void;
existing?: EditableTimeOff | null;
}) {
const router = useRouter();
const [startDate, setStartDate] = useState(existing?.startDate ?? todayIso());
const [endDate, setEndDate] = useState(existing?.endDate ?? existing?.startDate ?? todayIso());
const [reason, setReason] = useState(existing?.reason ?? "");
const [error, setError] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<TimeOffConflictItem[] | null>(null);
const [isPending, startTransition] = useTransition();
const [isDeleting, startDeleteTransition] = useTransition();
const reset = () => {
setStartDate(existing?.startDate ?? todayIso());
setEndDate(existing?.endDate ?? existing?.startDate ?? todayIso());
setReason(existing?.reason ?? "");
setError(null);
setConflicts(null);
};
const handleClose = () => {
onClose();
reset();
};
const submit = (acknowledgeConflicts: boolean) => {
if (startDate > endDate) {
setError("Start date must be on or before the end date.");
return;
}
setError(null);
startTransition(async () => {
const input = { startDate, endDate, reason: reason.trim() || undefined };
const result: TimeOffActionResult = existing
? await updateTimeOffAction(existing.id, input, acknowledgeConflicts)
: await createTimeOffAction(input, acknowledgeConflicts);
if (!result.success) {
setError(result.error ?? "Something went wrong.");
setConflicts(result.conflicts ?? null);
return;
}
handleClose();
router.refresh();
});
};
const handleDelete = () => {
if (!existing) return;
startDeleteTransition(async () => {
const result = await deleteTimeOffAction(existing.id);
if (!result.success) {
setError(result.error ?? "Couldn't remove this time off.");
return;
}
handleClose();
router.refresh();
});
};
return (
<BottomSheet open={open} onClose={handleClose} title={existing ? "Edit Time Off" : "New Time Off"}>
<div className="space-y-4 pb-2">
{conflicts && conflicts.length > 0 ? (
<div className="space-y-3">
<p role="alert" className="text-danger text-sm font-medium">
This overlaps {conflicts.length} existing appointment
{conflicts.length > 1 ? "s" : ""}. These won&apos;t be cancelled automatically —
you&apos;ll need to reschedule or cancel them yourself.
</p>
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/staff/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={handleClose}>
Cancel
</Button>
<Button
variant="destructive"
className="flex-1"
isLoading={isPending}
onClick={() => submit(true)}
>
Save Anyway
</Button>
</div>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<Input label="Start date" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
<Input label="End date" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
<Input
label="Reason (optional)"
placeholder="e.g. Vacation"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
{existing ? "Save Changes" : "Add Time Off"}
</Button>
{existing && (
<Button variant="ghost" className="w-full text-danger" isLoading={isDeleting} onClick={handleDelete}>
Remove Time Off
</Button>
)}
</>
)}
</div>
</BottomSheet>
);
}
@@ -0,0 +1,92 @@
"use client";
import { Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { EditableTimeOff } from "./time-off-form-sheet";
import { TimeOffFormSheet } from "./time-off-form-sheet";
function formatRange(startDate: string, endDate: string): string {
const start = new Date(`${startDate}T00:00:00`);
const end = new Date(`${endDate}T00:00:00`);
const startLabel = start.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });
if (startDate === endDate) return startLabel;
const endLabel = end.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });
return `${startLabel} – ${endLabel}`;
}
export function TimeOffList({
upcoming,
past,
}: {
upcoming: EditableTimeOff[];
past: EditableTimeOff[];
}) {
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<EditableTimeOff | null>(null);
const openNew = () => {
setEditing(null);
setSheetOpen(true);
};
const openEdit = (t: EditableTimeOff) => {
setEditing(t);
setSheetOpen(true);
};
return (
<div className="space-y-6">
<Button variant="secondary" className="w-full" onClick={openNew}>
<Plus className="h-4 w-4" aria-hidden="true" />
Add Time Off
</Button>
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Upcoming</p>
{upcoming.length === 0 ? (
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
No upcoming time off.
</p>
) : (
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
{upcoming.map((t) => (
<li key={t.id}>
<button
type="button"
onClick={() => openEdit(t)}
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left hover:bg-stone"
>
<div>
<p className="text-sm font-bold">{formatRange(t.startDate, t.endDate)}</p>
{t.reason && <p className="text-xs text-muted">{t.reason}</p>}
</div>
</button>
</li>
))}
</ul>
)}
</div>
{past.length > 0 && (
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Past</p>
<ul className="divide-y divide-line rounded-jumbo border border-line bg-stone/50">
{past.map((t) => (
<li key={t.id} className="px-5 py-4">
<p className="text-sm font-bold text-muted">{formatRange(t.startDate, t.endDate)}</p>
{t.reason && <p className="text-xs text-muted">{t.reason}</p>}
</li>
))}
</ul>
</div>
)}
<TimeOffFormSheet
key={editing?.id ?? "new"}
open={sheetOpen}
onClose={() => setSheetOpen(false)}
existing={editing}
/>
</div>
);
}
@@ -0,0 +1,159 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Weekday } from "@/generated/prisma/enums";
import { saveWorkingHoursAction } from "../actions/working-hours";
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
const DAY_LABEL: Record<Weekday, string> = {
MONDAY: "Monday",
TUESDAY: "Tuesday",
WEDNESDAY: "Wednesday",
THURSDAY: "Thursday",
FRIDAY: "Friday",
SATURDAY: "Saturday",
SUNDAY: "Sunday",
};
interface DayState {
dayOfWeek: Weekday;
isOff: boolean;
startTime: string; // "HH:MM"
endTime: string;
}
function toTimeString(minutes: number | null): string {
if (minutes === null) return "09:00";
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
function toMinutes(time: string): number {
const [h, m] = time.split(":").map(Number);
return (h ?? 0) * 60 + (m ?? 0);
}
export interface InitialWorkingHoursRow {
dayOfWeek: Weekday;
isOff: boolean;
startMinutes: number | null;
endMinutes: number | null;
}
export function WorkingHoursForm({ initialRows, businessHoursLabel }: { initialRows: InitialWorkingHoursRow[]; businessHoursLabel: Record<Weekday, string> }) {
const router = useRouter();
const [days, setDays] = useState<DayState[]>(() =>
DAY_ORDER.map((dayOfWeek) => {
const existing = initialRows.find((r) => r.dayOfWeek === dayOfWeek);
return {
dayOfWeek,
isOff: existing?.isOff ?? true,
startTime: toTimeString(existing?.startMinutes ?? null),
endTime: toTimeString(existing?.endMinutes ?? 19 * 60),
};
}),
);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const updateDay = (dayOfWeek: Weekday, patch: Partial<DayState>) => {
setSaved(false);
setDays((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
};
const copyMondayToAll = () => {
const monday = days.find((d) => d.dayOfWeek === Weekday.MONDAY);
if (!monday) return;
setSaved(false);
setDays((prev) => prev.map((d) => (d.dayOfWeek === Weekday.MONDAY ? d : { ...d, isOff: monday.isOff, startTime: monday.startTime, endTime: monday.endTime })));
};
const handleSave = () => {
setError(null);
startTransition(async () => {
const result = await saveWorkingHoursAction(
days.map((d) => ({
dayOfWeek: d.dayOfWeek,
isOff: d.isOff,
...(d.isOff ? {} : { startMinutes: toMinutes(d.startTime), endMinutes: toMinutes(d.endTime) }),
})),
);
if (!result.success) {
setError(result.error ?? "Couldn't save working hours.");
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-4">
<button type="button" onClick={copyMondayToAll} className="text-xs font-bold uppercase tracking-widest text-accent underline">
Copy Monday to all days
</button>
<div className="divide-y divide-line rounded-jumbo border border-line bg-white">
{days.map((day) => (
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-center gap-3">
<Switch checked={!day.isOff} onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isOff: !checked })} aria-label={`${DAY_LABEL[day.dayOfWeek]} working`} />
<div>
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
<p className="text-[10px] text-muted">Business hours: {businessHoursLabel[day.dayOfWeek]}</p>
</div>
</div>
{day.isOff ? (
<span className="text-xs text-muted">Off</span>
) : (
<div className="flex items-center gap-2">
<input
type="time"
value={day.startTime}
onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })}
className="rounded-lg border border-line px-2 py-1.5 text-xs"
aria-label={`${DAY_LABEL[day.dayOfWeek]} start time`}
/>
<span className="text-xs text-muted">to</span>
<input
type="time"
value={day.endTime}
onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })}
className="rounded-lg border border-line px-2 py-1.5 text-xs"
aria-label={`${DAY_LABEL[day.dayOfWeek]} end time`}
/>
</div>
)}
</div>
))}
</div>
{error && (
<p role="alert" className="text-sm font-medium text-danger">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-sm font-medium text-accent">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
);
}