import { Loader2 } from "lucide-react"; export function Spinner({ className = "" }: { className?: string }) { return ; } /** * Fallback de Suspense para las rutas que se cargan por demanda (calendario y * tablero, que arrastran FullCalendar y recharts). Ocupa el alto disponible para * que el shell no dé un salto de layout al resolverse el chunk. */ export function RouteSpinner() { return (
); } export function EmptyState({ icon: Icon, title, description, action, }: { icon?: React.ComponentType<{ className?: string }>; title: string; description?: string; action?: React.ReactNode; }) { return (
{Icon && (
)}
{title}
{description &&

{description}

}
{action}
); } export function PageHeader({ title, subtitle, actions, }: { title: string; subtitle?: string; actions?: React.ReactNode; }) { return (

{title}

{/* El subtítulo se oculta en teléfono: son 2 líneas de texto descriptivo (~70px) que en una pantalla de 390px le hacen falta al contenido, y en el calendario además describe gestos de ratón ("arrastra", "redimensiona") que no aplican al tacto. */} {subtitle &&

{subtitle}

}
{actions &&
{actions}
}
); } export function Badge({ children, bg, fg, dot, }: { children: React.ReactNode; bg?: string; fg?: string; dot?: string; }) { return ( {dot && } {children} ); } export function Avatar({ name, color, size = 36, }: { name: string; color?: string; size?: number; }) { const init = name .split(" ") .filter(Boolean) .slice(0, 2) .map((p) => p[0]?.toUpperCase()) .join(""); return (
{init}
); }