import type { FC, ReactNode, KeyboardEvent as ReactKeyboardEvent } from 'react'; import { useEffect, useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Calendar, Users, DollarSign, ShoppingCart, Clock, Wallet, Sparkles, Activity, TrendingUp, ArrowRight, ArrowUpRight, ArrowDownRight, CalendarClock, Armchair, Banknote, Vault, Cake, MessageCircle, } from 'lucide-react'; import { AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts'; import Layout from '../components/Layout'; import { Card, Button, Badge, EmptyState, PageHeader, SkeletonStat, Skeleton, toast, badgeForAppointmentState, } from '../components/ui'; import { odooApi, type DashboardStats, type Appointment, type Service, type ChartPoint, type Birthday } from '../services/odoo'; import { usePatientDrawer } from '../components/PatientDrawer'; import { useTheme } from '../lib/theme'; import { useAuth } from '../lib/auth'; import { useGlosario } from '../lib/glosario'; import { waLink } from '../lib/whatsapp'; import { aptStateLabel } from './Agenda'; const formatCurrency = (value: number) => new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value); type StatDelta = { pct: number; direction: 'up' | 'down' | 'flat' } | null; interface StatCardProps { title: string; value: string | number; icon: ReactNode; trend?: string; loading?: boolean; onOpen?: () => void; delta?: StatDelta; deltaLabel?: string; } const StatCard: FC = ({ title, value, icon, trend, loading, onOpen, delta, deltaLabel }) => { if (loading) return ; const clickable = typeof onOpen === 'function'; const deltaColor = delta?.direction === 'up' ? 'text-emerald-600' : delta?.direction === 'down' ? 'text-rose-600' : 'text-theme-muted'; return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen?.(); } }, className: 'cursor-pointer group focus:outline-none focus-visible:ring-2 focus-visible:ring-theme-accent rounded-2xl', } : {})} >
{icon}
{trend && ( {trend} )}

{title}

{value}

{delta && (

{delta.direction === 'up' && } {delta.direction === 'down' && } {Math.abs(delta.pct)}% {deltaLabel || 'vs período anterior'}

)}
); }; interface TodayTileProps { label: string; icon: ReactNode; onClick: () => void; children: ReactNode; className?: string; ariaLabel: string; } const TodayTile: FC = ({ label, icon, onClick, children, className, ariaLabel }) => ( ); const Dashboard: FC = () => { const { theme } = useTheme(); const { user, brand } = useAuth(); const { term } = useGlosario(); const navigate = useNavigate(); const isHomeNest = theme === 'homenest'; const [stats, setStats] = useState(null); const [chartData, setChartData] = useState([]); const [appointments, setAppointments] = useState([]); const [services, setServices] = useState([]); const [loadingStats, setLoadingStats] = useState(true); const [loadingChart, setLoadingChart] = useState(true); const [loadingAppointments, setLoadingAppointments] = useState(true); const [loadingServices, setLoadingServices] = useState(true); const [topServices, setTopServices] = useState<{ service: string; qty: number; revenue: number }[]>([]); const [aptReport, setAptReport] = useState<{ total: number; by_state: Record } | null>(null); const [loadingExtras, setLoadingExtras] = useState(true); const { openPatient } = usePatientDrawer(); // Cumpleaños del día (endpoint /reports/birthdays?period=today) const [birthdays, setBirthdays] = useState([]); const [loadingBirthdays, setLoadingBirthdays] = useState(true); // Datos de "Hoy en la clínica": sala de espera, cobros del día y estado de caja const [todayInfo, setTodayInfo] = useState<{ waiting: number | null; cobros: number | null; caja: { state: string; name: string } | null; } | null>(null); useEffect(() => { const loadStats = async () => { try { setLoadingStats(true); const res = await odooApi.getDashboard(); if (res.status === 'success' && res.stats) { setStats(res.stats); } } catch (err) { toast.error('Error al cargar estadísticas del dashboard'); console.error(err); } finally { setLoadingStats(false); } }; const loadChart = async () => { try { setLoadingChart(true); const res = await odooApi.getWeeklyChart(); if (res.status === 'success' && res.data) { setChartData(res.data); } } catch (err) { toast.error('Error al cargar gráfica semanal'); console.error(err); } finally { setLoadingChart(false); } }; const loadAppointments = async () => { try { setLoadingAppointments(true); const today = new Date().toISOString().split('T')[0]; const res = await odooApi.getAppointments({ date: today, limit: '5' }); if (res.status === 'success' && res.appointments) { setAppointments(res.appointments.slice(0, 5)); } } catch (err) { toast.error('Error al cargar próximas citas'); console.error(err); } finally { setLoadingAppointments(false); } }; const loadServices = async () => { try { setLoadingServices(true); const res = await odooApi.getServices(); if (res.status === 'success' && res.services) { setServices(res.services.slice(0, 6)); } } catch (err) { toast.error('Error al cargar servicios'); console.error(err); } finally { setLoadingServices(false); } }; const loadExtras = async () => { try { setLoadingExtras(true); const start = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0]; const end = new Date().toISOString().split('T')[0]; const [topRes, aptRes] = await Promise.all([ odooApi.getTopServices(start, end, 6), odooApi.getAppointmentsReport(start, end), ]); if (topRes.status === 'success') setTopServices(topRes.services || []); if (aptRes.status === 'success') setAptReport({ total: aptRes.total, by_state: aptRes.by_state }); } catch (err) { console.error(err); } finally { setLoadingExtras(false); } }; const loadBirthdays = async () => { try { setLoadingBirthdays(true); const res = await odooApi.getBirthdays('today'); if (res.status === 'success') setBirthdays(res.birthdays || []); } catch (err) { console.error(err); } finally { setLoadingBirthdays(false); } }; const loadToday = async () => { // Los endpoints de reportes/caja no están disponibles para todos los // roles (p. ej. médico): ante error el tile muestra "—" sin molestar. const today = new Date().toISOString().split('T')[0]; const [dailyRes, cashRes] = await Promise.allSettled([ odooApi.getDailyReport(today), odooApi.getCashClosings(today), ]); const next: { waiting: number | null; cobros: number | null; caja: { state: string; name: string } | null } = { waiting: null, cobros: null, caja: null, }; if (dailyRes.status === 'fulfilled' && dailyRes.value.status === 'success') { next.waiting = dailyRes.value.citas?.by_state?.arrived ?? 0; next.cobros = dailyRes.value.pagos?.total ?? 0; } if (cashRes.status === 'fulfilled' && cashRes.value.status === 'success') { const closing = (cashRes.value.cash_closings || [])[0]; if (closing) next.caja = { state: closing.state, name: closing.name }; } setTodayInfo(next); }; loadStats(); loadChart(); loadAppointments(); loadServices(); loadExtras(); loadBirthdays(); loadToday(); }, []); const buildDelta = (current?: number, prev?: number): StatDelta => { if (current == null || prev == null || prev <= 0) return null; const pct = Math.round(((current - prev) / prev) * 100); if (pct === 0) return { pct: 0, direction: 'flat' }; return { pct: Math.abs(pct), direction: pct > 0 ? 'up' : 'down' }; }; const statsConfig = useMemo( () => [ { title: 'Citas Hoy', value: stats?.appointments_today ?? 0, icon: , trend: stats ? `${stats.appointments_month} este mes` : undefined, to: '/agenda', }, { title: 'Pacientes', value: stats?.total_patients ?? 0, icon: , trend: stats ? `+${stats.new_patients_month} nuevos` : undefined, to: '/pacientes', }, { title: 'Ingresos Mes', value: stats ? formatCurrency(stats.revenue_month) : '$0', icon: , to: '/reportes?tab=ingresos', delta: buildDelta(stats?.revenue_month, stats?.prev_revenue_month), deltaLabel: 'vs mes anterior', }, { title: 'Ventas Hoy', value: stats?.sales_today ?? 0, icon: , to: '/ventas', delta: buildDelta(stats?.sales_today, stats?.prev_sales_today), deltaLabel: 'vs ayer', }, { title: 'Pendientes', value: stats?.pending_payments ?? 0, icon: , to: '/reportes?tab=adeudos', }, { title: 'Puntos Monedero', value: stats?.total_wallet_points ?? 0, icon: , to: '/monedero', }, ], [stats] ); // Próximas citas de hoy (hora >= ahora); si ya no queda ninguna, las // primeras del día ordenadas por hora. const upcomingToday = useMemo(() => { const nowMin = new Date().getHours() * 60 + new Date().getMinutes(); const toMinutes = (t: string) => { const [h, m] = String(t || '0:0').split(':').map(Number); return (h || 0) * 60 + (m || 0); }; const active = appointments.filter((a) => !['cancelled', 'done', 'no_show'].includes(a.state)); const sorted = [...active].sort((a, b) => toMinutes(a.time) - toMinutes(b.time)); const upcoming = sorted.filter((a) => toMinutes(a.time) >= nowMin); return (upcoming.length > 0 ? upcoming : sorted).slice(0, 3); }, [appointments]); const todayLabel = new Date().toLocaleDateString('es-MX', { weekday: 'long', day: 'numeric', month: 'long', }); const cajaStatus = useMemo(() => { if (!todayInfo) return { label: '—', ref: 'Consultando…', tone: 'text-theme-muted' }; if (!todayInfo.caja) return { label: 'Sin abrir', ref: 'Sin corte de caja hoy', tone: 'text-theme-muted' }; if (todayInfo.caja.state === 'open') return { label: 'Abierta', ref: todayInfo.caja.name, tone: 'text-emerald-600' }; return { label: 'Cerrada', ref: todayInfo.caja.name, tone: 'text-theme-heading' }; }, [todayInfo]); const activityItems = useMemo(() => { return appointments.slice(0, 4).map((apt) => ({ id: apt.id, title: `${apt.patient} — ${apt.service}`, subtitle: `${apt.date} ${apt.time}`, state: apt.state, })); }, [appointments]); const attendanceData = useMemo(() => { if (!aptReport) return [] as { name: string; value: number; color: string }[]; const s = aptReport.by_state || {}; const pick = (...keys: string[]) => keys.reduce((a, k) => a + (s[k] || 0), 0); return [ { name: 'Completadas', value: pick('done'), color: '#CAE2C1' }, { name: 'Confirmadas', value: pick('confirmed', 'arrived', 'in_progress'), color: '#A4CCE2' }, { name: 'Pendientes', value: pick('pending'), color: '#EDA588' }, { name: 'No show', value: pick('no_show'), color: '#E57373' }, { name: 'Canceladas', value: pick('cancelled'), color: '#E5E0DC' }, ].filter((d) => d.value > 0); }, [aptReport]); const noShowRate = useMemo(() => { if (!aptReport || !aptReport.total) return 0; return Math.round(((aptReport.by_state?.no_show || 0) / aptReport.total) * 100); }, [aptReport]); return ( {isHomeNest ? (
Bienvenido de vuelta

{brand?.nombre || 'Panel Clínico'}

Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con calidez y confianza.

{statsConfig.slice(0, 4).map((s) => (

{s.title}

{loadingStats ? '-' : s.value}

))}
) : ( <> Bienvenido{user?.name ? `, ${user.name.split(' ')[0]}` : ''}

SKEEN Derma Experts

Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con confianza.

)} {/* Hoy en la clínica: acciones rápidas del día */}

