/** * DEPENDENCIES NEEDED (not installed — convention fork): * @fullcalendar/react@^6 * @fullcalendar/daygrid@^6 * @fullcalendar/timegrid@^6 * @fullcalendar/interaction@^6 * * Rationale for FullCalendar over react-big-calendar: * - More mature accessibility (ARIA roles, keyboard nav) * - Better drag-drop support via @fullcalendar/interaction * - Built-in month/week/day views with consistent API * - Active maintenance and large community */ import { useState } from "react"; import { Text, Stack, Skeleton, Alert, Button, SegmentedControl } from "@mantine/core"; import { IconAlertCircle } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useDisclosure } from "@mantine/hooks"; // FullCalendar imports — these will resolve once the deps are installed. import FullCalendar from "@fullcalendar/react"; import dayGridPlugin from "@fullcalendar/daygrid"; import timeGridPlugin from "@fullcalendar/timegrid"; import interactionPlugin, { EventDropArg } from "@fullcalendar/interaction"; import { EventClickArg } from "@fullcalendar/core"; import { useViewData } from "../hooks/use-view-data"; import { useUpdateRow } from "../hooks/use-update-row"; import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates"; import { usePermissions } from "../hooks/use-permissions"; import { RowDetailModal } from "../components/row-detail-modal"; import type { BridgeRow, BridgeField } from "../types/database-view.types"; import styles from "./calendar-renderer.module.css"; const PAGE_SIZE = 500; type CalendarView = "dayGridMonth" | "timeGridWeek" | "timeGridDay"; interface CalendarRendererProps { tableId: string; viewId: string; bridgeUrl?: string | null; } /** Resolve the date field used to position events on the calendar. */ function resolveDateField( fields: BridgeField[], viewMeta?: { dateFieldId?: string }, ): BridgeField | undefined { if (viewMeta?.dateFieldId) { return fields.find((f) => f.id === viewMeta.dateFieldId); } return fields.find((f) => f.type === "date" || f.type === "created_on"); } /** Convert a row to a FullCalendar EventInput. Returns null when no date. */ function rowToEvent( row: BridgeRow, dateField: BridgeField, primaryField?: BridgeField, ): { id: string; title: string; start: string; extendedProps: { row: BridgeRow; fields: BridgeField[] } } | null { const rawDate = row.fields[dateField.name] ?? row.fields[dateField.id]; if (!rawDate) return null; const dateStr = typeof rawDate === "string" ? rawDate : String(rawDate); // Validate the date string. if (isNaN(new Date(dateStr).getTime())) return null; const primaryValue = primaryField ? (row.fields[primaryField.name] ?? row.fields[primaryField.id]) : row.id; const title = typeof primaryValue === "string" ? primaryValue : typeof primaryValue === "number" ? String(primaryValue) : row.id; return { id: row.id, title: title || row.id, start: dateStr, extendedProps: { row, fields: [] }, }; } function CalendarSkeleton() { return ( ); } export function CalendarRenderer({ tableId, viewId, bridgeUrl }: CalendarRendererProps) { const { t } = useTranslation(); const [calView, setCalView] = useState("dayGridMonth"); const [selectedRow, setSelectedRow] = useState(null); const [selectedFields, setSelectedFields] = useState([]); const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false); const { data, isLoading, isError, error, refetch } = useViewData({ viewId, bridgeUrl, page: 1, size: PAGE_SIZE, }); const { canWriteRows } = usePermissions(); const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl }); useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl); if (isLoading) return ; if (isError) { const status = (error as { response?: { status?: number } })?.response?.status; let message = t("database_view.error.generic"); if (status === 403) message = t("database_view.error.permission_denied"); else if (status === 404) message = t("database_view.error.view_not_found"); return ( } color="red" title={t("database_view.error.title")} > {message} ); } const { rows, fields } = data ?? { rows: [], fields: [] }; const dateField = resolveDateField(fields); if (!dateField) { return ( } color="yellow"> {t("database_view.calendar.no_date_field")} ); } const primaryField = fields.find((f) => f.primary) ?? fields[0]; const events = rows .map((row) => rowToEvent(row, dateField, primaryField)) .filter((e): e is NonNullable => e !== null) .map((e) => ({ ...e, extendedProps: { ...e.extendedProps, fields } })); function handleEventClick(arg: EventClickArg) { const row = arg.event.extendedProps.row as BridgeRow; const eventFields = arg.event.extendedProps.fields as BridgeField[]; setSelectedRow(row); setSelectedFields(eventFields); openModal(); } function handleEventDrop(arg: EventDropArg) { if (!canWriteRows) { arg.revert(); return; } const rowId = arg.event.id; const newStart = arg.event.start; if (!newStart) { arg.revert(); return; } updateRow.mutate( { rowId, payload: { fields: { [dateField.name]: newStart.toISOString(), }, }, }, { onError: () => { arg.revert(); }, }, ); } const viewOptions = [ { label: t("database_view.calendar.view_month"), value: "dayGridMonth" }, { label: t("database_view.calendar.view_week"), value: "timeGridWeek" }, { label: t("database_view.calendar.view_day"), value: "timeGridDay" }, ]; return (
setCalView(v as CalendarView)} data={viewOptions} />
[styles.calendarEvent]} />
); }