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

@@ -615,6 +615,80 @@ todo respeta el **modo prueba** y queda en la bitácora con su campaña o encues
de origen.
## 14 quater. Tablero de dirección (indicadores del dueño)
Pantalla **Dirección** (menú, ruta `/direccion`) con los indicadores del
negocio en una sola vista. Es de **solo lectura** y **no depende de ningún
interruptor**: se ve quien tenga el permiso.
- **Permisos**: `direccion.ver` — por defecto **admin, gerencia y marketing**.
El rol **médico no lo ve** (ni la sección de menú ni la API: el endpoint
responde 403). Se administra en **Configuración → Roles → Permisos**.
- **Filtros**: rango de fechas con atajos (hoy, esta semana, este mes, mes
pasado, este año, personalizado), **sucursal** y un **comparativo**
(periodo anterior equivalente, mismo periodo del año pasado o ninguno).
Todo el tablero se **exporta a Excel**.
- **Secciones**:
1. **Resumen del periodo**: ingresos cobrados, ventas, ticket promedio,
visitas atendidas, pacientes nuevos y recurrentes, tasa de no presentados
y de cancelación, con la variación contra el comparativo elegido.
2. **Clientes**: pacientes activos, nuevos por mes, **retención por
cohortes** (de quienes vinieron por primera vez en un mes, cuántos
volvieron en 1, 3, 6 y 12 meses), **frecuencia promedio** de visita,
**valor por cliente** (ingreso histórico promedio y mediana) y **clientes
en riesgo** (los que solían venir con regularidad y llevan más de X días
sin aparecer), con un botón directo para **armar una campaña** con ellos.
3. **Ocupación y agenda**: horas vendidas contra disponibles por médico, por
sucursal y por recurso, porcentaje de ocupación, franjas horarias más y
menos ocupadas y no-show por médico.
4. **Ingresos por concepto**: por servicio, categoría, sucursal y método de
pago, **propinas**, membresías (activas, ingreso recurrente mensual
estimado, renovaciones y cancelaciones), paquetes (vendidos y sesiones
pendientes de usar, que son un compromiso del negocio) y el **pasivo de
lealtad** en pesos.
5. **Metas y proyección**: metas por sucursal y por empleado, avance del mes
y **proyección de cierre** con el ritmo actual.
- **Indicadores no disponibles**: los que no se pueden calcular con los datos
del sistema (p. ej. entregas de productos o resultados de WhatsApp) se
muestran como **no disponible** con su motivo; nunca se estiman.
- **Criterios de cálculo** (coinciden con los reportes existentes):
- *Ingresos cobrados*: pagos con estado **completado** del periodo (igual que
**Reportes → Cortes/Caja** y el *Concentrado*).
- *Ventas*: ventas **no canceladas** del periodo (las devoluciones se
reportan aparte); *ventas cobradas* = suma de lo pagado en esas ventas.
- *Pacientes nuevos*: citas del periodo cuya fecha es la **primera cita
histórica** no cancelada del paciente (igual que **Reportes → KPIs**).
- *No-show y ocupación por médico*: mismas reglas que **KPIs**; *franjas
horarias* igual que **Horas agenda**; *recursos* igual que **Recursos**.
- *Pasivo de lealtad* y *propinas*: mismos criterios que sus reportes.
### 14 quater.1 Bloque de ajustes `tablero`
**Configuración → Dirección**. Guarda los parámetros del tablero; con los
valores por defecto el tablero funciona igual y solo deja de mostrar el avance
de metas. Claves (defaults):
- `metas_sucursal`: lista `[{sucursal: <código>, meta_mensual: <monto>}]`
(default: **vacía**). Es la **meta mensual de venta de cada sucursal**.
- `meta_mensual_global`: meta de venta mensual de toda la clínica; **0** usa la
suma de las metas por sucursal (default: **0**).
- `dias_inactividad`: días sin venir para considerar a un cliente **en riesgo**
(default: **90**).
- `visitas_minimas_riesgo`: visitas históricas mínimas para entrar a *en
riesgo*; un paciente de una sola visita no se marca (default: **3**).
- `comparativo_default`: `anterior` | `anio` | `ninguno` (default:
**anterior**).
La clave se llama **`tablero`** y no `direccion` para no chocar con el campo
homónimo de ajustes que guarda la **dirección postal** de la clínica. Se guarda
dentro del JSON de `skeen.clinic.settings` (`GET`/`PUT
/skeen/frontend/v1/clinic-settings`), con **merge anidado**: un PUT parcial no
borra el resto.
El endpoint de datos es **`GET /skeen/frontend/v1/direccion`** (parámetros
`start`, `end`, `sucursal`, `comparar`), de solo lectura y con las cifras ya
agrupadas en el servidor.
## 15. Tickets y documentos
**Configuración → Tickets**: ancho (58/80 mm), fuente, logo y qué secciones se

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;

View File

