"use client" /** * CalendarView — shared month/week/day event grid. * * Wraps react-big-calendar with a date-fns localizer pre-wired and a * simplified event shape so consumers don't have to reach for raw RBC types. * * IMPORTANT: react-big-calendar ships its own CSS. Consumers must import * `react-big-calendar/lib/css/react-big-calendar.css` once at the app level * (e.g. in _app.tsx / layout.tsx). This package does NOT auto-inject CSS so * consumers can layer Tailwind overrides predictably. * * lifecycle:calendar-ui (raise-simpli-k4u) */ import * as React from "react" import { Calendar, dateFnsLocalizer, Views, type View, type SlotInfo, } from "react-big-calendar" import { format, parse, startOfWeek, getDay } from "date-fns" import { enUS } from "date-fns/locale" const locales = { "en-US": enUS } const localizer = dateFnsLocalizer({ format, parse, startOfWeek: () => startOfWeek(new Date(), { weekStartsOn: 0 }), getDay, locales, }) /** Domain-friendly event shape callers pass in. */ export interface CalendarEvent { /** Stable identifier — passed through to onEventClick payload */ id: string | number /** Event title shown on the grid */ title: string /** Event start (Date or ISO string) */ start: Date | string /** Event end. If omitted, defaults to start + 30min */ end?: Date | string /** Whether to render as all-day across the top of the day */ allDay?: boolean /** Optional pass-through payload (the original ScheduledMeeting / etc.) */ resource?: T } interface InternalRBCEvent { id: string | number title: string start: Date end: Date allDay: boolean resource: unknown } const DEFAULT_DURATION_MS = 30 * 60 * 1000 function toDate(value: Date | string): Date { return value instanceof Date ? value : new Date(value) } function normalizeEvent(e: CalendarEvent): InternalRBCEvent { const start = toDate(e.start) const end = e.end ? toDate(e.end) : new Date(start.getTime() + DEFAULT_DURATION_MS) return { id: e.id, title: e.title, start, end, allDay: e.allDay ?? false, resource: e.resource, } } export type CalendarViewMode = "month" | "week" | "day" | "agenda" const VIEW_BY_MODE: Record = { month: Views.MONTH, week: Views.WEEK, day: Views.DAY, agenda: Views.AGENDA, } export interface CalendarViewProps { /** Events to render on the grid */ events: CalendarEvent[] /** Currently navigated date (controlled) */ date?: Date /** Default date if uncontrolled */ defaultDate?: Date /** Active view (controlled) */ view?: CalendarViewMode /** Default view if uncontrolled */ defaultView?: CalendarViewMode /** Allowed views the toolbar can switch between */ views?: CalendarViewMode[] /** Fires when the user clicks an event. Receives the original event passed in. */ onEventClick?: (event: CalendarEvent) => void /** Fires when the user clicks an empty slot (or drags to select a range). */ onSlotClick?: (slot: { start: Date; end: Date; action: "click" | "select" }) => void /** Fires when the user navigates to a new date. */ onNavigate?: (date: Date) => void /** Fires when the user changes view. */ onViewChange?: (view: CalendarViewMode) => void /** Allow drag-to-select empty time slots (defaults true). */ selectable?: boolean /** Min height for the grid (default 600px). */ minHeight?: number /** className passed to the wrapping div for Tailwind overrides. */ className?: string /** * Per-event styling. Receives the original CalendarEvent (with resource) * and returns optional className / inline style applied to the rendered * event block. Useful for color-coding by source (e.g. real Outlook event * vs locally-scheduled meeting). */ eventPropGetter?: (event: CalendarEvent) => { className?: string style?: React.CSSProperties } /** * When the month grid runs out of vertical space, react-big-calendar * collapses the overflow into a "+N more" link. Set true (default) to * open the day's full event list as a popover when that link is clicked. */ popup?: boolean } /** * Shared calendar grid. Pre-wired with date-fns localizer. * * @example * import "react-big-calendar/lib/css/react-big-calendar.css" * import { CalendarView } from "@startsimpli/ui" * * ({ * id: m.id, * title: m.title, * start: m.scheduled_at, * end: addMinutes(new Date(m.scheduled_at), m.duration_minutes), * resource: m, * }))} * onEventClick={e => openMeetingDialog(e.resource)} * onSlotClick={s => openCreateMeetingDialog(s.start)} * /> */ export function CalendarView({ events, date, defaultDate, view, defaultView, views, onEventClick, onSlotClick, onNavigate, onViewChange, selectable = true, minHeight = 600, className, eventPropGetter, popup = true, }: CalendarViewProps) { const normalized = React.useMemo(() => events.map(normalizeEvent), [events]) // Map our public CalendarEvent[] back from the internal event RBC hands us // on click — keeps the consumer API clean (they only see what they passed). const eventByInternalId = React.useMemo(() => { const map = new Map>() for (const e of events) map.set(e.id, e) return map }, [events]) const handleSelectEvent = React.useCallback( (rbcEvent: object) => { const id = (rbcEvent as InternalRBCEvent).id const original = eventByInternalId.get(id) if (original && onEventClick) onEventClick(original) }, [eventByInternalId, onEventClick], ) const handleSelectSlot = React.useCallback( (slot: SlotInfo) => { if (!onSlotClick) return onSlotClick({ start: slot.start as Date, end: slot.end as Date, action: slot.action === "select" ? "select" : "click", }) }, [onSlotClick], ) const handleViewChange = React.useCallback( (v: View) => { if (!onViewChange) return const mode = (Object.entries(VIEW_BY_MODE).find(([, val]) => val === v)?.[0] ?? "month") as CalendarViewMode onViewChange(mode) }, [onViewChange], ) const allowedViews = React.useMemo( () => (views ?? ["month", "week", "day", "agenda"]).map(v => VIEW_BY_MODE[v]), [views], ) const rbcEventPropGetter = React.useCallback( (rbcEvent: object) => { if (!eventPropGetter) return {} const id = (rbcEvent as InternalRBCEvent).id const original = eventByInternalId.get(id) if (!original) return {} return eventPropGetter(original) }, [eventPropGetter, eventByInternalId], ) return (
) }