301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useTransition } from "react";
|
|
import {
|
|
getAvailableDatesAction,
|
|
getAvailableTimeSlotsAction,
|
|
} from "@/features/booking/actions/availability";
|
|
import {
|
|
cancelBookingAction,
|
|
createBookingAction,
|
|
rescheduleBookingAction,
|
|
} from "@/features/booking/actions/booking";
|
|
import {
|
|
checkInAction,
|
|
completeAppointmentAction,
|
|
listAppointmentsAction,
|
|
markNoShowAction,
|
|
startAppointmentAction,
|
|
} from "@/features/booking/actions/appointment";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { MonthCalendar } from "@/components/ui/month-calendar";
|
|
import { Select } from "@/components/ui/select";
|
|
import { StatusPill } from "@/components/ui/status-pill";
|
|
import type { AppointmentStatus } from "@/types/domain";
|
|
|
|
interface Service {
|
|
id: string;
|
|
name: string;
|
|
durationMinutes: number;
|
|
price: string;
|
|
}
|
|
interface Staff {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
interface TimeSlot {
|
|
startMinutes: number;
|
|
startAt: string;
|
|
endAt: string;
|
|
staffOptions: string[];
|
|
}
|
|
|
|
/**
|
|
* Local calendar-date components, not `.toISOString().slice(0, 10)` — that
|
|
* converts to UTC first, which silently shifts the date back a day for
|
|
* anyone in a timezone ahead of UTC (e.g. the business's own Europe/London
|
|
* during BST). `date` here is always local midnight from MonthCalendar's
|
|
* own `new Date(year, month, day)`, so reading the local parts back out is
|
|
* the correct inverse.
|
|
*/
|
|
function toYMD(date: Date): string {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
const EMPTY_SET: Set<string> = new Set();
|
|
const EMPTY_SLOTS: TimeSlot[] = [];
|
|
|
|
const STATUS_MAP: Record<string, AppointmentStatus> = {
|
|
PENDING: "pending_approval",
|
|
CONFIRMED: "confirmed",
|
|
CHECKED_IN: "arrived",
|
|
IN_PROGRESS: "in_progress",
|
|
COMPLETED: "completed",
|
|
CANCELLED: "cancelled",
|
|
NO_SHOW: "no_show",
|
|
};
|
|
|
|
export function SchedulingTestClient(props: {
|
|
business: { id: string; name: string; timezone: string };
|
|
services: Service[];
|
|
staff: Staff[];
|
|
customerId: string;
|
|
}) {
|
|
const { business, services, staff, customerId } = props;
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
const [selectedServiceIds, setSelectedServiceIds] = useState<string[]>(services[0] ? [services[0].id] : []);
|
|
const [selectedStaffId, setSelectedStaffId] = useState<string>("any");
|
|
const [month, setMonth] = useState(() => new Date());
|
|
const [availableDates, setAvailableDates] = useState<Set<string>>(new Set());
|
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>();
|
|
const [slots, setSlots] = useState<TimeSlot[]>([]);
|
|
const [notes, setNotes] = useState("");
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const [appointments, setAppointments] = useState<any[]>([]);
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
|
|
const refreshAppointments = () => {
|
|
listAppointmentsAction({ businessId: business.id, businessTimezone: business.timezone }).then(setAppointments);
|
|
};
|
|
|
|
useEffect(() => {
|
|
refreshAppointments();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Available dates for the visible month. When nothing is selected there's
|
|
// nothing to fetch — `effectiveAvailableDates` below derives the empty
|
|
// case at render time rather than this effect resetting state
|
|
// synchronously (which cascades an extra render for no reason).
|
|
useEffect(() => {
|
|
if (selectedServiceIds.length === 0) return;
|
|
const from = new Date(month.getFullYear(), month.getMonth(), 1);
|
|
const to = new Date(month.getFullYear(), month.getMonth() + 1, 0);
|
|
getAvailableDatesAction({
|
|
businessId: business.id,
|
|
serviceIds: selectedServiceIds,
|
|
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
|
fromDate: toYMD(from),
|
|
toDate: toYMD(to),
|
|
}).then((res) => setAvailableDates(new Set(res.dates)));
|
|
}, [business.id, month, selectedServiceIds, selectedStaffId]);
|
|
const effectiveAvailableDates = selectedServiceIds.length === 0 ? EMPTY_SET : availableDates;
|
|
|
|
// Time slots for the selected date — same derive-at-render-time pattern.
|
|
useEffect(() => {
|
|
if (!selectedDate || selectedServiceIds.length === 0) return;
|
|
getAvailableTimeSlotsAction({
|
|
businessId: business.id,
|
|
serviceIds: selectedServiceIds,
|
|
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
|
date: toYMD(selectedDate),
|
|
}).then((res) => setSlots(res.slots));
|
|
}, [business.id, selectedDate, selectedServiceIds, selectedStaffId]);
|
|
const effectiveSlots = !selectedDate || selectedServiceIds.length === 0 ? EMPTY_SLOTS : slots;
|
|
|
|
const toggleService = (id: string) => {
|
|
setSelectedServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
|
|
};
|
|
|
|
const book = (slot: TimeSlot) => {
|
|
startTransition(async () => {
|
|
const result = await createBookingAction({
|
|
businessId: business.id,
|
|
customerId,
|
|
serviceIds: selectedServiceIds,
|
|
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
|
|
startAt: slot.startAt,
|
|
notes: notes || undefined,
|
|
});
|
|
if (result.success) {
|
|
setMessage(`Booked: ${result.appointment.id}`);
|
|
setNotes("");
|
|
setSelectedDate(undefined);
|
|
refreshAppointments();
|
|
} else {
|
|
setMessage(`Failed: ${result.validation.issues.map((i) => `${i.code} — ${i.message}`).join("; ")}`);
|
|
}
|
|
});
|
|
};
|
|
|
|
const cancel = (appointmentId: string) => {
|
|
startTransition(async () => {
|
|
const result = await cancelBookingAction({ appointmentId, cancelledByUserId: customerId, reason: "Dev test" });
|
|
setMessage(result.success ? "Cancelled" : `Failed: ${result.validation.issues.map((i) => i.message).join("; ")}`);
|
|
refreshAppointments();
|
|
});
|
|
};
|
|
|
|
const reschedule = (appointmentId: string) => {
|
|
const input = window.prompt("New start time (ISO, e.g. 2026-08-20T10:00:00.000Z):");
|
|
if (!input) return;
|
|
startTransition(async () => {
|
|
const result = await rescheduleBookingAction({ appointmentId, startAt: input });
|
|
setMessage(result.success ? "Rescheduled" : `Failed: ${result.validation.issues.map((i) => i.message).join("; ")}`);
|
|
refreshAppointments();
|
|
});
|
|
};
|
|
|
|
const runTransition = (fn: (id: string) => Promise<{ success: boolean }>, appointmentId: string) => {
|
|
startTransition(async () => {
|
|
const result = await fn(appointmentId);
|
|
setMessage(result.success ? "Status updated" : "Transition rejected");
|
|
refreshAppointments();
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="mx-auto max-w-2xl space-y-8 px-6 py-10">
|
|
<header>
|
|
<h1 className="text-2xl font-bold">Scheduling Engine — Dev Test</h1>
|
|
<p className="mt-1 text-sm text-muted">
|
|
{business.name} · {business.timezone} · not a real feature page, see docs/phase-3-summary.md
|
|
</p>
|
|
</header>
|
|
|
|
{message && (
|
|
<div className="rounded-xl border border-line bg-white p-3 text-sm" role="status">
|
|
{message}
|
|
</div>
|
|
)}
|
|
|
|
<Card className="space-y-4 p-6">
|
|
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">1. Services</h2>
|
|
<div className="space-y-2">
|
|
{services.map((s) => (
|
|
<Checkbox
|
|
key={s.id}
|
|
label={`${s.name} (${s.durationMinutes}m, £${s.price})`}
|
|
checked={selectedServiceIds.includes(s.id)}
|
|
onChange={() => toggleService(s.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<h2 className="pt-2 text-xs font-bold uppercase tracking-widest text-muted">2. Barber</h2>
|
|
<Select value={selectedStaffId} onChange={(e) => setSelectedStaffId(e.target.value)}>
|
|
<option value="any">Anyone available</option>
|
|
{staff.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.name}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<h2 className="mb-4 text-xs font-bold uppercase tracking-widest text-muted">3. Date</h2>
|
|
<MonthCalendar
|
|
month={month}
|
|
onMonthChange={setMonth}
|
|
selectedDate={selectedDate}
|
|
onSelectDate={setSelectedDate}
|
|
isDateDisabled={(date) => !effectiveAvailableDates.has(toYMD(date))}
|
|
/>
|
|
<p className="mt-3 text-xs text-muted">Selectable dates are ones the engine reports as having openings.</p>
|
|
</Card>
|
|
|
|
{selectedDate && (
|
|
<Card className="space-y-4 p-6">
|
|
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">4. Time</h2>
|
|
{effectiveSlots.length === 0 ? (
|
|
<p className="text-sm text-muted">No available slots this day for this selection.</p>
|
|
) : (
|
|
<div className="flex flex-wrap gap-2">
|
|
{effectiveSlots.map((slot) => (
|
|
<button
|
|
key={slot.startAt}
|
|
onClick={() => book(slot)}
|
|
disabled={isPending}
|
|
className="rounded-lg border border-line px-4 py-2 text-sm font-medium hover:border-ink disabled:opacity-50"
|
|
>
|
|
{new Date(slot.startAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: business.timezone })}
|
|
<span className="ml-1 text-[10px] text-muted">({slot.staffOptions.length} avail.)</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<input
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
placeholder="Notes (optional)"
|
|
className="w-full rounded-lg border border-line px-3 py-2 text-sm"
|
|
/>
|
|
</Card>
|
|
)}
|
|
|
|
<Card className="space-y-3 p-6">
|
|
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">Appointments</h2>
|
|
{appointments.length === 0 && <p className="text-sm text-muted">None yet.</p>}
|
|
{appointments.map((a) => (
|
|
<div key={a.id} className="rounded-xl border border-line p-4 text-sm">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<StatusPill status={STATUS_MAP[a.status] ?? "confirmed"} />
|
|
<span className="text-xs text-muted">{new Date(a.startAt).toLocaleString("en-GB", { timeZone: business.timezone })}</span>
|
|
</div>
|
|
<p className="mb-3 text-xs text-muted">
|
|
{a.staff.user.name} · {a.services.map((s: { service: { name: string } }) => s.service.name).join(" + ")}
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => checkInAction(id), a.id)} disabled={isPending}>
|
|
Check in
|
|
</Button>
|
|
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => startAppointmentAction(id), a.id)} disabled={isPending}>
|
|
Start
|
|
</Button>
|
|
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => completeAppointmentAction(id), a.id)} disabled={isPending}>
|
|
Complete
|
|
</Button>
|
|
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => markNoShowAction(id), a.id)} disabled={isPending}>
|
|
No-show
|
|
</Button>
|
|
<Button size="sm" variant="secondary" onClick={() => reschedule(a.id)} disabled={isPending}>
|
|
Reschedule
|
|
</Button>
|
|
<Button size="sm" variant="destructive" onClick={() => cancel(a.id)} disabled={isPending}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|