Hoy en la clínica

{todayLabel}

} onClick={() => navigate('/agenda')} ariaLabel="Ver próximas citas en la agenda" className="md:col-span-2" > {upcomingToday.length === 0 ? (

Sin citas activas para hoy.

) : (
{upcomingToday.map((apt) => (
{apt.time} {apt.patient} {apt.doctor || 'Sin médico'}
))}
)}
} onClick={() => navigate('/agenda')} ariaLabel="Ver pacientes en sala de espera" >

{todayInfo ? (todayInfo.waiting ?? '—') : '—'}

{todayInfo?.waiting != null ? todayInfo.waiting === 1 ? 'paciente en espera' : 'pacientes en espera' : 'no disponible'}

} onClick={() => navigate('/cortes')} ariaLabel="Ir a cortes de caja" >

{cajaStatus.label}

{cajaStatus.ref}

} onClick={() => navigate('/ventas')} ariaLabel="Ver cobros del día en ventas" >

{todayInfo ? (todayInfo.cobros != null ? formatCurrency(todayInfo.cobros) : '—') : '—'}

pagos completados hoy

{/* Stats grid */}
{statsConfig.map((s) => ( navigate(s.to!) : undefined} delta={'delta' in s ? (s.delta as StatDelta) : undefined} deltaLabel={'deltaLabel' in s ? (s.deltaLabel as string) : undefined} /> ))}
{/* Cumpleaños de hoy */}

