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:
@@ -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>} />
|
||||
|
||||
133
frontend/src/components/DireccionPanel.tsx
Normal file
133
frontend/src/components/DireccionPanel.tsx
Normal 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;
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
694
frontend/src/pages/Direccion.tsx
Normal file
694
frontend/src/pages/Direccion.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user