bloque 5: tablero de direccion (pagina /direccion con permiso propio: resumen con comparativos, cohortes de retencion, valor por cliente, clientes en riesgo, ocupacion por medico/sucursal/recurso, ingresos por servicio y sucursal, membresias y paquetes, pasivo de lealtad, metas por sucursal con proyeccion y exportacion) con cifras cruzadas contra los reportes existentes y consultas agrupadas (menos de 2 s)

This commit is contained in:
2026-10-12 03:34:00 +00:00
parent a6d6df4ebd
commit 6139df6327
15 changed files with 2256 additions and 3 deletions

View File

@@ -15,6 +15,7 @@ import { PatientDrawerProvider } from './components/PatientDrawer';
import ChatNotifications from './lib/chat-notifications';
import { useClinicSettings, useClinicSettingsCargado } from './lib/clinic-settings';
import Dashboard from './pages/Dashboard';
import Direccion from './pages/Direccion';
import Agenda from './pages/Agenda';
import Pacientes from './pages/Pacientes';
import Pos from './pages/Pos';
@@ -211,6 +212,7 @@ const App: FC = () => {
<Route path="/mi-cuenta" element={<MiCuenta />} />
<Route path="/mi-cuenta/acceso/:token" element={<MiCuenta />} />
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
<Route path="/direccion" element={<RequireAuth href="/direccion"><Direccion /></RequireAuth>} />
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
<Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></RequireAuth>} />
<Route path="/pacientes" element={<RequireAuth href="/pacientes"><Pacientes /></RequireAuth>} />

View File

