Files
AgendaPro/src/pages/CalendarPage.tsx
T
AgendaPro DevandClaude Opus 5 4c19244df9 feat: public landing page with magic login, brand palette and demo build flag
Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt
around the panel's own colors instead of an invented palette.

Landing (`src/components/landing/`, one section per file, no props):
- Seven funnel sections composed by `LandingPage`. The hero's eight swatches are
  literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands
  to avatars and services, so "your colors become your numbers" is literal.
- `BrandMark` becomes the single source for the logo, replicating
  `public/favicon.svg`. Blue is the action surface, orange only ever marks.
- `NotebookVisual` is the one deliberate exception to the palette: it is what the
  product replaces.
- Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a
  single easing, and reduced-motion resolves `initial` to the final state so a
  never-firing `whileInView` cannot leave a section invisible forever.

Routing and bundle:
- `homePathFor` is the single definition of each role's destination.
- Landing, login, dashboard and calendar load lazily. Eager, the login dragged
  framer-motion (~40 KB gz) into every panel load and the landing downloaded
  recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is
  pinned to the `react` chunk because Rollup otherwise assigns it to `charts`,
  making the entry import 111 KB gz for a 200-byte utility.

Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`:
- No touch form field below 16px, `dvh` height utilities, safe-area insets, and
  40px touch targets keyed off `pointer: coarse` rather than `sm:`.
- New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`,
  so it left the login's side padding at 0 on anything but an iPhone in
  landscape. No overflow check could see it — there was no overflow, just zero
  margin. The audit now guards it with a `gutter` check.
- `shell-height` no longer fires on pages that legitimately scroll; the static
  `raw-viewport-unit` scan covers those instead.

Production build:
- The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time
  constant, so without it Vite eliminated the magic login and the "Ver como…"
  switcher: the deployed landing promised "no registration" and led to an empty
  form. Verified by building both ways and diffing the bundle.
- Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged
  from returning visitors' caches.

Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12,
landing 13/13 (WebKit), PWA passed against the real production build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:25:58 -06:00

