PHP Rebuild
CI / ci (push) Canceled after 0s

Initial PHP rebuild of the app
This commit is contained in:
Stefan Lazic committed 2026-09-05 08:21:33 +01:00
1 parent 8a2b211069
commit 2ef3654841
690 files changed
+33944 -12046

No files matched your search

@@ -1,117 +0,0 @@
"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 { cn } from "@/lib/utils/cn";
import { addLoyaltyStampAction } from "../actions/loyalty-actions";
export function AddStampButton({
customerId,
appointmentId,
className,
size = "sm",
}: {
customerId: string;
appointmentId?: string;
className?: string;
size?: "sm" | "md" | "lg";
}) {
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={size}
className={cn("shrink-0", className)}
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>
</>
);
}
@@ -1,251 +0,0 @@
"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";
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
* 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). `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,
layout = "wrap",
}: {
appointmentId: string;
status: RawStatus;
actions?: AppointmentActionSet;
/** "stack" renders one full-width button per row (Current Appointment); "wrap" (default, used by Appointment Details) keeps the original inline small buttons. */
layout?: "wrap" | "stack";
}) {
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 actions.complete(appointmentId)
: confirmKind === "no_show"
? await actions.markNoShow(appointmentId)
: await actions.cancel({ 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={layout === "stack" ? "flex flex-col gap-2" : "flex flex-wrap gap-2"}>
{status === "PENDING" && (
<Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
isLoading={isPending}
onClick={() => runQuiet(() => actions.confirm(appointmentId))}
>
Confirm Booking
</Button>
)}
{status === "CONFIRMED" && (
<Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
isLoading={isPending}
onClick={() => runQuiet(() => actions.checkIn(appointmentId))}
>
Check In
</Button>
)}
{status === "CHECKED_IN" && (
<Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
isLoading={isPending}
onClick={() => runQuiet(() => actions.start(appointmentId))}
>
Start Appointment
</Button>
)}
{status === "IN_PROGRESS" && (
<Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
onClick={() => setConfirmKind("complete")}
>
Complete Appointment
</Button>
)}
{status === "CONFIRMED" && (
<Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
variant="secondary"
onClick={() => setConfirmKind("no_show")}
>
Mark No-Show
</Button>
)}
{(status === "PENDING" || status === "CONFIRMED" || status === "CHECKED_IN") && (
<Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
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>
);
}
@@ -1,147 +0,0 @@
"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" aria-label="Block Time" onClick={() => setOpen(true)}>
<CalendarOff className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline">Block Time</span>
</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>
</>
);
}
@@ -1,27 +0,0 @@
"use client";
import { useTransition } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils/cn";
import { startStaffFollowUpAction } from "../actions/staff-booking";
export function BookFollowUpButton({
appointmentId,
className,
}: {
appointmentId: string;
className?: string;
}) {
const [isPending, startTransition] = useTransition();
return (
<Button
variant="secondary"
className={cn("rounded-md border-line border bg-white px-5 py-3 text-sm font-bold", className)}
isLoading={isPending}
onClick={() => startTransition(() => startStaffFollowUpAction(appointmentId))}
>
Book Follow-Up
</Button>
);
}
@@ -1,230 +0,0 @@
"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";
import type { BreakInput } from "@/services/staff/staff.service";
/** Same DI shape as AppointmentStatusActions' `actions` prop — the owner-on-behalf-of-staff editor injects its own Server Actions bound to a chosen staffId instead of forking this component. */
export interface BreakActionSet {
create: (input: BreakInput, acknowledgeConflicts?: boolean) => Promise<BreakActionResult>;
update: (breakId: string, input: BreakInput, acknowledgeConflicts?: boolean) => Promise<BreakActionResult>;
remove: (breakId: string) => Promise<BreakActionResult>;
}
const DEFAULT_ACTIONS: BreakActionSet = {
create: createBreakAction,
update: updateBreakAction,
remove: deleteBreakAction,
};
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,
actions = DEFAULT_ACTIONS,
appointmentHrefBase = "/staff/appointments",
}: {
open: boolean;
onClose: () => void;
existing?: EditableBreak | null;
actions?: BreakActionSet;
appointmentHrefBase?: string;
}) {
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 actions.update(existing.id, input, acknowledgeConflicts)
: await actions.create(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 actions.remove(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={`${appointmentHrefBase}/${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>
);
}
@@ -1,106 +0,0 @@
"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 { BreakActionSet, 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,
actions,
appointmentHrefBase,
}: {
breaks: EditableBreak[];
actions?: BreakActionSet;
appointmentHrefBase?: string;
}) {
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-md border border-line bg-white p-5 text-center text-sm">
No recurring breaks yet.
</p>
) : (
<ul className="divide-y divide-line rounded-md 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}
actions={actions}
appointmentHrefBase={appointmentHrefBase}
/>
</div>
);
}
@@ -1,39 +0,0 @@
"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>
);
}
@@ -1,108 +0,0 @@
"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 { cn } from "@/lib/utils/cn";
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,
className,
}: {
loyaltyAccountId: string;
className?: 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" className={cn(className)} 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>
</>
);
}
@@ -1,187 +0,0 @@
"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>
);
}
@@ -1,103 +0,0 @@
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,
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 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", {
weekday: "long",
day: "numeric",
month: "long",
});
return (
<div className="space-y-3 px-6 pt-4">
<div className="border-line flex items-center overflow-hidden rounded-full border bg-white">
<Link
href={hrefFor(addDays(selectedDate, -1))}
aria-label="Previous day"
className="hover:bg-stone flex h-11 w-12 shrink-0 items-center justify-center"
>
<ChevronLeft className="text-ink h-4 w-4" aria-hidden="true" />
</Link>
<div className="flex-1 py-1.5 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="hover:bg-stone flex h-11 w-12 shrink-0 items-center justify-center"
>
<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="flex flex-1 flex-col items-center gap-1 py-2 text-xs"
>
<span className="text-muted text-[9px] font-bold tracking-widest uppercase">
{d.toLocaleDateString("en-GB", { weekday: "short" })}
</span>
<span
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full font-bold",
isSelected ? "bg-ink text-white" : isToday ? "text-accent" : "text-ink",
)}
>
{d.getDate()}
</span>
</Link>
);
})}
</div>
</div>
);
}
@@ -1,354 +0,0 @@
import Image from "next/image";
import Link from "next/link";
import { Ban, CheckCheck, CircleDot, Clock, Gift, LogIn, NotebookText, UserX } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils/cn";
import type { AppointmentStatus } from "@/types/domain";
const START_HOUR = 7;
const END_HOUR = 21;
const PX_PER_HOUR = 112;
const MINUTES_PER_HOUR = 60;
const RANGE_START_MINUTES = START_HOUR * MINUTES_PER_HOUR;
const RANGE_END_MINUTES = END_HOUR * MINUTES_PER_HOUR;
const HEADER_HEIGHT = 56;
function topFor(minutes: number): number {
return ((clampToRange(minutes) - RANGE_START_MINUTES) / MINUTES_PER_HOUR) * 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(24, topFor(endMinutes) - topFor(startMinutes));
}
/** Compact "9:30" / "1:15" clock text — no AM/PM, matching the tight in-block time-range label. */
function formatCompactTime(minutes: number): string {
const h = Math.floor(minutes / MINUTES_PER_HOUR);
const m = minutes % MINUTES_PER_HOUR;
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(m).padStart(2, "0")}`;
}
/** "2:47 pm" — the current-time indicator's own label, read out next to its dot on the axis. */
function formatCurrentTimeLabel(minutes: number): string {
const h = Math.floor(minutes / MINUTES_PER_HOUR);
const m = minutes % MINUTES_PER_HOUR;
const period = h >= 12 ? "pm" : "am";
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(m).padStart(2, "0")} ${period}`;
}
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";
}
export interface CalendarColumn {
id: string;
name: string;
avatarUrl?: string | null;
/** Formatted working-hours range for the day (e.g. "10:00 AM – 7:00 PM"), or null if off entirely. */
hoursLabel: string | null;
wholeDayOff: boolean;
appointments: CalendarAppointment[];
blocks: CalendarBlock[];
/** The column's working window that day, for shading outside-hours space — null if off entirely. */
workingWindow: { startMinutes: number; endMinutes: number } | null;
}
const STATUS_BADGE_ICON: Record<AppointmentStatus, LucideIcon> = {
pending_approval: Clock,
confirmed: CheckCheck,
arrived: LogIn,
in_progress: CircleDot,
completed: CheckCheck,
cancelled: Ban,
no_show: UserX,
};
/** Column width on small screens — under 100% so the next column peeks in at the edge, inviting a swipe (per the reference design). Desktop ignores this and flexes to fill instead. */
const COLUMN_WIDTH_CLASS = "w-[86%] shrink-0 snap-start sm:w-[300px] lg:w-auto lg:flex-1";
/**
* A shared-axis, multi-column, swipeable day grid — one time axis pinned to
* the left edge (via `sticky`, so it never scrolls out of view), one column
* per staff member snapping into place as the row is swiped horizontally
* (CSS scroll-snap — no touch/gesture JS needed, matching this app's
* "reliable tap/scroll over fragile custom gestures" preference). A single
* staff member's own view (staff/page.tsx) is just the one-column case of
* the same component. Deliberately no drag-and-drop — every interactive
* element is a plain Link (open details / start a reschedule from there).
* Pure presentation: every minute value here is already computed by the
* page from real WorkingHours/Break/TimeOff/Appointment rows.
*/
export function DayCalendar({
columns,
currentTimeMinutes,
appointmentHrefBase = "/staff/appointments",
}: {
columns: CalendarColumn[];
/** 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;
const showCurrentTime =
currentTimeMinutes !== null && currentTimeMinutes >= RANGE_START_MINUTES && currentTimeMinutes <= RANGE_END_MINUTES;
return (
<div className="flex gap-2 px-6 pb-8" data-testid="calendar-grid">
{/* Time axis — a plain sibling of the scrolling columns strip below, not part of its horizontal scroll at all, so it's immune to any scroll-snap/sticky edge cases: it simply never moves. */}
<div className="w-11 shrink-0" style={{ height: HEADER_HEIGHT + gridHeight }}>
<div style={{ height: HEADER_HEIGHT }} aria-hidden="true" />
<div className="relative" style={{ height: gridHeight }}>
{hourMarks.map((hour) =>
hour === END_HOUR ? null : (
<div key={hour} className="absolute inset-x-0" style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}>
<span className="text-muted absolute -top-2 right-0 text-[10px] font-bold">{formatHour(hour)}</span>
{[15, 30, 45].map((minute) => (
<span
key={minute}
className="text-muted/70 absolute right-0 text-[9px]"
style={{ top: (minute / MINUTES_PER_HOUR) * PX_PER_HOUR - 6 }}
>
{minute}
</span>
))}
</div>
),
)}
{showCurrentTime && (
<div
className="absolute inset-x-0 z-40 flex items-center justify-end gap-1"
style={{ top: topFor(currentTimeMinutes!) }}
aria-hidden="true"
>
<span className="bg-danger h-2 w-2 shrink-0 -translate-y-1/2 rounded-full" />
<span className="bg-danger absolute left-full -translate-y-1/2 rounded px-1.5 py-0.5 text-[9px] font-bold whitespace-nowrap text-white">
{formatCurrentTimeLabel(currentTimeMinutes!)}
</span>
</div>
)}
</div>
</div>
{/* Staff columns — a separately-scrolling strip, so swiping through staff never drags the axis along with it. Each column is a full swipeable "page": its own header, then its own grid body. */}
<div className="snap-x snap-mandatory flex flex-1 gap-3 overflow-x-auto">
{columns.map((column) => (
<div key={column.id} className={COLUMN_WIDTH_CLASS}>
<div className="flex items-center gap-2 pb-3" style={{ height: HEADER_HEIGHT }}>
{column.avatarUrl ? (
<Image
src={column.avatarUrl}
alt=""
width={36}
height={36}
className="h-9 w-9 shrink-0 rounded-full border border-line object-cover"
/>
) : (
<div className="bg-white text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-line text-sm font-bold">
{column.name.charAt(0)}
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-bold">{column.name}</p>
<p className="text-muted truncate text-[10px]">{column.hoursLabel ?? "Off today"}</p>
</div>
</div>
<div className="border-line relative border-l" style={{ height: gridHeight }}>
{/* Hour gridlines */}
{hourMarks.map((hour) => (
<div
key={hour}
className="border-line absolute inset-x-0 border-t"
style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}
/>
))}
{column.wholeDayOff ? (
<div className="bg-line/10 absolute inset-0 flex items-center justify-center">
<span className="text-muted text-xs font-bold tracking-widest uppercase">Off today</span>
</div>
) : (
<>
{/* Outside-working-hours shading — diagonal hatch, same texture as the break/time-off blocks below. */}
{column.workingWindow ? (
<>
{column.workingWindow.startMinutes > RANGE_START_MINUTES && (
<div
className={HATCH_CLASS}
style={{ top: 0, height: topFor(column.workingWindow.startMinutes) }}
aria-hidden="true"
/>
)}
{column.workingWindow.endMinutes < RANGE_END_MINUTES && (
<div
className={HATCH_CLASS}
style={{ top: topFor(column.workingWindow.endMinutes), bottom: 0 }}
aria-hidden="true"
/>
)}
</>
) : (
<div className={cn(HATCH_CLASS, "inset-0")} aria-hidden="true" />
)}
{/* Breaks / time off */}
{column.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 */}
{column.appointments.map((appt) => {
const blockHeight = heightFor(appt.startMinutes, appt.endMinutes);
const BadgeIcon = STATUS_BADGE_ICON[appt.status];
return (
<Link
key={appt.id}
href={`${appointmentHrefBase}/${appt.id}`}
className={cn(
"absolute inset-x-1 z-20 overflow-hidden rounded-lg border-l-4 p-2 shadow-sm transition-shadow hover:shadow-md",
"focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1",
statusBlockClass(appt.status),
)}
style={{
top: topFor(appt.startMinutes),
height: blockHeight,
}}
>
<span
className={cn(
"absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full",
statusBadgeClass(appt.status),
)}
>
<BadgeIcon className="h-2.5 w-2.5" aria-hidden="true" />
<span className="sr-only">{statusLabel(appt.status)}</span>
</span>
<div className="flex items-center gap-1 pr-4">
<p className="truncate text-[11px] font-bold">
{formatCompactTime(appt.startMinutes)} – {formatCompactTime(appt.endMinutes)}
</p>
{appt.hasNotes && <NotebookText className="text-muted h-2.5 w-2.5 shrink-0" aria-label="Has notes" />}
{appt.hasAvailableReward && (
<Gift className="text-accent-loyalty h-2.5 w-2.5 shrink-0" aria-label="Reward available" />
)}
</div>
{blockHeight >= 44 && (
<p className="truncate text-[11px] opacity-80">
{appt.customerName} • {appt.serviceNames}
</p>
)}
</Link>
);
})}
{/* Current-time indicator */}
{showCurrentTime && (
<div
className="border-danger pointer-events-none absolute inset-x-0 z-30 border-t-2"
style={{ top: topFor(currentTimeMinutes!) }}
aria-hidden="true"
/>
)}
</>
)}
</div>
</div>
))}
</div>
</div>
);
}
const HATCH_CLASS =
"bg-line/15 absolute inset-x-0 bg-[repeating-linear-gradient(135deg,transparent,transparent_6px,rgba(0,0,0,0.05)_6px,rgba(0,0,0,0.05)_12px)]";
function formatHour(hour: number): string {
if (hour === 0 || hour === 24) return "12:00";
if (hour === 12) return "12:00";
return `${hour > 12 ? hour - 12 : hour}:00`;
}
function statusLabel(status: AppointmentStatus): string {
switch (status) {
case "pending_approval":
return "Pending approval";
case "confirmed":
return "Confirmed";
case "arrived":
return "Arrived";
case "in_progress":
return "In progress";
case "completed":
return "Completed";
case "cancelled":
return "Cancelled";
case "no_show":
return "No-show";
}
}
function statusBlockClass(status: AppointmentStatus): string {
switch (status) {
case "cancelled":
case "no_show":
return "bg-status-cancelled/10 border-status-cancelled text-status-cancelled-foreground opacity-70";
case "completed":
return "bg-line/40 border-line text-muted";
case "in_progress":
return "bg-status-progress/15 border-status-progress text-ink";
case "arrived":
return "bg-status-confirmed/15 border-status-confirmed text-ink";
case "pending_approval":
return "bg-status-pending/15 border-status-pending text-ink";
default:
return "bg-status-confirmed/15 border-status-confirmed text-ink";
}
}
function statusBadgeClass(status: AppointmentStatus): string {
switch (status) {
case "cancelled":
case "no_show":
return "bg-status-cancelled text-white";
case "completed":
return "bg-muted text-white";
case "in_progress":
return "bg-status-progress text-white";
case "arrived":
return "bg-status-confirmed text-white";
case "pending_approval":
return "bg-status-pending text-white";
default:
return "bg-status-confirmed text-white";
}
}
@@ -1,61 +0,0 @@
"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>
);
}
@@ -1,88 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { NotificationChannel } from "@/generated/prisma/enums";
const CHANNEL_ORDER: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
const CHANNEL_LABEL: Record<NotificationChannel, string> = { EMAIL: "Email", PUSH: "Push", IN_APP: "In-app" };
export interface PreferenceCellState {
channel: NotificationChannel;
isEnabled: boolean;
}
/**
* Every other notification a barber gets (new appointment, reschedule,
* cancellation, schedule change) is required — always sent, no toggle —
* so this form only ever shows the one type that's genuinely optional:
* appointment reminders. See NotificationService/types.ts,
* PREFERENCE_CONTROLLED_TYPES, and docs/phase-7-summary.md — showing a
* toggle for a required type would just be a no-op switch that does
* nothing when flipped, which the Phase 7 brief explicitly calls out to
* avoid.
*/
export interface NotificationPreferencesFormProps {
initial: PreferenceCellState[];
/** Recipient-specific — see features/staff/actions/profile.ts (staff) and features/notifications/actions/preferences.ts (customer). */
action: (cells: PreferenceCellState[]) => Promise<{ success: boolean; error?: string }>;
}
export function NotificationPreferencesForm({ initial, action }: NotificationPreferencesFormProps) {
const router = useRouter();
const [cells, setCells] = useState(initial);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const isEnabled = (channel: NotificationChannel) => cells.find((c) => c.channel === channel)?.isEnabled ?? true;
const toggle = (channel: NotificationChannel) => {
setSaved(false);
setCells((prev) => prev.map((c) => (c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c)));
};
const handleSave = () => {
startTransition(async () => {
await action(cells);
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-4">
<p className="text-muted text-sm">
Booking confirmations, changes, and cancellations always notify you — those aren&apos;t
optional. Choose how you&apos;d like to hear about upcoming appointment reminders.
</p>
<div className="rounded-md border-line divide-line divide-y overflow-hidden border bg-white">
{CHANNEL_ORDER.map((channel) => (
<div key={channel} className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-medium">Reminders via {CHANNEL_LABEL[channel]}</span>
<button
type="button"
role="switch"
aria-checked={isEnabled(channel)}
aria-label={`Appointment reminders via ${CHANNEL_LABEL[channel]}`}
onClick={() => toggle(channel)}
className={`h-5 w-5 rounded-md border transition-colors ${
isEnabled(channel) ? "border-accent bg-accent" : "border-line bg-white"
}`}
/>
</div>
))}
</div>
{saved && (
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
);
}
@@ -1,87 +0,0 @@
"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 { MediaPicker } from "@/features/media/components/media-picker";
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)}
/>
<MediaPicker label="Photo" value={image} onChange={(url) => setImage(url ?? "")} category="staff" />
<Textarea
label="Bio"
placeholder="A short introduction customers will see on your profile"
value={bio}
onChange={(e) => setBio(e.target.value)}
/>
<div className="rounded-md 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-foreground text-sm font-medium">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
);
}
@@ -1,177 +0,0 @@
"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";
import type { TimeOffInput } from "@/services/staff/staff.service";
export interface EditableTimeOff {
id: string;
startDate: string;
endDate: string;
reason: string | null;
}
/** Same DI shape as AppointmentStatusActions' `actions` prop — the owner-on-behalf-of-staff editor injects its own Server Actions bound to a chosen staffId instead of forking this component. */
export interface TimeOffActionSet {
create: (
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
acknowledgeConflicts?: boolean,
) => Promise<TimeOffActionResult>;
update: (
timeOffId: string,
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
acknowledgeConflicts?: boolean,
) => Promise<TimeOffActionResult>;
remove: (timeOffId: string) => Promise<TimeOffActionResult>;
}
const DEFAULT_ACTIONS: TimeOffActionSet = {
create: createTimeOffAction,
update: updateTimeOffAction,
remove: deleteTimeOffAction,
};
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export function TimeOffFormSheet({
open,
onClose,
existing,
actions = DEFAULT_ACTIONS,
appointmentHrefBase = "/staff/appointments",
}: {
open: boolean;
onClose: () => void;
existing?: EditableTimeOff | null;
actions?: TimeOffActionSet;
appointmentHrefBase?: string;
}) {
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 actions.update(existing.id, input, acknowledgeConflicts)
: await actions.create(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 actions.remove(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={`${appointmentHrefBase}/${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>
);
}
@@ -1,98 +0,0 @@
"use client";
import { Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { EditableTimeOff, TimeOffActionSet } 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,
actions,
appointmentHrefBase,
}: {
upcoming: EditableTimeOff[];
past: EditableTimeOff[];
actions?: TimeOffActionSet;
appointmentHrefBase?: string;
}) {
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-md border border-line bg-white p-5 text-center text-sm">
No upcoming time off.
</p>
) : (
<ul className="divide-y divide-line rounded-md 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-md 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}
actions={actions}
appointmentHrefBase={appointmentHrefBase}
/>
</div>
);
}
@@ -1,22 +0,0 @@
import Link from "next/link";
import { cn } from "@/lib/utils/cn";
/** Day/Agenda as one segmented switch (a single bordered pill, active side filled) rather than two separate floating pills. Plain Links — same "no client JS needed just to change a view" reasoning as DateStrip. */
export function ViewSwitch({ dayHref, agendaHref, view }: { dayHref: string; agendaHref: string; view: "day" | "agenda" }) {
const segmentClass = (active: boolean) =>
cn(
"flex-1 rounded-full px-4 py-1.5 text-center text-xs font-bold tracking-widest uppercase transition-colors",
active ? "bg-ink text-white" : "text-muted",
);
return (
<div className="border-line inline-flex rounded-full border bg-white p-1">
<Link href={dayHref} aria-current={view === "day" ? "true" : undefined} className={segmentClass(view === "day")}>
Day
</Link>
<Link href={agendaHref} aria-current={view === "agenda" ? "true" : undefined} className={segmentClass(view === "agenda")}>
Agenda
</Link>
</div>
);
}
@@ -1,213 +0,0 @@
"use client";
import Link from "next/link";
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";
import type { WorkingHoursActionResult, WorkingHoursConflictItem } from "../actions/working-hours";
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
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,
onSave = saveWorkingHoursAction,
appointmentHrefBase = "/staff/appointments",
}: {
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[],
acknowledgeConflicts?: boolean,
) => Promise<WorkingHoursActionResult>;
/** Conflict links point at /owner/appointments/[id] when the owner is editing on a staff member's behalf. */
appointmentHrefBase?: 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 [conflicts, setConflicts] = useState<WorkingHoursConflictItem[]>([]);
const [isPending, startTransition] = useTransition();
const updateDay = (dayOfWeek: Weekday, patch: Partial<DayState>) => {
setSaved(false);
setConflicts([]);
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);
setConflicts([]);
setDays((prev) => prev.map((d) => (d.dayOfWeek === Weekday.MONDAY ? d : { ...d, isOff: monday.isOff, startTime: monday.startTime, endTime: monday.endTime })));
};
const submit = (acknowledgeConflicts: boolean) => {
setError(null);
startTransition(async () => {
const result = await onSave(
days.map((d) => ({
dayOfWeek: d.dayOfWeek,
isOff: d.isOff,
...(d.isOff ? {} : { startMinutes: toMinutes(d.startTime), endMinutes: toMinutes(d.endTime) }),
})),
acknowledgeConflicts,
);
if (!result.success) {
setError(result.error ?? "Couldn't save working hours.");
setConflicts(result.conflicts ?? []);
return;
}
setSaved(true);
setConflicts([]);
router.refresh();
});
};
if (conflicts.length > 0) {
return (
<div className="space-y-4">
<p role="alert" className="text-danger text-sm font-medium">
These hours would leave {conflicts.length} upcoming appointment{conflicts.length > 1 ? "s" : ""} outside
working hours. They won&apos;t be cancelled automatically — review them, then confirm if you still want to
save.
</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={`${appointmentHrefBase}/${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={() => setConflicts([])}>
Go Back
</Button>
<Button variant="destructive" className="flex-1" isLoading={isPending} onClick={() => submit(true)}>
Save Anyway
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
<button type="button" onClick={copyMondayToAll} className="text-accent text-xs font-bold tracking-widest uppercase underline">
Copy Monday to all days
</button>
<div className="divide-line rounded-md border-line divide-y border 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-muted text-[10px]">Business hours: {businessHoursLabel[day.dayOfWeek]}</p>
</div>
</div>
{day.isOff ? (
<span className="text-muted text-xs">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="border-line rounded-lg border px-2 py-1.5 text-xs"
aria-label={`${DAY_LABEL[day.dayOfWeek]} start time`}
/>
<span className="text-muted text-xs">to</span>
<input
type="time"
value={day.endTime}
onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })}
className="border-line rounded-lg border px-2 py-1.5 text-xs"
aria-label={`${DAY_LABEL[day.dayOfWeek]} end time`}
/>
</div>
)}
</div>
))}
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
Save
</Button>
</div>
);
}