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