439 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<FullCalendar>(null);
const pendingRevert = useRef<(() => void) | null>(null);
const [moveError, setMoveError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Appointment | null>(null);
const [draftSlot, setDraftSlot] = useState<{ start: string; end: string } | null>(null);
const [employeeFilter, setEmployeeFilter] = useState<number | "all">("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const bp = useBreakpoint();
const isMobile = bp === "phone";
const isTablet = bp === "tablet";
const calWrapRef = useRef<HTMLDivElement>(null);
const chromeRef = useRef<HTMLDivElement>(null);
const [calHeight, setCalHeight] = useState<number | null>(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 (
<div className="flex h-full flex-col">
<PageHeader
title="Calendario"
subtitle={
isOwner
? "Arrastra para reagendar, redimensiona para cambiar duración, clic para editar."
: "Tu agenda personal. Crea citas y se te asignarán automáticamente."
}
actions={
<>
<button onClick={() => refetch()} className="btn btn-secondary" title="Refrescar">
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">Refrescar</span>
</button>
<button
onClick={() => {
setEditing(null);
setDraftSlot({ start: new Date().toISOString(), end: new Date(Date.now() + 60 * 60000).toISOString() });
setModalOpen(true);
}}
className="btn btn-primary"
>
<Plus className="h-4 w-4" />
Nueva cita
</button>
</>
}
/>
{moveError && (
<div className="mx-5 mt-2 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 sm:mx-7">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<span className="flex-1">{moveError}</span>
<button
type="button"
onClick={() => setMoveError(null)}
className="shrink-0 text-amber-500 hover:text-amber-700"
aria-label="Cerrar aviso"
>
×
</button>
</div>
)}
<div ref={chromeRef} className="flex shrink-0 flex-wrap items-center gap-2 px-5 pb-3 pt-3 sm:gap-3 sm:px-7 sm:pt-4">
{isOwner && (
<div className="flex min-w-0 flex-1 items-center gap-2 sm:flex-none">
<Filter className="h-4 w-4 shrink-0 text-slate-400" />
<select
className="select w-full min-w-0 sm:w-auto sm:min-w-[180px]"
value={employeeFilter === "all" ? "all" : String(employeeFilter)}
onChange={(e) => setEmployeeFilter(e.target.value === "all" ? "all" : Number(e.target.value))}
>
<option value="all">Todos los empleados</option>
{employeesData?.employees.map((e) => (
<option key={e.id} value={e.id}>
{e.name} {e.role}
</option>
))}
</select>
</div>
)}
{/* 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. */}
<div className="scrollbar-none flex max-w-full items-center gap-1 overflow-x-auto rounded-xl border border-slate-200 bg-white p-1 sm:flex-wrap sm:overflow-visible">
{STATUS_FILTERS.map((s) => (
<button
key={s.value}
onClick={() => setStatusFilter(s.value)}
className={`tap-target shrink-0 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors ${
statusFilter === s.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
}`}
>
{s.label}
</button>
))}
</div>
</div>
{/* 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. */}
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-4 sm:px-7 sm:pb-6">
{/* 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. */}
<div className="card flex h-full min-h-[420px] flex-col overflow-y-auto p-1.5 sm:p-4">
{isLoading && !data && (
<div className="flex h-64 items-center justify-center">
<Spinner className="h-6 w-6 text-brand-500" />
</div>
)}
{/* 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. */}
<div ref={calWrapRef} className="min-h-[360px] flex-1 sm:min-h-[420px]">
<FullCalendar
ref={calRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
locale={esLocale}
/* En tablet, 3 días en vez de 7: en un iPad portrait (820px) la semana
completa con varios especialistas a la misma hora parte cada evento
en columnas de ~30px y el texto queda en "A..", "Fe...". Con 3 días
cada columna ronda los 250px y se lee. Semana sigue a un toque. */
initialView={isMobile ? "timeGridDay" : isTablet ? "timeGridThreeDay" : "timeGridWeek"}
headerToolbar={
isMobile
? // "today" va en la barra superior en lugar de un footerToolbar
// propio: esa fila extra costaba ~90px de grilla. El CSS móvil
// pone el título en su propia línea, así que los tres grupos
// caben en una sola fila por debajo.
{ left: "prev,next today", center: "title", right: "timeGridDay,listWeek" }
: isTablet
? { left: "prev,next today", center: "title", right: "timeGridThreeDay,timeGridWeek,timeGridDay,listWeek" }
: { left: "prev,next today", center: "title", right: "dayGridMonth,timeGridWeek,timeGridDay,listWeek" }
}
footerToolbar={false}
buttonText={{
today: "Hoy",
month: "Mes",
week: "Semana",
day: "Día",
list: "Lista",
}}
views={{
listWeek: {
buttonText: "Lista",
listDayFormat: { weekday: "long", day: "numeric", month: "short" },
listDaySideFormat: false,
noEventsContent: "Sin citas esta semana",
},
timeGridDay: { buttonText: "Día" },
timeGridThreeDay: {
type: "timeGrid",
duration: { days: 3 },
buttonText: "3 días",
},
}}
/* Antes: contentHeight fijo (560/680px). Con .fc-timegrid-slot a 2.4rem
la grilla de 08:00-21:00 mide ~1000px, así que el calendario crecía
a 1.4× la pantalla del iPhone 12 y arrastraba la página. Ahora ocupa
exactamente su contenedor y scrollea dentro. */
height={calHeight ?? "100%"}
expandRows
stickyHeaderDates
firstDay={1}
slotMinTime="08:00:00"
slotMaxTime="21:00:00"
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
/* En teléfono el eje horario se lleva ancho que necesitan las
columnas: "8 a.m." en vez de "08:00 a. m.". */
slotLabelFormat={
isMobile
? { hour: "numeric", hour12: true }
: { hour: "2-digit", minute: "2-digit", hour12: true }
}
scrollTime={scrollTime}
nowIndicator
allDaySlot={false}
selectable
selectMirror
editable
eventResizableFromStart
eventDurationEditable
eventStartEditable
slotEventOverlap={false}
eventMinHeight={28}
dayMaxEvents={isMobile ? 2 : isTablet ? 3 : 4}
eventTimeFormat={{
hour: "2-digit",
minute: "2-digit",
hour12: true,
}}
events={events}
select={onSelectSlot}
eventClick={onClickEvent}
eventDrop={onDrop}
eventResize={onResize}
datesSet={() => refetch()}
eventContent={(arg) => <EventContent arg={arg} />}
/>
</div>
{/* 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. */}
</div>
<button onClick={onToday} className="sr-only">Hoy</button>
</div>
<AppointmentModal
open={modalOpen}
onClose={() => {
setModalOpen(false);
setEditing(null);
setDraftSlot(null);
}}
appointment={editing}
draftSlot={draftSlot}
/>
</div>
);
}
function EventContent({ arg }: { arg: any }) {
const a = arg.event.extendedProps.appointment as Appointment | undefined;
if (!a) return <div>{arg.timeText} {arg.event.title}</div>;
const c = statusColor(a.status);
const client = a.client as (NonNullable<Appointment["client"]> & {
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 (
<div className="flex h-full min-h-0 flex-col gap-0.5 overflow-hidden">
<div className="flex items-center gap-1 leading-none">
<span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: c.dot }} />
<span className="truncate text-[10px] font-semibold opacity-70">{arg.timeText}</span>
</div>
<div className="flex items-center gap-1">
<span className="truncate font-bold leading-tight">{a.client?.name ?? "Cliente"}</span>
{risky && (
<span title="Ausencias previas sin avisar" aria-label="Ausencias previas sin avisar">
<AlertTriangle className="h-3 w-3 shrink-0 text-amber-500" />
</span>
)}
</div>
<div className="truncate text-[10px] leading-tight opacity-80">
{a.service?.name}{dur ? ` (${dur} min)` : ""}
</div>
{isTime && a.employee && !isNarrow && (
<div className="mt-auto truncate text-[10px] leading-tight opacity-70">con {a.employee.name}</div>
)}
</div>
);
}