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. 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 ## 15. Tickets y documentos
**Configuración → Tickets**: ancho (58/80 mm), fuente, logo y qué secciones se **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 ChatNotifications from './lib/chat-notifications';
import { useClinicSettings, useClinicSettingsCargado } from './lib/clinic-settings'; import { useClinicSettings, useClinicSettingsCargado } from './lib/clinic-settings';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import Direccion from './pages/Direccion';
import Agenda from './pages/Agenda'; import Agenda from './pages/Agenda';
import Pacientes from './pages/Pacientes'; import Pacientes from './pages/Pacientes';
import Pos from './pages/Pos'; import Pos from './pages/Pos';
@@ -211,6 +212,7 @@ const App: FC = () => {
<Route path="/mi-cuenta" element={<MiCuenta />} /> <Route path="/mi-cuenta" element={<MiCuenta />} />
<Route path="/mi-cuenta/acceso/:token" element={<MiCuenta />} /> <Route path="/mi-cuenta/acceso/:token" element={<MiCuenta />} />
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} /> <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="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
<Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></RequireAuth>} /> <Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></RequireAuth>} />
<Route path="/pacientes" element={<RequireAuth href="/pacientes"><Pacientes /></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'], '/compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/cortes': ['admin', 'recepcion', 'gerencia'], '/cortes': ['admin', 'recepcion', 'gerencia'],
'/reportes': ['admin', 'recepcion', 'lectura', '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=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura', 'gerencia'],

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; 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). // 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. // 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 || {}) }, limites: { ...LICENCIA_LIMITES_DEFAULT, ...((settings?.licencia || {}).limites || {}) },
}, },
portal_cliente: { ...PORTAL_CLIENTE_DEFAULT, ...(settings?.portal_cliente || {}) }, 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); let actual: ClinicSettings = normalizar(null);

View File

