import { useEffect, useMemo, useRef, useState } from "react"; import FullCalendar from "@fullcalendar/react"; import dayGridPlugin from "@fullcalendar/daygrid"; import timeGridPlugin from "@fullcalendar/timegrid"; import interactionPlugin from "@fullcalendar/interaction"; import listPlugin from "@fullcalendar/list"; import type { DateSelectArg, EventClickArg, EventDropArg, } from "@fullcalendar/core"; import type { EventResizeDoneArg } from "@fullcalendar/interaction"; import esLocale from "@fullcalendar/core/locales/es"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Plus, Filter, RotateCcw, AlertTriangle } from "lucide-react"; import { api } from "../lib/api"; import { useAuth } from "../lib/auth"; import type { Appointment } from "../../shared/types"; import { PageHeader, Spinner } from "../components/ui"; import { AppointmentModal } from "../components/AppointmentModal"; import { statusColor } from "../lib/format"; import { useBreakpoint } from "../lib/useBreakpoint"; /** Aire bajo el calendario (padding inferior del contenedor + respiro visual). */ const CAL_BOTTOM_GAP = 20; /** Por debajo de esto la grilla es inservible: mejor que la página scrollee. */ const CAL_MIN_HEIGHT = 360; const STATUS_FILTERS = [ { value: "all", label: "Todas" }, { value: "scheduled", label: "Programadas" }, { value: "completed", label: "Completadas" }, { value: "cancelled", label: "Canceladas" }, ]; export function CalendarPage() { const { user } = useAuth(); const qc = useQueryClient(); const calRef = useRef(null); const pendingRevert = useRef<(() => void) | null>(null); const [moveError, setMoveError] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [draftSlot, setDraftSlot] = useState<{ start: string; end: string } | null>(null); const [employeeFilter, setEmployeeFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all"); const bp = useBreakpoint(); const isMobile = bp === "phone"; const isTablet = bp === "tablet"; const calWrapRef = useRef(null); const chromeRef = useRef(null); const [calHeight, setCalHeight] = useState(null); const isOwner = user?.role === "owner"; // Employees see only their own by default const effectiveEmployee = !isOwner && user?.employee_id ? user.employee_id : employeeFilter; const { data: employeesData } = useQuery({ queryKey: ["employees"], queryFn: () => api.employees.list(), enabled: isOwner, }); const rangeRef = useRef<{ from: string; to: string }>({ from: "", to: "" }); const { data, isLoading, refetch } = useQuery({ queryKey: ["appointments", "calendar", effectiveEmployee, statusFilter], queryFn: async () => { const cal = calRef.current?.getApi(); const from = cal?.view.activeStart.toISOString() ?? new Date().toISOString(); const to = cal?.view.activeEnd.toISOString() ?? new Date().toISOString(); rangeRef.current = { from, to }; return api.appointments.list({ from, to, employee_id: effectiveEmployee === "all" ? undefined : Number(effectiveEmployee), status: statusFilter === "all" ? undefined : statusFilter, }); }, }); // La altura del calendario se le pasa como número en lugar de height="100%": // FullCalendar mide su contenedor una sola vez al montar y solo re-mide en // resize de ventana, así que se quedaba con una altura vieja cuando el layout // cambiaba después (en un iPhone 12 daba 360px dentro de un contenedor de // 525px). Se mide contra el viewport —posición real del contenedor e // innerHeight— y no contra el contenedor flex, cuya altura se resuelve más // tarde que el primer ciclo de medición. useEffect(() => { const compute = () => { const el = calWrapRef.current; if (!el) return; const top = el.getBoundingClientRect().top; const available = Math.round(window.innerHeight - top - CAL_BOTTOM_GAP); setCalHeight(Math.max(CAL_MIN_HEIGHT, available)); }; compute(); // Se observa la barra de filtros y no el body: el shell es overflow-hidden con // altura fija, así que el body nunca cambia de tamaño y un observador sobre él // no volvería a dispararse. Lo que sí reflowea es ese bloque (el filtro de // empleados pasa a dos filas al llegar la lista), y eso mueve el `top` del // calendario, que es justo lo que hay que recalcular. const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(compute) : null; if (chromeRef.current) ro?.observe(chromeRef.current); window.addEventListener("resize", compute); window.addEventListener("orientationchange", compute); return () => { ro?.disconnect(); window.removeEventListener("resize", compute); window.removeEventListener("orientationchange", compute); }; // employeesData y moveError cambian el alto del chrome; bp cambia la toolbar. }, [employeesData, moveError, bp]); const events = useMemo(() => { return (data?.appointments ?? []).map((a) => { const empColor = a.employee?.color ?? "#3b66ff"; const bg = a.status === "cancelled" ? "#f3f4f6" : a.status === "completed" ? `${empColor}22` : `${empColor}1a`; const border = a.status === "cancelled" ? "#d1d5db" : empColor; const fg = a.status === "cancelled" ? "#6b7280" : a.status === "completed" ? empColor : "#1e293b"; return { id: String(a.id), start: a.start_at, end: a.end_at, title: `${a.client?.name ?? "Cliente"} · ${a.service?.name ?? ""}`, backgroundColor: bg, borderColor: border, textColor: fg, extendedProps: { appointment: a }, editable: a.status !== "completed", }; }); }, [data]); const moveMutation = useMutation({ mutationFn: async ({ id, start, end }: { id: number; start: string; end: string }) => api.appointments.update(id, { start_at: start, end_at: end }), onSuccess: () => { pendingRevert.current = null; setMoveError(null); qc.invalidateQueries({ queryKey: ["appointments"] }); }, onError: () => { pendingRevert.current?.(); pendingRevert.current = null; setMoveError("No se pudo reagendar la cita (quizá se ocupó el horario). Se regresó a su lugar."); }, }); const onDrop = (arg: EventDropArg) => { pendingRevert.current = arg.revert; const id = Number(arg.event.id); const start = arg.event.start!.toISOString(); const end = (arg.event.end ?? new Date(arg.event.start!.getTime() + 60 * 60000)).toISOString(); moveMutation.mutate({ id, start, end }); }; const onResize = (arg: EventResizeDoneArg) => { pendingRevert.current = arg.revert; const id = Number(arg.event.id); moveMutation.mutate({ id, start: arg.event.start!.toISOString(), end: arg.event.end!.toISOString(), }); }; const onSelectSlot = (arg: DateSelectArg) => { setEditing(null); setDraftSlot({ start: arg.start.toISOString(), end: arg.end.toISOString() }); setModalOpen(true); }; const onClickEvent = (arg: EventClickArg) => { arg.jsEvent.preventDefault(); const a = arg.event.extendedProps.appointment as Appointment; setEditing(a); setDraftSlot(null); setModalOpen(true); }; const onToday = () => calRef.current?.getApi().today(); // La grilla empieza a las 08:00, así que en pantallas cortas la hora actual // puede quedar fuera de vista al abrir. Arrancamos una hora antes de "ahora", // acotado al rango visible de la grilla. const scrollTime = useMemo(() => { const h = Math.min(20, Math.max(8, new Date().getHours() - 1)); return `${String(h).padStart(2, "0")}:00:00`; }, []); return (
} /> {moveError && (
{moveError}
)}
{isOwner && (
)} {/* Una sola fila con scroll lateral en móvil: envueltos en dos filas los 4 chips se llevaban ~150px de alto que necesita la grilla. */}
{STATUS_FILTERS.map((s) => ( ))}
{/* min-h-0 permite que el calendario se encoja dentro del flex; min-h en el card evita que se aplaste en landscape de teléfono (440px de alto), donde es mejor que la página scrollee que comprimir la grilla. */}
{/* p-1.5 en móvil: el padding interno del card es ancho que le robábamos a la grilla (antes p-3), y aquí no cuesta consistencia visual. */}
{isLoading && !data && (
)} {/* min-h aquí y no solo en el card: si aparece el aviso de "sin citas", no debe robarle altura a la grilla hasta dejarla inservible. */}
refetch()} eventContent={(arg) => } />
{/* Sin EmptyState debajo de la grilla: con la altura del calendario ya fijada, ese bloque no cabía en el card y se solapaba con las horas. El vacío ya se comunica por la propia grilla y, en la vista Lista, por noEventsContent — más el botón "Nueva cita" de la cabecera. */}
{ setModalOpen(false); setEditing(null); setDraftSlot(null); }} appointment={editing} draftSlot={draftSlot} />
); } function EventContent({ arg }: { arg: any }) { const a = arg.event.extendedProps.appointment as Appointment | undefined; if (!a) return
{arg.timeText} {arg.event.title}
; const c = statusColor(a.status); const client = a.client as (NonNullable & { notes?: string | null; no_show_count?: number; }) | undefined; const risky = (client?.no_show_count ?? 0) >= 2 || !!client?.notes?.startsWith("[Riesgo"); const isTime = arg.view.type !== "dayGridMonth"; // When the event is rendered in a narrow column (because FullCalendar // splits overlapping events side-by-side) we drop the employee line so // client + service still fit without bleeding into adjacent slots. const isNarrow = arg.view.type === "timeGridWeek" || arg.view.type === "timeGridDay"; const dur = a.service?.duration_min; return (
{arg.timeText}
{a.client?.name ?? "Cliente"} {risky && ( )}
{a.service?.name}{dur ? ` (${dur} min)` : ""}
{isTime && a.employee && !isNarrow && (
con {a.employee.name}
)}
); }