import type { ReactNode } from "react"; import { Avatar, AvatarFallback } from "./avatar"; import { Badge } from "./badge"; import { Button } from "./button"; import { Separator } from "./separator"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs"; import { ChevronLeft, ChevronRight } from "lucide-react"; import type { AppointmentStatus } from "./appointment-time-slot-picker"; import { formatWeekdayShort } from "@/lib/format-date"; export type { AppointmentStatus } from "./appointment-time-slot-picker"; export interface AppointmentCalendarItem { id: string; status: AppointmentStatus; clientName: string; clientAvatarInitials: string; /** ISO date string — "2026-04-22" */ date: string; /** Display time — "10:00 AM" */ timeStart: string; /** Display time — "10:30 AM" */ timeEnd: string; } export interface AppointmentWeekDay { /** Short weekday label — "Mon" */ label: string; /** ISO date string — "2026-04-20" */ date: string; /** Short date number — "20" */ short: string; } export interface AppointmentCalendarViewProps { appointments: AppointmentCalendarItem[]; /** ISO date string for the "today" highlight — "2026-04-22" */ today: string; /** Label shown in the toolbar — "April 2026" */ periodLabel: string; /** Days shown in week view */ weekDays: AppointmentWeekDay[]; /** Hours displayed in day/week view — defaults to [9..17] */ hours?: number[]; /** ISO date controlling which day/month is displayed — defaults to `today` */ viewDate?: string; /** @deprecated Use `viewDate` instead */ dayViewDate?: string; defaultView?: "day" | "week" | "month"; onPrev?: () => void; onNext?: () => void; onToday?: () => void; onViewChange?: (view: "day" | "week" | "month") => void; onSelectAppointment?: (apt: AppointmentCalendarItem) => void; /** Slot rendered in the calendar toolbar right side */ toolbarActions?: ReactNode; } // --------------------------------------------------------------------------- // Status config // --------------------------------------------------------------------------- const STATUS: Record< AppointmentStatus, { card: string; badge: "warning" | "success" | "destructive" | "info"; label: string; } > = { pending: { card: "border-l-warning bg-warning/[0.06]", badge: "warning", label: "Pending", }, confirmed: { card: "border-l-success bg-success/[0.06]", badge: "success", label: "Confirmed", }, cancelled: { card: "border-l-destructive bg-destructive/[0.06]", badge: "destructive", label: "Cancelled", }, rescheduled: { card: "border-l-info bg-info/[0.06]", badge: "info", label: "Rescheduled", }, }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function formatHour(h: number) { if (h === 12) return "12 PM"; return h < 12 ? `${h} AM` : `${h - 12} PM`; } function getHourFromTime(time: string): number { const match = time.match(/^(\d+):/); if (!match) return 0; const h = parseInt(match[1], 10); return time.includes("PM") && h !== 12 ? h + 12 : h; } const DEFAULT_HOURS = [9, 10, 11, 12, 13, 14, 15, 16, 17]; // --------------------------------------------------------------------------- // Shared sub-components // --------------------------------------------------------------------------- function TimeGutter({ hour }: { hour: number }) { return (
{formatHour(hour)}
); } function DayCard({ apt, onSelect, }: { apt: AppointmentCalendarItem; onSelect?: (apt: AppointmentCalendarItem) => void; }) { return ( ); } function AptChip({ apt, onSelect, className, }: { apt: AppointmentCalendarItem; onSelect?: (apt: AppointmentCalendarItem) => void; className?: string; }) { return ( ); } // --------------------------------------------------------------------------- // Day view // --------------------------------------------------------------------------- function DayView({ appointments, date, today, hours, onSelectAppointment, }: { appointments: AppointmentCalendarItem[]; date: string; today: string; hours: number[]; onSelectAppointment?: (apt: AppointmentCalendarItem) => void; }) { const d = new Date(date + "T00:00:00"); const isToday = date === today; const dayApts = appointments.filter((a) => a.date === date); return (
{formatWeekdayShort(d)} {d.getDate()}
{hours.map((hour) => { const aptsAtHour = dayApts.filter( (a) => getHourFromTime(a.timeStart) === hour, ); return (
{aptsAtHour.map((apt) => ( ))}
); })}
); } // --------------------------------------------------------------------------- // Week view // --------------------------------------------------------------------------- function WeekView({ appointments, weekDays, today, hours, onSelectAppointment, }: { appointments: AppointmentCalendarItem[]; weekDays: AppointmentWeekDay[]; today: string; hours: number[]; onSelectAppointment?: (apt: AppointmentCalendarItem) => void; }) { return (
{weekDays.map((day) => (
{day.label} {day.short}
))}
{hours.map((hour) => (
{weekDays.map((day) => { const aptsAtCell = appointments.filter( (a) => a.date === day.date && getHourFromTime(a.timeStart) === hour, ); return (
{aptsAtCell.map((apt) => ( ))}
); })}
))}
); } // --------------------------------------------------------------------------- // Month view // --------------------------------------------------------------------------- const MONTH_DAY_HEADERS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; function generateMonthGrid(year: number, month: number): (Date | null)[][] { const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const startDow = firstDay.getDay(); const grid: (Date | null)[][] = []; let week: (Date | null)[] = Array.from({ length: startDow }, () => null); for (let day = 1; day <= lastDay.getDate(); day++) { week.push(new Date(year, month, day)); if (week.length === 7) { grid.push(week); week = []; } } if (week.length > 0) { while (week.length < 7) week.push(null); grid.push(week); } return grid; } function MonthView({ appointments, viewDate, today, onSelectAppointment, }: { appointments: AppointmentCalendarItem[]; viewDate: string; today: string; onSelectAppointment?: (apt: AppointmentCalendarItem) => void; }) { const vd = new Date(viewDate + "T00:00:00"); const grid = generateMonthGrid(vd.getFullYear(), vd.getMonth()); // Use local date parts — toISOString() converts to UTC first, which shifts the // date back 1 day for UTC+ timezones (e.g. UTC+7 shows Thu booking on Wed cell). const toIso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; return (
{MONTH_DAY_HEADERS.map((d) => (
{d}
))}
{grid.map((week, wi) => (
{week.map((day, di) => { const iso = day ? toIso(day) : null; const dayApts = iso ? appointments.filter((a) => a.date === iso) : []; const isToday = iso === today; return (
{day && ( <>
{day.getDate()}
{dayApts.slice(0, 2).map((apt) => ( ))} {dayApts.length > 2 && (

+{dayApts.length - 2} more

)}
)}
); })}
))}
); } // --------------------------------------------------------------------------- // Composite calendar view // --------------------------------------------------------------------------- export function AppointmentCalendarView({ appointments, today, periodLabel, weekDays, hours = DEFAULT_HOURS, viewDate, dayViewDate, defaultView = "week", onPrev, onNext, onToday, onViewChange, onSelectAppointment, toolbarActions, }: AppointmentCalendarViewProps) { const effectiveViewDate = viewDate ?? dayViewDate ?? today; return (

{periodLabel}

{toolbarActions && (
{toolbarActions}
)}
onViewChange?.(v as "day" | "week" | "month")} className="flex flex-col flex-1 min-h-0" >
Day Week Month
); }