The calendar is now react-big-calendar.
This commit is contained in:
10 files changed
+956
-230
No files matched your search
@@ -244,7 +244,13 @@ export default async function OwnerCalendarPage({
|
||||
<EmptyState icon={CalendarX2} title="No active staff" description="Add a staff member from Team." />
|
||||
</div>
|
||||
) : (
|
||||
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} appointmentHrefBase="/owner/appointments" />
|
||||
<DayCalendar
|
||||
columns={columns}
|
||||
date={date}
|
||||
currentTimeMinutes={currentTimeMinutes}
|
||||
appointmentHrefBase="/owner/appointments"
|
||||
reschedule="owner"
|
||||
/>
|
||||
)
|
||||
) : appointments.length === 0 ? (
|
||||
<div className="px-6">
|
||||
|
||||
@@ -187,7 +187,7 @@ export default async function StaffCalendarPage({
|
||||
description={wholeDayOff.reason ?? "Approved time off."}
|
||||
/>
|
||||
) : view === "day" ? (
|
||||
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} />
|
||||
<DayCalendar columns={columns} date={date} currentTimeMinutes={currentTimeMinutes} reschedule="staff" />
|
||||
) : calendarAppointments.length === 0 ? (
|
||||
<div className="px-6 lg:px-0">
|
||||
<EmptyState
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { wallClockToInstant } from "@/lib/time/business-time";
|
||||
import { ownerRescheduleAppointmentAction } from "@/features/owner/actions/appointments";
|
||||
import { rescheduleStaffAppointmentAction } from "./appointment-actions";
|
||||
|
||||
const moveSchema = z.object({
|
||||
appointmentId: z.string().min(1),
|
||||
/** The column it was dropped in — a different one than it started in is a reassignment (owner only). */
|
||||
staffId: z.string().min(1),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
startMinutes: z.number().int().min(0).max(24 * 60 - 1),
|
||||
mode: z.enum(["owner", "staff"]),
|
||||
});
|
||||
|
||||
export interface MoveCalendarAppointmentResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag-to-reschedule from the calendar. A thin adapter, not a second
|
||||
* reschedule path: it only turns "this date, these business-local minutes"
|
||||
* into the instant the existing actions already take, then delegates — so
|
||||
* slot validation (working hours, breaks, overlaps, rules), the audit log,
|
||||
* customer notifications, reminder rescheduling and Google Calendar sync all
|
||||
* behave exactly as they do from the appointment screen. Each delegate
|
||||
* enforces its own permission (owner: any appointment in the business;
|
||||
* staff: their own only), and the `mode` here is a request, never trusted.
|
||||
*/
|
||||
export async function moveCalendarAppointmentAction(input: unknown): Promise<MoveCalendarAppointmentResult> {
|
||||
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;
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, { startMinutes: number; resourceId: string }> = {};
|
||||
const DnDCalendar = withDragAndDrop<CalendarEvent, CalendarResource>(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<CalendarEvent, { kind: "appointment" }>;
|
||||
|
||||
/** 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<AppointmentStatus, LucideIcon> = {
|
||||
pending_approval: Clock,
|
||||
confirmed: CheckCheck,
|
||||
@@ -80,222 +98,350 @@ const STATUS_BADGE_ICON: Record<AppointmentStatus, LucideIcon> = {
|
||||
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<HTMLDivElement>(null);
|
||||
const [pendingMove, setPendingMove] = useState<{
|
||||
event: AppointmentEvent;
|
||||
resourceId: string;
|
||||
startMinutes: number;
|
||||
} | null>(null);
|
||||
const [moveError, setMoveError] = useState<string | null>(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<string, { startMinutes: number; resourceId: string }>;
|
||||
}>({ 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<CalendarResource>((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<CalendarEvent>) => {
|
||||
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 (
|
||||
<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 ref={rootRef} className="day-cal px-6 pb-8" data-testid="calendar-grid">
|
||||
<div style={{ height: CALENDAR_HEIGHT }}>
|
||||
<DnDCalendar
|
||||
localizer={localizer}
|
||||
culture="en-GB"
|
||||
toolbar={false}
|
||||
view="day"
|
||||
views={["day"]}
|
||||
date={floatingDisplayDate()}
|
||||
onNavigate={() => {}}
|
||||
onView={() => {}}
|
||||
getNow={() => (currentTimeMinutes === null ? floatingNotToday() : minutesToFloatingDate(currentTimeMinutes))}
|
||||
events={events}
|
||||
backgroundEvents={backgroundEvents}
|
||||
resources={resources}
|
||||
resourceIdAccessor={(r) => r.id}
|
||||
resourceTitleAccessor={(r) => r.column.name}
|
||||
step={15}
|
||||
timeslots={4}
|
||||
min={minutesToFloatingDate(START_HOUR * 60)}
|
||||
max={minutesToFloatingDate(END_HOUR * 60)}
|
||||
scrollToTime={minutesToFloatingDate(scrollMinutes)}
|
||||
dayLayoutAlgorithm="no-overlap"
|
||||
selectable={false}
|
||||
resizable={false}
|
||||
draggableAccessor={canMove}
|
||||
onEventDrop={handleDrop}
|
||||
onSelectEvent={(event) => {
|
||||
if (event.kind === "appointment") router.push(`${appointmentHrefBase}/${event.id}`);
|
||||
}}
|
||||
eventPropGetter={(event) => ({
|
||||
className: cn("day-cal-appointment", canMove(event) && "day-cal-movable"),
|
||||
})}
|
||||
slotPropGetter={(slotDate, resourceId) =>
|
||||
isOffHours(typeof resourceId === "string" ? columnById.get(resourceId) : undefined, slotDate)
|
||||
? { className: "day-cal-offhours" }
|
||||
: {}
|
||||
}
|
||||
formats={{
|
||||
timeGutterFormat: (value) => formatHourLabel(value.getHours()),
|
||||
// Hidden visually (the block draws its own compact time) but kept for
|
||||
// the accessible name, which the library builds as "<range>: <title>".
|
||||
eventTimeRangeFormat: ({ start, end }) =>
|
||||
`${formatClock(floatingDateToMinutes(start))} – ${formatClock(floatingDateToMinutes(end))}`,
|
||||
}}
|
||||
components={{
|
||||
event: EventContent,
|
||||
resourceHeader: ({ resource }) => <ColumnHeader column={(resource as CalendarResource).column} />,
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
<Modal
|
||||
open={!!pendingMove}
|
||||
onClose={() => {
|
||||
if (!isPending) setPendingMove(null);
|
||||
}}
|
||||
title={reassigning ? "Move to another barber?" : "Move this appointment?"}
|
||||
>
|
||||
{pendingMove && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-bold">{pendingMove.event.appointment.customerName}</p>
|
||||
<p className="text-muted">{pendingMove.event.appointment.serviceNames}</p>
|
||||
<p className="pt-2">
|
||||
<span className="text-muted">From </span>
|
||||
{formatClock(pendingMove.event.appointment.startMinutes)}
|
||||
{fromName ? ` with ${fromName}` : ""}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted">To </span>
|
||||
<strong>{formatClock(pendingMove.startMinutes)}</strong>
|
||||
{toName ? ` with ${toName}` : ""}
|
||||
</p>
|
||||
</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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<p className="text-muted text-xs">The customer will be notified of the change.</p>
|
||||
{moveError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{moveError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" disabled={isPending} onClick={() => setPendingMove(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="flex-1" isLoading={isPending} onClick={confirmMove}>
|
||||
Move
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** True for slots outside a staff member's working window (or the whole column when they're off) — those get the same hatch texture the break/time-off blocks use. */
|
||||
function isOffHours(column: CalendarColumn | undefined, slotDate: Date): boolean {
|
||||
if (!column) return false;
|
||||
const minutes = floatingDateToMinutes(slotDate);
|
||||
return (
|
||||
column.wholeDayOff ||
|
||||
!column.workingWindow ||
|
||||
minutes < column.workingWindow.startMinutes ||
|
||||
minutes >= column.workingWindow.endMinutes
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnHeader({ column }: { column: CalendarColumn }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 text-left" style={{ height: HEADER_HEIGHT }}>
|
||||
{column.avatarUrl ? (
|
||||
<Image
|
||||
src={column.avatarUrl}
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
className="border-line h-9 w-9 shrink-0 rounded-full border object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="border-line text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border bg-white 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] font-normal">
|
||||
{column.wholeDayOff ? "Off today" : (column.hoursLabel ?? "Off today")}
|
||||
</p>
|
||||
</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 EventContent({ event }: { event: CalendarEvent }) {
|
||||
if (event.kind === "block") {
|
||||
return (
|
||||
<div className="border-warning/40 bg-warning/10 h-full overflow-hidden rounded-lg border p-1.5">
|
||||
<p className="text-warning-foreground truncate text-[10px] font-bold tracking-widest uppercase">{event.block.label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
const { appointment } = event;
|
||||
const BadgeIcon = STATUS_BADGE_ICON[appointment.status];
|
||||
const height = ((appointment.endMinutes - appointment.startMinutes) / 60) * PX_PER_HOUR;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-full overflow-hidden rounded-lg border-l-4 p-2 text-left shadow-sm transition-shadow hover:shadow-md",
|
||||
statusBlockClass(appointment.status),
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full",
|
||||
statusBadgeClass(appointment.status),
|
||||
)}
|
||||
>
|
||||
<BadgeIcon className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
<span className="sr-only">{statusLabel(appointment.status)}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1 pr-4">
|
||||
<p className="truncate text-[11px] font-bold">
|
||||
{formatCompactTime(appointment.startMinutes)} – {formatCompactTime(appointment.endMinutes)}
|
||||
</p>
|
||||
{appointment.hasNotes && <NotebookText className="text-muted h-2.5 w-2.5 shrink-0" aria-label="Has notes" />}
|
||||
{appointment.hasAvailableReward && (
|
||||
<Gift className="text-accent-loyalty h-2.5 w-2.5 shrink-0" aria-label="Reward available" />
|
||||
)}
|
||||
</div>
|
||||
{height >= 44 && (
|
||||
<p className="truncate text-[11px] opacity-80">
|
||||
{appointment.customerName} • {appointment.serviceNames}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "9 AM" / "12 PM" gutter label. */
|
||||
function formatHourLabel(hour: number): string {
|
||||
const period = hour >= 12 ? "PM" : "AM";
|
||||
return `${hour % 12 === 0 ? 12 : hour % 12} ${period}`;
|
||||
}
|
||||
|
||||
/** 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 / 60);
|
||||
const m = minutes % 60;
|
||||
const displayHour = h % 12 === 0 ? 12 : h % 12;
|
||||
return `${displayHour}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** "2:45 PM" for the confirmation dialog. */
|
||||
function formatClock(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return `${h % 12 === 0 ? 12 : h % 12}:${String(m).padStart(2, "0")} ${h >= 12 ? "PM" : "AM"}`;
|
||||
}
|
||||
|
||||
function statusLabel(status: AppointmentStatus): string {
|
||||
|
||||
Reference in new issue
Block a user