Staf Booking

fixed staff ability to book a customer in
This commit is contained in:
Stefan Lazic committed 2026-08-06 14:49:46 +01:00
1 parent 973f18f64c
commit 90ec663e6e
8 files changed
+378 -174

No files matched your search

+8 -13
View File
@@ -81,22 +81,17 @@ export function DateStrip({
key={date}
href={hrefFor(date)}
aria-current={isSelected ? "date" : undefined}
className={cn(
"flex flex-1 flex-col items-center gap-1 rounded-xl border py-2 text-xs",
isSelected
? "border-ink bg-ink text-white"
: "border-line text-ink hover:bg-stone bg-white",
)}
className="flex flex-1 flex-col items-center gap-1 py-2 text-xs"
>
<span
className={cn(
"text-[9px] font-bold tracking-widest uppercase",
isSelected ? "text-white/70" : "text-muted",
)}
>
<span className="text-muted text-[9px] font-bold tracking-widest uppercase">
{d.toLocaleDateString("en-GB", { weekday: "short" })}
</span>
<span className={cn("font-bold", isToday && !isSelected && "text-accent")}>
<span
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full font-bold",
isSelected ? "bg-ink text-white" : isToday ? "text-accent" : "text-ink",
)}
>
{d.getDate()}
</span>
</Link>
+276 -123
View File
@@ -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<AppointmentStatus, LucideIcon> = {
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 (
<div className="overflow-x-auto px-6 pb-8">
<div className="relative ml-14" style={{ height: gridHeight }}>
{/* Hour gridlines + labels */}
{hourMarks.map((hour) => (
<div
key={hour}
className="border-line absolute inset-x-0 border-t"
style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}
>
<span className="text-muted absolute -top-2.5 -left-14 w-12 text-right text-[10px] font-medium">
{hour === 12 ? "12pm" : hour > 12 ? `${hour - 12}pm` : `${hour}am`}
</span>
</div>
))}
{/* Outside-working-hours shading */}
{workingWindow ? (
<>
{workingWindow.startMinutes > RANGE_START_MINUTES && (
<div
className="bg-line/20 absolute inset-x-0"
style={{ top: 0, height: topFor(workingWindow.startMinutes) }}
aria-hidden="true"
/>
)}
{workingWindow.endMinutes < RANGE_END_MINUTES && (
<div
className="bg-line/20 absolute inset-x-0"
style={{ top: topFor(workingWindow.endMinutes), bottom: 0 }}
aria-hidden="true"
/>
)}
</>
) : (
<div className="bg-line/20 absolute inset-0" aria-hidden="true" />
)}
{/* Breaks / time off */}
{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 */}
{appointments.map((appt) => (
<Link
key={appt.id}
href={`${appointmentHrefBase}/${appt.id}`}
className={cn(
"absolute inset-x-1 z-20 overflow-hidden rounded-lg border-l-4 bg-white p-2 shadow-sm transition-shadow hover:shadow-md",
"focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1",
statusBorderClass(appt.status),
)}
style={{
top: topFor(appt.startMinutes),
height: heightFor(appt.startMinutes, appt.endMinutes),
}}
>
<div className="flex items-center gap-1">
<StatusPill status={appt.status} iconOnly className="px-1.5 py-0.5" />
{appt.hasNotes && (
<NotebookText className="text-muted h-3 w-3 shrink-0" aria-label="Has notes" />
)}
{appt.hasAvailableReward && (
<Gift
className="text-accent-loyalty h-3 w-3 shrink-0"
aria-label="Reward available"
/>
)}
</div>
<p className="truncate text-xs font-bold">{appt.customerName}</p>
<p className="text-muted truncate text-[10px]">{appt.serviceNames}</p>
</Link>
))}
{/* Current-time indicator */}
{currentTimeMinutes !== null &&
currentTimeMinutes >= RANGE_START_MINUTES &&
currentTimeMinutes <= RANGE_END_MINUTES && (
<div
className="border-danger absolute inset-x-0 z-30 border-t-2"
style={{ top: topFor(currentTimeMinutes) }}
aria-hidden="true"
>
<span className="bg-danger absolute -top-1.5 -left-1.5 h-3 w-3 rounded-full" />
</div>
<div className="flex gap-2 px-6 pb-8">
{/* 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 && (
<span
className="bg-danger absolute right-0 top-0 h-2 w-2 -translate-y-1/2 rounded-full"
style={{ top: topFor(currentTimeMinutes!) }}
aria-hidden="true"
/>
)}
</div>
</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>
</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"
/>
)}
</>
)}
</div>
</div>
))}
</div>
</div>
);
}
function statusBorderClass(status: AppointmentStatus): string {
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 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`;
}
function statusLabel(status: AppointmentStatus): string {
switch (status) {
case "pending_approval":
return "Pending approval";
case "confirmed":
return "Confirmed";
case "arrived":
return "Arrived";
case "in_progress":
return "In progress";
case "completed":
return "Completed";
case "cancelled":
return "Cancelled";
case "no_show":
return "No-show";
}
}
function statusBlockClass(status: AppointmentStatus): string {
switch (status) {
case "cancelled":
case "no_show":
return "border-status-cancelled opacity-60";
return "bg-status-cancelled/10 border-status-cancelled text-status-cancelled-foreground opacity-70";
case "completed":
return "border-line opacity-70";
return "bg-line/40 border-line text-muted";
case "in_progress":
return "border-status-progress";
return "bg-status-progress/15 border-status-progress text-ink";
case "arrived":
return "border-status-confirmed";
return "bg-status-confirmed/15 border-status-confirmed text-ink";
case "pending_approval":
return "border-status-pending";
return "bg-status-pending/15 border-status-pending text-ink";
default:
return "border-status-confirmed";
return "bg-status-confirmed/15 border-status-confirmed text-ink";
}
}
function statusBadgeClass(status: AppointmentStatus): string {
switch (status) {
case "cancelled":
case "no_show":
return "bg-status-cancelled text-white";
case "completed":
return "bg-muted text-white";
case "in_progress":
return "bg-status-progress text-white";
case "arrived":
return "bg-status-confirmed text-white";
case "pending_approval":
return "bg-status-pending text-white";
default:
return "bg-status-confirmed text-white";
}
}