Cumpleaños de hoy

Pacientes que cumplen años hoy — contáctalos

{loadingBirthdays ? ( ) : birthdays.length === 0 ? (

Ningún paciente cumple años hoy.

) : (
{birthdays.map((b) => (
cumple {b.turning_age}{b.is_vip ? ' · VIP' : ''} {(b.whatsapp || b.phone) && ( {b.whatsapp || b.phone} )}
))}
)}
{/* Chart + appointments */}

Actividad semanal

Citas e ingresos de los últimos 7 días

{loadingChart ? ( ) : chartData.length === 0 ? ( } /> ) : (
)}

Próximas citas

Citas programadas para hoy

{loadingAppointments ? (
{Array.from({ length: 4 }).map((_, i) => ( ))}
) : appointments.length === 0 ? ( } /> ) : (
{appointments.map((apt) => (

{apt.patient}

{apt.service} • {apt.time}

{aptStateLabel(apt.state, term)}
))}
)}
{/* Top servicios + Asistencia del mes */}

Top servicios del mes

Por ingresos generados

{loadingExtras ? ( ) : topServices.length === 0 ? ( } /> ) : (
formatCurrency(v)} /> (v.length > 22 ? `${v.slice(0, 22)}…` : v)} /> formatCurrency(Number(v))} contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} />
)}

Asistencia del mes

{aptReport ? `${aptReport.total} citas · tasa no-show ${noShowRate}%` : 'Distribución por estado'}

{loadingExtras ? ( ) : attendanceData.length === 0 ? ( } /> ) : (
{attendanceData.map((d, i) => ( ))}
{attendanceData.map((d) => (
{d.name} {d.value}
))}
)}
{/* Services + activity */}

Servicios destacados

Catálogo activo de tratamientos

{loadingServices ? (
{Array.from({ length: 4 }).map((_, i) => ( ))}
) : services.length === 0 ? ( } /> ) : (
{services.map((s) => (

{s.name}

${s.price} • {s.duration_min} min

))}
)}

Actividad reciente

Últimos movimientos en la clínica

{loadingAppointments ? (
{Array.from({ length: 4 }).map((_, i) => ( ))}
) : activityItems.length === 0 ? ( } /> ) : (
{activityItems.map((item) => (

{item.title}

{item.subtitle}

{item.state}
))}
)}
); }; export default Dashboard;