import { useCallback, useEffect, useMemo, useState } from "react"; import { ActionIcon, Alert, Badge, Box, Button, Center, ColorSwatch, Group, Indicator, Loader, Paper, ScrollArea, SegmentedControl, Select, Stack, Text, Title, Tooltip, } from "@mantine/core"; import { Calendar } from "@mantine/dates"; import { notifications } from "@mantine/notifications"; import { IconChevronLeft, IconChevronRight, IconPlus, } from "@tabler/icons-react"; import { deleteEvent, fetchCalendar, fetchCalendars, type CalendarCollection, type CalendarEvent, } from "../api"; import { dayjs, eventColor, eventStart, HOURS, monthMatrix, positionEventsForDay, TIMEZONES, WEEKDAY_LABELS, weekDays, browserTz, } from "../calendar"; import { EventModal, type EventDraft } from "./EventModal"; type ViewMode = "mes" | "semana" | "dia"; // Calendario: vista Mes/Semana/Día con eventos posicionados por hora, selector // de calendario (con color), selector de zona horaria, colores por evento y CRUD // completo (crear en un hueco, editar/borrar un evento). El mini-calendario de la // izquierda navega; el cuerpo muestra la vista activa en la TZ seleccionada. export function CalendarView() { const [events, setEvents] = useState([]); const [calendars, setCalendars] = useState([]); const [activeCal, setActiveCal] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [view, setView] = useState("semana"); const [tz, setTz] = useState(browserTz()); // `cursor` es el día de referencia (la vista lo expande a su mes/semana/día). const [cursor, setCursor] = useState(dayjs().format("YYYY-MM-DD")); const [modal, setModal] = useState(null); const [reloadKey, setReloadKey] = useState(0); // Carga de calendarios (selector) una vez. useEffect(() => { let alive = true; fetchCalendars() .then((d) => { if (!alive) return; if (d.status === "ok" && d.calendars) { setCalendars(d.calendars); if (!activeCal && d.default) setActiveCal(d.default); else if (!activeCal && d.calendars[0]) setActiveCal(d.calendars[0].href); } }) .catch(() => { /* el selector degrada a "calendario" implícito; no es fatal */ }); return () => { alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Carga de eventos de la colección activa. useEffect(() => { let alive = true; setLoading(true); setError(null); fetchCalendar(activeCal) .then((d) => { if (!alive) return; if (d.status !== "ok") { setError(d.error || "Xandikos no respondió"); return; } setEvents(d.events ?? []); }) .catch((e) => alive && setError(String(e))) .finally(() => alive && setLoading(false)); return () => { alive = false; }; }, [activeCal, reloadKey]); const calColor = useMemo(() => { const c = calendars.find((c) => c.href === activeCal); return c?.color ?? null; }, [calendars, activeCal]); // Días con eventos (para el punto en el mini-calendario), en la TZ activa. const daysWithEvents = useMemo(() => { const set = new Set(); for (const e of events) { const s = eventStart(e, tz); if (s) set.add(s.format("YYYY-MM-DD")); } return set; }, [events, tz]); const reload = useCallback(() => setReloadKey((k) => k + 1), []); const onSaved = useCallback( (msg: string) => { notifications.show({ color: "teal", title: "Calendario", message: msg }); setModal(null); reload(); }, [reload], ); const onDelete = useCallback( async (uid: string) => { try { await deleteEvent(uid, activeCal); notifications.show({ color: "teal", title: "Evento borrado", message: "", }); setModal(null); reload(); } catch (e) { notifications.show({ color: "red", title: "No se pudo borrar", message: String(e), }); } }, [activeCal, reload], ); // Abre el modal en modo "nuevo" en un hueco (día + hora opcional). const openNew = useCallback( (day: string, hour?: number) => { const start = hour === undefined ? `${day}T09:00` : `${day}T${String(hour).padStart(2, "0")}:00`; const endHour = hour === undefined ? 10 : Math.min(hour + 1, 23); const end = hour === undefined ? `${day}T10:00` : `${day}T${String(endHour).padStart(2, "0")}:00`; setModal({ mode: "new", summary: "", dtstart: start, dtend: end, tz, all_day: false, location: "", description: "", color: "", cal: activeCal, }); }, [tz, activeCal], ); // Abre el modal en modo "editar" desde un evento existente. const openEdit = useCallback( (ev: CalendarEvent) => { const s = eventStart(ev, tz); const e = ev.dtend ? eventStart({ ...ev, dtstart: ev.dtend }, tz) : null; setModal({ mode: "edit", uid: ev.uid ?? undefined, summary: ev.summary ?? "", dtstart: ev.all_day ? (ev.dtstart ?? "").slice(0, 10) : (s?.format("YYYY-MM-DDTHH:mm") ?? ""), dtend: ev.all_day ? (ev.dtend ?? "").slice(0, 10) : (e?.format("YYYY-MM-DDTHH:mm") ?? ""), tz: ev.tz || tz, all_day: !!ev.all_day, location: ev.location ?? "", description: ev.description ?? "", color: ev.color ?? "", cal: activeCal, }); }, [tz, activeCal], ); function navigate(dir: -1 | 1) { const unit = view === "mes" ? "month" : view === "semana" ? "week" : "day"; setCursor(dayjs(cursor).add(dir, unit).format("YYYY-MM-DD")); } if (error) { return (
{error} El calendario viene del servidor Xandikos. El resto de la app (grafo, tablas) funciona sin él.
); } const headerTitle = view === "mes" ? dayjs(cursor).format("MMMM YYYY") : view === "semana" ? (() => { const wd = weekDays(dayjs(cursor)); return `${wd[0].format("D MMM")} – ${wd[6].format("D MMM YYYY")}`; })() : dayjs(cursor).format("dddd D [de] MMMM YYYY"); return ( {/* Panel izquierdo: navegación + selectores */} setCursor(d)} getDayProps={(date) => ({ selected: cursor === date, onClick: () => { setCursor(date); if (view === "mes") setView("dia"); }, })} renderDay={(date) => { const has = daysWithEvents.has(date); const day = Number(date.slice(8, 10)); return (
{day}
); }} /> v && setTz(v)} searchable comboboxProps={{ withinPortal: true }} /> {events.length} eventos · TZ {tz}
{/* Cuerpo: barra de vista + rejilla */} navigate(-1)}> navigate(1)}> {headerTitle} setView(v as ViewMode)} data={[ { value: "mes", label: "Mes" }, { value: "semana", label: "Semana" }, { value: "dia", label: "Día" }, ]} /> {loading ? (
) : view === "mes" ? ( { setCursor(d); setView("dia"); }} onEvent={openEdit} /> ) : view === "semana" ? ( ) : ( )}
setModal(null)} onSaved={onSaved} onDelete={onDelete} />
); } // --- Vista de semana / día: rejilla horas × días --------------------------- function TimeGrid({ days, events, tz, calColor, onSlot, onEvent, }: { days: dayjs.Dayjs[]; events: CalendarEvent[]; tz: string; calColor: string | null; onSlot: (day: string, hour: number) => void; onEvent: (ev: CalendarEvent) => void; }) { const HOUR_PX = 44; const today = dayjs().format("YYYY-MM-DD"); return ( 1 ? 640 : 0 }}> {/* Columna de horas */} {HOURS.map((h) => ( {String(h).padStart(2, "0")}:00 ))} {/* Una columna por día */} {days.map((day) => { const dayKey = day.format("YYYY-MM-DD"); const positioned = positionEventsForDay(events, dayKey, tz); const allDay = positioned.filter((p) => p.allDay); const timed = positioned.filter((p) => !p.allDay); const isToday = dayKey === today; return ( {/* Cabecera del día */} {WEEKDAY_LABELS[(day.day() + 6) % 7]} {day.format("D")} {/* Franja de "todo el día" */} {allDay.length > 0 && ( {allDay.map((p, i) => ( onEvent(p.ev)} compact /> ))} )} {/* Rejilla horaria con posicionado absoluto */} {HOURS.map((h) => ( onSlot(dayKey, h)} style={{ position: "absolute", top: h * HOUR_PX, left: 0, right: 0, height: HOUR_PX, borderTop: "1px solid var(--mantine-color-default-border)", cursor: "pointer", }} /> ))} {timed.map((p, i) => ( { e.stopPropagation(); onEvent(p.ev); }} style={{ position: "absolute", top: `${p.topPct}%`, height: `${p.heightPct}%`, left: 2, right: 2, overflow: "hidden", borderRadius: 4, padding: "1px 5px", fontSize: 11, lineHeight: 1.25, color: "#fff", background: eventColor(p.ev, calColor), cursor: "pointer", }} > {eventStart(p.ev, tz)?.format("HH:mm")} ·{" "} {p.ev.summary || "(sin título)"} ))} ); })} ); } // --- Vista de mes: rejilla de semanas -------------------------------------- function MonthView({ cursor, events, tz, calColor, onDay, onEvent, }: { cursor: string; events: CalendarEvent[]; tz: string; calColor: string | null; onDay: (day: string) => void; onEvent: (ev: CalendarEvent) => void; }) { const weeks = monthMatrix(dayjs(cursor)); const month = dayjs(cursor).month(); const today = dayjs().format("YYYY-MM-DD"); const byDay = useMemo(() => { const map = new Map(); for (const e of events) { const s = eventStart(e, tz); if (!s) continue; const k = s.format("YYYY-MM-DD"); const list = map.get(k) ?? []; list.push(e); map.set(k, list); } return map; }, [events, tz]); return ( {WEEKDAY_LABELS.map((l) => ( {l} ))} {weeks.map((week, wi) => ( {week.map((day) => { const k = day.format("YYYY-MM-DD"); const list = (byDay.get(k) ?? []).sort((a, b) => (a.dtstart ?? "").localeCompare(b.dtstart ?? ""), ); const dim = day.month() !== month; const isToday = k === today; return ( onDay(k)} style={{ flex: 1, minWidth: 0, minHeight: 92, border: "1px solid var(--mantine-color-default-border)", padding: 3, cursor: "pointer", background: dim ? "var(--mantine-color-default-hover)" : undefined, }} > {day.format("D")} {list.slice(0, 3).map((ev, i) => ( onEvent(ev)} compact /> ))} {list.length > 3 && ( +{list.length - 3} más )} ); })} ))} ); } // Chip compacto de un evento (mes / franja all-day). function EventChip({ ev, calColor, onClick, compact, }: { ev: CalendarEvent; calColor: string | null; onClick: () => void; compact?: boolean; }) { const color = eventColor(ev, calColor); return ( { e.stopPropagation(); onClick(); }} variant="filled" radius="sm" fullWidth style={{ background: color, cursor: "pointer", justifyContent: "flex-start", textTransform: "none", fontWeight: 500, height: compact ? 16 : 20, }} > {ev.summary || "(sin título)"} ); }