Files
SKEEN-Proyecto/frontend/src/components/reportes/KpisReport.tsx
Consultoría Alcaraz Salazar 2f7cbe237e white-label: brand de la clinica por configuracion (fase 1 de instancia sin branding SKEEN)
- backend: GET/PUT /skeen/frontend/v1/brand leyendo ir.config_parameter
  skeen.clinic.brand (JSON: nombre, logo_claro, logo_oscuro, pie) con
  defaults SKEEN; GET publico (el login lo necesita sin sesion), PUT admin
  con auditoria. frontend_users.xml: 'Administrador SKEEN' -> 'Administrador'
- frontend: ClinicBrand + BRAND_DEFAULT en odoo.ts; brand y setBrand en el
  contexto de auth (fetch al boot + cache localStorage + document.title)
- Login/layouts/mensajes/exports leen el brand en vez de strings SKEEN:
  Login.tsx, SkeenLayout, HomeNestLayout, Cumpleanos (wa + export),
  Pacientes/Ventas/Cortes/Reportes/Kpis/Alertas/AgendaPrint (titulos),
  useClinicaPrint (fallback nombre), Login hace GET publico al montar
- Configuracion > Clinica: tarjeta 'Identidad de la clinica' (solo admin)
  para editar nombre/pie/logos con preview
- no renombrado (invisible al usuario): rutas API /skeen/*, claves
  localStorage, nombres de modulos, parametros skeen.*

Verificacion API: 11/11 tests OK (defaults, PUT merge, campo invalido
ignorado, restore, admin neutral). Deploy: build + rsync + Odoo reiniciado
2026-10-08 00:09:00 +00:00

243 lines
12 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 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 }) => (
<Card className="p-4 bg-theme-bg border-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-theme-muted">{icon}</span>
<p className="text-xs text-theme-muted">{label}</p>
</div>
<p className={`text-xl font-heading font-semibold ${rose ? 'text-rose-600' : 'text-theme-heading'}`}>{value}</p>
</Card>
);
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<KpisData | null>(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<string, unknown>[] = [];
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 (
<div className="space-y-4 sm:space-y-6">
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row gap-3 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
</Card.Body>
</Card>
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : data ? (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Kpi label="Tasa no-show" value={pct(data.no_show.tasa)} icon={<UserX size={15} />} rose={data.no_show.tasa > 0.15} />
<Kpi label="Ocupación promedio" value={pct(data.ocupacion.promedio)} icon={<Gauge size={15} />} />
<Kpi label="Conversión de leads" value={pct(data.leads.tasa)} icon={<TrendingUp size={15} />} />
<Kpi label="Pacientes primera vez" value={pct(data.primera_vez.tasa)} icon={<UserPlus size={15} />} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
{/* No-show por médico */}
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1">No-show por médico</h3>
<p className="text-xs text-theme-muted mb-4">
{data.no_show.total_no_shows} no-shows de {data.no_show.total_citas} citas (canceladas no cuentan).
</p>
{data.no_show.por_medico.length === 0 ? (
<p className="text-sm text-theme-muted">Sin citas en el periodo.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Médico</th>
<th className="text-right px-4 py-3">Citas</th>
<th className="text-right px-4 py-3">No-shows</th>
<th className="text-right px-4 py-3">Tasa</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.no_show.por_medico.map((m) => (
<tr key={m.medico} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{m.medico}</td>
<td className="px-4 py-3 text-right text-theme-muted">{m.citas}</td>
<td className="px-4 py-3 text-right text-theme-muted">{m.no_shows}</td>
<td className={`px-4 py-3 text-right font-medium ${m.tasa > 0.15 ? 'text-rose-600' : 'text-theme-heading'}`}>
{pct(m.tasa)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
{/* Ocupación por médico */}
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1">Ocupación por médico</h3>
<p className="text-xs text-theme-muted mb-4">
{data.ocupacion.dias_habiles} días hábiles × 8h (domingos no cuentan), menos bloqueos de agenda.
</p>
{data.ocupacion.por_medico.length === 0 ? (
<p className="text-sm text-theme-muted">Sin citas en el periodo.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Médico</th>
<th className="text-right px-4 py-3">Vendidas</th>
<th className="text-right px-4 py-3">Disponibles</th>
<th className="text-right px-4 py-3">Libres</th>
<th className="text-right px-4 py-3">Ocupación</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.ocupacion.por_medico.map((o) => (
<tr key={o.medico} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{o.medico}</td>
<td className="px-4 py-3 text-right text-theme-muted">{o.horas_vendidas}h</td>
<td className="px-4 py-3 text-right text-theme-muted">{o.horas_disponibles}h</td>
<td className="px-4 py-3 text-right text-theme-muted">{o.horas_libres}h</td>
<td className="px-4 py-3 text-right font-medium text-theme-heading">{pct(o.ocupacion)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{data.ocupacion.por_medico.length > 0 && (
<div className="mt-4">
<ResponsiveContainer width="100%" height={180}>
<BarChart data={data.ocupacion.por_medico.map((o) => ({ name: o.medico.split(' ')[0], ocupacion: Math.round(o.ocupacion * 100) }))} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 11 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 11 }} unit="%" />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} formatter={(v) => `${v}%`} />
<Bar dataKey="ocupacion" fill="#57534e" radius={[4, 4, 0, 0]} name="Ocupación" />
</BarChart>
</ResponsiveContainer>
</div>
)}
</Card.Body>
</Card>
</div>
{/* Leads y primera vez */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted mb-1">Conversión de leads (deals creados en el periodo)</p>
<p className="text-sm text-theme-heading">
<span className="font-semibold">{data.leads.ganados}</span> ganados de{' '}
<span className="font-semibold">{data.leads.total}</span> leads ({pct(data.leads.tasa)})
{data.leads.dias_promedio_ganar !== null && (
<span className="text-theme-muted"> · prom. {data.leads.dias_promedio_ganar} días a ganar</span>
)}
</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted mb-1">Primera vez vs subsecuentes</p>
<p className="text-sm text-theme-heading">
<span className="font-semibold">{data.primera_vez.primera}</span> primera vez ·{' '}
<span className="font-semibold">{data.primera_vez.subsecuentes}</span> subsecuentes
</p>
</Card>
</div>
</>
) : (
<EmptyState title="Sin datos" subtitle="No se pudieron calcular los KPIs." />
)}
</div>
);
};
export default KpisReport;