@@ -1730,6 +1730,134 @@ def get_encuestas(env):
"""Bloque de encuestas vigente (copia). Apagado por defecto."""
return normalizar_encuestas(get_settings(env).get('encuestas'))
# ---------------------------------------------------------------------------
# Tablero de dirección (indicadores del dueño).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La clave es
# `tablero` (no `direccion`, que ya existe a nivel raíz para la dirección
# postal de la clínica). No enciende ni apaga nada por sí solo: son los
# PARÁMETROS del tablero y las metas por sucursal. Con los defaults (sin metas,
# 90 días de inactividad y 3 visitas mínimas) el tablero funciona y no altera
# ninguna cifra existente.
#
# metas_sucursal: [{sucursal: <code>, meta_mensual: <monto>}]
# meta_mensual_global: meta de venta mensual de toda la clínica (0 = sin meta)
# dias_inactividad: días sin aparecer para considerar a un cliente "en riesgo"
# visitas_minimas_riesgo: visitas históricas mínimas para entrar a "en riesgo"
# (un paciente de una sola visita no se considera en riesgo)
# comparativo_default: anterior | anio | ninguno (interruptor del tablero)
# ---------------------------------------------------------------------------
DIRECCION_DEFAULT = {
'metas_sucursal': [],
'meta_mensual_global': 0.0,
'dias_inactividad': 90,
'visitas_minimas_riesgo': 3,
'comparativo_default': 'anterior',
}
DIRECCION_COMPARATIVOS = ('anterior', 'anio', 'ninguno')
DIRECCION_DIAS_MAX = 3650
DIRECCION_VISITAS_MAX = 1000
DIRECCION_METAS_MAX = 100
def _normalizar_metas_sucursal(raw):
"""Lista de metas por sucursal normalizada (una por código)."""
out = []
if not isinstance(raw, (list, tuple)):
return out
vistos = set()
for item in raw:
if not isinstance(item, dict):
continue
codigo = str(item.get('sucursal') or '').strip()[:40]
if not codigo or codigo in vistos:
continue
vistos.add(codigo)
out.append({
'sucursal': codigo,
'meta_mensual': max(0.0, _num(item.get('meta_mensual'), 0.0)),
})
return out[:DIRECCION_METAS_MAX]
def normalizar_direccion(raw):
"""Bloque `direccion` completo, con defaults. Tolerante: nunca lanza."""
out = dict(DIRECCION_DEFAULT)
out['metas_sucursal'] = []
if not isinstance(raw, dict):
return out
out['metas_sucursal'] = _normalizar_metas_sucursal(raw.get('metas_sucursal'))
out['meta_mensual_global'] = max(0.0, _num(
raw.get('meta_mensual_global'), DIRECCION_DEFAULT['meta_mensual_global']))
out['dias_inactividad'] = min(max(_int(
raw.get('dias_inactividad'), DIRECCION_DEFAULT['dias_inactividad']),
1), DIRECCION_DIAS_MAX)
out['visitas_minimas_riesgo'] = min(max(_int(
raw.get('visitas_minimas_riesgo'), DIRECCION_DEFAULT['visitas_minimas_riesgo']),
0), DIRECCION_VISITAS_MAX)
comp = str(raw.get('comparativo_default') or '').strip().lower()
out['comparativo_default'] = (
comp if comp in DIRECCION_COMPARATIVOS else DIRECCION_DEFAULT['comparativo_default'])
return out
def validar_direccion(raw):
"""Valida el bloque `direccion` de un PUT. Lanza ValueError si algo no
cuadra. Devuelve SOLO las claves presentes y normalizadas (merge)."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de dirección debe ser un objeto')
out = {}
if 'metas_sucursal' in raw:
valor = raw['metas_sucursal']
if not isinstance(valor, list):
raise ValueError('Las metas por sucursal deben ser una lista')
for item in valor:
if not isinstance(item, dict):
raise ValueError('Cada meta de sucursal debe ser un objeto')
if not str(item.get('sucursal') or '').strip():
raise ValueError('Cada meta de sucursal necesita una sucursal')
n = _num(item.get('meta_mensual'), None)
if n is None or n < 0:
raise ValueError('La meta mensual debe ser un número no negativo')
out['metas_sucursal'] = _normalizar_metas_sucursal(valor)
if 'meta_mensual_global' in raw:
n = _num(raw['meta_mensual_global'], None)
if n is None or n < 0:
raise ValueError('La meta mensual global debe ser un número no negativo')
out['meta_mensual_global'] = n
if 'dias_inactividad' in raw:
if isinstance(raw['dias_inactividad'], bool) or not isinstance(raw['dias_inactividad'], int):
raise ValueError('Los días de inactividad deben ser un número entero')
if not (1 <= raw['dias_inactividad'] <= DIRECCION_DIAS_MAX):
raise ValueError('Los días de inactividad deben estar entre 1 y %d' % DIRECCION_DIAS_MAX)
out['dias_inactividad'] = raw['dias_inactividad']
if 'visitas_minimas_riesgo' in raw:
if isinstance(raw['visitas_minimas_riesgo'], bool) or \
not isinstance(raw['visitas_minimas_riesgo'], int):
raise ValueError('Las visitas mínimas deben ser un número entero')
if not (0 <= raw['visitas_minimas_riesgo'] <= DIRECCION_VISITAS_MAX):
raise ValueError('Las visitas mínimas deben estar entre 0 y %d'
% DIRECCION_VISITAS_MAX)
out['visitas_minimas_riesgo'] = raw['visitas_minimas_riesgo']
if 'comparativo_default' in raw:
comp = str(raw['comparativo_default'] or '').strip().lower()
if comp not in DIRECCION_COMPARATIVOS:
raise ValueError("El comparativo debe ser 'anterior', 'anio' o 'ninguno'")
out['comparativo_default'] = comp
return out
def get_direccion(env):
"""Bloque del tablero de dirección vigente (copia).
La clave se llama `tablero` (no `direccion`) para no chocar con el campo
`direccion` de nivel raíz, que guarda la DIRECCIÓN POSTAL de la clínica.
"""
return normalizar_direccion(get_settings(env).get('tablero'))
# ---------------------------------------------------------------------------
# Licencia por plan (producto comercial).
#
@@ -2163,6 +2291,7 @@ def get_settings(env, aplicar_licencia=True):
'propinas': dict(PROPINAS_DEFAULT),
'campanas': dict(CAMPANAS_DEFAULT),
'encuestas': dict(ENCUESTAS_DEFAULT),
'tablero': normalizar_direccion(None),
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
'portal_cliente': normalizar_portal_cliente(None),
}
@@ -2187,6 +2316,7 @@ def get_settings(env, aplicar_licencia=True):
out['propinas'] = normalizar_propinas(guardado.get('propinas'))
out['campanas'] = normalizar_campanas(guardado.get('campanas'))
out['encuestas'] = normalizar_encuestas(guardado.get('encuestas'))
out['tablero'] = normalizar_direccion(guardado.get('tablero'))
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
out['portal_cliente'] = normalizar_portal_cliente(
guardado.get('portal_cliente'))
@@ -2268,6 +2398,11 @@ def set_settings(env, vals):
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['encuestas'] = {**actual['encuestas'], **bloque}
if 'tablero' in vals:
bloque = validar_direccion(vals['tablero'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo las metas) no borra el resto.
actual['tablero'] = {**actual['tablero'], **bloque}
if 'licencia' in vals:
bloque = validar_licencia(vals['licencia'])
if bloque is not None:

View File

@@ -4,6 +4,7 @@ from odoo import http
from odoo.exceptions import ValidationError
from odoo.http import request, Response
import base64
import calendar
import json
import re
import uuid
@@ -24,6 +25,7 @@ from odoo.addons.skeen_citas.clinic_settings import (
get_compras,
get_lealtad,
get_propinas,
get_direccion,
)
from odoo.addons.skeen_monedero.lealtad import (
nivel_por_codigo as lealtad_nivel_por_codigo,
@@ -7991,6 +7993,752 @@ class SkeenFrontendController(http.Controller):
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Tablero de dirección (indicadores del dueño)
#
# Todas las cifras se calculan con los MISMOS criterios que los reportes
# existentes: ventas con state != 'cancelled' (igual que /reports/sales y
# /reports/concentrado), cobros de pagos state='completed' (igual que
# /reports/cash), no-show y ocupación con las reglas de /reports/kpis,
# recursos con /reports/recursos y franjas con /reports/horas-agenda.
# Es un tablero de SOLO LECTURA; requiere el permiso `direccion.ver`.
# ============================================================
DIRECCION_MAX_COHORTES = 12
def _dir_minus_year(self, d):
"""Mismo día del año anterior (29-feb cae al 28 en años no bisiestos)."""
try:
return d.replace(year=d.year - 1)
except ValueError:
return d.replace(year=d.year - 1, day=28)
def _dir_add_months(self, d, n):
y = d.year + (d.month - 1 + n) // 12
m = (d.month - 1 + n) % 12 + 1
return date(y, m, min(d.day, calendar.monthrange(y, m)[1]))
def _dir_mes_key(self, d):
return d.strftime('%Y-%m')
def _dir_domain_sucursal(self, modelo, sucursal):
"""Filtro por sucursal. Las ventas y los pagos no guardan sucursal
propia: se atribuyen a la de su cita origen (`cita_id.branch`)."""
if not sucursal:
return []
if modelo == 'skeen.cita':
return [('branch', '=', sucursal)]
if modelo in ('skeen.venta', 'skeen.pago'):
return [('cita_id.branch', '=', sucursal)]
return []
def _dir_periodo_anterior(self, start, end):
d1 = datetime.strptime(start, '%Y-%m-%d').date()
d2 = datetime.strptime(end, '%Y-%m-%d').date()
dias = (d2 - d1).days + 1
prev_fin = d1 - timedelta(days=1)
prev_ini = prev_fin - timedelta(days=dias - 1)
return prev_ini.strftime('%Y-%m-%d'), prev_fin.strftime('%Y-%m-%d')
def _dir_periodo_anio(self, start, end):
d1 = datetime.strptime(start, '%Y-%m-%d').date()
d2 = datetime.strptime(end, '%Y-%m-%d').date()
return (self._dir_minus_year(d1).strftime('%Y-%m-%d'),
self._dir_minus_year(d2).strftime('%Y-%m-%d'))
def _dir_kpis(self, start, end, sucursal):
"""Cifras del resumen del periodo (y de su comparativo)."""
V = request.env['skeen.venta'].sudo()
C = request.env['skeen.cita'].sudo()
P = request.env['skeen.pago'].sudo()
vdom = [('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end)]
vdom += self._dir_domain_sucursal('skeen.venta', sucursal)
vgrp = V.read_group(vdom, ['total:sum', 'amount_paid:sum', 'amount_due:sum', 'propina:sum'], [])
vrow = vgrp[0] if vgrp else {}
num_ventas = vrow.get('__count') or vrow.get('total_count') or 0
ventas_total = vrow.get('total') or 0.0
pagado_ventas = vrow.get('amount_paid') or 0.0
propinas = vrow.get('propina') or 0.0
pdom = [('payment_date', '>=', f'{start} 00:00:00'),
('payment_date', '<=', f'{end} 23:59:59'),
('state', '=', 'completed')]
pdom += self._dir_domain_sucursal('skeen.pago', sucursal)
pgrp = P.read_group(pdom, ['amount:sum'], [])
cobros = (pgrp[0].get('amount') or 0.0) if pgrp else 0.0
cdom = [('date', '>=', start), ('date', '<=', end)]
cdom += self._dir_domain_sucursal('skeen.cita', sucursal)
estados = {}
for g in C.read_group(cdom, ['state'], ['state']):
estados[g['state']] = g.get('__count') or g.get('state_count') or 0
total_citas = sum(estados.values())
canceladas = estados.get('cancelled', 0)
no_show = estados.get('no_show', 0)
atendidas = estados.get('done', 0)
activas = total_citas - canceladas
# Pacientes activos y pacientes nuevos ("primera vez"). La definición de
# "primera vez" es EXACTAMENTE la de /reports/kpis: una cita del periodo
# cuya fecha es la primera cita histórica no cancelada del paciente.
# Se resuelve con consultas agrupadas para no traer miles de citas.
cr = request.env.cr
params = [start, end]
suc_sql = ' AND branch = %s' if sucursal else ''
if sucursal:
params.append(sucursal)
cr.execute("SELECT COUNT(DISTINCT partner_id) FROM skeen_cita "
"WHERE state <> 'cancelled' AND date >= %s AND date <= %s" + suc_sql,
params)
fila = cr.fetchone()
pacientes = fila[0] if fila else 0
cr.execute(
"SELECT COUNT(*) FROM skeen_cita c "
"JOIN (SELECT partner_id, MIN(date) AS f FROM skeen_cita "
" WHERE state <> 'cancelled' GROUP BY partner_id) m "
" ON m.partner_id = c.partner_id "
"WHERE c.state <> 'cancelled' AND c.date >= %s AND c.date <= %s "
" AND c.date = m.f" + (' AND c.branch = %s' if sucursal else ''),
params)
fila = cr.fetchone()
primera = fila[0] if fila else 0
return {
'ingresos_cobrados': round(cobros, 2),
'ventas_total': round(ventas_total, 2),
'ventas_count': num_ventas,
'pagado_en_ventas': round(pagado_ventas, 2),
'ticket_promedio': round(ventas_total / num_ventas, 2) if num_ventas else 0.0,
'propinas': round(propinas, 2),
'visitas_atendidas': atendidas,
'citas_total': total_citas,
'citas_activas': activas,
'pacientes_activos': pacientes,
'pacientes_nuevos': primera,
'pacientes_recurrentes': max(activas - primera, 0),
'tasa_no_show': round(no_show / activas, 4) if activas else 0.0,
'tasa_cancelacion': round(canceladas / total_citas, 4) if total_citas else 0.0,
}
def _dir_variacion(self, actual, anterior):
if anterior in (None, 0):
return None
return round((actual - anterior) / anterior, 4)
def _dir_clientes(self, start, end, sucursal):
"""Pacientes activos, nuevos por mes, cohortes de retención, frecuencia,
valor por cliente y clientes en riesgo."""
cr = request.env.cr
cfg = get_direccion(request.env)
# Pacientes activos y citas del periodo, con una sola consulta agrupada.
params = [start, end]
suc_sql = ''
if sucursal:
suc_sql = ' AND branch = %s'
params.append(sucursal)
cr.execute(
"SELECT COUNT(*), COUNT(DISTINCT partner_id) FROM skeen_cita "
"WHERE state <> 'cancelled' AND date >= %s AND date <= %s" + suc_sql, params)
n_citas_periodo, n_pac_periodo = cr.fetchone()
n_citas_periodo = n_citas_periodo or 0
n_pac_periodo = n_pac_periodo or 0
frecuencia_periodo = (round(n_citas_periodo / n_pac_periodo, 2)
if n_pac_periodo else 0.0)
# Primera y última visita + visitas por paciente (histórico). Una sola
# consulta agrupada: con el volumen real (52k citas) el ORM por separado
# (tres read_group) costaba la mayor parte del tiempo del tablero.
cr.execute("SELECT partner_id, MIN(date), MAX(date), COUNT(*) FROM skeen_cita "
"WHERE state <> 'cancelled' GROUP BY partner_id")
mins, maxs, conteos = {}, {}, {}
total_citas_hist = 0
for pid, d_min, d_max, n_vis in cr.fetchall():
mins[pid] = d_min
maxs[pid] = d_max
conteos[pid] = n_vis or 0
total_citas_hist += n_vis or 0
frecuencia_historica = (round(total_citas_hist / len(mins), 2) if mins else 0.0)
# Nuevos por mes (últimos 12 meses)
hoy = date.today()
meses = [self._dir_mes_key(self._dir_add_months(hoy.replace(day=1), -i))
for i in range(11, -1, -1)]
nuevos_mes = {m: 0 for m in meses}
for pid, d in mins.items():
key = self._dir_mes_key(d)
if key in nuevos_mes:
nuevos_mes[key] += 1
# Retención por cohortes (últimas 12 cohortes de primera visita)
cohort_ids = [pid for pid, d in mins.items() if self._dir_mes_key(d) in nuevos_mes]
citas_cohorte = {}
if cohort_ids:
cr.execute("SELECT partner_id, date FROM skeen_cita "
"WHERE state <> 'cancelled' AND partner_id = ANY(%s) "
"ORDER BY partner_id, date", (cohort_ids,))
for pid, d_fecha in cr.fetchall():
citas_cohorte.setdefault(pid, []).append(d_fecha)
retencion = []
for m in meses:
ids = [pid for pid, d in mins.items() if self._dir_mes_key(d) == m]
fila = {'cohorte': m, 'tamano': len(ids)}
for k in (1, 3, 6, 12):
clave = 'ret%d' % k
if not ids:
fila[clave] = 0
fila[clave + '_tasa'] = None
continue
primero = min(mins[pid] for pid in ids)
maduro = self._dir_add_months(primero, k) <= hoy
ret = 0
for pid in ids:
tope = self._dir_add_months(mins[pid], k)
if any(d > mins[pid] and d <= tope for d in citas_cohorte.get(pid, [])):
ret += 1
fila[clave] = ret
fila[clave + '_tasa'] = round(ret / len(ids), 4) if maduro else None
retencion.append(fila)
# Valor por cliente (ingreso histórico por paciente, ventas no canceladas)
cr.execute("SELECT SUM(total) FROM skeen_venta "
"WHERE state <> 'cancelled' GROUP BY partner_id")
valores = sorted(float(r[0] or 0.0) for r in cr.fetchall())
valor_prom = round(sum(valores) / len(valores), 2) if valores else 0.0
valor_mediana = 0.0
if valores:
n = len(valores)
mitad = n // 2
valor_mediana = round((valores[mitad] if n % 2 else
(valores[mitad - 1] + valores[mitad]) / 2.0), 2)
# Clientes en riesgo
dias = cfg['dias_inactividad']
visitas_min = cfg['visitas_minimas_riesgo']
limite = hoy - timedelta(days=dias)
riesgo = []
for pid, ultimo in maxs.items():
if ultimo and ultimo <= limite and conteos.get(pid, 0) >= visitas_min:
riesgo.append((pid, ultimo, conteos.get(pid, 0),
hoy - ultimo if ultimo else 0))
riesgo.sort(key=lambda x: (-x[2], x[1]))
total_riesgo = len(riesgo)
top = riesgo[:100]
partners = request.env['res.partner'].sudo().browse([r[0] for r in top])
riesgo_rows = []
for partner in partners:
ultimo = maxs.get(partner.id)
riesgo_rows.append({
'id': partner.id,
'name': partner.name,
'phone': partner.phone or partner.mobile or partner.whatsapp or '',
'ultima_visita': ultimo.strftime('%Y-%m-%d') if ultimo else None,
'visitas': conteos.get(partner.id, 0),
'dias_sin_venir': (hoy - ultimo).days if ultimo else None,
})
return {
'activos': n_pac_periodo,
'frecuencia_promedio': frecuencia_periodo,
'frecuencia_historica': frecuencia_historica,
'nuevos_por_mes': [{'mes': m, 'total': nuevos_mes[m]} for m in meses],
'retencion_cohortes': retencion,
'valor_por_cliente': {
'promedio': valor_prom,
'mediana': valor_mediana,
'total': round(sum(valores), 2),
'pacientes_con_ventas': len(valores),
},
'en_riesgo': {
'total': total_riesgo,
'dias_inactividad': dias,
'visitas_minimas': visitas_min,
'pacientes': riesgo_rows,
'filtro_campana': {'ultima_visita_dias': dias},
'link_campana': '/campanas',
},
}
def _dir_ocupacion(self, start, end, sucursal):
"""Horas vendidas vs disponibles (reglas de /reports/kpis), recursos
(reglas de /reports/recursos) y franjas horarias (/reports/horas-agenda)."""
cr = request.env.cr
d1 = datetime.strptime(start, '%Y-%m-%d').date()
d2 = datetime.strptime(end, '%Y-%m-%d').date()
dias_habiles = sum(1 for i in range((d2 - d1).days + 1)
if (d1 + timedelta(days=i)).weekday() != 6)
bloqueos = request.env['skeen.cita.bloqueo'].sudo().search(
[('date', '>=', start), ('date', '<=', end)])
bloqueo_horas = {}
for b in bloqueos:
if not b.doctor_id or b.date.weekday() == 6:
continue
horas = 8.0 if b.all_day else max(0.0, b.time_to - b.time_from)
bloqueo_horas[b.doctor_id.name] = bloqueo_horas.get(b.doctor_id.name, 0.0) + horas
params = [start, end]
suc_sql = ' AND c.branch = %s' if sucursal else ''
if sucursal:
params.append(sucursal)
# Ocupación por médico / no-show por médico (reglas de /reports/kpis).
# El cálculo por horas se hace en SQL agrupado: con rangos largos el
# ORM por cita era el cuello de botella del tablero.
cr.execute(
"SELECT c.doctor_id, COALESCE(e.name, 'Sin médico'), "
" SUM(COALESCE(s.duration_min, 30)), COUNT(*), "
" COUNT(*) FILTER (WHERE c.state = 'no_show') "
"FROM skeen_cita c "
"LEFT JOIN skeen_servicio s ON s.id = c.servicio_id "
"LEFT JOIN hr_employee e ON e.id = c.doctor_id "
"WHERE c.state <> 'cancelled' AND c.date >= %s AND c.date <= %s"
+ suc_sql + " GROUP BY c.doctor_id, e.name", params)
medicos = []
no_show_medico = {}
for doctor_id, doc, minutos, n_citas, n_ns in cr.fetchall():
no_show_medico[doc] = {'citas': n_citas or 0, 'no_shows': n_ns or 0}
if not doctor_id:
continue # /reports/kpis no cuenta horas de citas sin médico
vendidas = (minutos or 0) / 60.0
disponibles = max(dias_habiles * 8.0 - bloqueo_horas.get(doc, 0.0), 0.0)
medicos.append({
'medico': doc,
'horas_vendidas': round(vendidas, 2),
'horas_disponibles': round(disponibles, 2),
'horas_libres': round(max(disponibles - vendidas, 0.0), 2),
'ocupacion': round(min(vendidas / disponibles, 1.0), 4) if disponibles else 0.0,
})
medicos.sort(key=lambda x: -x['ocupacion'])
no_show_rows = [{
'medico': k,
'citas': v['citas'],
'no_shows': v['no_shows'],
'tasa': round(v['no_shows'] / v['citas'], 4) if v['citas'] else 0.0,
} for k, v in no_show_medico.items()]
no_show_rows.sort(key=lambda x: -x['tasa'])
# Ocupación por sucursal (mismas citas; capacidad = jornada de la agenda).
# La duración usa la regla de `_duracion()`: cita > servicio > 30 min.
ini_h, fin_h = self._agenda_bounds()
jornada = max(0.0, fin_h - ini_h)
disponibles_suc = dias_habiles * jornada
cr.execute(
"SELECT c.branch, SUM(COALESCE(NULLIF(c.duration, 0), s.duration_min, 30)) "
"FROM skeen_cita c LEFT JOIN skeen_servicio s ON s.id = c.servicio_id "
"WHERE c.state <> 'cancelled' AND c.date >= %s AND c.date <= %s"
+ suc_sql + " GROUP BY c.branch", params)
por_sucursal = {br or 'sin_sucursal': (minutos or 0) / 60.0
for br, minutos in cr.fetchall()}
sucursales = [{
'sucursal': k,
'horas_vendidas': round(v, 2),
'horas_disponibles': round(disponibles_suc, 2),
'ocupacion': round(min(v / disponibles_suc, 1.0), 4) if disponibles_suc else 0.0,
} for k, v in por_sucursal.items()]
sucursales.sort(key=lambda x: -x['horas_vendidas'])
# Ocupación por recurso (igual que /reports/recursos)
horas_dia = jornada
disponibles_rec = horas_dia * ((d2 - d1).days + 1)
cr.execute(
"SELECT rel.recurso_id, COUNT(*), "
" SUM(COALESCE(NULLIF(c.duration, 0), s.duration_min, 30)) "
"FROM skeen_cita c "
"JOIN skeen_cita_recurso_rel rel ON rel.cita_id = c.id "
"LEFT JOIN skeen_servicio s ON s.id = c.servicio_id "
"WHERE c.state NOT IN ('cancelled', 'no_show', 'waitlist') "
" AND c.date >= %s AND c.date <= %s" + suc_sql + " GROUP BY rel.recurso_id",
params)
acumulado = {rid: {'citas': n or 0, 'minutos': m or 0}
for rid, n, m in cr.fetchall()}
# Franjas horarias (/reports/horas-agenda)
cr.execute(
"SELECT CAST(FLOOR(c.time) AS INT), c.state, COUNT(*) FROM skeen_cita c "
"WHERE c.state NOT IN ('cancelled', 'no_show', 'waitlist') "
" AND c.date >= %s AND c.date <= %s" + suc_sql +
" GROUP BY 1, 2", params)
franjas = {}
for hora, estado, n in cr.fetchall():
fr = franjas.setdefault(int(hora), {'total': 0, 'by_state': {}})
fr['total'] += n or 0
fr['by_state'][estado] = fr['by_state'].get(estado, 0) + (n or 0)
recursos = request.env['skeen.recurso'].sudo().search(
[('activo', '=', True)], order='sequence, name')
filas_rec = []
for r in recursos:
acc = acumulado.get(r.id, {'citas': 0, 'minutos': 0})
horas = round(acc['minutos'] / 60.0, 2)
ocup = round(horas / disponibles_rec * 100.0, 1) if disponibles_rec else 0.0
filas_rec.append({
'recurso_id': r.id,
'recurso': r.name,
'citas': acc['citas'],
'horas_ocupadas': horas,
'horas_disponibles': round(disponibles_rec, 2),
'ocupacion_pct': min(ocup, 100.0),
})
filas_hora = []
for hora in range(8, 20):
entry = franjas.get(hora, {'total': 0, 'by_state': {}})
filas_hora.append({'hora': f'{hora:02d}:00', 'total': entry['total'],
'by_state': entry['by_state']})
con_citas = [f for f in filas_hora if f['total'] > 0]
mas = sorted(con_citas, key=lambda x: -x['total'])[:3]
menos = sorted(filas_hora, key=lambda x: x['total'])[:3]
return {
'dias_habiles': dias_habiles,
'jornada_horas': round(jornada, 2),
'por_medico': medicos,
'no_show_por_medico': no_show_rows,
'por_sucursal': sucursales,
'por_recurso': filas_rec,
'franjas': filas_hora,
'franjas_mas_ocupadas': mas,
'franjas_menos_ocupadas': menos,
'total_horas_vendidas': round(sum(m['horas_vendidas'] for m in medicos), 2),
'total_horas_ocupadas_recursos': round(sum(f['horas_ocupadas'] for f in filas_rec), 2),
}
def _dir_category_label(self, servicio, code):
"""Etiqueta de la categoría de un servicio (la selección puede ser una
lista o un método, según cómo esté definida)."""
if not servicio or not code:
return ''
try:
sel = servicio._fields['category'].selection
if isinstance(sel, str):
sel = getattr(servicio, sel)()
elif callable(sel):
sel = sel(servicio)
return dict(sel).get(code, '') or ''
except Exception:
return ''
def _dir_ingresos(self, start, end, sucursal):
"""Ingresos por servicio, categoría, sucursal, método de pago, propinas,
membresías, paquetes y pasivo de lealtad."""
P = request.env['skeen.pago'].sudo()
cr = request.env.cr
params = [start, end]
suc_sql = ' AND c.branch = %s' if sucursal else ''
if sucursal:
params.append(sucursal)
# Por servicio y por categoría: líneas de las ventas del periodo, en una
# sola consulta agrupada (no se traen las ventas al proceso).
cr.execute(
"SELECT l.service_id, SUM(l.subtotal), SUM(l.quantity) "
"FROM skeen_venta_line l JOIN skeen_venta v ON v.id = l.venta_id "
"LEFT JOIN skeen_cita c ON c.id = v.cita_id "
"WHERE v.state <> 'cancelled' AND v.date >= %s AND v.date <= %s"
+ suc_sql + " GROUP BY l.service_id", params)
servicios = [{
'service_id': sid or 0,
'servicio': 'Sin servicio',
'cantidad': float(qty or 0.0),
'total': round(float(subtotal or 0.0), 2),
} for sid, subtotal, qty in cr.fetchall()]
categorias = {}
svc_ids = [s['service_id'] for s in servicios if s['service_id']]
svc_map = {x.id: x for x in request.env['skeen.servicio'].sudo().browse(svc_ids)}
for s in servicios:
svc = svc_map.get(s['service_id'])
s['servicio'] = svc.name if svc else 'Sin servicio'
code = svc.category if svc else ''
label = self._dir_category_label(svc, code) if svc else ''
cat = categorias.setdefault(code or 'sin', {'categoria': label or 'Sin categoría',
'total': 0.0, 'cantidad': 0.0})
cat['total'] += s['total']
cat['cantidad'] += s['cantidad']
servicios.sort(key=lambda x: -x['total'])
# Por sucursal (la sucursal sale de la cita origen de la venta)
cr.execute(
"SELECT c.branch, SUM(v.total), COUNT(*) FROM skeen_venta v "
"LEFT JOIN skeen_cita c ON c.id = v.cita_id "
"WHERE v.state <> 'cancelled' AND v.date >= %s AND v.date <= %s"
+ suc_sql + " GROUP BY c.branch", params)
por_sucursal = {}
for branch, total, n in cr.fetchall():
por_sucursal[branch or 'sin_sucursal'] = {
'total': float(total or 0.0), 'ventas': n or 0}
# Por método de pago (igual que /reports/cash)
pdom = [('payment_date', '>=', f'{start} 00:00:00'),
('payment_date', '<=', f'{end} 23:59:59'),
('state', '=', 'completed')]
pdom += self._dir_domain_sucursal('skeen.pago', sucursal)
por_metodo = {}
for g in P.read_group(pdom, ['payment_method', 'amount:sum'], ['payment_method']):
por_metodo[g['payment_method'] or 'otro'] = round(g.get('amount') or 0.0, 2)
# Membresías
Memb = request.env['skeen.membresia'].sudo()
activas_grp = Memb.read_group([('estado', 'in', ('activa', 'congelada'))],
['plan_id'], ['plan_id'])
FACTOR = {'semanal': 52 / 12.0, 'mensual': 1.0, 'trimestral': 1 / 3.0,
'semestral': 1 / 6.0, 'anual': 1 / 12.0}
mrr = 0.0
for g in activas_grp:
if not g.get('plan_id'):
continue
plan = request.env['skeen.membresia.plan'].sudo().browse(g['plan_id'][0])
n = g.get('__count') or g.get('plan_id_count') or 0
mrr += n * (plan.precio or 0.0) * FACTOR.get(plan.periodicidad, 1.0)
Periodo = request.env['skeen.membresia.periodo'].sudo()
recurrentes = Periodo.read_group([
('estado', '=', 'pagado'), ('tipo', '=', 'periodo'),
('fecha_pago', '>=', f'{start} 00:00:00'), ('fecha_pago', '<=', f'{end} 23:59:59')],
['monto:sum'], [])
membresias = {
'activas': sum((g.get('__count') or g.get('plan_id_count') or 0)
for g in activas_grp),
'mrr_estimado': round(mrr, 2),
'renovaciones': sum((g.get('__count') or 0) for g in recurrentes),
'ingreso_recurrente_periodo': round((recurrentes[0].get('monto') or 0.0)
if recurrentes else 0.0, 2),
'altas': Memb.search_count([('fecha_inicio', '>=', start),
('fecha_inicio', '<=', end)]),
'cancelaciones': Memb.search_count([
('estado', '=', 'cancelada'),
('fecha_cancelacion', '>=', f'{start} 00:00:00'),
('fecha_cancelacion', '<=', f'{end} 23:59:59')]),
}
# Paquetes vendidos y sesiones pendientes (compromiso del negocio)
PC = request.env['skeen.paquete.compra'].sudo()
pv = PC.read_group([('date', '>=', start), ('date', '<=', end),
('state', '!=', 'cancelled')], ['price:sum'], [])
ses = PC.read_group([('state', '!=', 'cancelled')],
['sessions_total:sum', 'sessions_used:sum'], [])
paquetes = {
'vendidos': (PC.search_count([('date', '>=', start), ('date', '<=', end),
('state', '!=', 'cancelled')])),
'total_vendido': round((pv[0].get('price') or 0.0) if pv else 0.0, 2),
'activos': PC.search_count([('state', '=', 'active')]),
'sesiones_pendientes': int(((ses[0].get('sessions_total') or 0) -
(ses[0].get('sessions_used') or 0)) if ses else 0),
}
# Lealtad: pasivo en pesos (igual que /reports/lealtad)
cfg_l = get_lealtad(request.env)
valor = float(cfg_l.get('valor_punto') or 1.0)
mon = request.env['skeen.monedero'].sudo().read_group([], ['points:sum'], [])
puntos = int((mon[0].get('points') or 0) if mon else 0)
Tx = request.env['skeen.monedero.transaction'].sudo()
by_type = {}
for g in Tx.read_group([('create_date', '>=', f'{start} 00:00:00'),
('create_date', '<=', f'{end} 23:59:59')],
['type', 'points:sum'], ['type']):
by_type[g['type']] = g['points']
lealtad = {
'valor_punto': valor,
'puntos_activos': puntos,
'pasivo_mxn': round(puntos * valor, 2),
'otorgados': by_type.get('accrual', 0) + by_type.get('deposito', 0),
'canjeados': abs(by_type.get('redemption', 0)),
'expirados': abs(by_type.get('expiration', 0)),
}
return {
'por_servicio': servicios,
'por_categoria': sorted(categorias.values(), key=lambda x: -x['total']),
'por_sucursal': [{'sucursal': k, 'total': round(v['total'], 2),
'ventas': v['ventas']} for k, v in por_sucursal.items()],
'por_metodo_pago': por_metodo,
'membresias': membresias,
'paquetes': paquetes,
'lealtad': lealtad,
}
def _dir_metas(self, start, end, sucursal, kpis=None):
"""Metas por sucursal y por empleado, avance del mes y proyección."""
cfg = get_direccion(request.env)
V = request.env['skeen.venta'].sudo()
Lineas = request.env['skeen.venta.line'].sudo()
hoy = date.today()
d_fin = datetime.strptime(end, '%Y-%m-%d').date()
mes_ini = d_fin.replace(day=1)
mes_fin = d_fin.replace(day=calendar.monthrange(d_fin.year, d_fin.month)[1])
dias_mes = (mes_fin - mes_ini).days + 1
dias_transcurridos = min((min(hoy, mes_fin) - mes_ini).days + 1, dias_mes)
if dias_transcurridos < 1:
dias_transcurridos = 1
mes_cerrado = hoy > mes_fin
def proyectar(actual):
return round(actual, 2) if mes_cerrado else round(actual / dias_transcurridos * dias_mes, 2)
# Metas por sucursal
metas = {m['sucursal']: m['meta_mensual'] for m in cfg['metas_sucursal']}
sucursales = request.env['skeen.sucursal'].sudo().search([], order='sequence, name')
codigos = [s.code for s in sucursales if s.code]
for code in metas:
if code not in codigos:
codigos.append(code)
filas_suc = []
for code in codigos:
grp = V.read_group([('state', '!=', 'cancelled'),
('date', '>=', mes_ini.strftime('%Y-%m-%d')),
('date', '<=', mes_fin.strftime('%Y-%m-%d')),
('cita_id.branch', '=', code)], ['total:sum'], [])
actual = round((grp[0].get('total') or 0.0) if grp else 0.0, 2)
meta = metas.get(code, 0.0)
filas_suc.append({
'sucursal': code,
'meta_mensual': round(meta, 2),
'ventas_mes': actual,
'pct': round(actual / meta, 4) if meta else None,
'proyeccion': proyectar(actual),
})
# Metas por empleado (ventas del mes atribuidas por línea recetada)
mes_ventas = V.search([('state', '!=', 'cancelled'),
('date', '>=', mes_ini.strftime('%Y-%m-%d')),
('date', '<=', mes_fin.strftime('%Y-%m-%d'))])
vendido_emp = {}
if mes_ventas:
for g in Lineas.read_group([('venta_id', 'in', mes_ventas.ids),
('prescribed_by_id', '!=', False)],
['prescribed_by_id', 'subtotal:sum'],
['prescribed_by_id']):
vendido_emp[g['prescribed_by_id'][0]] = g.get('subtotal') or 0.0
Emp = request.env['hr.employee'].sudo()
empleados = Emp.search(['|', ('monthly_goal', '>', 0),
('id', 'in', list(vendido_emp.keys()))])
filas_emp = []
for e in empleados:
actual = round(vendido_emp.get(e.id, 0.0), 2)
meta = e.monthly_goal or 0.0
filas_emp.append({
'employee_id': e.id,
'empleado': e.name,
'meta_mensual': round(meta, 2),
'ventas_mes': actual,
'pct': round(actual / meta, 4) if meta else None,
'proyeccion': proyectar(actual),
})
filas_emp.sort(key=lambda x: -(x['ventas_mes']))
# Proyección del periodo seleccionado
p1 = datetime.strptime(start, '%Y-%m-%d').date()
p2 = d_fin
dias_periodo = (p2 - p1).days + 1
dias_trans_p = min((min(hoy, p2) - p1).days + 1, dias_periodo)
if dias_trans_p < 1:
dias_trans_p = 1
if kpis is None:
kpis = self._dir_kpis(start, end, sucursal)
periodo_cerrado = hoy > p2
proy_ventas = (kpis['ventas_total'] if periodo_cerrado
else round(kpis['ventas_total'] / dias_trans_p * dias_periodo, 2))
proy_cobros = (kpis['ingresos_cobrados'] if periodo_cerrado
else round(kpis['ingresos_cobrados'] / dias_trans_p * dias_periodo, 2))
# Mes actual (para metas)
mes_grp = V.read_group([('state', '!=', 'cancelled'),
('date', '>=', mes_ini.strftime('%Y-%m-%d')),
('date', '<=', mes_fin.strftime('%Y-%m-%d'))],
['total:sum'], [])
ventas_mes = round((mes_grp[0].get('total') or 0.0) if mes_grp else 0.0, 2)
meta_global = cfg['meta_mensual_global'] or sum(metas.values())
return {
'por_sucursal': filas_suc,
'por_empleado': filas_emp,
'periodo': {
'dias': dias_periodo,
'dias_transcurridos': dias_trans_p,
'ventas': kpis['ventas_total'],
'cobros': kpis['ingresos_cobrados'],
'proyeccion_ventas': proy_ventas,
'proyeccion_cobros': proy_cobros,
},
'mes': {
'mes': self._dir_mes_key(mes_ini),
'dias_mes': dias_mes,
'dias_transcurridos': dias_transcurridos,
'ventas': ventas_mes,
'meta': round(meta_global, 2),
'pct': round(ventas_mes / meta_global, 4) if meta_global else None,
'proyeccion': proyectar(ventas_mes),
'proyeccion_vs_meta': (round(proyectar(ventas_mes) / meta_global, 4)
if meta_global else None),
},
}
@http.route('/skeen/frontend/v1/direccion', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('direccion.ver')
def direccion_tablero(self, **kw):
"""Tablero ejecutivo del dueño. Solo lectura."""
try:
if request.httprequest.method == 'OPTIONS':
return json_response({}, 200)
hoy = date.today().strftime('%Y-%m-%d')
start = kw.get('start') or hoy
end = kw.get('end') or hoy
try:
d1 = datetime.strptime(start, '%Y-%m-%d').date()
d2 = datetime.strptime(end, '%Y-%m-%d').date()
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Fecha inválida'}, 400)
if d2 < d1:
d1, d2 = d2, d1
start, end = d1.strftime('%Y-%m-%d'), d2.strftime('%Y-%m-%d')
if (d2 - d1).days > 4000:
return json_response({'status': 'error',
'message': 'El rango no puede pasar de 4000 días'}, 400)
sucursal = (kw.get('sucursal') or '').strip() or None
cfg = get_direccion(request.env)
comparar = (kw.get('comparar') or cfg['comparativo_default'] or 'anterior').lower()
if comparar not in ('anterior', 'anio', 'ninguno'):
comparar = 'anterior'
t0 = datetime.now()
kpis = self._dir_kpis(start, end, sucursal)
# Serie diaria de ventas (mismo criterio que /reports/sales.by_day)
params = [start, end]
suc_sql = ' AND c.branch = %s' if sucursal else ''
if sucursal:
params.append(sucursal)
request.env.cr.execute(
"SELECT v.date, SUM(v.total) FROM skeen_venta v "
"LEFT JOIN skeen_cita c ON c.id = v.cita_id "
"WHERE v.state <> 'cancelled' AND v.date >= %s AND v.date <= %s"
+ suc_sql + " GROUP BY v.date ORDER BY v.date", params)
by_day = {str(d)[:10]: round(float(t or 0.0), 2)
for d, t in request.env.cr.fetchall()}
kpis['por_dia'] = [{'date': k, 'total': by_day[k]} for k in sorted(by_day)]
resumen = kpis
comparativo = None
if comparar != 'ninguno':
if comparar == 'anio':
cs, ce = self._dir_periodo_anio(start, end)
else:
cs, ce = self._dir_periodo_anterior(start, end)
ck = self._dir_kpis(cs, ce, sucursal)
variaciones = {}
for campo in ('ingresos_cobrados', 'ventas_total', 'ticket_promedio',
'visitas_atendidas', 'pacientes_nuevos', 'pacientes_recurrentes',
'tasa_no_show', 'tasa_cancelacion'):
variaciones[campo] = self._dir_variacion(kpis.get(campo, 0), ck.get(campo, 0))
comparativo = {'modo': comparar, 'start': cs, 'end': ce,
'kpis': ck, 'variacion': variaciones}
t_sec = {'resumen': int((datetime.now() - t0).total_seconds() * 1000)}
t_a = datetime.now()
clientes = self._dir_clientes(start, end, sucursal)
t_b = datetime.now()
ocupacion = self._dir_ocupacion(start, end, sucursal)
t_c = datetime.now()
ingresos = self._dir_ingresos(start, end, sucursal)
t_d = datetime.now()
metas = self._dir_metas(start, end, sucursal, kpis=kpis)
t_e = datetime.now()
t_sec['clientes'] = int((t_b - t_a).total_seconds() * 1000)
t_sec['ocupacion'] = int((t_c - t_b).total_seconds() * 1000)
t_sec['ingresos'] = int((t_d - t_c).total_seconds() * 1000)
t_sec['metas'] = int((t_e - t_d).total_seconds() * 1000)
no_disponible = [
{'clave': 'entregas', 'etiqueta': 'Entregas',
'motivo': 'El sistema no registra entregas de productos, así que no hay dato.'},
{'clave': 'whatsapp_resultados', 'etiqueta': 'Resultados de WhatsApp',
'motivo': 'No hay una métrica de resultados de WhatsApp disponible en este tablero.'},
]
ms = int((datetime.now() - t0).total_seconds() * 1000)
respuesta = {
'status': 'success',
'start': start,
'end': end,
'sucursal': sucursal,
'comparativo': comparativo,
'resumen': resumen,
'clientes': clientes,
'ocupacion': ocupacion,
'ingresos': ingresos,
'metas': metas,
'no_disponible': no_disponible,
'ms': ms,
}
if kw.get('debug') in ('1', 'true'):
respuesta['_ms_secciones'] = t_sec
return json_response(respuesta)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Sucursales (configurables)
# ============================================================

View File

@@ -85,6 +85,9 @@ PERMISSION_GROUPS = [
('caja.ver', 'Ver cortes de caja'),
('caja.cerrar', 'Cerrar caja'),
]),
('direccion', 'Tablero de dirección', [
('direccion.ver', 'Ver el tablero de dirección'),
]),
('whatsapp', 'WhatsApp y leads', [
('whatsapp.ver', 'Ver conversaciones y leads'),
('whatsapp.responder', 'Responder conversaciones'),
@@ -272,6 +275,10 @@ DEFAULT_PERM_ROLES = {
'reportes.exportar': ALL,
'caja.ver': ALL,
'caja.cerrar': MONEY,
# Tablero de dirección: solo dueño/administración y marketing. NO entra el
# rol médico (ni los roles de solo lectura); su visibilidad de menú también
# queda excluida para 'medico' y 'lectura' en roles.py.
'direccion.ver': ('admin', 'gerencia', 'marketing'),
# WhatsApp y leads (menú WACRM: admin, recepcion, gerencia, marketing)
'whatsapp.ver': W,
'whatsapp.responder': W,

View File

@@ -174,6 +174,9 @@ DEFAULT_MENU_ROLES = {
'/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 rol médico
# no lo ve (tampoco 'lectura'). Su acceso a la API lo cierra `direccion.ver`.
'/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'],
@@ -202,7 +205,7 @@ DEFAULT_MENU_ROLES = {
# Marketing NO hereda los menús de 'lectura': sólo estos (mínimo privilegio).
MARKETING_MENUS = (
'/', '/agenda', '/pacientes', '/cumpleanos', '/visitas',
'/wacrm/messages', '/wacrm/leads', '/campanas',
'/wacrm/messages', '/wacrm/leads', '/campanas', '/direccion',
)
# Roles nuevos que por default ven lo mismo que 'lectura'.