@@ -0,0 +1,133 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Save, Plus, Trash2, LayoutDashboard } from 'lucide-react';
import { Button, Input, toast } from './ui';
import { odooApi, TABLERO_DEFAULT, type ClinicSettings, type TableroSettings } from '../services/odoo';
import { useSucursales } from '../lib/sucursales';
import { useI18n } from '../lib/i18n';
interface Props {
settings: ClinicSettings;
onSaved: (settings: ClinicSettings) => void;
}
// Configuración → Dirección: bloque `tablero` de clinic-settings. Solo guarda
// los parámetros del tablero (metas por sucursal y criterios de riesgo); con
// los defaults (sin metas) el tablero funciona igual.
const DireccionPanel: FC<Props> = ({ settings, onSaved }) => {
const { t } = useI18n();
const { sucursales } = useSucursales();
const [form, setForm] = useState<TableroSettings>(settings.tablero || TABLERO_DEFAULT);
const [saving, setSaving] = useState(false);
useEffect(() => {
setForm(settings.tablero || TABLERO_DEFAULT);
}, [settings.tablero]);
const set = <K extends keyof TableroSettings>(k: K, v: TableroSettings[K]) =>
setForm((prev) => ({ ...prev, [k]: v }));
const setMeta = (i: number, campo: 'sucursal' | 'meta_mensual', valor: string | number) =>
setForm((prev) => ({
...prev,
metas_sucursal: prev.metas_sucursal.map((m, idx) => (idx === i ? { ...m, [campo]: valor } : m)),
}));
const addMeta = () => {
const usados = new Set(form.metas_sucursal.map((m) => m.sucursal));
const libre = sucursales.find((s) => !usados.has(s.code));
set('metas_sucursal', [...form.metas_sucursal, { sucursal: libre?.code || '', meta_mensual: 0 }]);
};
const delMeta = (i: number) =>
set('metas_sucursal', form.metas_sucursal.filter((_, idx) => idx !== i));
const guardar = async () => {
setSaving(true);
try {
const metas = form.metas_sucursal.filter((m) => m.sucursal);
const res = await odooApi.putClinicSettings({
tablero: { ...form, metas_sucursal: metas },
motivo: 'Parámetros del tablero de dirección',
});
if (res.status === 'success') {
onSaved(res.settings);
toast.success(t('dir.cfg.saved'));
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || t('dir.cfg.error'));
} finally {
setSaving(false);
}
};
const opcionesSucursal = sucursales.map((s) => ({ value: s.code, label: s.name }));
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
<LayoutDashboard size={18} /> {t('dir.cfg.title')}
</h3>
<p className="text-sm text-theme-muted mt-1">{t('dir.cfg.hint')}</p>
</div>
<Button onClick={guardar} loading={saving}><Save size={16} className="mr-1.5" />{t('common.save')}</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input label={t('dir.cfg.metaGlobal')} type="number" min={0} value={String(form.meta_mensual_global)}
onChange={(e) => set('meta_mensual_global', parseFloat(e.target.value) || 0)} />
<Input label={t('dir.cfg.dias')} type="number" min={1} value={String(form.dias_inactividad)}
onChange={(e) => set('dias_inactividad', parseInt(e.target.value, 10) || 1)} />
<Input label={t('dir.cfg.visitas')} type="number" min={0} value={String(form.visitas_minimas_riesgo)}
onChange={(e) => set('visitas_minimas_riesgo', parseInt(e.target.value, 10) || 0)} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1.5">{t('dir.cfg.comparativo')}</label>
<select value={form.comparativo_default}
onChange={(e) => set('comparativo_default', e.target.value as TableroSettings['comparativo_default'])}
className="w-full sm:max-w-xs rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading">
<option value="anterior">{t('dir.cmp.anterior')}</option>
<option value="anio">{t('dir.cmp.anio')}</option>
<option value="ninguno">{t('dir.cmp.ninguno')}</option>
</select>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-theme-heading">{t('dir.cfg.metas')}</h4>
<Button size="sm" variant="ghost" onClick={addMeta}><Plus size={14} className="mr-1" />{t('dir.cfg.addMeta')}</Button>
</div>
{form.metas_sucursal.length === 0 ? (
<p className="text-sm text-theme-muted">{t('dir.cfg.sinMetas')}</p>
) : (
<div className="space-y-2">
{form.metas_sucursal.map((m, i) => (
<div key={i} className="flex flex-col sm:flex-row gap-2 sm:items-end">
<div className="flex-1">
<select value={m.sucursal} onChange={(e) => setMeta(i, 'sucursal', e.target.value)}
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading">
<option value="">{t('dir.sucursal')}</option>
{opcionesSucursal.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="sm:w-48">
<Input label={t('dir.metas.meta')} type="number" min={0} value={String(m.meta_mensual)}
onChange={(e) => setMeta(i, 'meta_mensual', parseFloat(e.target.value) || 0)} />
</div>
<Button variant="ghost" onClick={() => delMeta(i)} aria-label={t('common.delete')}>
<Trash2 size={16} />
</Button>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default DireccionPanel;

View File

@@ -53,6 +53,8 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/cortes': ['admin', 'recepcion', 'gerencia'],
'/reportes': ['admin', 'recepcion', 'lectura', 'gerencia'],
// Tablero de dirección: solo dueño/administración y marketing (el médico no).
'/direccion': ['admin', 'gerencia', 'marketing'],
'/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura', 'gerencia'],

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LEALTAD_DEFAULT, PROPINAS_DEFAULT, CAMPANAS_DEFAULT, ENCUESTAS_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT, PORTAL_CLIENTE_DEFAULT } from '../services/odoo';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LEALTAD_DEFAULT, PROPINAS_DEFAULT, CAMPANAS_DEFAULT, ENCUESTAS_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT, PORTAL_CLIENTE_DEFAULT, TABLERO_DEFAULT } from '../services/odoo';
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
@@ -33,6 +33,11 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
limites: { ...LICENCIA_LIMITES_DEFAULT, ...((settings?.licencia || {}).limites || {}) },
},
portal_cliente: { ...PORTAL_CLIENTE_DEFAULT, ...(settings?.portal_cliente || {}) },
tablero: {
...TABLERO_DEFAULT,
...(settings?.tablero || {}),
metas_sucursal: settings?.tablero?.metas_sucursal ?? TABLERO_DEFAULT.metas_sucursal,
},
});
let actual: ClinicSettings = normalizar(null);

View File

@@ -969,6 +969,128 @@ export const es: Dict = {
'nps.processed': 'Encuestas procesadas.',
'nps.replySent': 'Respuesta enviada.',
'nps.replySimulated': 'Modo prueba: la respuesta quedó registrada como simulada.',
// --- Tablero de dirección ---
'dir.title': 'Dirección',
'dir.subtitle': 'Tablero ejecutivo del negocio',
'dir.periodo': 'Periodo',
'dir.inicio': 'Inicio',
'dir.fin': 'Fin',
'dir.sucursal': 'Sucursal',
'dir.todasSucursales': 'Todas las sucursales',
'dir.comparar': 'Comparar con',
'dir.cmp.anterior': 'Periodo anterior',
'dir.cmp.anio': 'Mismo periodo del año pasado',
'dir.cmp.ninguno': 'Sin comparativo',
'dir.preset.hoy': 'Hoy',
'dir.preset.semana': 'Esta semana',
'dir.preset.mes': 'Este mes',
'dir.preset.mesPasado': 'Mes pasado',
'dir.preset.anio': 'Este año',
'dir.preset.custom': 'Personalizado',
'dir.exportar': 'Exportar',
'dir.cargando': 'Cargando…',
'dir.sinDatos': 'Sin datos en el periodo.',
'dir.error': 'No se pudo cargar el tablero.',
'dir.exportOk': 'Excel descargado',
'dir.exportError': 'Error al exportar',
'dir.noDisponible': 'No disponible',
'dir.noDisponibleHint': 'Estos indicadores no se pueden calcular con los datos del sistema.',
'dir.vsAnterior': 'vs. periodo anterior',
'dir.vsAnio': 'vs. año pasado',
'dir.section.resumen': 'Resumen del periodo',
'dir.kpi.ingresos': 'Ingresos cobrados',
'dir.kpi.ventas': 'Ventas',
'dir.kpi.ticket': 'Ticket promedio',
'dir.kpi.visitas': 'Visitas atendidas',
'dir.kpi.nuevos': 'Pacientes nuevos',
'dir.kpi.recurrentes': 'Pacientes recurrentes',
'dir.kpi.noShow': 'Tasa de no presentados',
'dir.kpi.cancelacion': 'Tasa de cancelación',
'dir.section.clientes': 'Clientes',
'dir.kpi.activos': 'Pacientes activos',
'dir.kpi.frecuencia': 'Frecuencia promedio',
'dir.kpi.frecuenciaHint': 'Visitas por paciente en el periodo',
'dir.kpi.valor': 'Valor por cliente',
'dir.kpi.valorHint': 'Ingreso histórico promedio por paciente',
'dir.clientes.nuevosMes': 'Pacientes nuevos por mes',
'dir.clientes.retencion': 'Retención por cohortes',
'dir.clientes.retencionHint': 'De los pacientes que vinieron por primera vez en un mes, cuántos volvieron en los siguientes 1, 3, 6 y 12 meses.',
'dir.clientes.cohorte': 'Cohorte',
'dir.clientes.tamano': 'Pacientes',
'dir.clientes.m1': '1 mes',
'dir.clientes.m3': '3 meses',
'dir.clientes.m6': '6 meses',
'dir.clientes.m12': '12 meses',
'dir.clientes.riesgo': 'Clientes en riesgo',
'dir.clientes.riesgoHint': 'Con {visitas} o más visitas y más de {dias} días sin venir',
'dir.clientes.crearCampana': 'Armar campaña',
'dir.clientes.valorMediana': 'Mediana',
'dir.clientes.valorTotal': 'Ingreso histórico',
'dir.clientes.ultimaVisita': 'Última visita',
'dir.clientes.visitas': 'Visitas',
'dir.clientes.diasSinVenir': 'Días sin venir',
'dir.clientes.ventanaPendiente': 'Aún no se cumple la ventana',
'dir.section.ocupacion': 'Ocupación y agenda',
'dir.ocup.porMedico': 'Ocupación por médico',
'dir.ocup.porSucursal': 'Ocupación por sucursal',
'dir.ocup.porRecurso': 'Ocupación por recurso',
'dir.ocup.franjas': 'Franjas horarias',
'dir.ocup.franjasHint': 'Citas por hora del día (canceladas, no-show y lista de espera no cuentan).',
'dir.ocup.masOcupadas': 'Franjas más ocupadas',
'dir.ocup.menosOcupadas': 'Franjas menos ocupadas',
'dir.ocup.horasVendidas': 'Horas vendidas',
'dir.ocup.horasDisponibles': 'Horas disponibles',
'dir.ocup.horasLibres': 'Horas libres',
'dir.ocup.ocupacion': 'Ocupación',
'dir.ocup.noShow': 'No-show por médico',
'dir.ocup.citas': 'Citas',
'dir.section.ingresos': 'Ingresos por concepto',
'dir.ing.porServicio': 'Por servicio',
'dir.ing.porCategoria': 'Por categoría',
'dir.ing.porSucursal': 'Por sucursal',
'dir.ing.porMetodo': 'Por método de pago',
'dir.ing.propinas': 'Propinas',
'dir.ing.membresias': 'Membresías',
'dir.ing.paquetes': 'Paquetes',
'dir.ing.lealtad': 'Lealtad',
'dir.ing.concepto': 'Concepto',
'dir.ing.cantidad': 'Cantidad',
'dir.ing.total': 'Total',
'dir.ing.activas': 'Membresías activas',
'dir.ing.mrr': 'Ingreso recurrente mensual (estimado)',
'dir.ing.renovaciones': 'Renovaciones del periodo',
'dir.ing.cancelaciones': 'Cancelaciones del periodo',
'dir.ing.paquetesVendidos': 'Paquetes vendidos',
'dir.ing.sesionesPendientes': 'Sesiones pendientes de usar',
'dir.ing.pasivoLealtad': 'Pasivo de lealtad',
'dir.ing.puntosActivos': 'Puntos activos',
'dir.section.metas': 'Metas y proyección',
'dir.metas.porSucursal': 'Metas por sucursal (mes en curso)',
'dir.metas.porEmpleado': 'Metas por empleado (mes en curso)',
'dir.metas.meta': 'Meta',
'dir.metas.avance': 'Ventas del mes',
'dir.metas.pct': 'Avance',
'dir.metas.proyeccion': 'Proyección de cierre',
'dir.metas.mes': 'Mes en curso',
'dir.metas.periodo': 'Proyección del periodo',
'dir.metas.sinMeta': 'Sin meta',
'dir.metas.concepto': 'Concepto',
'dir.metas.empleado': 'Empleado',
'dir.metas.global': 'Global',
'dir.metas.ventasPeriodo': 'Ventas del periodo',
'dir.metas.cobrosPeriodo': 'Cobros del periodo',
'dir.ms': 'Respuesta',
'dir.cfg.title': 'Parámetros del tablero',
'dir.cfg.hint': 'Metas por sucursal y criterios de "clientes en riesgo". Con las metas en cero el tablero funciona igual y solo no muestra avance.',
'dir.cfg.metas': 'Metas mensuales por sucursal',
'dir.cfg.metaGlobal': 'Meta mensual global',
'dir.cfg.dias': 'Días sin venir para "en riesgo"',
'dir.cfg.visitas': 'Visitas mínimas para "en riesgo"',
'dir.cfg.comparativo': 'Comparativo por defecto',
'dir.cfg.addMeta': 'Agregar sucursal',
'dir.cfg.sinMetas': 'Sin metas configuradas.',
'dir.cfg.saved': 'Parámetros del tablero guardados',
'dir.cfg.error': 'No se pudieron guardar los parámetros',
};
// ------------------------------------------------------------
@@ -1893,6 +2015,128 @@ export const en: Dict = {
'nps.processed': 'Surveys processed.',
'nps.replySent': 'Reply sent.',
'nps.replySimulated': 'Test mode: the reply was logged as simulated.',
// --- Executive dashboard (Dirección) ---
'dir.title': 'Management',
'dir.subtitle': 'Business executive dashboard',
'dir.periodo': 'Period',
'dir.inicio': 'Start',
'dir.fin': 'End',
'dir.sucursal': 'Branch',
'dir.todasSucursales': 'All branches',
'dir.comparar': 'Compare with',
'dir.cmp.anterior': 'Previous period',
'dir.cmp.anio': 'Same period last year',
'dir.cmp.ninguno': 'No comparison',
'dir.preset.hoy': 'Today',
'dir.preset.semana': 'This week',
'dir.preset.mes': 'This month',
'dir.preset.mesPasado': 'Last month',
'dir.preset.anio': 'This year',
'dir.preset.custom': 'Custom',
'dir.exportar': 'Export',
'dir.cargando': 'Loading…',
'dir.sinDatos': 'No data for the period.',
'dir.error': 'The dashboard could not be loaded.',
'dir.exportOk': 'Excel downloaded',
'dir.exportError': 'Export failed',
'dir.noDisponible': 'Not available',
'dir.noDisponibleHint': 'These indicators cannot be computed with the available data.',
'dir.vsAnterior': 'vs. previous period',
'dir.vsAnio': 'vs. last year',
'dir.section.resumen': 'Period summary',
'dir.kpi.ingresos': 'Collected revenue',
'dir.kpi.ventas': 'Sales',
'dir.kpi.ticket': 'Average ticket',
'dir.kpi.visitas': 'Visits attended',
'dir.kpi.nuevos': 'New patients',
'dir.kpi.recurrentes': 'Returning patients',
'dir.kpi.noShow': 'No-show rate',
'dir.kpi.cancelacion': 'Cancellation rate',
'dir.section.clientes': 'Customers',
'dir.kpi.activos': 'Active patients',
'dir.kpi.frecuencia': 'Average frequency',
'dir.kpi.frecuenciaHint': 'Visits per patient in the period',
'dir.kpi.valor': 'Value per customer',
'dir.kpi.valorHint': 'Average historical revenue per patient',
'dir.clientes.nuevosMes': 'New patients by month',
'dir.clientes.retencion': 'Cohort retention',
'dir.clientes.retencionHint': 'Of the patients who came for the first time in a month, how many returned within 1, 3, 6 and 12 months.',
'dir.clientes.cohorte': 'Cohort',
'dir.clientes.tamano': 'Patients',
'dir.clientes.m1': '1 month',
'dir.clientes.m3': '3 months',
'dir.clientes.m6': '6 months',
'dir.clientes.m12': '12 months',
'dir.clientes.riesgo': 'Customers at risk',
'dir.clientes.riesgoHint': 'With {visitas}+ visits and more than {dias} days without coming',
'dir.clientes.crearCampana': 'Build campaign',
'dir.clientes.valorMediana': 'Median',
'dir.clientes.valorTotal': 'Historical revenue',
'dir.clientes.ultimaVisita': 'Last visit',
'dir.clientes.visitas': 'Visits',
'dir.clientes.diasSinVenir': 'Days since last visit',
'dir.clientes.ventanaPendiente': 'Window not reached yet',
'dir.section.ocupacion': 'Occupancy and schedule',
'dir.ocup.porMedico': 'Occupancy by doctor',
'dir.ocup.porSucursal': 'Occupancy by branch',
'dir.ocup.porRecurso': 'Occupancy by resource',
'dir.ocup.franjas': 'Time slots',
'dir.ocup.franjasHint': 'Appointments by hour (cancelled, no-show and waitlist do not count).',
'dir.ocup.masOcupadas': 'Busiest slots',
'dir.ocup.menosOcupadas': 'Least busy slots',
'dir.ocup.horasVendidas': 'Hours sold',
'dir.ocup.horasDisponibles': 'Hours available',
'dir.ocup.horasLibres': 'Hours free',
'dir.ocup.ocupacion': 'Occupancy',
'dir.ocup.noShow': 'No-show by doctor',
'dir.ocup.citas': 'Appointments',
'dir.section.ingresos': 'Revenue by concept',
'dir.ing.porServicio': 'By service',
'dir.ing.porCategoria': 'By category',
'dir.ing.porSucursal': 'By branch',
'dir.ing.porMetodo': 'By payment method',
'dir.ing.propinas': 'Tips',
'dir.ing.membresias': 'Memberships',
'dir.ing.paquetes': 'Packages',
'dir.ing.lealtad': 'Loyalty',
'dir.ing.concepto': 'Concept',
'dir.ing.cantidad': 'Quantity',
'dir.ing.total': 'Total',
'dir.ing.activas': 'Active memberships',
'dir.ing.mrr': 'Estimated monthly recurring revenue',
'dir.ing.renovaciones': 'Renewals in the period',
'dir.ing.cancelaciones': 'Cancellations in the period',
'dir.ing.paquetesVendidos': 'Packages sold',
'dir.ing.sesionesPendientes': 'Sessions pending use',
'dir.ing.pasivoLealtad': 'Loyalty liability',
'dir.ing.puntosActivos': 'Active points',
'dir.section.metas': 'Goals and projection',
'dir.metas.porSucursal': 'Goals by branch (current month)',
'dir.metas.porEmpleado': 'Goals by employee (current month)',
'dir.metas.meta': 'Goal',
'dir.metas.avance': 'Month sales',
'dir.metas.pct': 'Progress',
'dir.metas.proyeccion': 'Closing projection',
'dir.metas.mes': 'Current month',
'dir.metas.periodo': 'Period projection',
'dir.metas.sinMeta': 'No goal',
'dir.metas.concepto': 'Concept',
'dir.metas.empleado': 'Employee',
'dir.metas.global': 'Global',
'dir.metas.ventasPeriodo': 'Period sales',
'dir.metas.cobrosPeriodo': 'Period collections',
'dir.ms': 'Response',
'dir.cfg.title': 'Dashboard parameters',
'dir.cfg.hint': 'Goals by branch and "customers at risk" criteria. With zeroed goals the dashboard still works, it just shows no progress.',
'dir.cfg.metas': 'Monthly goals by branch',
'dir.cfg.metaGlobal': 'Global monthly goal',
'dir.cfg.dias': 'Days without coming for "at risk"',
'dir.cfg.visitas': 'Minimum visits for "at risk"',
'dir.cfg.comparativo': 'Default comparison',
'dir.cfg.addMeta': 'Add branch',
'dir.cfg.sinMetas': 'No goals configured.',
'dir.cfg.saved': 'Dashboard parameters saved',
'dir.cfg.error': 'Could not save the parameters',
};
export const DICT: Record<Locale, Dict> = { es, en };

View File

@@ -13,6 +13,8 @@ export interface MenuItem {
export const MENU_ITEMS: MenuItem[] = [
{ label: 'Dashboard', href: '/' },
// Tablero ejecutivo del dueño (solo admin, gerencia y marketing).
{ label: 'Dirección', href: '/direccion' },
{ label: 'Agenda', href: '/agenda' },
{ label: 'Pacientes', href: '/pacientes', termKey: 'patient_plural' },
{ label: 'Visitas', href: '/visitas', termKey: 'visit_plural' },

View File

@@ -16,6 +16,7 @@ export const PERM_KEYS: string[] = [
'inventario.ver', 'inventario.ajustar', 'inventario.mover', 'inventario.costos',
'servicios.ver', 'servicios.editar',
'reportes.ver', 'reportes.exportar', 'caja.ver', 'caja.cerrar',
'direccion.ver',
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar',
'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar',
@@ -84,6 +85,7 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'inventario.costos': WN,
'servicios.ver': ALL, 'servicios.editar': AD,
'reportes.ver': ALL, 'reportes.exportar': ALL, 'caja.ver': ALL, 'caja.cerrar': MONEY,
'direccion.ver': ['admin', 'gerencia', 'marketing'],
'whatsapp.ver': W, 'whatsapp.responder': W, 'whatsapp.asignar': W,
'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG,
'formularios.ver': ALL, 'formularios.crear': FORM_WRITE, 'formularios.editar': AG,

View File

@@ -26,6 +26,7 @@ import LealtadPanel from '../components/LealtadPanel';
import PropinasPanel from '../components/PropinasPanel';
import CampanasPanel from '../components/CampanasPanel';
import EncuestasPanel from '../components/EncuestasPanel';
import DireccionPanel from '../components/DireccionPanel';
import LicenciaPanel from '../components/LicenciaPanel';
import ApiKeysPanel from '../components/ApiKeysPanel';
import WebhooksPanel from '../components/WebhooksPanel';
@@ -38,6 +39,7 @@ import { LanguageSelect } from '../components/LanguageSelect';
const TABS = [
{ key: 'clinica', label: 'Clínica' },
{ key: 'direccion', label: 'Dirección' },
{ key: 'reservas', label: 'Reservas en línea' },
{ key: 'portal', label: 'Portal del cliente' },
{ key: 'notificaciones', label: 'Notificaciones' },
@@ -66,7 +68,7 @@ const TABS = [
] as const;
// Secciones que ve gerencia (admin del sistema ve todas).
const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'lealtad', 'propinas', 'campanas', 'encuestas', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
const TABS_GERENCIA = ['direccion', 'reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'lealtad', 'propinas', 'campanas', 'encuestas', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' },
@@ -87,6 +89,7 @@ const Configuracion: FC = () => {
const esAdmin = user?.role === 'admin';
// Cada pestaña requiere su permiso por acción (Configuración › Roles › Permisos).
const TABS_PERM: Record<string, string> = {
direccion: 'config.editar',
usuarios: 'config.usuarios',
roles: 'config.roles',
auditoria: 'config.auditoria',
@@ -518,6 +521,12 @@ const Configuracion: FC = () => {
<ApiKeysPanel />
) : tab === 'webhooks' ? (
<WebhooksPanel />
) : tab === 'direccion' ? (
<Card>
<Card.Body>
<DireccionPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : (
<>
<Card className="mb-4 sm:mb-6">

View File

@@ -0,0 +1,694 @@
import type { FC, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ArrowDownRight,
ArrowUpRight,
Minus,
Users,
CalendarCheck,
DollarSign,
Receipt,
Activity,
AlertTriangle,
Gauge,
} from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Layout from '../components/Layout';
import { Card, EmptyState, Input, Select, Skeleton, PageHeader, toast } from '../components/ui';
import { ExportButton } from '../components/ui/ExportButton';
import { exportToExcel } from '../lib/exporter';
import { odooApi, type DireccionTablero, type TableroComparativo } from '../services/odoo';
import { useClinicSettings } from '../lib/clinic-settings';
import { useSucursales } from '../lib/sucursales';
import { useGlosario } from '../lib/glosario';
import { useI18n } from '../lib/i18n';
import { rangeForPreset, isoDate } from '../lib/dateRange';
type Preset = 'hoy' | 'semana' | 'mes' | 'mes_pasado' | 'anio' | 'custom';
const PRESETS: { value: Preset; key: string }[] = [
{ value: 'hoy', key: 'dir.preset.hoy' },
{ value: 'semana', key: 'dir.preset.semana' },
{ value: 'mes', key: 'dir.preset.mes' },
{ value: 'mes_pasado', key: 'dir.preset.mesPasado' },
{ value: 'anio', key: 'dir.preset.anio' },
{ value: 'custom', key: 'dir.preset.custom' },
];
const money = (v: number | null | undefined) =>
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const num = (v: number | null | undefined, d = 0) =>
(v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: d, maximumFractionDigits: d });
const pct = (v: number | null | undefined) =>
v === null || v === undefined ? '—' : `${Math.round(v * 100)}%`;
const rangoPreset = (p: Preset): { start: string; end: string } => {
if (p === 'anio') {
const y = new Date().getFullYear();
return { start: `${y}-01-01`, end: isoDate(new Date()) };
}
return rangeForPreset((p === 'custom' ? 'hoy' : p) as 'hoy' | 'semana' | 'mes' | 'mes_pasado');
};
const Kpi: FC<{ label: string; value: ReactNode; hint?: ReactNode; icon?: ReactNode; variant?: 'a' | 'b' | 'c' }> = ({
label, value, hint, icon, variant,
}) => (
<Card className={`p-4 border-0 ${variant === 'a' ? 'bg-theme-bg' : variant === 'b' ? 'bg-theme-surface' : 'bg-theme-bg'}`}>
<div className="flex items-center gap-2 mb-1">
{icon && <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 text-theme-heading">{value}</p>
{hint && <p className="text-[11px] text-theme-muted mt-0.5">{hint}</p>}
</Card>
);
const Variacion: FC<{ v: number | null; label: string }> = ({ v, label }) => {
if (v === null || v === undefined) {
return <span className="inline-flex items-center gap-1 text-[11px] text-theme-muted"><Minus size={12} /> {label}</span>;
}
const up = v >= 0;
return (
<span className={`inline-flex items-center gap-1 text-[11px] font-medium ${up ? 'text-emerald-600' : 'text-rose-600'}`}>
{up ? <ArrowUpRight size={12} /> : <ArrowDownRight size={12} />}
{(v * 100).toFixed(1)}% <span className="text-theme-muted font-normal">{label}</span>
</span>
);
};
const Direccion: FC = () => {
const { t } = useI18n();
const { term } = useGlosario();
const { sucursales, labelFor } = useSucursales();
const { tablero } = useClinicSettings();
const navigate = useNavigate();
const [sp] = useSearchParams();
const [preset, setPreset] = useState<Preset>('mes');
const [start, setStart] = useState(() => rangoPreset('mes').start);
const [end, setEnd] = useState(() => rangoPreset('mes').end);
const [sucursal, setSucursal] = useState<string>(sp.get('sucursal') || '');
const [comparar, setComparar] = useState<TableroComparativo>(tablero.comparativo_default);
const [data, setData] = useState<DireccionTablero | null>(null);
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getDireccion({ start, end, sucursal: sucursal || undefined, comparar });
if (res.status === 'success') setData(res);
else toast.error(res.message || t('dir.error'));
} catch (err) {
toast.error(t('dir.error'));
console.error(err);
} finally {
setLoading(false);
}
}, [start, end, sucursal, comparar, t]);
useEffect(() => { load(); }, [load]);
const cambiarPreset = (p: Preset) => {
setPreset(p);
if (p !== 'custom') {
const r = rangoPreset(p);
setStart(r.start);
setEnd(r.end);
}
};
const cmpLabel = (comparar as TableroComparativo) === 'anio' ? t('dir.vsAnio') : t('dir.vsAnterior');
const opcionesSucursal = useMemo(
() => [{ value: '', label: t('dir.todasSucursales') },
...sucursales.map((s) => ({ value: s.code, label: s.name }))],
[sucursales, t],
);
const exportar = async () => {
if (!data) return;
try {
setExporting(true);
const rows: Record<string, unknown>[] = [];
const push = (sec: string, concepto: string, valor: number | string | null, detalle = '') =>
rows.push({ seccion: sec, concepto, valor, detalle });
const r = data.resumen;
const secRes = t('dir.section.resumen');
push(secRes, t('dir.kpi.ingresos'), r.ingresos_cobrados);
push(secRes, t('dir.kpi.ventas'), r.ventas_total);
push(secRes, t('dir.kpi.ticket'), r.ticket_promedio);
push(secRes, t('dir.kpi.visitas'), r.visitas_atendidas);
push(secRes, t('dir.kpi.nuevos'), r.pacientes_nuevos);
push(secRes, t('dir.kpi.recurrentes'), r.pacientes_recurrentes);
push(secRes, t('dir.kpi.noShow'), r.tasa_no_show);
push(secRes, t('dir.kpi.cancelacion'), r.tasa_cancelacion);
const secCli = t('dir.section.clientes');
push(secCli, t('dir.kpi.activos'), data.clientes.activos);
push(secCli, t('dir.kpi.frecuencia'), data.clientes.frecuencia_promedio);
push(secCli, t('dir.kpi.valor'), data.clientes.valor_por_cliente.promedio);
for (const c of data.clientes.retencion_cohortes) {
push(secCli, `${t('dir.clientes.cohorte')} ${c.cohorte} (${t('dir.clientes.tamano')} ${c.tamano})`,
c.ret1_tasa, `1m=${pct(c.ret1_tasa)} 3m=${pct(c.ret3_tasa)} 6m=${pct(c.ret6_tasa)} 12m=${pct(c.ret12_tasa)}`);
}
const secOc = t('dir.section.ocupacion');
for (const m of data.ocupacion.por_medico) {
push(secOc, `${t('dir.ocup.porMedico')}: ${m.medico}`, m.horas_vendidas, `${pct(m.ocupacion)} (${num(m.horas_disponibles, 1)}h)`);
}
for (const s of data.ocupacion.por_sucursal) {
push(secOc, `${t('dir.ocup.porSucursal')}: ${labelFor(s.sucursal)}`, s.horas_vendidas, pct(s.ocupacion));
}
for (const fr of data.ocupacion.franjas) push(secOc, `${t('dir.ocup.franjas')} ${fr.hora}`, fr.total);
const secIn = t('dir.section.ingresos');
for (const s of data.ingresos.por_servicio) push(secIn, `${t('dir.ing.porServicio')}: ${s.servicio}`, s.total, `${num(s.cantidad, 1)}`);
for (const c of data.ingresos.por_categoria) push(secIn, `${t('dir.ing.porCategoria')}: ${c.categoria}`, c.total);
for (const s of data.ingresos.por_sucursal) push(secIn, `${t('dir.ing.porSucursal')}: ${labelFor(s.sucursal)}`, s.total);
for (const [k, v] of Object.entries(data.ingresos.por_metodo_pago)) push(secIn, `${t('dir.ing.porMetodo')}: ${k}`, v);
push(secIn, t('dir.ing.propinas'), r.propinas);
const secMe = t('dir.section.metas');
push(secMe, `${t('dir.metas.mes')} ${data.metas.mes.mes}`, data.metas.mes.ventas, `${pct(data.metas.mes.pct)} · ${money(data.metas.mes.meta)}`);
for (const m of data.metas.por_sucursal) push(secMe, `${t('dir.metas.porSucursal')}: ${labelFor(m.sucursal)}`, m.ventas_mes, `${pct(m.pct)} · ${money(m.meta_mensual)}`);
for (const m of data.metas.por_empleado) push(secMe, `${t('dir.metas.porEmpleado')}: ${m.empleado}`, m.ventas_mes, `${pct(m.pct)} · ${money(m.meta_mensual)}`);
await exportToExcel({
filename: `direccion-${start}_${end}`,
sheetName: t('dir.title'),
title: `${t('dir.title')} · ${data.start} a ${data.end}`,
columns: [
{ header: t('dir.metas.concepto'), key: 'seccion' },
{ header: t('dir.ing.concepto'), key: 'concepto' },
{ header: t('dir.ing.total'), key: 'valor', format: 'number' },
{ header: t('dir.ms'), key: 'detalle' },
],
rows,
});
toast.success(t('dir.exportOk'));
} catch (err) {
toast.error(t('dir.exportError'));
console.error(err);
} finally {
setExporting(false);
}
};
return (
<Layout title={t('dir.title')} subtitle={t('dir.subtitle')}>
<Card className="mb-6">
<Card.Body>
<div className="flex flex-col lg:flex-row gap-3 lg:items-end">
<Select
label={t('dir.periodo')}
value={preset}
onChange={(e) => cambiarPreset(e.target.value as Preset)}
options={PRESETS.map((p) => ({ value: p.value, label: t(p.key) }))}
className="lg:max-w-[170px]"
/>
<Input label={t('dir.inicio')} type="date" value={start}
onChange={(e) => { setStart(e.target.value); setPreset('custom'); }} className="lg:max-w-[150px]" />
<Input label={t('dir.fin')} type="date" value={end}
onChange={(e) => { setEnd(e.target.value); setPreset('custom'); }} className="lg:max-w-[150px]" />
<Select
label={t('dir.sucursal')}
value={sucursal}
onChange={(e) => setSucursal(e.target.value)}
options={opcionesSucursal}
className="lg:max-w-[200px]"
/>
<Select
label={t('dir.comparar')}
value={comparar}
onChange={(e) => setComparar(e.target.value as TableroComparativo)}
options={[
{ value: 'anterior', label: t('dir.cmp.anterior') },
{ value: 'anio', label: t('dir.cmp.anio') },
{ value: 'ninguno', label: t('dir.cmp.ninguno') },
]}
className="lg:max-w-[210px]"
/>
<div className="lg:ml-auto">
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
</Card.Body>
</Card>
{loading ? (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
<Skeleton className="h-64 w-full" />
</div>
) : !data ? (
<EmptyState title={t('dir.title')} subtitle={t('dir.error')} />
) : (
<div className="space-y-6">
{/* ---- Resumen ---- */}
<section>
<PageHeader title={t('dir.section.resumen')} />
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Kpi label={t('dir.kpi.ingresos')} value={money(data.resumen.ingresos_cobrados)}
icon={<DollarSign size={15} />}
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.ingresos_cobrados} label={cmpLabel} /> : undefined} />
<Kpi label={t('dir.kpi.ventas')} value={money(data.resumen.ventas_total)}
icon={<Receipt size={15} />}
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.ventas_total} label={cmpLabel} /> : undefined} />
<Kpi label={t('dir.kpi.ticket')} value={money(data.resumen.ticket_promedio)}
icon={<Activity size={15} />}
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.ticket_promedio} label={cmpLabel} /> : undefined} />
<Kpi label={t('dir.kpi.visitas')} value={num(data.resumen.visitas_atendidas)}
icon={<CalendarCheck size={15} />}
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.visitas_atendidas} label={cmpLabel} /> : undefined} />
<Kpi label={t('dir.kpi.nuevos')} value={num(data.resumen.pacientes_nuevos)}
icon={<Users size={15} />}
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.pacientes_nuevos} label={cmpLabel} /> : undefined} />
<Kpi label={t('dir.kpi.recurrentes')} value={num(data.resumen.pacientes_recurrentes)}
icon={<Users size={15} />}
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.pacientes_recurrentes} label={cmpLabel} /> : undefined} />
<Kpi label={t('dir.kpi.noShow')} value={pct(data.resumen.tasa_no_show)}
icon={<AlertTriangle size={15} />}
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.tasa_no_show} label={cmpLabel} /> : undefined} />
<Kpi label={t('dir.kpi.cancelacion')} value={pct(data.resumen.tasa_cancelacion)}
icon={<AlertTriangle size={15} />}
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.tasa_cancelacion} label={cmpLabel} /> : undefined} />
</div>
</section>
{/* ---- Clientes ---- */}
<section>
<PageHeader title={t('dir.section.clientes')} />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<Kpi label={t('dir.kpi.activos')} value={num(data.clientes.activos)} icon={<Users size={15} />} />
<Kpi label={t('dir.kpi.frecuencia')} value={num(data.clientes.frecuencia_promedio, 2)} hint={t('dir.kpi.frecuenciaHint')} />
<Kpi label={t('dir.kpi.valor')} value={money(data.clientes.valor_por_cliente.promedio)}
hint={`${t('dir.kpi.valorHint')} · ${t('dir.clientes.valorMediana')}: ${money(data.clientes.valor_por_cliente.mediana)}`} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.clientes.nuevosMes')}</h3>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={data.clientes.nuevos_por_mes.map((m) => ({ mes: m.mes.slice(5), total: m.total }))}
margin={{ top: 4, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
<XAxis dataKey="mes" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 11 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 11 }} allowDecimals={false} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
<Bar dataKey="total" fill="#57534e" radius={[4, 4, 0, 0]} name={t('dir.clientes.tamano')} />
</BarChart>
</ResponsiveContainer>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-1">{t('dir.clientes.retencion')}</h3>
<p className="text-xs text-theme-muted mb-3">{t('dir.clientes.retencionHint')}</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-3 py-2">{t('dir.clientes.cohorte')}</th>
<th className="text-right px-3 py-2">{t('dir.clientes.tamano')}</th>
<th className="text-right px-3 py-2">{t('dir.clientes.m1')}</th>
<th className="text-right px-3 py-2">{t('dir.clientes.m3')}</th>
<th className="text-right px-3 py-2">{t('dir.clientes.m6')}</th>
<th className="text-right px-3 py-2">{t('dir.clientes.m12')}</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.clientes.retencion_cohortes.map((c) => (
<tr key={c.cohorte} className="hover:bg-theme-bg">
<td className="px-3 py-2 font-medium text-theme-heading">{c.cohorte}</td>
<td className="px-3 py-2 text-right text-theme-muted">{c.tamano}</td>
<td className="px-3 py-2 text-right text-theme-heading">{pct(c.ret1_tasa)}</td>
<td className="px-3 py-2 text-right text-theme-heading">{pct(c.ret3_tasa)}</td>
<td className="px-3 py-2 text-right text-theme-heading">{pct(c.ret6_tasa)}</td>
<td className="px-3 py-2 text-right text-theme-heading">{pct(c.ret12_tasa)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card.Body>
</Card>
</div>
<Card className="mt-4">
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
<div>
<h3 className="font-heading text-lg text-theme-heading">{t('dir.clientes.riesgo')}</h3>
<p className="text-xs text-theme-muted">
{t('dir.clientes.riesgoHint', {
visitas: data.clientes.en_riesgo.visitas_minimas,
dias: data.clientes.en_riesgo.dias_inactividad,
})} · {num(data.clientes.en_riesgo.total)}
</p>
</div>
<button
type="button"
onClick={() => navigate(`${data.clientes.en_riesgo.link_campana}?nuevo_segmento=1&ultima_visita_dias=${data.clientes.en_riesgo.filtro_campana.ultima_visita_dias}`)}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium bg-theme-accent text-theme-inverse hover:opacity-90 transition"
>
{t('dir.clientes.crearCampana')}
</button>
</div>
{data.clientes.en_riesgo.pacientes.length === 0 ? (
<p className="text-sm text-theme-muted">{t('dir.sinDatos')}</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-3 py-2">{term('patient_singular')}</th>
<th className="text-left px-3 py-2">{t('common.phone')}</th>
<th className="text-right px-3 py-2">{t('dir.clientes.visitas')}</th>
<th className="text-left px-3 py-2">{t('dir.clientes.ultimaVisita')}</th>
<th className="text-right px-3 py-2">{t('dir.clientes.diasSinVenir')}</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.clientes.en_riesgo.pacientes.map((p) => (
<tr key={p.id} className="hover:bg-theme-bg">
<td className="px-3 py-2 font-medium text-theme-heading">{p.name}</td>
<td className="px-3 py-2 text-theme-muted">{p.phone || '—'}</td>
<td className="px-3 py-2 text-right text-theme-muted">{p.visitas}</td>
<td className="px-3 py-2 text-theme-muted">{p.ultima_visita || '—'}</td>
<td className="px-3 py-2 text-right font-medium text-rose-600">{p.dias_sin_venir ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</section>
{/* ---- Ocupación ---- */}
<section>
<PageHeader title={t('dir.section.ocupacion')}
subtitle={`${num(data.ocupacion.dias_habiles)} ${t('common.date').toLowerCase()} · ${num(data.ocupacion.jornada_horas, 1)}h`} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.ocup.porMedico')}</h3>
{data.ocupacion.por_medico.length === 0 ? (
<p className="text-sm text-theme-muted">{t('dir.sinDatos')}</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-3 py-2">{term('doctor_singular')}</th>
<th className="text-right px-3 py-2">{t('dir.ocup.horasVendidas')}</th>
<th className="text-right px-3 py-2">{t('dir.ocup.horasDisponibles')}</th>
<th className="text-right px-3 py-2">{t('dir.ocup.ocupacion')}</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.ocupacion.por_medico.map((m) => (
<tr key={m.medico} className="hover:bg-theme-bg">
<td className="px-3 py-2 font-medium text-theme-heading">{m.medico}</td>
<td className="px-3 py-2 text-right text-theme-muted">{num(m.horas_vendidas, 1)}</td>
<td className="px-3 py-2 text-right text-theme-muted">{num(m.horas_disponibles, 1)}</td>
<td className="px-3 py-2 text-right font-medium text-theme-heading">{pct(m.ocupacion)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-1">{t('dir.ocup.franjas')}</h3>
<p className="text-xs text-theme-muted mb-3">{t('dir.ocup.franjasHint')}</p>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={data.ocupacion.franjas.map((f) => ({ hora: f.hora.slice(0, 2), total: f.total }))}
margin={{ top: 4, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
<XAxis dataKey="hora" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 11 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 11 }} allowDecimals={false} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
<Bar dataKey="total" fill="#a8a29e" radius={[4, 4, 0, 0]} name={t('dir.ocup.citas')} />
</BarChart>
</ResponsiveContainer>
<div className="grid grid-cols-2 gap-3 mt-3 text-xs">
<div>
<p className="text-theme-muted mb-1 flex items-center gap-1"><Gauge size={12} /> {t('dir.ocup.masOcupadas')}</p>
{data.ocupacion.franjas_mas_ocupadas.map((f) => (
<p key={`mx-${f.hora}`} className="text-theme-heading">{f.hora} · {f.total}</p>
))}
</div>
<div>
<p className="text-theme-muted mb-1">{t('dir.ocup.menosOcupadas')}</p>
{data.ocupacion.franjas_menos_ocupadas.map((f) => (
<p key={`mn-${f.hora}`} className="text-theme-heading">{f.hora} · {f.total}</p>
))}
</div>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.ocup.porSucursal')}</h3>
{data.ocupacion.por_sucursal.length === 0 ? (
<p className="text-sm text-theme-muted">{t('dir.sinDatos')}</p>
) : (
<table className="w-full text-sm">
<tbody className="divide-y divide-theme-border">
{data.ocupacion.por_sucursal.map((s) => (
<tr key={s.sucursal} className="hover:bg-theme-bg">
<td className="py-2 font-medium text-theme-heading">{labelFor(s.sucursal)}</td>
<td className="py-2 text-right text-theme-muted">{num(s.horas_vendidas, 1)}h</td>
<td className="py-2 text-right font-medium text-theme-heading">{pct(s.ocupacion)}</td>
</tr>
))}
</tbody>
</table>
)}
{data.ocupacion.por_recurso.length > 0 && (
<>
<h3 className="font-heading text-base text-theme-heading mt-4 mb-2">{t('dir.ocup.porRecurso')}</h3>
<table className="w-full text-sm">
<tbody className="divide-y divide-theme-border">
{data.ocupacion.por_recurso.map((r) => (
<tr key={r.recurso_id} className="hover:bg-theme-bg">
<td className="py-2 font-medium text-theme-heading">{r.recurso}</td>
<td className="py-2 text-right text-theme-muted">{num(r.horas_ocupadas, 1)}h</td>
<td className="py-2 text-right font-medium text-theme-heading">{num(r.ocupacion_pct, 1)}%</td>
</tr>
))}
</tbody>
</table>
</>
)}
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.ocup.noShow')}</h3>
{data.ocupacion.no_show_por_medico.length === 0 ? (
<p className="text-sm text-theme-muted">{t('dir.sinDatos')}</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-3 py-2">{term('doctor_singular')}</th>
<th className="text-right px-3 py-2">{t('dir.ocup.citas')}</th>
<th className="text-right px-3 py-2">{t('dir.kpi.noShow')}</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.ocupacion.no_show_por_medico.map((m) => (
<tr key={m.medico} className="hover:bg-theme-bg">
<td className="px-3 py-2 font-medium text-theme-heading">{m.medico}</td>
<td className="px-3 py-2 text-right text-theme-muted">{m.citas}</td>
<td className={`px-3 py-2 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>
</div>
</section>
{/* ---- Ingresos ---- */}
<section>
<PageHeader title={t('dir.section.ingresos')} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.ing.porServicio')}</h3>
<TablaSimple
cols={[t('dir.ing.concepto'), t('dir.ing.cantidad'), t('dir.ing.total')]}
rows={data.ingresos.por_servicio.slice(0, 12).map((s) => [s.servicio, num(s.cantidad, 1), money(s.total)])}
empty={t('dir.sinDatos')}
/>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.ing.porCategoria')}</h3>
<TablaSimple
cols={[t('dir.ing.concepto'), t('dir.ing.total')]}
rows={data.ingresos.por_categoria.map((c) => [c.categoria, money(c.total)])}
empty={t('dir.sinDatos')}
/>
<h3 className="font-heading text-base text-theme-heading mt-4 mb-2">{t('dir.ing.porSucursal')}</h3>
<TablaSimple
cols={[t('dir.ing.concepto'), t('dir.ing.total')]}
rows={data.ingresos.por_sucursal.map((s) => [labelFor(s.sucursal), money(s.total)])}
empty={t('dir.sinDatos')}
/>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.ing.porMetodo')}</h3>
<TablaSimple
cols={[t('dir.ing.concepto'), t('dir.ing.total')]}
rows={Object.entries(data.ingresos.por_metodo_pago).map(([k, v]) => [k, money(v)])}
empty={t('dir.sinDatos')}
/>
<div className="mt-4 flex items-center justify-between text-sm">
<span className="text-theme-muted">{t('dir.ing.propinas')}</span>
<span className="font-medium text-theme-heading">{money(data.resumen.propinas)}</span>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.ing.membresias')}</h3>
<Linea label={t('dir.ing.activas')} value={num(data.ingresos.membresias.activas)} />
<Linea label={t('dir.ing.mrr')} value={money(data.ingresos.membresias.mrr_estimado)} />
<Linea label={t('dir.ing.renovaciones')} value={num(data.ingresos.membresias.renovaciones)} />
<Linea label={t('dir.ing.cancelaciones')} value={num(data.ingresos.membresias.cancelaciones)} />
<h3 className="font-heading text-base text-theme-heading mt-4 mb-2">{t('dir.ing.paquetes')}</h3>
<Linea label={t('dir.ing.paquetesVendidos')} value={num(data.ingresos.paquetes.vendidos)} />
<Linea label={t('dir.ing.sesionesPendientes')} value={num(data.ingresos.paquetes.sesiones_pendientes)} />
<h3 className="font-heading text-base text-theme-heading mt-4 mb-2">{t('dir.ing.lealtad')}</h3>
<Linea label={t('dir.ing.puntosActivos')} value={num(data.ingresos.lealtad.puntos_activos)} />
<Linea label={t('dir.ing.pasivoLealtad')} value={money(data.ingresos.lealtad.pasivo_mxn)} />
</Card.Body>
</Card>
</div>
</section>
{/* ---- Metas ---- */}
<section>
<PageHeader title={t('dir.section.metas')} />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<Kpi label={`${t('dir.metas.mes')} ${data.metas.mes.mes}`} value={money(data.metas.mes.ventas)}
hint={`${t('dir.metas.meta')}: ${money(data.metas.mes.meta)} · ${pct(data.metas.mes.pct)}`} />
<Kpi label={t('dir.metas.proyeccion')} value={money(data.metas.mes.proyeccion)}
hint={`${t('dir.metas.proyeccion')} vs ${t('dir.metas.meta')}: ${pct(data.metas.mes.proyeccion_vs_meta)}`} />
<Kpi label={t('dir.metas.periodo')} value={money(data.metas.periodo.proyeccion_ventas)}
hint={`${t('dir.metas.cobrosPeriodo')}: ${money(data.metas.periodo.proyeccion_cobros)}`} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.metas.porSucursal')}</h3>
<TablaSimple
cols={[t('dir.sucursal'), t('dir.metas.meta'), t('dir.metas.avance'), t('dir.metas.pct'), t('dir.metas.proyeccion')]}
rows={data.metas.por_sucursal.map((m) => [
labelFor(m.sucursal), money(m.meta_mensual), money(m.ventas_mes),
m.pct === null ? t('dir.metas.sinMeta') : pct(m.pct), money(m.proyeccion),
])}
empty={t('dir.sinDatos')}
/>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.metas.porEmpleado')}</h3>
<TablaSimple
cols={[t('dir.metas.empleado'), t('dir.metas.meta'), t('dir.metas.avance'), t('dir.metas.pct')]}
rows={data.metas.por_empleado.slice(0, 15).map((m) => [
m.empleado, money(m.meta_mensual), money(m.ventas_mes),
m.pct === null ? t('dir.metas.sinMeta') : pct(m.pct),
])}
empty={t('dir.sinDatos')}
/>
</Card.Body>
</Card>
</div>
</section>
{/* ---- No disponible ---- */}
{data.no_disponible.length > 0 && (
<section>
<Card className="border border-amber-200 bg-amber-50/50">
<Card.Body>
<h3 className="font-heading text-base text-theme-heading mb-1 flex items-center gap-2">
<AlertTriangle size={15} /> {t('dir.noDisponible')}
</h3>
<p className="text-xs text-theme-muted mb-2">{t('dir.noDisponibleHint')}</p>
<ul className="text-sm text-theme-heading space-y-1">
{data.no_disponible.map((n) => (
<li key={n.clave}>
<span className="font-medium">{n.etiqueta}</span>
<span className="text-theme-muted"> — {n.motivo}</span>
</li>
))}
</ul>
</Card.Body>
</Card>
</section>
)}
<p className="text-[11px] text-theme-muted text-right">{t('dir.ms')}: {data.ms} ms</p>
</div>
)}
</Layout>
);
};
const TablaSimple: FC<{ cols: string[]; rows: (string | number)[][]; empty: string }> = ({ cols, rows, empty }) => (
rows.length === 0 ? <p className="text-sm text-theme-muted">{empty}</p> : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
{cols.map((c, i) => (
<th key={c} className={`px-3 py-2 ${i === 0 ? 'text-left' : 'text-right'}`}>{c}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{rows.map((r, ri) => (
<tr key={ri} className="hover:bg-theme-bg">
{r.map((cell, ci) => (
<td key={ci} className={`px-3 py-2 ${ci === 0 ? 'font-medium text-theme-heading' : 'text-right text-theme-muted'}`}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
);
const Linea: FC<{ label: string; value: string }> = ({ label, value }) => (
<div className="flex items-center justify-between text-sm py-1">
<span className="text-theme-muted">{label}</span>
<span className="font-medium text-theme-heading">{value}</span>
</div>
);
export default Direccion;

View File

@@ -1099,6 +1099,192 @@ export interface ClinicSettings {
portal_cliente: PortalClienteSettings;
campanas: CampanasSettings;
encuestas: EncuestasSettings;
/** Parámetros del tablero de dirección y metas por sucursal. */
tablero: TableroSettings;
}
// ---- Tablero de dirección (bloque `tablero` de clinic-settings) ----
export type TableroComparativo = 'anterior' | 'anio' | 'ninguno';
export interface TableroMetaSucursal {
sucursal: string;
meta_mensual: number;
}
export interface TableroSettings {
metas_sucursal: TableroMetaSucursal[];
meta_mensual_global: number;
dias_inactividad: number;
visitas_minimas_riesgo: number;
comparativo_default: TableroComparativo;
}
export const TABLERO_DEFAULT: TableroSettings = {
metas_sucursal: [],
meta_mensual_global: 0,
dias_inactividad: 90,
visitas_minimas_riesgo: 3,
comparativo_default: 'anterior',
};
// ---- Respuesta del endpoint del tablero de dirección ----
export interface DireccionKpis {
ingresos_cobrados: number;
ventas_total: number;
ventas_count: number;
pagado_en_ventas: number;
ticket_promedio: number;
propinas: number;
visitas_atendidas: number;
citas_total: number;
citas_activas: number;
pacientes_activos: number;
pacientes_nuevos: number;
pacientes_recurrentes: number;
tasa_no_show: number;
tasa_cancelacion: number;
por_dia?: { date: string; total: number }[];
}
export interface DireccionComparativo {
modo: TableroComparativo;
start: string;
end: string;
kpis: DireccionKpis;
variacion: Record<string, number | null>;
}
export interface DireccionCohorte {
cohorte: string;
tamano: number;
ret1: number; ret1_tasa: number | null;
ret3: number; ret3_tasa: number | null;
ret6: number; ret6_tasa: number | null;
ret12: number; ret12_tasa: number | null;
}
export interface DireccionClienteRiesgo {
id: number;
name: string;
phone: string;
ultima_visita: string | null;
visitas: number;
dias_sin_venir: number | null;
}
export interface DireccionClientes {
activos: number;
frecuencia_promedio: number;
frecuencia_historica: number;
nuevos_por_mes: { mes: string; total: number }[];
retencion_cohortes: DireccionCohorte[];
valor_por_cliente: {
promedio: number; mediana: number; total: number; pacientes_con_ventas: number;
};
en_riesgo: {
total: number;
dias_inactividad: number;
visitas_minimas: number;
pacientes: DireccionClienteRiesgo[];
filtro_campana: { ultima_visita_dias: number };
link_campana: string;
};
}
export interface DireccionMedico {
medico: string;
horas_vendidas: number;
horas_disponibles: number;
horas_libres: number;
ocupacion: number;
}
export interface DireccionFranja {
hora: string;
total: number;
by_state: Record<string, number>;
}
export interface DireccionOcupacion {
dias_habiles: number;
jornada_horas: number;
por_medico: DireccionMedico[];
no_show_por_medico: { medico: string; citas: number; no_shows: number; tasa: number }[];
por_sucursal: { sucursal: string; horas_vendidas: number; horas_disponibles: number; ocupacion: number }[];
por_recurso: {
recurso_id: number; recurso: string; citas: number;
horas_ocupadas: number; horas_disponibles: number; ocupacion_pct: number;
}[];
franjas: DireccionFranja[];
franjas_mas_ocupadas: DireccionFranja[];
franjas_menos_ocupadas: DireccionFranja[];
total_horas_vendidas: number;
total_horas_ocupadas_recursos: number;
}
export interface DireccionIngresos {
por_servicio: { service_id: number; servicio: string; cantidad: number; total: number }[];
por_categoria: { categoria: string; total: number; cantidad: number }[];
por_sucursal: { sucursal: string; total: number; ventas: number }[];
por_metodo_pago: Record<string, number>;
membresias: {
activas: number; mrr_estimado: number; renovaciones: number;
ingreso_recurrente_periodo: number; altas: number; cancelaciones: number;
};
paquetes: { vendidos: number; total_vendido: number; activos: number; sesiones_pendientes: number };
lealtad: {
valor_punto: number; puntos_activos: number; pasivo_mxn: number;
otorgados: number; canjeados: number; expirados: number;
};
}
export interface DireccionMetaSucursalFila {
sucursal: string;
meta_mensual: number;
ventas_mes: number;
pct: number | null;
proyeccion: number;
}
export interface DireccionMetaEmpleado {
employee_id: number;
empleado: string;
meta_mensual: number;
ventas_mes: number;
pct: number | null;
proyeccion: number;
}
export interface DireccionMetas {
por_sucursal: DireccionMetaSucursalFila[];
por_empleado: DireccionMetaEmpleado[];
periodo: {
dias: number; dias_transcurridos: number; ventas: number; cobros: number;
proyeccion_ventas: number; proyeccion_cobros: number;
};
mes: {
mes: string; dias_mes: number; dias_transcurridos: number; ventas: number;
meta: number; pct: number | null; proyeccion: number;
proyeccion_vs_meta: number | null;
};
}
export interface DireccionNoDisponible { clave: string; etiqueta: string; motivo: string; }
export interface DireccionTablero {
status: string;
start: string;
end: string;
sucursal: string | null;
comparativo: DireccionComparativo | null;
resumen: DireccionKpis;
clientes: DireccionClientes;
ocupacion: DireccionOcupacion;
ingresos: DireccionIngresos;
metas: DireccionMetas;
no_disponible: DireccionNoDisponible[];
ms: number;
message?: string;
}
// ---- Licencia por plan (bloque `licencia` de clinic-settings) ----
@@ -2801,6 +2987,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = { nombre: BRAND_DEFAULT.
portal_cliente: PORTAL_CLIENTE_DEFAULT,
campanas: CAMPANAS_DEFAULT,
encuestas: ENCUESTAS_DEFAULT,
tablero: TABLERO_DEFAULT,
};
export interface FrontendUser {
@@ -5053,6 +5240,12 @@ export const odooApi = {
return data;
},
// Tablero de dirección (indicadores del dueño). Solo lectura.
async getDireccion(params: { start?: string; end?: string; sucursal?: string; comparar?: TableroComparativo }): Promise<DireccionTablero> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/direccion`, { params });
return data;
},
async getNotifications(): Promise<{ status: string; notifications: AppNotification[]; total: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/notifications`);
return data;