Revert "PHP Rebuild"

This reverts commit 2ef3654841.
This commit is contained in:
Stefan Lazic committed 2026-09-05 09:32:05 +01:00
1 parent a59f998f2b
commit 2aa8405006
690 files changed
+12045 -33943

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db/prisma";
import { SchedulingTestClient } from "./scheduling-test-client";
export const metadata: Metadata = { title: "Scheduling Engine — Dev Test" };
// Always reflect live DB state — this is a manual test harness, not a page that should serve a build-time snapshot.
export const dynamic = "force-dynamic";
/**
* Internal-only harness for manually exercising the booking engine
* (Phase 3) — not a customer-facing screen, not linked from any nav. Uses
* the seeded demo business/staff/customer from prisma/seed.ts. See
* docs/phase-3-summary.md.
*/
export default async function SchedulingTestPage() {
const business = await prisma.business.findUnique({
where: { slug: "blake-heritage-barbershop" },
select: { id: true, name: true, timezone: true },
});
if (!business) {
notFound();
}
const [services, staff, customer] = await Promise.all([
prisma.service.findMany({
where: { businessId: business.id, isActive: true },
orderBy: { displayOrder: "asc" },
}),
prisma.staffProfile.findMany({
where: { businessId: business.id },
include: { user: { select: { name: true } } },
}),
prisma.customerProfile.findFirst({
where: { email: "alex.chen@example.com" },
}),
]);
if (!customer) {
return (
<div className="text-danger p-8 text-sm">
No seeded customer found — run <code>npm run prisma:seed</code> first.
</div>
);
}
return (
<SchedulingTestClient
business={business}
services={services.map((s) => ({
id: s.id,
name: s.name,
durationMinutes: s.durationMinutes,
price: s.price.toString(),
}))}
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff member" }))}
customerId={customer.id}
/>
);
}
@@ -0,0 +1,300 @@
"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>
);
}