diff --git a/src/app/(owner)/owner/calendar/page.tsx b/src/app/(owner)/owner/calendar/page.tsx index a76ba52..b31223b 100644 --- a/src/app/(owner)/owner/calendar/page.tsx +++ b/src/app/(owner)/owner/calendar/page.tsx @@ -21,7 +21,7 @@ import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service"; import { listAppointments } from "@/services/scheduling/appointment.service"; import { DateStrip } from "@/features/staff/components/date-strip"; import { DayCalendar } from "@/features/staff/components/day-calendar"; -import type { CalendarAppointment, CalendarBlock } from "@/features/staff/components/day-calendar"; +import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar"; import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button"; import { cn } from "@/lib/utils/cn"; import type { AppointmentStatus } from "@/types/domain"; @@ -74,7 +74,7 @@ export default async function OwnerCalendarPage({ const allStaff = await prisma.staffProfile.findMany({ where: { businessId: context.businessId, status: "ACTIVE" }, - include: { user: { select: { id: true, name: true } } }, + include: { user: { select: { id: true, name: true, image: true } } }, orderBy: { createdAt: "asc" }, }); const selectedStaff = staffIdParam ? allStaff.find((s) => s.id === staffIdParam) : undefined; @@ -159,6 +159,20 @@ export default async function OwnerCalendarPage({ return { workingWindow, blocks, wholeDayOff, staffAppointments }; } + const columns: CalendarColumn[] = staffToRender.map((s) => { + const column = buildColumnFor(s.id); + return { + id: s.id, + name: s.user.name ?? "Staff", + avatarUrl: s.user.image, + hoursLabel: column.workingWindow ? formatRange(column.workingWindow.startMinutes, column.workingWindow.endMinutes) : null, + wholeDayOff: column.wholeDayOff, + appointments: column.staffAppointments, + blocks: column.blocks, + workingWindow: column.workingWindow, + }; + }); + return (
@@ -237,31 +251,7 @@ export default async function OwnerCalendarPage({
) : ( -
- {staffToRender.map((s) => { - const column = buildColumnFor(s.id); - return ( -
-

- {s.user.name} -

- {column.wholeDayOff ? ( -
- -
- ) : ( - - )} -
- ); - })} -
+ ) ) : appointments.length === 0 ? (
diff --git a/src/app/(staff)/staff/page.tsx b/src/app/(staff)/staff/page.tsx index e008f09..8b31775 100644 --- a/src/app/(staff)/staff/page.tsx +++ b/src/app/(staff)/staff/page.tsx @@ -18,7 +18,7 @@ import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service"; import { listAppointments, selectCurrentAppointment } from "@/services/scheduling/appointment.service"; import { DateStrip } from "@/features/staff/components/date-strip"; import { DayCalendar } from "@/features/staff/components/day-calendar"; -import type { CalendarAppointment, CalendarBlock } from "@/features/staff/components/day-calendar"; +import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar"; import { BlockTimeButton } from "@/features/staff/components/block-time-button"; import { cn } from "@/lib/utils/cn"; import type { AppointmentStatus } from "@/types/domain"; @@ -124,6 +124,19 @@ export default async function StaffCalendarPage({ const currentTimeMinutes = date === today ? minutesFromMidnight(new Date(), staff.business.timezone) : null; + const columns: CalendarColumn[] = [ + { + id: staff.id, + name: staff.user.name ?? "You", + avatarUrl: staff.user.image, + hoursLabel: workingWindow ? formatRange(workingWindow.startMinutes, workingWindow.endMinutes) : null, + wholeDayOff: false, + appointments: calendarAppointments, + blocks, + workingWindow, + }, + ]; + // Desktop-only: a "Right Now" panel beside the calendar so a wide screen // does more than center the mobile layout (see docs/phase-5-summary.md, // "Responsive Design"). Only meaningful when viewing today. @@ -181,12 +194,7 @@ export default async function StaffCalendarPage({ description={wholeDayOff.reason ?? "Approved time off."} /> ) : view === "day" ? ( - + ) : calendarAppointments.length === 0 ? (
- + {d.toLocaleDateString("en-GB", { weekday: "short" })} - + {d.getDate()} diff --git a/src/features/staff/components/day-calendar.tsx b/src/features/staff/components/day-calendar.tsx index 017d985..bd2c5fa 100644 --- a/src/features/staff/components/day-calendar.tsx +++ b/src/features/staff/components/day-calendar.tsx @@ -1,23 +1,33 @@ +import Image from "next/image"; import Link from "next/link"; -import { Gift, NotebookText } from "lucide-react"; -import { StatusPill } from "@/components/ui/status-pill"; +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 = 64; -const RANGE_START_MINUTES = START_HOUR * 60; -const RANGE_END_MINUTES = END_HOUR * 60; +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) / 60) * PX_PER_HOUR; + 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(20, topFor(endMinutes) - topFor(startMinutes)); + 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")}`; } export interface CalendarAppointment { @@ -39,25 +49,50 @@ export interface CalendarBlock { 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 = { + 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 single day's grid for one staff member. Deliberately no drag-and-drop — - * every interactive element is a plain Link (open details / start a - * reschedule from there), per the brief's preference for a reliable - * tap-to-act flow over an unreliable drag interaction. Pure presentation: - * every minute value here is already computed by the page from real - * WorkingHours/Break/TimeOff/Appointment rows, nothing is derived here. + * 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({ - appointments, - blocks, - workingWindow, + columns, currentTimeMinutes, appointmentHrefBase = "/staff/appointments", }: { - appointments: CalendarAppointment[]; - blocks: CalendarBlock[]; - /** The staff member's working window that day, for shading outside-hours space — null if they're off entirely. */ - workingWindow: { startMinutes: number; endMinutes: number } | null; + 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". */ @@ -65,124 +100,242 @@ export function DayCalendar({ }) { 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 ( -
-
- {/* Hour gridlines + labels */} - {hourMarks.map((hour) => ( -
- - {hour === 12 ? "12pm" : hour > 12 ? `${hour - 12}pm` : `${hour}am`} - -
- ))} - - {/* Outside-working-hours shading */} - {workingWindow ? ( - <> - {workingWindow.startMinutes > RANGE_START_MINUTES && ( -