import type { FC } from 'react'; import { useEffect, useState, useCallback } from 'react'; import { Gauge, UserX, TrendingUp, UserPlus } from 'lucide-react'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts'; import { Card, Input, EmptyState, Skeleton, toast } from '../ui'; import { ExportButton } from '../ui/ExportButton'; import { exportToExcel } from '../../lib/exporter'; import { odooApi, type KpisReport as KpisData } from '../../services/odoo'; import { useReportRange } from '../../lib/reportRange'; import { useAuth } from '../../lib/auth'; const pct = (v: number) => `${Math.round(v * 100)}%`; const Kpi: FC<{ label: string; value: React.ReactNode; icon: React.ReactNode; rose?: boolean }> = ({ label, value, icon, rose }) => (
{icon}

{label}

{value}

); const KpisReport: FC = () => { const { brand } = useAuth(); // Rango global de Reportes; los campos Inicio/Fin fijan uno propio const { start, end, setStart, setEnd } = useReportRange('kpis'); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [exporting, setExporting] = useState(false); const load = useCallback(async () => { try { setLoading(true); const res = await odooApi.getKpis(start, end); if (res.status === 'success') { const { no_show, ocupacion, leads, primera_vez, start: s, end: e } = res; setData({ no_show, ocupacion, leads, primera_vez, start: s, end: e }); } } catch (err) { toast.error('Error al cargar KPIs'); console.error(err); } finally { setLoading(false); } }, [start, end]); useEffect(() => { load(); }, [load]); const exportar = async () => { if (!data) return; try { setExporting(true); const rows: Record[] = []; data.no_show.por_medico.forEach((m) => rows.push({ seccion: 'No-show', medico: m.medico, citas: m.citas, casos: m.no_shows, horas: null, tasa: m.tasa })); data.ocupacion.por_medico.forEach((o) => rows.push({ seccion: 'Ocupación', medico: o.medico, citas: null, casos: null, horas: o.horas_vendidas, tasa: o.ocupacion })); rows.push({ seccion: 'Resumen', medico: 'No-show global', citas: data.no_show.total_citas, casos: data.no_show.total_no_shows, horas: null, tasa: data.no_show.tasa }); rows.push({ seccion: 'Resumen', medico: 'Conversión leads', citas: data.leads.total, casos: data.leads.ganados, horas: null, tasa: data.leads.tasa }); rows.push({ seccion: 'Resumen', medico: 'Primera vez', citas: data.primera_vez.primera + data.primera_vez.subsecuentes, casos: data.primera_vez.primera, horas: null, tasa: data.primera_vez.tasa }); await exportToExcel({ filename: `kpis-${start}_${end}`, sheetName: 'KPIs', title: `KPIs clínicos ${brand.nombre}`, subtitle: `Del ${start} al ${end}`, columns: [ { header: 'Sección', key: 'seccion' }, { header: 'Médico / Concepto', key: 'medico' }, { header: 'Citas / Leads', key: 'citas', format: 'number' }, { header: 'Casos', key: 'casos', format: 'number' }, { header: 'Horas vendidas', key: 'horas', format: 'number' }, { header: 'Tasa', key: 'tasa', format: 'percent' }, ], rows, }); toast.success('Excel descargado'); } catch (err) { toast.error('Error al exportar'); console.error(err); } finally { setExporting(false); } }; return (
setStart(e.target.value)} className="sm:max-w-[160px]" /> setEnd(e.target.value)} className="sm:max-w-[160px]" />
{loading ? (
{Array.from({ length: 4 }).map((_, i) => )}
) : data ? ( <>
} rose={data.no_show.tasa > 0.15} /> } /> } /> } />
{/* No-show por médico */}

No-show por médico

{data.no_show.total_no_shows} no-shows de {data.no_show.total_citas} citas (canceladas no cuentan).

{data.no_show.por_medico.length === 0 ? (

Sin citas en el periodo.

) : (
{data.no_show.por_medico.map((m) => ( ))}
Médico Citas No-shows Tasa
{m.medico} {m.citas} {m.no_shows} 0.15 ? 'text-rose-600' : 'text-theme-heading'}`}> {pct(m.tasa)}
)}
{/* Ocupación por médico */}

Ocupación por médico

{data.ocupacion.dias_habiles} días hábiles × 8h (domingos no cuentan), menos bloqueos de agenda.

{data.ocupacion.por_medico.length === 0 ? (

Sin citas en el periodo.

) : (
{data.ocupacion.por_medico.map((o) => ( ))}
Médico Vendidas Disponibles Libres Ocupación
{o.medico} {o.horas_vendidas}h {o.horas_disponibles}h {o.horas_libres}h {pct(o.ocupacion)}
)} {data.ocupacion.por_medico.length > 0 && (
({ name: o.medico.split(' ')[0], ocupacion: Math.round(o.ocupacion * 100) }))} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}> `${v}%`} />
)}
{/* Leads y primera vez */}

Conversión de leads (deals creados en el periodo)

{data.leads.ganados} ganados de{' '} {data.leads.total} leads ({pct(data.leads.tasa)}) {data.leads.dias_promedio_ganar !== null && ( · prom. {data.leads.dias_promedio_ganar} días a ganar )}

Primera vez vs subsecuentes

{data.primera_vez.primera} primera vez ·{' '} {data.primera_vez.subsecuentes} subsecuentes

) : ( )}
); }; export default KpisReport;