{
+ const parsed = moveSchema.safeParse(input);
+ if (!parsed.success) return { success: false, error: "That move isn't valid." };
+
+ const context = await getActingBusinessContext();
+ if (!context) return { success: false, error: "You must be signed in." };
+
+ const { appointmentId, staffId, date, startMinutes, mode } = parsed.data;
+ const startAt = wallClockToInstant(date, startMinutes, context.businessTimezone).toISOString();
+
+ const result =
+ mode === "owner"
+ ? await ownerRescheduleAppointmentAction({ appointmentId, staffId, startAt })
+ : // A barber moves their own bookings in time only — never to someone else.
+ await rescheduleStaffAppointmentAction({ appointmentId, startAt });
+
+ if (result.success) {
+ revalidatePath("/owner/calendar");
+ revalidatePath("/staff");
+ }
+ return result;
+}
diff --git a/src/features/staff/components/__tests__/calendar-time.test.ts b/src/features/staff/components/__tests__/calendar-time.test.ts
new file mode 100644
index 0000000..672627e
--- /dev/null
+++ b/src/features/staff/components/__tests__/calendar-time.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import {
+ floatingDateToMinutes,
+ floatingDisplayDate,
+ floatingNotToday,
+ minutesToFloatingDate,
+} from "../calendar-time";
+
+describe("calendar floating time", () => {
+ it("round-trips every quarter hour of the day", () => {
+ for (let minutes = 0; minutes < 24 * 60; minutes += 15) {
+ expect(floatingDateToMinutes(minutesToFloatingDate(minutes))).toBe(minutes);
+ }
+ });
+
+ it("puts every time on the same single calendar day, regardless of the minute", () => {
+ const day = (m: number) => minutesToFloatingDate(m).toDateString();
+ expect(day(0)).toBe(day(7 * 60));
+ expect(day(0)).toBe(day(23 * 60 + 45));
+ expect(day(0)).toBe(floatingDisplayDate().toDateString());
+ });
+
+ it("'not today' is a different day than the displayed one", () => {
+ expect(floatingNotToday().toDateString()).not.toBe(floatingDisplayDate().toDateString());
+ });
+});
diff --git a/src/features/staff/components/calendar-time.ts b/src/features/staff/components/calendar-time.ts
new file mode 100644
index 0000000..71cd5b8
--- /dev/null
+++ b/src/features/staff/components/calendar-time.ts
@@ -0,0 +1,34 @@
+/**
+ * The calendar library works in JavaScript `Date`s, which carry the
+ * BROWSER's time zone — but this app's scheduling data is in business-local
+ * minutes-since-midnight on a business-local calendar date (see
+ * lib/time/business-time.ts). Mixing the two is how a barbershop in
+ * London shows a 10:00 booking at 05:00 to someone viewing from New York.
+ *
+ * So the calendar never sees a real instant. It's handed "floating" dates —
+ * minutes laid onto one fixed, arbitrary day — and reports a drop back as
+ * plain minutes-since-midnight; the actual date and the business time zone
+ * are applied on the server (see features/staff/actions/calendar-move.ts).
+ * The day is a mid-winter Monday so no time zone's DST change can land on it.
+ */
+const BASE_YEAR = 2000;
+const BASE_MONTH = 0;
+const BASE_DAY = 3;
+
+export function minutesToFloatingDate(minutes: number): Date {
+ return new Date(BASE_YEAR, BASE_MONTH, BASE_DAY, Math.floor(minutes / 60), minutes % 60, 0, 0);
+}
+
+export function floatingDateToMinutes(date: Date): number {
+ return date.getHours() * 60 + date.getMinutes();
+}
+
+/** The day the calendar displays — any date on the floating day. */
+export function floatingDisplayDate(): Date {
+ return minutesToFloatingDate(0);
+}
+
+/** A moment guaranteed to fall on a different day than the displayed one — passed as "now" when the viewed day isn't today, so the library draws no current-time line. */
+export function floatingNotToday(): Date {
+ return new Date(BASE_YEAR, BASE_MONTH, BASE_DAY - 2, 12, 0, 0, 0);
+}
diff --git a/src/features/staff/components/day-calendar.css b/src/features/staff/components/day-calendar.css
new file mode 100644
index 0000000..538b9de
--- /dev/null
+++ b/src/features/staff/components/day-calendar.css
@@ -0,0 +1,158 @@
+/*
+ * Restyles react-big-calendar (see day-calendar.tsx) to match the app: the
+ * library draws its own box, colours and event chrome, so everything here
+ * either removes that or swaps in the app's tokens. Scoped under .day-cal so
+ * nothing leaks to any other page. Colours use the theme variables with a
+ * literal fallback in case Tailwind hasn't emitted one.
+ */
+.day-cal {
+ --dc-line: var(--color-line, #e1e7e2);
+ --dc-muted: var(--color-muted, #5c6961);
+ --dc-danger: var(--color-danger, #ef4444);
+}
+
+/* Dragging a booking must never highlight the text around it. */
+.day-cal .rbc-calendar {
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+.day-cal .rbc-calendar,
+.day-cal .rbc-time-view {
+ background: transparent;
+ border: 0;
+ font-family: inherit;
+}
+
+/* Header row: the app's avatar + name + hours, no library chrome. */
+.day-cal .rbc-time-header {
+ border: 0;
+ margin-right: 0 !important; /* the library reserves scrollbar width; the page scrolls instead */
+}
+/* Stays in view under the page's sticky top bar (81px tall) so, halfway down
+ the day, you can still see whose column you're looking at. */
+.day-cal .rbc-time-header {
+ position: sticky;
+ top: 81px;
+ z-index: 25;
+ background: var(--color-stone, #f4f6f4);
+}
+.day-cal .rbc-time-header-content {
+ border-left: 1px solid var(--dc-line);
+}
+.day-cal .rbc-header {
+ border-bottom: 0;
+ border-left: 1px solid var(--dc-line);
+ padding: 0;
+ font-weight: inherit;
+ overflow: visible;
+}
+.day-cal .rbc-header:first-child {
+ border-left: 0;
+}
+/* No all-day row — appointments always have a time. */
+.day-cal .rbc-allday-cell,
+.day-cal .rbc-time-header-cell + .rbc-row {
+ display: none;
+}
+
+/* Grid. */
+.day-cal .rbc-time-content {
+ border-top: 1px solid var(--dc-line);
+ overflow-y: visible; /* full-height grid; the page owns the scrollbar */
+}
+.day-cal .rbc-time-content > * + * > * {
+ border-left: 1px solid var(--dc-line);
+}
+.day-cal .rbc-timeslot-group {
+ min-height: 112px; /* 4 x 28px quarter-hour rows == the app's 112px per hour */
+ border-bottom: 1px solid var(--dc-line);
+}
+.day-cal .rbc-time-slot {
+ border-top: 0;
+}
+.day-cal .rbc-day-slot .rbc-time-slot {
+ border-top: 1px solid rgba(0, 0, 0, 0.04);
+}
+.day-cal .rbc-day-slot .rbc-time-slot:first-child {
+ border-top: 0;
+}
+.day-cal .rbc-today {
+ background: transparent;
+}
+/* Wide enough to read a booking; narrower screens scroll sideways with the
+ hour labels pinned. The header cells must match the column width exactly or
+ a name stops lining up with its column once the row overflows. */
+.day-cal .rbc-time-view-resources .rbc-day-slot,
+.day-cal .rbc-time-view-resources .rbc-header,
+.day-cal .rbc-time-view-resources .rbc-day-bg {
+ min-width: 190px; /* a minimum, not a width — wider screens still stretch the columns to fill */
+}
+
+/* Hour labels. */
+.day-cal .rbc-time-gutter .rbc-timeslot-group {
+ border-bottom: 0;
+}
+.day-cal .rbc-label {
+ padding: 0 8px 0 0;
+ color: var(--dc-muted);
+ font-size: 10px;
+ font-weight: 700;
+}
+.day-cal .rbc-time-gutter,
+.day-cal .rbc-time-header-gutter {
+ background: var(--color-stone, #f4f6f4);
+ z-index: 5;
+}
+
+/* Current time. */
+.day-cal .rbc-current-time-indicator {
+ height: 2px;
+ background-color: var(--dc-danger);
+ z-index: 30;
+}
+
+/* Outside the working window: the same diagonal hatch as breaks/time off. */
+.day-cal .day-cal-offhours {
+ background-color: rgba(225, 231, 226, 0.15);
+ background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, rgba(0, 0, 0, 0.05) 6px, rgba(0, 0, 0, 0.05) 12px);
+}
+
+/* Events: the library's coloured box is replaced by the app's own block (EventContent). */
+.day-cal .rbc-event,
+.day-cal .rbc-background-event {
+ background: transparent;
+ border: 0;
+ padding: 0;
+ box-shadow: none;
+ border-radius: 0;
+ outline: none;
+}
+.day-cal .rbc-event .rbc-event-content {
+ height: 100%;
+}
+.day-cal .rbc-event.rbc-selected {
+ background: transparent;
+}
+.day-cal .rbc-background-event {
+ z-index: 1;
+ opacity: 1;
+ pointer-events: none;
+}
+.day-cal .rbc-event:focus-visible > .rbc-event-content > div {
+ outline: 2px solid var(--color-ink, #111b15);
+ outline-offset: 1px;
+}
+/* The library's own time label is hidden — EventContent draws a compact one. */
+.day-cal .rbc-event-label {
+ display: none;
+}
+.day-cal .day-cal-movable {
+ cursor: grab;
+}
+.day-cal .day-cal-movable:active {
+ cursor: grabbing;
+}
+.day-cal .rbc-addons-dnd-drag-preview {
+ opacity: 0.85;
+}
diff --git a/src/features/staff/components/day-calendar.tsx b/src/features/staff/components/day-calendar.tsx
index 8baa6d1..23df8fd 100644
--- a/src/features/staff/components/day-calendar.tsx
+++ b/src/features/staff/components/day-calendar.tsx
@@ -1,42 +1,36 @@
+"use client";
+
import Image from "next/image";
-import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useEffect, useMemo, useRef, useState, useTransition } from "react";
+import { Calendar, dateFnsLocalizer } from "react-big-calendar";
+import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
+import type { EventInteractionArgs } from "react-big-calendar/lib/addons/dragAndDrop";
+import { format, getDay, parse, startOfWeek } from "date-fns";
+import { enGB } from "date-fns/locale";
import { Ban, CheckCheck, CircleDot, Clock, Gift, LogIn, NotebookText, UserX } from "lucide-react";
import type { LucideIcon } from "lucide-react";
+import "react-big-calendar/lib/css/react-big-calendar.css";
+import "react-big-calendar/lib/addons/dragAndDrop/styles.css";
+import "./day-calendar.css";
+import { Button } from "@/components/ui/button";
+import { Modal } from "@/components/ui/modal";
import { cn } from "@/lib/utils/cn";
import type { AppointmentStatus } from "@/types/domain";
+import { moveCalendarAppointmentAction } from "../actions/calendar-move";
+import {
+ floatingDateToMinutes,
+ floatingDisplayDate,
+ floatingNotToday,
+ minutesToFloatingDate,
+} from "./calendar-time";
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}`;
-}
+/** The grid is drawn full height and the PAGE scrolls — one scroll area, not a scrollbox inside the page. */
+const CALENDAR_HEIGHT = HEADER_HEIGHT + (END_HOUR - START_HOUR) * PX_PER_HOUR + 8;
export interface CalendarAppointment {
id: string;
@@ -70,6 +64,30 @@ export interface CalendarColumn {
workingWindow: { startMinutes: number; endMinutes: number } | null;
}
+const localizer = dateFnsLocalizer({
+ format,
+ parse,
+ getDay,
+ startOfWeek: (date: Date) => startOfWeek(date, { weekStartsOn: 1 }),
+ locales: { "en-GB": enGB },
+});
+const EMPTY_MOVES: Record = {};
+const DnDCalendar = withDragAndDrop(Calendar);
+
+interface CalendarResource {
+ id: string;
+ column: CalendarColumn;
+}
+
+type CalendarEvent =
+ | { kind: "appointment"; id: string; resourceId: string; start: Date; end: Date; title: string; appointment: CalendarAppointment }
+ | { kind: "block"; id: string; resourceId: string; start: Date; end: Date; title: string; block: CalendarBlock };
+
+type AppointmentEvent = Extract;
+
+/** Statuses the booking service will actually reschedule (see NON_RESCHEDULABLE_STATUSES in booking.service.ts). */
+const MOVABLE_STATUSES: AppointmentStatus[] = ["pending_approval", "confirmed", "arrived"];
+
const STATUS_BADGE_ICON: Record = {
pending_approval: Clock,
confirmed: CheckCheck,
@@ -80,222 +98,350 @@ const STATUS_BADGE_ICON: Record = {
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.
+ * The day grid for one date — one time axis, one column per staff member.
+ * A single staff member's own view is just the one-column case.
+ *
+ * Built on react-big-calendar, but everything it draws is restyled to match
+ * the app (see day-calendar.css) and it never sees a real time zone: times
+ * are handed over as "floating" dates (see calendar-time.ts).
+ *
+ * `reschedule` turns on drag-to-move. A drop never saves by itself — it opens
+ * a confirmation first, because a reschedule emails the customer. "owner"
+ * lets a booking be dragged to another barber's column (a reassignment);
+ * "staff" only along the time axis of their own column. The server re-checks
+ * everything (see moveCalendarAppointmentAction), so this is a convenience,
+ * not the gate. Omit it for a read-only calendar.
*/
export function DayCalendar({
columns,
+ date,
currentTimeMinutes,
appointmentHrefBase = "/staff/appointments",
+ reschedule,
}: {
columns: CalendarColumn[];
+ /** The business-local calendar date being shown ("YYYY-MM-DD") — needed only to turn a drop back into a real instant on the server. */
+ date: string;
/** 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". */
+ /** Owner's all-staff calendar links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar". */
appointmentHrefBase?: string;
+ reschedule?: "owner" | "staff";
}) {
- 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;
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+ const rootRef = useRef(null);
+ const [pendingMove, setPendingMove] = useState<{
+ event: AppointmentEvent;
+ resourceId: string;
+ startMinutes: number;
+ } | null>(null);
+ const [moveError, setMoveError] = useState(null);
+ /**
+ * Moves that already succeeded, shown in place until the refreshed server
+ * data arrives. Tied to the `columns` they were made against, so a new
+ * `columns` (the refresh landing) discards them with no effect needed.
+ */
+ const [movedState, setMovedState] = useState<{
+ forColumns: CalendarColumn[];
+ positions: Record;
+ }>({ forColumns: columns, positions: {} });
+ const moved = movedState.forColumns === columns ? movedState.positions : EMPTY_MOVES;
+
+ const { events, backgroundEvents, resources } = useMemo(() => {
+ const events: CalendarEvent[] = [];
+ const backgroundEvents: CalendarEvent[] = [];
+ for (const column of columns) {
+ for (const appointment of column.appointments) {
+ const override = moved[appointment.id];
+ const start = override ? override.startMinutes : appointment.startMinutes;
+ const length = appointment.endMinutes - appointment.startMinutes;
+ events.push({
+ kind: "appointment",
+ id: appointment.id,
+ resourceId: override ? override.resourceId : column.id,
+ start: minutesToFloatingDate(start),
+ end: minutesToFloatingDate(start + length),
+ title: appointment.customerName,
+ appointment,
+ });
+ }
+ for (const block of column.blocks) {
+ backgroundEvents.push({
+ kind: "block",
+ id: block.id,
+ resourceId: column.id,
+ start: minutesToFloatingDate(block.startMinutes),
+ end: minutesToFloatingDate(block.endMinutes),
+ title: block.label,
+ block,
+ });
+ }
+ }
+ return {
+ events,
+ backgroundEvents,
+ resources: columns.map((column) => ({ id: column.id, column })),
+ };
+ }, [columns, moved]);
+
+ const columnById = useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns]);
+
+ // On today's view, bring the current time into view — the grid is a full
+ // 14 hours tall and the page itself scrolls, so without this a barber
+ // opening the calendar at 3pm lands on 7am.
+ useEffect(() => {
+ if (currentTimeMinutes === null) return;
+ // The library draws the line a moment after mount, so wait for it.
+ const timer = window.setTimeout(() => {
+ rootRef.current?.querySelector(".rbc-current-time-indicator")?.scrollIntoView({ block: "center" });
+ }, 150);
+ return () => window.clearTimeout(timer);
+ // Once, on first render of the day — not on every refresh after a move.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const canMove = (event: CalendarEvent) =>
+ !!reschedule && event.kind === "appointment" && MOVABLE_STATUSES.includes(event.appointment.status);
+
+ const handleDrop = ({ event, start, resourceId }: EventInteractionArgs) => {
+ if (event.kind !== "appointment") return;
+ const startMinutes = floatingDateToMinutes(new Date(start));
+ // A barber moves their own booking in time only.
+ const targetResourceId = reschedule === "owner" && typeof resourceId === "string" ? resourceId : event.resourceId;
+ if (startMinutes === event.appointment.startMinutes && targetResourceId === event.resourceId) return;
+ setMoveError(null);
+ setPendingMove({ event, resourceId: targetResourceId, startMinutes });
+ };
+
+ const confirmMove = () => {
+ if (!pendingMove || !reschedule) return;
+ const { event, resourceId, startMinutes } = pendingMove;
+ startTransition(async () => {
+ const result = await moveCalendarAppointmentAction({
+ appointmentId: event.id,
+ staffId: resourceId,
+ date,
+ startMinutes,
+ mode: reschedule,
+ });
+ if (!result.success) {
+ setMoveError(result.error ?? "Couldn't move this appointment.");
+ return;
+ }
+ setMovedState({ forColumns: columns, positions: { ...moved, [event.id]: { startMinutes, resourceId } } });
+ setPendingMove(null);
+ router.refresh();
+ });
+ };
+
+ const fromName = pendingMove ? columnById.get(pendingMove.event.resourceId)?.name : undefined;
+ const toName = pendingMove ? columnById.get(pendingMove.resourceId)?.name : undefined;
+ const reassigning = !!pendingMove && pendingMove.resourceId !== pendingMove.event.resourceId;
+
+ const scrollMinutes = Math.max(START_HOUR * 60, (currentTimeMinutes ?? 9 * 60) - 60);
return (
-
- {/* 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. */}
-
- {/* 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. */}
-
- {columns.map((column) => (
-
-
- {column.avatarUrl ? (
-
- ) : (
-
- {column.name.charAt(0)}
-
- )}
-
-
{column.name}
-
{column.hoursLabel ?? "Off today"}
-
+ {
+ if (!isPending) setPendingMove(null);
+ }}
+ title={reassigning ? "Move to another barber?" : "Move this appointment?"}
+ >
+ {pendingMove && (
+
+
+
{pendingMove.event.appointment.customerName}
+
{pendingMove.event.appointment.serviceNames}
+
+ From
+ {formatClock(pendingMove.event.appointment.startMinutes)}
+ {fromName ? ` with ${fromName}` : ""}
+
+
+ To
+ {formatClock(pendingMove.startMinutes)}
+ {toName ? ` with ${toName}` : ""}
+