@@ -969,6 +969,128 @@ export const es: Dict = {
'nps.processed': 'Encuestas procesadas.', 'nps.processed': 'Encuestas procesadas.',
'nps.replySent': 'Respuesta enviada.', 'nps.replySent': 'Respuesta enviada.',
'nps.replySimulated': 'Modo prueba: la respuesta quedó registrada como simulada.', '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.processed': 'Surveys processed.',
'nps.replySent': 'Reply sent.', 'nps.replySent': 'Reply sent.',
'nps.replySimulated': 'Test mode: the reply was logged as simulated.', '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 }; export const DICT: Record<Locale, Dict> = { es, en };

View File

@@ -13,6 +13,8 @@ export interface MenuItem {
export const MENU_ITEMS: MenuItem[] = [ export const MENU_ITEMS: MenuItem[] = [
{ label: 'Dashboard', href: '/' }, { label: 'Dashboard', href: '/' },
// Tablero ejecutivo del dueño (solo admin, gerencia y marketing).
{ label: 'Dirección', href: '/direccion' },
{ label: 'Agenda', href: '/agenda' }, { label: 'Agenda', href: '/agenda' },
{ label: 'Pacientes', href: '/pacientes', termKey: 'patient_plural' }, { label: 'Pacientes', href: '/pacientes', termKey: 'patient_plural' },
{ label: 'Visitas', href: '/visitas', termKey: 'visit_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', 'inventario.ver', 'inventario.ajustar', 'inventario.mover', 'inventario.costos',
'servicios.ver', 'servicios.editar', 'servicios.ver', 'servicios.editar',
'reportes.ver', 'reportes.exportar', 'caja.ver', 'caja.cerrar', 'reportes.ver', 'reportes.exportar', 'caja.ver', 'caja.cerrar',
'direccion.ver',
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar', 'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar', 'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar',
'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar', 'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar',
@@ -84,6 +85,7 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'inventario.costos': WN, 'inventario.costos': WN,
'servicios.ver': ALL, 'servicios.editar': AD, 'servicios.ver': ALL, 'servicios.editar': AD,
'reportes.ver': ALL, 'reportes.exportar': ALL, 'caja.ver': ALL, 'caja.cerrar': MONEY, '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, 'whatsapp.ver': W, 'whatsapp.responder': W, 'whatsapp.asignar': W,
'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG, 'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG,
'formularios.ver': ALL, 'formularios.crear': FORM_WRITE, 'formularios.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 PropinasPanel from '../components/PropinasPanel';
import CampanasPanel from '../components/CampanasPanel'; import CampanasPanel from '../components/CampanasPanel';
import EncuestasPanel from '../components/EncuestasPanel'; import EncuestasPanel from '../components/EncuestasPanel';
import DireccionPanel from '../components/DireccionPanel';
import LicenciaPanel from '../components/LicenciaPanel'; import LicenciaPanel from '../components/LicenciaPanel';
import ApiKeysPanel from '../components/ApiKeysPanel'; import ApiKeysPanel from '../components/ApiKeysPanel';
import WebhooksPanel from '../components/WebhooksPanel'; import WebhooksPanel from '../components/WebhooksPanel';
@@ -38,6 +39,7 @@ import { LanguageSelect } from '../components/LanguageSelect';
const TABS = [ const TABS = [
{ key: 'clinica', label: 'Clínica' }, { key: 'clinica', label: 'Clínica' },
{ key: 'direccion', label: 'Dirección' },
{ key: 'reservas', label: 'Reservas en línea' }, { key: 'reservas', label: 'Reservas en línea' },
{ key: 'portal', label: 'Portal del cliente' }, { key: 'portal', label: 'Portal del cliente' },
{ key: 'notificaciones', label: 'Notificaciones' }, { key: 'notificaciones', label: 'Notificaciones' },
@@ -66,7 +68,7 @@ const TABS = [
] as const; ] as const;
// Secciones que ve gerencia (admin del sistema ve todas). // 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 = [ const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' }, { key: 'diagnosticos', label: 'Diagnósticos' },
@@ -87,6 +89,7 @@ const Configuracion: FC = () => {
const esAdmin = user?.role === 'admin'; const esAdmin = user?.role === 'admin';
// Cada pestaña requiere su permiso por acción (Configuración › Roles › Permisos). // Cada pestaña requiere su permiso por acción (Configuración › Roles › Permisos).
const TABS_PERM: Record<string, string> = { const TABS_PERM: Record<string, string> = {
direccion: 'config.editar',
usuarios: 'config.usuarios', usuarios: 'config.usuarios',
roles: 'config.roles', roles: 'config.roles',
auditoria: 'config.auditoria', auditoria: 'config.auditoria',
@@ -518,6 +521,12 @@ const Configuracion: FC = () => {
<ApiKeysPanel /> <ApiKeysPanel />
) : tab === 'webhooks' ? ( ) : tab === 'webhooks' ? (
<WebhooksPanel /> <WebhooksPanel />
) : tab === 'direccion' ? (
<Card>
<Card.Body>
<DireccionPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : ( ) : (
<> <>
<Card className="mb-4 sm:mb-6"> <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; portal_cliente: PortalClienteSettings;
campanas: CampanasSettings; campanas: CampanasSettings;
encuestas: EncuestasSettings; 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) ---- // ---- 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, portal_cliente: PORTAL_CLIENTE_DEFAULT,
campanas: CAMPANAS_DEFAULT, campanas: CAMPANAS_DEFAULT,
encuestas: ENCUESTAS_DEFAULT, encuestas: ENCUESTAS_DEFAULT,
tablero: TABLERO_DEFAULT,
}; };
export interface FrontendUser { export interface FrontendUser {
@@ -5053,6 +5240,12 @@ export const odooApi = {
return data; 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 }> { async getNotifications(): Promise<{ status: string; notifications: AppNotification[]; total: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/notifications`); const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/notifications`);
return data; return data;

View File

@@ -1730,6 +1730,134 @@ def get_encuestas(env):
"""Bloque de encuestas vigente (copia). Apagado por defecto.""" """Bloque de encuestas vigente (copia). Apagado por defecto."""
return normalizar_encuestas(get_settings(env).get('encuestas')) 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). # Licencia por plan (producto comercial).
# #
@@ -2163,6 +2291,7 @@ def get_settings(env, aplicar_licencia=True):
'propinas': dict(PROPINAS_DEFAULT), 'propinas': dict(PROPINAS_DEFAULT),
'campanas': dict(CAMPANAS_DEFAULT), 'campanas': dict(CAMPANAS_DEFAULT),
'encuestas': dict(ENCUESTAS_DEFAULT), 'encuestas': dict(ENCUESTAS_DEFAULT),
'tablero': normalizar_direccion(None),
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])), 'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
'portal_cliente': normalizar_portal_cliente(None), '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['propinas'] = normalizar_propinas(guardado.get('propinas'))
out['campanas'] = normalizar_campanas(guardado.get('campanas')) out['campanas'] = normalizar_campanas(guardado.get('campanas'))
out['encuestas'] = normalizar_encuestas(guardado.get('encuestas')) out['encuestas'] = normalizar_encuestas(guardado.get('encuestas'))
out['tablero'] = normalizar_direccion(guardado.get('tablero'))
out['licencia'] = normalizar_licencia(guardado.get('licencia')) out['licencia'] = normalizar_licencia(guardado.get('licencia'))
out['portal_cliente'] = normalizar_portal_cliente( out['portal_cliente'] = normalizar_portal_cliente(
guardado.get('portal_cliente')) guardado.get('portal_cliente'))
@@ -2268,6 +2398,11 @@ def set_settings(env, vals):
if bloque is not None: if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto. # Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['encuestas'] = {**actual['encuestas'], **bloque} 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: if 'licencia' in vals:
bloque = validar_licencia(vals['licencia']) bloque = validar_licencia(vals['licencia'])
if bloque is not None: if bloque is not None:

View File

@@ -4,6 +4,7 @@ from odoo import http
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from odoo.http import request, Response from odoo.http import request, Response
import base64 import base64
import calendar
import json import json
import re import re
import uuid import uuid
@@ -24,6 +25,7 @@ from odoo.addons.skeen_citas.clinic_settings import (
get_compras, get_compras,
get_lealtad, get_lealtad,
get_propinas, get_propinas,
get_direccion,
) )
from odoo.addons.skeen_monedero.lealtad import ( from odoo.addons.skeen_monedero.lealtad import (
nivel_por_codigo as lealtad_nivel_por_codigo, nivel_por_codigo as lealtad_nivel_por_codigo,
@@ -7991,6 +7993,752 @@ class SkeenFrontendController(http.Controller):
except Exception as e: except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500) 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) # Sucursales (configurables)
# ============================================================ # ============================================================

View File

@@ -85,6 +85,9 @@ PERMISSION_GROUPS = [
('caja.ver', 'Ver cortes de caja'), ('caja.ver', 'Ver cortes de caja'),
('caja.cerrar', 'Cerrar caja'), ('caja.cerrar', 'Cerrar caja'),
]), ]),
('direccion', 'Tablero de dirección', [
('direccion.ver', 'Ver el tablero de dirección'),
]),
('whatsapp', 'WhatsApp y leads', [ ('whatsapp', 'WhatsApp y leads', [
('whatsapp.ver', 'Ver conversaciones y leads'), ('whatsapp.ver', 'Ver conversaciones y leads'),
('whatsapp.responder', 'Responder conversaciones'), ('whatsapp.responder', 'Responder conversaciones'),
@@ -272,6 +275,10 @@ DEFAULT_PERM_ROLES = {
'reportes.exportar': ALL, 'reportes.exportar': ALL,
'caja.ver': ALL, 'caja.ver': ALL,
'caja.cerrar': MONEY, '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 y leads (menú WACRM: admin, recepcion, gerencia, marketing)
'whatsapp.ver': W, 'whatsapp.ver': W,
'whatsapp.responder': W, 'whatsapp.responder': W,

View File

@@ -174,6 +174,9 @@ DEFAULT_MENU_ROLES = {
'/compras': ['admin', 'recepcion', 'lectura', 'gerencia'], '/compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/cortes': ['admin', 'recepcion', 'gerencia'], '/cortes': ['admin', 'recepcion', 'gerencia'],
'/reportes': ['admin', 'recepcion', 'lectura', '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=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=horas-agenda': ['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 NO hereda los menús de 'lectura': sólo estos (mínimo privilegio).
MARKETING_MENUS = ( MARKETING_MENUS = (
'/', '/agenda', '/pacientes', '/cumpleanos', '/visitas', '/', '/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'. # Roles nuevos que por default ven lo mismo que 'lectura'.