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

+ {t('dir.cfg.title')} +

+

{t('dir.cfg.hint')}

+
+ +
+ +
+ set('meta_mensual_global', parseFloat(e.target.value) || 0)} /> + set('dias_inactividad', parseInt(e.target.value, 10) || 1)} /> + set('visitas_minimas_riesgo', parseInt(e.target.value, 10) || 0)} /> +
+ +
+ + +
+ +
+
+

{t('dir.cfg.metas')}

+ +
+ {form.metas_sucursal.length === 0 ? ( +

{t('dir.cfg.sinMetas')}

+ ) : ( +
+ {form.metas_sucursal.map((m, i) => ( +
+
+ +
+
+ setMeta(i, 'meta_mensual', parseFloat(e.target.value) || 0)} /> +
+ +
+ ))} +
+ )} +
+
+ ); +}; + +export default DireccionPanel; diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx index b8b2211..8a86208 100644 --- a/frontend/src/lib/auth.tsx +++ b/frontend/src/lib/auth.tsx @@ -53,6 +53,8 @@ const MENU_ROLES: Record = { '/compras': ['admin', 'recepcion', 'lectura', 'gerencia'], '/cortes': ['admin', 'recepcion', 'gerencia'], '/reportes': ['admin', 'recepcion', 'lectura', 'gerencia'], + // Tablero de dirección: solo dueño/administración y marketing (el médico no). + '/direccion': ['admin', 'gerencia', 'marketing'], '/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura', 'gerencia'], diff --git a/frontend/src/lib/clinic-settings.ts b/frontend/src/lib/clinic-settings.ts index 6a4d361..62701d5 100644 --- a/frontend/src/lib/clinic-settings.ts +++ b/frontend/src/lib/clinic-settings.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LEALTAD_DEFAULT, PROPINAS_DEFAULT, CAMPANAS_DEFAULT, ENCUESTAS_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT, PORTAL_CLIENTE_DEFAULT } from '../services/odoo'; +import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LEALTAD_DEFAULT, PROPINAS_DEFAULT, CAMPANAS_DEFAULT, ENCUESTAS_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT, PORTAL_CLIENTE_DEFAULT, TABLERO_DEFAULT } from '../services/odoo'; // Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos). // Se leen una sola vez del servidor y se avisa a quien los use cuando cambian. @@ -33,6 +33,11 @@ const normalizar = (settings?: Partial | null): ClinicSettings = limites: { ...LICENCIA_LIMITES_DEFAULT, ...((settings?.licencia || {}).limites || {}) }, }, portal_cliente: { ...PORTAL_CLIENTE_DEFAULT, ...(settings?.portal_cliente || {}) }, + tablero: { + ...TABLERO_DEFAULT, + ...(settings?.tablero || {}), + metas_sucursal: settings?.tablero?.metas_sucursal ?? TABLERO_DEFAULT.metas_sucursal, + }, }); let actual: ClinicSettings = normalizar(null); diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index e5e172e..cae622b 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -969,6 +969,128 @@ export const es: Dict = { 'nps.processed': 'Encuestas procesadas.', 'nps.replySent': 'Respuesta enviada.', 'nps.replySimulated': 'Modo prueba: la respuesta quedó registrada como simulada.', + // --- Tablero de dirección --- + 'dir.title': 'Dirección', + 'dir.subtitle': 'Tablero ejecutivo del negocio', + 'dir.periodo': 'Periodo', + 'dir.inicio': 'Inicio', + 'dir.fin': 'Fin', + 'dir.sucursal': 'Sucursal', + 'dir.todasSucursales': 'Todas las sucursales', + 'dir.comparar': 'Comparar con', + 'dir.cmp.anterior': 'Periodo anterior', + 'dir.cmp.anio': 'Mismo periodo del año pasado', + 'dir.cmp.ninguno': 'Sin comparativo', + 'dir.preset.hoy': 'Hoy', + 'dir.preset.semana': 'Esta semana', + 'dir.preset.mes': 'Este mes', + 'dir.preset.mesPasado': 'Mes pasado', + 'dir.preset.anio': 'Este año', + 'dir.preset.custom': 'Personalizado', + 'dir.exportar': 'Exportar', + 'dir.cargando': 'Cargando…', + 'dir.sinDatos': 'Sin datos en el periodo.', + 'dir.error': 'No se pudo cargar el tablero.', + 'dir.exportOk': 'Excel descargado', + 'dir.exportError': 'Error al exportar', + 'dir.noDisponible': 'No disponible', + 'dir.noDisponibleHint': 'Estos indicadores no se pueden calcular con los datos del sistema.', + 'dir.vsAnterior': 'vs. periodo anterior', + 'dir.vsAnio': 'vs. año pasado', + 'dir.section.resumen': 'Resumen del periodo', + 'dir.kpi.ingresos': 'Ingresos cobrados', + 'dir.kpi.ventas': 'Ventas', + 'dir.kpi.ticket': 'Ticket promedio', + 'dir.kpi.visitas': 'Visitas atendidas', + 'dir.kpi.nuevos': 'Pacientes nuevos', + 'dir.kpi.recurrentes': 'Pacientes recurrentes', + 'dir.kpi.noShow': 'Tasa de no presentados', + 'dir.kpi.cancelacion': 'Tasa de cancelación', + 'dir.section.clientes': 'Clientes', + 'dir.kpi.activos': 'Pacientes activos', + 'dir.kpi.frecuencia': 'Frecuencia promedio', + 'dir.kpi.frecuenciaHint': 'Visitas por paciente en el periodo', + 'dir.kpi.valor': 'Valor por cliente', + 'dir.kpi.valorHint': 'Ingreso histórico promedio por paciente', + 'dir.clientes.nuevosMes': 'Pacientes nuevos por mes', + 'dir.clientes.retencion': 'Retención por cohortes', + 'dir.clientes.retencionHint': 'De los pacientes que vinieron por primera vez en un mes, cuántos volvieron en los siguientes 1, 3, 6 y 12 meses.', + 'dir.clientes.cohorte': 'Cohorte', + 'dir.clientes.tamano': 'Pacientes', + 'dir.clientes.m1': '1 mes', + 'dir.clientes.m3': '3 meses', + 'dir.clientes.m6': '6 meses', + 'dir.clientes.m12': '12 meses', + 'dir.clientes.riesgo': 'Clientes en riesgo', + 'dir.clientes.riesgoHint': 'Con {visitas} o más visitas y más de {dias} días sin venir', + 'dir.clientes.crearCampana': 'Armar campaña', + 'dir.clientes.valorMediana': 'Mediana', + 'dir.clientes.valorTotal': 'Ingreso histórico', + 'dir.clientes.ultimaVisita': 'Última visita', + 'dir.clientes.visitas': 'Visitas', + 'dir.clientes.diasSinVenir': 'Días sin venir', + 'dir.clientes.ventanaPendiente': 'Aún no se cumple la ventana', + 'dir.section.ocupacion': 'Ocupación y agenda', + 'dir.ocup.porMedico': 'Ocupación por médico', + 'dir.ocup.porSucursal': 'Ocupación por sucursal', + 'dir.ocup.porRecurso': 'Ocupación por recurso', + 'dir.ocup.franjas': 'Franjas horarias', + 'dir.ocup.franjasHint': 'Citas por hora del día (canceladas, no-show y lista de espera no cuentan).', + 'dir.ocup.masOcupadas': 'Franjas más ocupadas', + 'dir.ocup.menosOcupadas': 'Franjas menos ocupadas', + 'dir.ocup.horasVendidas': 'Horas vendidas', + 'dir.ocup.horasDisponibles': 'Horas disponibles', + 'dir.ocup.horasLibres': 'Horas libres', + 'dir.ocup.ocupacion': 'Ocupación', + 'dir.ocup.noShow': 'No-show por médico', + 'dir.ocup.citas': 'Citas', + 'dir.section.ingresos': 'Ingresos por concepto', + 'dir.ing.porServicio': 'Por servicio', + 'dir.ing.porCategoria': 'Por categoría', + 'dir.ing.porSucursal': 'Por sucursal', + 'dir.ing.porMetodo': 'Por método de pago', + 'dir.ing.propinas': 'Propinas', + 'dir.ing.membresias': 'Membresías', + 'dir.ing.paquetes': 'Paquetes', + 'dir.ing.lealtad': 'Lealtad', + 'dir.ing.concepto': 'Concepto', + 'dir.ing.cantidad': 'Cantidad', + 'dir.ing.total': 'Total', + 'dir.ing.activas': 'Membresías activas', + 'dir.ing.mrr': 'Ingreso recurrente mensual (estimado)', + 'dir.ing.renovaciones': 'Renovaciones del periodo', + 'dir.ing.cancelaciones': 'Cancelaciones del periodo', + 'dir.ing.paquetesVendidos': 'Paquetes vendidos', + 'dir.ing.sesionesPendientes': 'Sesiones pendientes de usar', + 'dir.ing.pasivoLealtad': 'Pasivo de lealtad', + 'dir.ing.puntosActivos': 'Puntos activos', + 'dir.section.metas': 'Metas y proyección', + 'dir.metas.porSucursal': 'Metas por sucursal (mes en curso)', + 'dir.metas.porEmpleado': 'Metas por empleado (mes en curso)', + 'dir.metas.meta': 'Meta', + 'dir.metas.avance': 'Ventas del mes', + 'dir.metas.pct': 'Avance', + 'dir.metas.proyeccion': 'Proyección de cierre', + 'dir.metas.mes': 'Mes en curso', + 'dir.metas.periodo': 'Proyección del periodo', + 'dir.metas.sinMeta': 'Sin meta', + 'dir.metas.concepto': 'Concepto', + 'dir.metas.empleado': 'Empleado', + 'dir.metas.global': 'Global', + 'dir.metas.ventasPeriodo': 'Ventas del periodo', + 'dir.metas.cobrosPeriodo': 'Cobros del periodo', + 'dir.ms': 'Respuesta', + 'dir.cfg.title': 'Parámetros del tablero', + 'dir.cfg.hint': 'Metas por sucursal y criterios de "clientes en riesgo". Con las metas en cero el tablero funciona igual y solo no muestra avance.', + 'dir.cfg.metas': 'Metas mensuales por sucursal', + 'dir.cfg.metaGlobal': 'Meta mensual global', + 'dir.cfg.dias': 'Días sin venir para "en riesgo"', + 'dir.cfg.visitas': 'Visitas mínimas para "en riesgo"', + 'dir.cfg.comparativo': 'Comparativo por defecto', + 'dir.cfg.addMeta': 'Agregar sucursal', + 'dir.cfg.sinMetas': 'Sin metas configuradas.', + 'dir.cfg.saved': 'Parámetros del tablero guardados', + 'dir.cfg.error': 'No se pudieron guardar los parámetros', }; // ------------------------------------------------------------ @@ -1893,6 +2015,128 @@ export const en: Dict = { 'nps.processed': 'Surveys processed.', 'nps.replySent': 'Reply sent.', 'nps.replySimulated': 'Test mode: the reply was logged as simulated.', + // --- Executive dashboard (Dirección) --- + 'dir.title': 'Management', + 'dir.subtitle': 'Business executive dashboard', + 'dir.periodo': 'Period', + 'dir.inicio': 'Start', + 'dir.fin': 'End', + 'dir.sucursal': 'Branch', + 'dir.todasSucursales': 'All branches', + 'dir.comparar': 'Compare with', + 'dir.cmp.anterior': 'Previous period', + 'dir.cmp.anio': 'Same period last year', + 'dir.cmp.ninguno': 'No comparison', + 'dir.preset.hoy': 'Today', + 'dir.preset.semana': 'This week', + 'dir.preset.mes': 'This month', + 'dir.preset.mesPasado': 'Last month', + 'dir.preset.anio': 'This year', + 'dir.preset.custom': 'Custom', + 'dir.exportar': 'Export', + 'dir.cargando': 'Loading…', + 'dir.sinDatos': 'No data for the period.', + 'dir.error': 'The dashboard could not be loaded.', + 'dir.exportOk': 'Excel downloaded', + 'dir.exportError': 'Export failed', + 'dir.noDisponible': 'Not available', + 'dir.noDisponibleHint': 'These indicators cannot be computed with the available data.', + 'dir.vsAnterior': 'vs. previous period', + 'dir.vsAnio': 'vs. last year', + 'dir.section.resumen': 'Period summary', + 'dir.kpi.ingresos': 'Collected revenue', + 'dir.kpi.ventas': 'Sales', + 'dir.kpi.ticket': 'Average ticket', + 'dir.kpi.visitas': 'Visits attended', + 'dir.kpi.nuevos': 'New patients', + 'dir.kpi.recurrentes': 'Returning patients', + 'dir.kpi.noShow': 'No-show rate', + 'dir.kpi.cancelacion': 'Cancellation rate', + 'dir.section.clientes': 'Customers', + 'dir.kpi.activos': 'Active patients', + 'dir.kpi.frecuencia': 'Average frequency', + 'dir.kpi.frecuenciaHint': 'Visits per patient in the period', + 'dir.kpi.valor': 'Value per customer', + 'dir.kpi.valorHint': 'Average historical revenue per patient', + 'dir.clientes.nuevosMes': 'New patients by month', + 'dir.clientes.retencion': 'Cohort retention', + 'dir.clientes.retencionHint': 'Of the patients who came for the first time in a month, how many returned within 1, 3, 6 and 12 months.', + 'dir.clientes.cohorte': 'Cohort', + 'dir.clientes.tamano': 'Patients', + 'dir.clientes.m1': '1 month', + 'dir.clientes.m3': '3 months', + 'dir.clientes.m6': '6 months', + 'dir.clientes.m12': '12 months', + 'dir.clientes.riesgo': 'Customers at risk', + 'dir.clientes.riesgoHint': 'With {visitas}+ visits and more than {dias} days without coming', + 'dir.clientes.crearCampana': 'Build campaign', + 'dir.clientes.valorMediana': 'Median', + 'dir.clientes.valorTotal': 'Historical revenue', + 'dir.clientes.ultimaVisita': 'Last visit', + 'dir.clientes.visitas': 'Visits', + 'dir.clientes.diasSinVenir': 'Days since last visit', + 'dir.clientes.ventanaPendiente': 'Window not reached yet', + 'dir.section.ocupacion': 'Occupancy and schedule', + 'dir.ocup.porMedico': 'Occupancy by doctor', + 'dir.ocup.porSucursal': 'Occupancy by branch', + 'dir.ocup.porRecurso': 'Occupancy by resource', + 'dir.ocup.franjas': 'Time slots', + 'dir.ocup.franjasHint': 'Appointments by hour (cancelled, no-show and waitlist do not count).', + 'dir.ocup.masOcupadas': 'Busiest slots', + 'dir.ocup.menosOcupadas': 'Least busy slots', + 'dir.ocup.horasVendidas': 'Hours sold', + 'dir.ocup.horasDisponibles': 'Hours available', + 'dir.ocup.horasLibres': 'Hours free', + 'dir.ocup.ocupacion': 'Occupancy', + 'dir.ocup.noShow': 'No-show by doctor', + 'dir.ocup.citas': 'Appointments', + 'dir.section.ingresos': 'Revenue by concept', + 'dir.ing.porServicio': 'By service', + 'dir.ing.porCategoria': 'By category', + 'dir.ing.porSucursal': 'By branch', + 'dir.ing.porMetodo': 'By payment method', + 'dir.ing.propinas': 'Tips', + 'dir.ing.membresias': 'Memberships', + 'dir.ing.paquetes': 'Packages', + 'dir.ing.lealtad': 'Loyalty', + 'dir.ing.concepto': 'Concept', + 'dir.ing.cantidad': 'Quantity', + 'dir.ing.total': 'Total', + 'dir.ing.activas': 'Active memberships', + 'dir.ing.mrr': 'Estimated monthly recurring revenue', + 'dir.ing.renovaciones': 'Renewals in the period', + 'dir.ing.cancelaciones': 'Cancellations in the period', + 'dir.ing.paquetesVendidos': 'Packages sold', + 'dir.ing.sesionesPendientes': 'Sessions pending use', + 'dir.ing.pasivoLealtad': 'Loyalty liability', + 'dir.ing.puntosActivos': 'Active points', + 'dir.section.metas': 'Goals and projection', + 'dir.metas.porSucursal': 'Goals by branch (current month)', + 'dir.metas.porEmpleado': 'Goals by employee (current month)', + 'dir.metas.meta': 'Goal', + 'dir.metas.avance': 'Month sales', + 'dir.metas.pct': 'Progress', + 'dir.metas.proyeccion': 'Closing projection', + 'dir.metas.mes': 'Current month', + 'dir.metas.periodo': 'Period projection', + 'dir.metas.sinMeta': 'No goal', + 'dir.metas.concepto': 'Concept', + 'dir.metas.empleado': 'Employee', + 'dir.metas.global': 'Global', + 'dir.metas.ventasPeriodo': 'Period sales', + 'dir.metas.cobrosPeriodo': 'Period collections', + 'dir.ms': 'Response', + 'dir.cfg.title': 'Dashboard parameters', + 'dir.cfg.hint': 'Goals by branch and "customers at risk" criteria. With zeroed goals the dashboard still works, it just shows no progress.', + 'dir.cfg.metas': 'Monthly goals by branch', + 'dir.cfg.metaGlobal': 'Global monthly goal', + 'dir.cfg.dias': 'Days without coming for "at risk"', + 'dir.cfg.visitas': 'Minimum visits for "at risk"', + 'dir.cfg.comparativo': 'Default comparison', + 'dir.cfg.addMeta': 'Add branch', + 'dir.cfg.sinMetas': 'No goals configured.', + 'dir.cfg.saved': 'Dashboard parameters saved', + 'dir.cfg.error': 'Could not save the parameters', }; export const DICT: Record = { es, en }; diff --git a/frontend/src/lib/menu-items.ts b/frontend/src/lib/menu-items.ts index 1ef5f8e..f0f31f8 100644 --- a/frontend/src/lib/menu-items.ts +++ b/frontend/src/lib/menu-items.ts @@ -13,6 +13,8 @@ export interface MenuItem { export const MENU_ITEMS: MenuItem[] = [ { label: 'Dashboard', href: '/' }, + // Tablero ejecutivo del dueño (solo admin, gerencia y marketing). + { label: 'Dirección', href: '/direccion' }, { label: 'Agenda', href: '/agenda' }, { label: 'Pacientes', href: '/pacientes', termKey: 'patient_plural' }, { label: 'Visitas', href: '/visitas', termKey: 'visit_plural' }, diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts index 30972b6..ceb2b86 100644 --- a/frontend/src/lib/permissions.ts +++ b/frontend/src/lib/permissions.ts @@ -16,6 +16,7 @@ export const PERM_KEYS: string[] = [ 'inventario.ver', 'inventario.ajustar', 'inventario.mover', 'inventario.costos', 'servicios.ver', 'servicios.editar', 'reportes.ver', 'reportes.exportar', 'caja.ver', 'caja.cerrar', + 'direccion.ver', 'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar', 'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar', 'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar', @@ -84,6 +85,7 @@ const DEFAULT_PERM_ROLES: Record = { 'inventario.costos': WN, 'servicios.ver': ALL, 'servicios.editar': AD, 'reportes.ver': ALL, 'reportes.exportar': ALL, 'caja.ver': ALL, 'caja.cerrar': MONEY, + 'direccion.ver': ['admin', 'gerencia', 'marketing'], 'whatsapp.ver': W, 'whatsapp.responder': W, 'whatsapp.asignar': W, 'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG, 'formularios.ver': ALL, 'formularios.crear': FORM_WRITE, 'formularios.editar': AG, diff --git a/frontend/src/pages/Configuracion.tsx b/frontend/src/pages/Configuracion.tsx index 5c7c621..7600ce3 100644 --- a/frontend/src/pages/Configuracion.tsx +++ b/frontend/src/pages/Configuracion.tsx @@ -26,6 +26,7 @@ import LealtadPanel from '../components/LealtadPanel'; import PropinasPanel from '../components/PropinasPanel'; import CampanasPanel from '../components/CampanasPanel'; import EncuestasPanel from '../components/EncuestasPanel'; +import DireccionPanel from '../components/DireccionPanel'; import LicenciaPanel from '../components/LicenciaPanel'; import ApiKeysPanel from '../components/ApiKeysPanel'; import WebhooksPanel from '../components/WebhooksPanel'; @@ -38,6 +39,7 @@ import { LanguageSelect } from '../components/LanguageSelect'; const TABS = [ { key: 'clinica', label: 'Clínica' }, + { key: 'direccion', label: 'Dirección' }, { key: 'reservas', label: 'Reservas en línea' }, { key: 'portal', label: 'Portal del cliente' }, { key: 'notificaciones', label: 'Notificaciones' }, @@ -66,7 +68,7 @@ const TABS = [ ] as const; // Secciones que ve gerencia (admin del sistema ve todas). -const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'lealtad', 'propinas', 'campanas', 'encuestas', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks']; +const TABS_GERENCIA = ['direccion', 'reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'lealtad', 'propinas', 'campanas', 'encuestas', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks']; const SUBCATALOGOS = [ { key: 'diagnosticos', label: 'Diagnósticos' }, @@ -87,6 +89,7 @@ const Configuracion: FC = () => { const esAdmin = user?.role === 'admin'; // Cada pestaña requiere su permiso por acción (Configuración › Roles › Permisos). const TABS_PERM: Record = { + direccion: 'config.editar', usuarios: 'config.usuarios', roles: 'config.roles', auditoria: 'config.auditoria', @@ -518,6 +521,12 @@ const Configuracion: FC = () => { ) : tab === 'webhooks' ? ( + ) : tab === 'direccion' ? ( + + + + + ) : ( <> diff --git a/frontend/src/pages/Direccion.tsx b/frontend/src/pages/Direccion.tsx new file mode 100644 index 0000000..35a23b6 --- /dev/null +++ b/frontend/src/pages/Direccion.tsx @@ -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, +}) => ( + +
+ {icon && {icon}} +

{label}

+
+

{value}

+ {hint &&

{hint}

} +
+); + +const Variacion: FC<{ v: number | null; label: string }> = ({ v, label }) => { + if (v === null || v === undefined) { + return {label}; + } + const up = v >= 0; + return ( + + {up ? : } + {(v * 100).toFixed(1)}% {label} + + ); +}; + +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('mes'); + const [start, setStart] = useState(() => rangoPreset('mes').start); + const [end, setEnd] = useState(() => rangoPreset('mes').end); + const [sucursal, setSucursal] = useState(sp.get('sucursal') || ''); + const [comparar, setComparar] = useState(tablero.comparativo_default); + const [data, setData] = useState(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[] = []; + 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 ( + + + +
+ { setStart(e.target.value); setPreset('custom'); }} className="lg:max-w-[150px]" /> + { setEnd(e.target.value); setPreset('custom'); }} className="lg:max-w-[150px]" /> + 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]" + /> +
+ +
+
+
+
+ + {loading ? ( +
+
+ {Array.from({ length: 8 }).map((_, i) => )} +
+ +
+ ) : !data ? ( + + ) : ( +
+ {/* ---- Resumen ---- */} +
+ +
+ } + hint={data.comparativo ? : undefined} /> + } + hint={data.comparativo ? : undefined} /> + } + hint={data.comparativo ? : undefined} /> + } + hint={data.comparativo ? : undefined} /> + } + hint={data.comparativo ? : undefined} /> + } + hint={data.comparativo ? : undefined} /> + } + hint={data.comparativo ? : undefined} /> + } + hint={data.comparativo ? : undefined} /> +
+
+ + {/* ---- Clientes ---- */} +
+ +
+ } /> + + +
+
+ + +

{t('dir.clientes.nuevosMes')}

+ + ({ mes: m.mes.slice(5), total: m.total }))} + margin={{ top: 4, right: 8, left: -18, bottom: 0 }}> + + + + + + + +
+
+ + +

{t('dir.clientes.retencion')}

+

{t('dir.clientes.retencionHint')}

+
+ + + + + + + + + + + + + {data.clientes.retencion_cohortes.map((c) => ( + + + + + + + + + ))} + +
{t('dir.clientes.cohorte')}{t('dir.clientes.tamano')}{t('dir.clientes.m1')}{t('dir.clientes.m3')}{t('dir.clientes.m6')}{t('dir.clientes.m12')}
{c.cohorte}{c.tamano}{pct(c.ret1_tasa)}{pct(c.ret3_tasa)}{pct(c.ret6_tasa)}{pct(c.ret12_tasa)}
+
+
+
+
+ + + +
+
+

{t('dir.clientes.riesgo')}

+

+ {t('dir.clientes.riesgoHint', { + visitas: data.clientes.en_riesgo.visitas_minimas, + dias: data.clientes.en_riesgo.dias_inactividad, + })} · {num(data.clientes.en_riesgo.total)} +

+
+ +
+ {data.clientes.en_riesgo.pacientes.length === 0 ? ( +

{t('dir.sinDatos')}

+ ) : ( +
+ + + + + + + + + + + + {data.clientes.en_riesgo.pacientes.map((p) => ( + + + + + + + + ))} + +
{term('patient_singular')}{t('common.phone')}{t('dir.clientes.visitas')}{t('dir.clientes.ultimaVisita')}{t('dir.clientes.diasSinVenir')}
{p.name}{p.phone || '—'}{p.visitas}{p.ultima_visita || '—'}{p.dias_sin_venir ?? '—'}
+
+ )} +
+
+
+ + {/* ---- Ocupación ---- */} +
+ +
+ + +

{t('dir.ocup.porMedico')}

+ {data.ocupacion.por_medico.length === 0 ? ( +

{t('dir.sinDatos')}

+ ) : ( +
+ + + + + + + + + + + {data.ocupacion.por_medico.map((m) => ( + + + + + + + ))} + +
{term('doctor_singular')}{t('dir.ocup.horasVendidas')}{t('dir.ocup.horasDisponibles')}{t('dir.ocup.ocupacion')}
{m.medico}{num(m.horas_vendidas, 1)}{num(m.horas_disponibles, 1)}{pct(m.ocupacion)}
+
+ )} +
+
+ + +

{t('dir.ocup.franjas')}

+

{t('dir.ocup.franjasHint')}

+ + ({ hora: f.hora.slice(0, 2), total: f.total }))} + margin={{ top: 4, right: 8, left: -18, bottom: 0 }}> + + + + + + + +
+
+

{t('dir.ocup.masOcupadas')}

+ {data.ocupacion.franjas_mas_ocupadas.map((f) => ( +

{f.hora} · {f.total}

+ ))} +
+
+

{t('dir.ocup.menosOcupadas')}

+ {data.ocupacion.franjas_menos_ocupadas.map((f) => ( +

{f.hora} · {f.total}

+ ))} +
+
+
+
+ + +

{t('dir.ocup.porSucursal')}

+ {data.ocupacion.por_sucursal.length === 0 ? ( +

{t('dir.sinDatos')}

+ ) : ( + + + {data.ocupacion.por_sucursal.map((s) => ( + + + + + + ))} + +
{labelFor(s.sucursal)}{num(s.horas_vendidas, 1)}h{pct(s.ocupacion)}
+ )} + {data.ocupacion.por_recurso.length > 0 && ( + <> +

{t('dir.ocup.porRecurso')}

+ + + {data.ocupacion.por_recurso.map((r) => ( + + + + + + ))} + +
{r.recurso}{num(r.horas_ocupadas, 1)}h{num(r.ocupacion_pct, 1)}%
+ + )} +
+
+ + +

{t('dir.ocup.noShow')}

+ {data.ocupacion.no_show_por_medico.length === 0 ? ( +

{t('dir.sinDatos')}

+ ) : ( +
+ + + + + + + + + + {data.ocupacion.no_show_por_medico.map((m) => ( + + + + + + ))} + +
{term('doctor_singular')}{t('dir.ocup.citas')}{t('dir.kpi.noShow')}
{m.medico}{m.citas} 0.15 ? 'text-rose-600' : 'text-theme-heading'}`}>{pct(m.tasa)}
+
+ )} +
+
+
+
+ + {/* ---- Ingresos ---- */} +
+ +
+ + +

{t('dir.ing.porServicio')}

+ [s.servicio, num(s.cantidad, 1), money(s.total)])} + empty={t('dir.sinDatos')} + /> +
+
+ + +

{t('dir.ing.porCategoria')}

+ [c.categoria, money(c.total)])} + empty={t('dir.sinDatos')} + /> +

{t('dir.ing.porSucursal')}

+ [labelFor(s.sucursal), money(s.total)])} + empty={t('dir.sinDatos')} + /> +
+
+ + +

{t('dir.ing.porMetodo')}

+ [k, money(v)])} + empty={t('dir.sinDatos')} + /> +
+ {t('dir.ing.propinas')} + {money(data.resumen.propinas)} +
+
+
+ + +

{t('dir.ing.membresias')}

+ + + + +

{t('dir.ing.paquetes')}

+ + +

{t('dir.ing.lealtad')}

+ + +
+
+
+
+ + {/* ---- Metas ---- */} +
+ +
+ + + +
+
+ + +

{t('dir.metas.porSucursal')}

+ [ + 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')} + /> +
+
+ + +

{t('dir.metas.porEmpleado')}

+ [ + m.empleado, money(m.meta_mensual), money(m.ventas_mes), + m.pct === null ? t('dir.metas.sinMeta') : pct(m.pct), + ])} + empty={t('dir.sinDatos')} + /> +
+
+
+
+ + {/* ---- No disponible ---- */} + {data.no_disponible.length > 0 && ( +
+ + +

+ {t('dir.noDisponible')} +

+

{t('dir.noDisponibleHint')}

+
    + {data.no_disponible.map((n) => ( +
  • + {n.etiqueta} + — {n.motivo} +
  • + ))} +
+
+
+
+ )} + +

{t('dir.ms')}: {data.ms} ms

+
+ )} +
+ ); +}; + +const TablaSimple: FC<{ cols: string[]; rows: (string | number)[][]; empty: string }> = ({ cols, rows, empty }) => ( + rows.length === 0 ?

{empty}

: ( +
+ + + + {cols.map((c, i) => ( + + ))} + + + + {rows.map((r, ri) => ( + + {r.map((cell, ci) => ( + + ))} + + ))} + +
{c}
{cell}
+
+ ) +); + +const Linea: FC<{ label: string; value: string }> = ({ label, value }) => ( +
+ {label} + {value} +
+); + +export default Direccion; diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index b28d8a5..cb15bc7 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -1099,6 +1099,192 @@ export interface ClinicSettings { portal_cliente: PortalClienteSettings; campanas: CampanasSettings; encuestas: EncuestasSettings; + /** Parámetros del tablero de dirección y metas por sucursal. */ + tablero: TableroSettings; +} + +// ---- Tablero de dirección (bloque `tablero` de clinic-settings) ---- +export type TableroComparativo = 'anterior' | 'anio' | 'ninguno'; + +export interface TableroMetaSucursal { + sucursal: string; + meta_mensual: number; +} + +export interface TableroSettings { + metas_sucursal: TableroMetaSucursal[]; + meta_mensual_global: number; + dias_inactividad: number; + visitas_minimas_riesgo: number; + comparativo_default: TableroComparativo; +} + +export const TABLERO_DEFAULT: TableroSettings = { + metas_sucursal: [], + meta_mensual_global: 0, + dias_inactividad: 90, + visitas_minimas_riesgo: 3, + comparativo_default: 'anterior', +}; + +// ---- Respuesta del endpoint del tablero de dirección ---- +export interface DireccionKpis { + ingresos_cobrados: number; + ventas_total: number; + ventas_count: number; + pagado_en_ventas: number; + ticket_promedio: number; + propinas: number; + visitas_atendidas: number; + citas_total: number; + citas_activas: number; + pacientes_activos: number; + pacientes_nuevos: number; + pacientes_recurrentes: number; + tasa_no_show: number; + tasa_cancelacion: number; + por_dia?: { date: string; total: number }[]; +} + +export interface DireccionComparativo { + modo: TableroComparativo; + start: string; + end: string; + kpis: DireccionKpis; + variacion: Record; +} + +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; +} + +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; + membresias: { + activas: number; mrr_estimado: number; renovaciones: number; + ingreso_recurrente_periodo: number; altas: number; cancelaciones: number; + }; + paquetes: { vendidos: number; total_vendido: number; activos: number; sesiones_pendientes: number }; + lealtad: { + valor_punto: number; puntos_activos: number; pasivo_mxn: number; + otorgados: number; canjeados: number; expirados: number; + }; +} + +export interface DireccionMetaSucursalFila { + sucursal: string; + meta_mensual: number; + ventas_mes: number; + pct: number | null; + proyeccion: number; +} + +export interface DireccionMetaEmpleado { + employee_id: number; + empleado: string; + meta_mensual: number; + ventas_mes: number; + pct: number | null; + proyeccion: number; +} + +export interface DireccionMetas { + por_sucursal: DireccionMetaSucursalFila[]; + por_empleado: DireccionMetaEmpleado[]; + periodo: { + dias: number; dias_transcurridos: number; ventas: number; cobros: number; + proyeccion_ventas: number; proyeccion_cobros: number; + }; + mes: { + mes: string; dias_mes: number; dias_transcurridos: number; ventas: number; + meta: number; pct: number | null; proyeccion: number; + proyeccion_vs_meta: number | null; + }; +} + +export interface DireccionNoDisponible { clave: string; etiqueta: string; motivo: string; } + +export interface DireccionTablero { + status: string; + start: string; + end: string; + sucursal: string | null; + comparativo: DireccionComparativo | null; + resumen: DireccionKpis; + clientes: DireccionClientes; + ocupacion: DireccionOcupacion; + ingresos: DireccionIngresos; + metas: DireccionMetas; + no_disponible: DireccionNoDisponible[]; + ms: number; + message?: string; } // ---- Licencia por plan (bloque `licencia` de clinic-settings) ---- @@ -2801,6 +2987,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = { nombre: BRAND_DEFAULT. portal_cliente: PORTAL_CLIENTE_DEFAULT, campanas: CAMPANAS_DEFAULT, encuestas: ENCUESTAS_DEFAULT, + tablero: TABLERO_DEFAULT, }; export interface FrontendUser { @@ -5053,6 +5240,12 @@ export const odooApi = { return data; }, + // Tablero de dirección (indicadores del dueño). Solo lectura. + async getDireccion(params: { start?: string; end?: string; sucursal?: string; comparar?: TableroComparativo }): Promise { + const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/direccion`, { params }); + return data; + }, + async getNotifications(): Promise<{ status: string; notifications: AppNotification[]; total: number }> { const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/notifications`); return data; diff --git a/odoo-addons/skeen_citas/clinic_settings.py b/odoo-addons/skeen_citas/clinic_settings.py index 14db1db..2970520 100644 --- a/odoo-addons/skeen_citas/clinic_settings.py +++ b/odoo-addons/skeen_citas/clinic_settings.py @@ -1730,6 +1730,134 @@ def get_encuestas(env): """Bloque de encuestas vigente (copia). Apagado por defecto.""" return normalizar_encuestas(get_settings(env).get('encuestas')) +# --------------------------------------------------------------------------- +# Tablero de dirección (indicadores del dueño). +# +# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La clave es +# `tablero` (no `direccion`, que ya existe a nivel raíz para la dirección +# postal de la clínica). No enciende ni apaga nada por sí solo: son los +# PARÁMETROS del tablero y las metas por sucursal. Con los defaults (sin metas, +# 90 días de inactividad y 3 visitas mínimas) el tablero funciona y no altera +# ninguna cifra existente. +# +# metas_sucursal: [{sucursal: , meta_mensual: }] +# meta_mensual_global: meta de venta mensual de toda la clínica (0 = sin meta) +# dias_inactividad: días sin aparecer para considerar a un cliente "en riesgo" +# visitas_minimas_riesgo: visitas históricas mínimas para entrar a "en riesgo" +# (un paciente de una sola visita no se considera en riesgo) +# comparativo_default: anterior | anio | ninguno (interruptor del tablero) +# --------------------------------------------------------------------------- +DIRECCION_DEFAULT = { + 'metas_sucursal': [], + 'meta_mensual_global': 0.0, + 'dias_inactividad': 90, + 'visitas_minimas_riesgo': 3, + 'comparativo_default': 'anterior', +} + +DIRECCION_COMPARATIVOS = ('anterior', 'anio', 'ninguno') +DIRECCION_DIAS_MAX = 3650 +DIRECCION_VISITAS_MAX = 1000 +DIRECCION_METAS_MAX = 100 + + +def _normalizar_metas_sucursal(raw): + """Lista de metas por sucursal normalizada (una por código).""" + out = [] + if not isinstance(raw, (list, tuple)): + return out + vistos = set() + for item in raw: + if not isinstance(item, dict): + continue + codigo = str(item.get('sucursal') or '').strip()[:40] + if not codigo or codigo in vistos: + continue + vistos.add(codigo) + out.append({ + 'sucursal': codigo, + 'meta_mensual': max(0.0, _num(item.get('meta_mensual'), 0.0)), + }) + return out[:DIRECCION_METAS_MAX] + + +def normalizar_direccion(raw): + """Bloque `direccion` completo, con defaults. Tolerante: nunca lanza.""" + out = dict(DIRECCION_DEFAULT) + out['metas_sucursal'] = [] + if not isinstance(raw, dict): + return out + out['metas_sucursal'] = _normalizar_metas_sucursal(raw.get('metas_sucursal')) + out['meta_mensual_global'] = max(0.0, _num( + raw.get('meta_mensual_global'), DIRECCION_DEFAULT['meta_mensual_global'])) + out['dias_inactividad'] = min(max(_int( + raw.get('dias_inactividad'), DIRECCION_DEFAULT['dias_inactividad']), + 1), DIRECCION_DIAS_MAX) + out['visitas_minimas_riesgo'] = min(max(_int( + raw.get('visitas_minimas_riesgo'), DIRECCION_DEFAULT['visitas_minimas_riesgo']), + 0), DIRECCION_VISITAS_MAX) + comp = str(raw.get('comparativo_default') or '').strip().lower() + out['comparativo_default'] = ( + comp if comp in DIRECCION_COMPARATIVOS else DIRECCION_DEFAULT['comparativo_default']) + return out + + +def validar_direccion(raw): + """Valida el bloque `direccion` de un PUT. Lanza ValueError si algo no + cuadra. Devuelve SOLO las claves presentes y normalizadas (merge).""" + if raw is None: + return None + if not isinstance(raw, dict): + raise ValueError('El bloque de dirección debe ser un objeto') + out = {} + if 'metas_sucursal' in raw: + valor = raw['metas_sucursal'] + if not isinstance(valor, list): + raise ValueError('Las metas por sucursal deben ser una lista') + for item in valor: + if not isinstance(item, dict): + raise ValueError('Cada meta de sucursal debe ser un objeto') + if not str(item.get('sucursal') or '').strip(): + raise ValueError('Cada meta de sucursal necesita una sucursal') + n = _num(item.get('meta_mensual'), None) + if n is None or n < 0: + raise ValueError('La meta mensual debe ser un número no negativo') + out['metas_sucursal'] = _normalizar_metas_sucursal(valor) + if 'meta_mensual_global' in raw: + n = _num(raw['meta_mensual_global'], None) + if n is None or n < 0: + raise ValueError('La meta mensual global debe ser un número no negativo') + out['meta_mensual_global'] = n + if 'dias_inactividad' in raw: + if isinstance(raw['dias_inactividad'], bool) or not isinstance(raw['dias_inactividad'], int): + raise ValueError('Los días de inactividad deben ser un número entero') + if not (1 <= raw['dias_inactividad'] <= DIRECCION_DIAS_MAX): + raise ValueError('Los días de inactividad deben estar entre 1 y %d' % DIRECCION_DIAS_MAX) + out['dias_inactividad'] = raw['dias_inactividad'] + if 'visitas_minimas_riesgo' in raw: + if isinstance(raw['visitas_minimas_riesgo'], bool) or \ + not isinstance(raw['visitas_minimas_riesgo'], int): + raise ValueError('Las visitas mínimas deben ser un número entero') + if not (0 <= raw['visitas_minimas_riesgo'] <= DIRECCION_VISITAS_MAX): + raise ValueError('Las visitas mínimas deben estar entre 0 y %d' + % DIRECCION_VISITAS_MAX) + out['visitas_minimas_riesgo'] = raw['visitas_minimas_riesgo'] + if 'comparativo_default' in raw: + comp = str(raw['comparativo_default'] or '').strip().lower() + if comp not in DIRECCION_COMPARATIVOS: + raise ValueError("El comparativo debe ser 'anterior', 'anio' o 'ninguno'") + out['comparativo_default'] = comp + return out + + +def get_direccion(env): + """Bloque del tablero de dirección vigente (copia). + + La clave se llama `tablero` (no `direccion`) para no chocar con el campo + `direccion` de nivel raíz, que guarda la DIRECCIÓN POSTAL de la clínica. + """ + return normalizar_direccion(get_settings(env).get('tablero')) + # --------------------------------------------------------------------------- # Licencia por plan (producto comercial). # @@ -2163,6 +2291,7 @@ def get_settings(env, aplicar_licencia=True): 'propinas': dict(PROPINAS_DEFAULT), 'campanas': dict(CAMPANAS_DEFAULT), 'encuestas': dict(ENCUESTAS_DEFAULT), + 'tablero': normalizar_direccion(None), 'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])), 'portal_cliente': normalizar_portal_cliente(None), } @@ -2187,6 +2316,7 @@ def get_settings(env, aplicar_licencia=True): out['propinas'] = normalizar_propinas(guardado.get('propinas')) out['campanas'] = normalizar_campanas(guardado.get('campanas')) out['encuestas'] = normalizar_encuestas(guardado.get('encuestas')) + out['tablero'] = normalizar_direccion(guardado.get('tablero')) out['licencia'] = normalizar_licencia(guardado.get('licencia')) out['portal_cliente'] = normalizar_portal_cliente( guardado.get('portal_cliente')) @@ -2268,6 +2398,11 @@ def set_settings(env, vals): if bloque is not None: # Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto. actual['encuestas'] = {**actual['encuestas'], **bloque} + if 'tablero' in vals: + bloque = validar_direccion(vals['tablero']) + if bloque is not None: + # Merge anidado: un PUT parcial (p. ej. solo las metas) no borra el resto. + actual['tablero'] = {**actual['tablero'], **bloque} if 'licencia' in vals: bloque = validar_licencia(vals['licencia']) if bloque is not None: diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py index f3e96bb..8b08d5a 100644 --- a/odoo-addons/skeen_whatsapp/controllers/frontend.py +++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py @@ -4,6 +4,7 @@ from odoo import http from odoo.exceptions import ValidationError from odoo.http import request, Response import base64 +import calendar import json import re import uuid @@ -24,6 +25,7 @@ from odoo.addons.skeen_citas.clinic_settings import ( get_compras, get_lealtad, get_propinas, + get_direccion, ) from odoo.addons.skeen_monedero.lealtad import ( nivel_por_codigo as lealtad_nivel_por_codigo, @@ -7991,6 +7993,752 @@ class SkeenFrontendController(http.Controller): except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) + # ============================================================ + # Tablero de dirección (indicadores del dueño) + # + # Todas las cifras se calculan con los MISMOS criterios que los reportes + # existentes: ventas con state != 'cancelled' (igual que /reports/sales y + # /reports/concentrado), cobros de pagos state='completed' (igual que + # /reports/cash), no-show y ocupación con las reglas de /reports/kpis, + # recursos con /reports/recursos y franjas con /reports/horas-agenda. + # Es un tablero de SOLO LECTURA; requiere el permiso `direccion.ver`. + # ============================================================ + DIRECCION_MAX_COHORTES = 12 + + def _dir_minus_year(self, d): + """Mismo día del año anterior (29-feb cae al 28 en años no bisiestos).""" + try: + return d.replace(year=d.year - 1) + except ValueError: + return d.replace(year=d.year - 1, day=28) + + def _dir_add_months(self, d, n): + y = d.year + (d.month - 1 + n) // 12 + m = (d.month - 1 + n) % 12 + 1 + return date(y, m, min(d.day, calendar.monthrange(y, m)[1])) + + def _dir_mes_key(self, d): + return d.strftime('%Y-%m') + + def _dir_domain_sucursal(self, modelo, sucursal): + """Filtro por sucursal. Las ventas y los pagos no guardan sucursal + propia: se atribuyen a la de su cita origen (`cita_id.branch`).""" + if not sucursal: + return [] + if modelo == 'skeen.cita': + return [('branch', '=', sucursal)] + if modelo in ('skeen.venta', 'skeen.pago'): + return [('cita_id.branch', '=', sucursal)] + return [] + + def _dir_periodo_anterior(self, start, end): + d1 = datetime.strptime(start, '%Y-%m-%d').date() + d2 = datetime.strptime(end, '%Y-%m-%d').date() + dias = (d2 - d1).days + 1 + prev_fin = d1 - timedelta(days=1) + prev_ini = prev_fin - timedelta(days=dias - 1) + return prev_ini.strftime('%Y-%m-%d'), prev_fin.strftime('%Y-%m-%d') + + def _dir_periodo_anio(self, start, end): + d1 = datetime.strptime(start, '%Y-%m-%d').date() + d2 = datetime.strptime(end, '%Y-%m-%d').date() + return (self._dir_minus_year(d1).strftime('%Y-%m-%d'), + self._dir_minus_year(d2).strftime('%Y-%m-%d')) + + def _dir_kpis(self, start, end, sucursal): + """Cifras del resumen del periodo (y de su comparativo).""" + V = request.env['skeen.venta'].sudo() + C = request.env['skeen.cita'].sudo() + P = request.env['skeen.pago'].sudo() + vdom = [('state', '!=', 'cancelled'), ('date', '>=', start), ('date', '<=', end)] + vdom += self._dir_domain_sucursal('skeen.venta', sucursal) + vgrp = V.read_group(vdom, ['total:sum', 'amount_paid:sum', 'amount_due:sum', 'propina:sum'], []) + vrow = vgrp[0] if vgrp else {} + num_ventas = vrow.get('__count') or vrow.get('total_count') or 0 + ventas_total = vrow.get('total') or 0.0 + pagado_ventas = vrow.get('amount_paid') or 0.0 + propinas = vrow.get('propina') or 0.0 + pdom = [('payment_date', '>=', f'{start} 00:00:00'), + ('payment_date', '<=', f'{end} 23:59:59'), + ('state', '=', 'completed')] + pdom += self._dir_domain_sucursal('skeen.pago', sucursal) + pgrp = P.read_group(pdom, ['amount:sum'], []) + cobros = (pgrp[0].get('amount') or 0.0) if pgrp else 0.0 + cdom = [('date', '>=', start), ('date', '<=', end)] + cdom += self._dir_domain_sucursal('skeen.cita', sucursal) + estados = {} + for g in C.read_group(cdom, ['state'], ['state']): + estados[g['state']] = g.get('__count') or g.get('state_count') or 0 + total_citas = sum(estados.values()) + canceladas = estados.get('cancelled', 0) + no_show = estados.get('no_show', 0) + atendidas = estados.get('done', 0) + activas = total_citas - canceladas + # Pacientes activos y pacientes nuevos ("primera vez"). La definición de + # "primera vez" es EXACTAMENTE la de /reports/kpis: una cita del periodo + # cuya fecha es la primera cita histórica no cancelada del paciente. + # Se resuelve con consultas agrupadas para no traer miles de citas. + cr = request.env.cr + params = [start, end] + suc_sql = ' AND branch = %s' if sucursal else '' + if sucursal: + params.append(sucursal) + cr.execute("SELECT COUNT(DISTINCT partner_id) FROM skeen_cita " + "WHERE state <> 'cancelled' AND date >= %s AND date <= %s" + suc_sql, + params) + fila = cr.fetchone() + pacientes = fila[0] if fila else 0 + cr.execute( + "SELECT COUNT(*) FROM skeen_cita c " + "JOIN (SELECT partner_id, MIN(date) AS f FROM skeen_cita " + " WHERE state <> 'cancelled' GROUP BY partner_id) m " + " ON m.partner_id = c.partner_id " + "WHERE c.state <> 'cancelled' AND c.date >= %s AND c.date <= %s " + " AND c.date = m.f" + (' AND c.branch = %s' if sucursal else ''), + params) + fila = cr.fetchone() + primera = fila[0] if fila else 0 + return { + 'ingresos_cobrados': round(cobros, 2), + 'ventas_total': round(ventas_total, 2), + 'ventas_count': num_ventas, + 'pagado_en_ventas': round(pagado_ventas, 2), + 'ticket_promedio': round(ventas_total / num_ventas, 2) if num_ventas else 0.0, + 'propinas': round(propinas, 2), + 'visitas_atendidas': atendidas, + 'citas_total': total_citas, + 'citas_activas': activas, + 'pacientes_activos': pacientes, + 'pacientes_nuevos': primera, + 'pacientes_recurrentes': max(activas - primera, 0), + 'tasa_no_show': round(no_show / activas, 4) if activas else 0.0, + 'tasa_cancelacion': round(canceladas / total_citas, 4) if total_citas else 0.0, + } + + def _dir_variacion(self, actual, anterior): + if anterior in (None, 0): + return None + return round((actual - anterior) / anterior, 4) + + def _dir_clientes(self, start, end, sucursal): + """Pacientes activos, nuevos por mes, cohortes de retención, frecuencia, + valor por cliente y clientes en riesgo.""" + cr = request.env.cr + cfg = get_direccion(request.env) + # Pacientes activos y citas del periodo, con una sola consulta agrupada. + params = [start, end] + suc_sql = '' + if sucursal: + suc_sql = ' AND branch = %s' + params.append(sucursal) + cr.execute( + "SELECT COUNT(*), COUNT(DISTINCT partner_id) FROM skeen_cita " + "WHERE state <> 'cancelled' AND date >= %s AND date <= %s" + suc_sql, params) + n_citas_periodo, n_pac_periodo = cr.fetchone() + n_citas_periodo = n_citas_periodo or 0 + n_pac_periodo = n_pac_periodo or 0 + frecuencia_periodo = (round(n_citas_periodo / n_pac_periodo, 2) + if n_pac_periodo else 0.0) + # Primera y última visita + visitas por paciente (histórico). Una sola + # consulta agrupada: con el volumen real (52k citas) el ORM por separado + # (tres read_group) costaba la mayor parte del tiempo del tablero. + cr.execute("SELECT partner_id, MIN(date), MAX(date), COUNT(*) FROM skeen_cita " + "WHERE state <> 'cancelled' GROUP BY partner_id") + mins, maxs, conteos = {}, {}, {} + total_citas_hist = 0 + for pid, d_min, d_max, n_vis in cr.fetchall(): + mins[pid] = d_min + maxs[pid] = d_max + conteos[pid] = n_vis or 0 + total_citas_hist += n_vis or 0 + frecuencia_historica = (round(total_citas_hist / len(mins), 2) if mins else 0.0) + # Nuevos por mes (últimos 12 meses) + hoy = date.today() + meses = [self._dir_mes_key(self._dir_add_months(hoy.replace(day=1), -i)) + for i in range(11, -1, -1)] + nuevos_mes = {m: 0 for m in meses} + for pid, d in mins.items(): + key = self._dir_mes_key(d) + if key in nuevos_mes: + nuevos_mes[key] += 1 + # Retención por cohortes (últimas 12 cohortes de primera visita) + cohort_ids = [pid for pid, d in mins.items() if self._dir_mes_key(d) in nuevos_mes] + citas_cohorte = {} + if cohort_ids: + cr.execute("SELECT partner_id, date FROM skeen_cita " + "WHERE state <> 'cancelled' AND partner_id = ANY(%s) " + "ORDER BY partner_id, date", (cohort_ids,)) + for pid, d_fecha in cr.fetchall(): + citas_cohorte.setdefault(pid, []).append(d_fecha) + retencion = [] + for m in meses: + ids = [pid for pid, d in mins.items() if self._dir_mes_key(d) == m] + fila = {'cohorte': m, 'tamano': len(ids)} + for k in (1, 3, 6, 12): + clave = 'ret%d' % k + if not ids: + fila[clave] = 0 + fila[clave + '_tasa'] = None + continue + primero = min(mins[pid] for pid in ids) + maduro = self._dir_add_months(primero, k) <= hoy + ret = 0 + for pid in ids: + tope = self._dir_add_months(mins[pid], k) + if any(d > mins[pid] and d <= tope for d in citas_cohorte.get(pid, [])): + ret += 1 + fila[clave] = ret + fila[clave + '_tasa'] = round(ret / len(ids), 4) if maduro else None + retencion.append(fila) + # Valor por cliente (ingreso histórico por paciente, ventas no canceladas) + cr.execute("SELECT SUM(total) FROM skeen_venta " + "WHERE state <> 'cancelled' GROUP BY partner_id") + valores = sorted(float(r[0] or 0.0) for r in cr.fetchall()) + valor_prom = round(sum(valores) / len(valores), 2) if valores else 0.0 + valor_mediana = 0.0 + if valores: + n = len(valores) + mitad = n // 2 + valor_mediana = round((valores[mitad] if n % 2 else + (valores[mitad - 1] + valores[mitad]) / 2.0), 2) + # Clientes en riesgo + dias = cfg['dias_inactividad'] + visitas_min = cfg['visitas_minimas_riesgo'] + limite = hoy - timedelta(days=dias) + riesgo = [] + for pid, ultimo in maxs.items(): + if ultimo and ultimo <= limite and conteos.get(pid, 0) >= visitas_min: + riesgo.append((pid, ultimo, conteos.get(pid, 0), + hoy - ultimo if ultimo else 0)) + riesgo.sort(key=lambda x: (-x[2], x[1])) + total_riesgo = len(riesgo) + top = riesgo[:100] + partners = request.env['res.partner'].sudo().browse([r[0] for r in top]) + riesgo_rows = [] + for partner in partners: + ultimo = maxs.get(partner.id) + riesgo_rows.append({ + 'id': partner.id, + 'name': partner.name, + 'phone': partner.phone or partner.mobile or partner.whatsapp or '', + 'ultima_visita': ultimo.strftime('%Y-%m-%d') if ultimo else None, + 'visitas': conteos.get(partner.id, 0), + 'dias_sin_venir': (hoy - ultimo).days if ultimo else None, + }) + return { + 'activos': n_pac_periodo, + 'frecuencia_promedio': frecuencia_periodo, + 'frecuencia_historica': frecuencia_historica, + 'nuevos_por_mes': [{'mes': m, 'total': nuevos_mes[m]} for m in meses], + 'retencion_cohortes': retencion, + 'valor_por_cliente': { + 'promedio': valor_prom, + 'mediana': valor_mediana, + 'total': round(sum(valores), 2), + 'pacientes_con_ventas': len(valores), + }, + 'en_riesgo': { + 'total': total_riesgo, + 'dias_inactividad': dias, + 'visitas_minimas': visitas_min, + 'pacientes': riesgo_rows, + 'filtro_campana': {'ultima_visita_dias': dias}, + 'link_campana': '/campanas', + }, + } + + def _dir_ocupacion(self, start, end, sucursal): + """Horas vendidas vs disponibles (reglas de /reports/kpis), recursos + (reglas de /reports/recursos) y franjas horarias (/reports/horas-agenda).""" + cr = request.env.cr + d1 = datetime.strptime(start, '%Y-%m-%d').date() + d2 = datetime.strptime(end, '%Y-%m-%d').date() + dias_habiles = sum(1 for i in range((d2 - d1).days + 1) + if (d1 + timedelta(days=i)).weekday() != 6) + bloqueos = request.env['skeen.cita.bloqueo'].sudo().search( + [('date', '>=', start), ('date', '<=', end)]) + bloqueo_horas = {} + for b in bloqueos: + if not b.doctor_id or b.date.weekday() == 6: + continue + horas = 8.0 if b.all_day else max(0.0, b.time_to - b.time_from) + bloqueo_horas[b.doctor_id.name] = bloqueo_horas.get(b.doctor_id.name, 0.0) + horas + params = [start, end] + suc_sql = ' AND c.branch = %s' if sucursal else '' + if sucursal: + params.append(sucursal) + # Ocupación por médico / no-show por médico (reglas de /reports/kpis). + # El cálculo por horas se hace en SQL agrupado: con rangos largos el + # ORM por cita era el cuello de botella del tablero. + cr.execute( + "SELECT c.doctor_id, COALESCE(e.name, 'Sin médico'), " + " SUM(COALESCE(s.duration_min, 30)), COUNT(*), " + " COUNT(*) FILTER (WHERE c.state = 'no_show') " + "FROM skeen_cita c " + "LEFT JOIN skeen_servicio s ON s.id = c.servicio_id " + "LEFT JOIN hr_employee e ON e.id = c.doctor_id " + "WHERE c.state <> 'cancelled' AND c.date >= %s AND c.date <= %s" + + suc_sql + " GROUP BY c.doctor_id, e.name", params) + medicos = [] + no_show_medico = {} + for doctor_id, doc, minutos, n_citas, n_ns in cr.fetchall(): + no_show_medico[doc] = {'citas': n_citas or 0, 'no_shows': n_ns or 0} + if not doctor_id: + continue # /reports/kpis no cuenta horas de citas sin médico + vendidas = (minutos or 0) / 60.0 + disponibles = max(dias_habiles * 8.0 - bloqueo_horas.get(doc, 0.0), 0.0) + medicos.append({ + 'medico': doc, + 'horas_vendidas': round(vendidas, 2), + 'horas_disponibles': round(disponibles, 2), + 'horas_libres': round(max(disponibles - vendidas, 0.0), 2), + 'ocupacion': round(min(vendidas / disponibles, 1.0), 4) if disponibles else 0.0, + }) + medicos.sort(key=lambda x: -x['ocupacion']) + no_show_rows = [{ + 'medico': k, + 'citas': v['citas'], + 'no_shows': v['no_shows'], + 'tasa': round(v['no_shows'] / v['citas'], 4) if v['citas'] else 0.0, + } for k, v in no_show_medico.items()] + no_show_rows.sort(key=lambda x: -x['tasa']) + # Ocupación por sucursal (mismas citas; capacidad = jornada de la agenda). + # La duración usa la regla de `_duracion()`: cita > servicio > 30 min. + ini_h, fin_h = self._agenda_bounds() + jornada = max(0.0, fin_h - ini_h) + disponibles_suc = dias_habiles * jornada + cr.execute( + "SELECT c.branch, SUM(COALESCE(NULLIF(c.duration, 0), s.duration_min, 30)) " + "FROM skeen_cita c LEFT JOIN skeen_servicio s ON s.id = c.servicio_id " + "WHERE c.state <> 'cancelled' AND c.date >= %s AND c.date <= %s" + + suc_sql + " GROUP BY c.branch", params) + por_sucursal = {br or 'sin_sucursal': (minutos or 0) / 60.0 + for br, minutos in cr.fetchall()} + sucursales = [{ + 'sucursal': k, + 'horas_vendidas': round(v, 2), + 'horas_disponibles': round(disponibles_suc, 2), + 'ocupacion': round(min(v / disponibles_suc, 1.0), 4) if disponibles_suc else 0.0, + } for k, v in por_sucursal.items()] + sucursales.sort(key=lambda x: -x['horas_vendidas']) + # Ocupación por recurso (igual que /reports/recursos) + horas_dia = jornada + disponibles_rec = horas_dia * ((d2 - d1).days + 1) + cr.execute( + "SELECT rel.recurso_id, COUNT(*), " + " SUM(COALESCE(NULLIF(c.duration, 0), s.duration_min, 30)) " + "FROM skeen_cita c " + "JOIN skeen_cita_recurso_rel rel ON rel.cita_id = c.id " + "LEFT JOIN skeen_servicio s ON s.id = c.servicio_id " + "WHERE c.state NOT IN ('cancelled', 'no_show', 'waitlist') " + " AND c.date >= %s AND c.date <= %s" + suc_sql + " GROUP BY rel.recurso_id", + params) + acumulado = {rid: {'citas': n or 0, 'minutos': m or 0} + for rid, n, m in cr.fetchall()} + # Franjas horarias (/reports/horas-agenda) + cr.execute( + "SELECT CAST(FLOOR(c.time) AS INT), c.state, COUNT(*) FROM skeen_cita c " + "WHERE c.state NOT IN ('cancelled', 'no_show', 'waitlist') " + " AND c.date >= %s AND c.date <= %s" + suc_sql + + " GROUP BY 1, 2", params) + franjas = {} + for hora, estado, n in cr.fetchall(): + fr = franjas.setdefault(int(hora), {'total': 0, 'by_state': {}}) + fr['total'] += n or 0 + fr['by_state'][estado] = fr['by_state'].get(estado, 0) + (n or 0) + recursos = request.env['skeen.recurso'].sudo().search( + [('activo', '=', True)], order='sequence, name') + filas_rec = [] + for r in recursos: + acc = acumulado.get(r.id, {'citas': 0, 'minutos': 0}) + horas = round(acc['minutos'] / 60.0, 2) + ocup = round(horas / disponibles_rec * 100.0, 1) if disponibles_rec else 0.0 + filas_rec.append({ + 'recurso_id': r.id, + 'recurso': r.name, + 'citas': acc['citas'], + 'horas_ocupadas': horas, + 'horas_disponibles': round(disponibles_rec, 2), + 'ocupacion_pct': min(ocup, 100.0), + }) + filas_hora = [] + for hora in range(8, 20): + entry = franjas.get(hora, {'total': 0, 'by_state': {}}) + filas_hora.append({'hora': f'{hora:02d}:00', 'total': entry['total'], + 'by_state': entry['by_state']}) + con_citas = [f for f in filas_hora if f['total'] > 0] + mas = sorted(con_citas, key=lambda x: -x['total'])[:3] + menos = sorted(filas_hora, key=lambda x: x['total'])[:3] + return { + 'dias_habiles': dias_habiles, + 'jornada_horas': round(jornada, 2), + 'por_medico': medicos, + 'no_show_por_medico': no_show_rows, + 'por_sucursal': sucursales, + 'por_recurso': filas_rec, + 'franjas': filas_hora, + 'franjas_mas_ocupadas': mas, + 'franjas_menos_ocupadas': menos, + 'total_horas_vendidas': round(sum(m['horas_vendidas'] for m in medicos), 2), + 'total_horas_ocupadas_recursos': round(sum(f['horas_ocupadas'] for f in filas_rec), 2), + } + + def _dir_category_label(self, servicio, code): + """Etiqueta de la categoría de un servicio (la selección puede ser una + lista o un método, según cómo esté definida).""" + if not servicio or not code: + return '' + try: + sel = servicio._fields['category'].selection + if isinstance(sel, str): + sel = getattr(servicio, sel)() + elif callable(sel): + sel = sel(servicio) + return dict(sel).get(code, '') or '' + except Exception: + return '' + + def _dir_ingresos(self, start, end, sucursal): + """Ingresos por servicio, categoría, sucursal, método de pago, propinas, + membresías, paquetes y pasivo de lealtad.""" + P = request.env['skeen.pago'].sudo() + cr = request.env.cr + params = [start, end] + suc_sql = ' AND c.branch = %s' if sucursal else '' + if sucursal: + params.append(sucursal) + # Por servicio y por categoría: líneas de las ventas del periodo, en una + # sola consulta agrupada (no se traen las ventas al proceso). + cr.execute( + "SELECT l.service_id, SUM(l.subtotal), SUM(l.quantity) " + "FROM skeen_venta_line l JOIN skeen_venta v ON v.id = l.venta_id " + "LEFT JOIN skeen_cita c ON c.id = v.cita_id " + "WHERE v.state <> 'cancelled' AND v.date >= %s AND v.date <= %s" + + suc_sql + " GROUP BY l.service_id", params) + servicios = [{ + 'service_id': sid or 0, + 'servicio': 'Sin servicio', + 'cantidad': float(qty or 0.0), + 'total': round(float(subtotal or 0.0), 2), + } for sid, subtotal, qty in cr.fetchall()] + categorias = {} + svc_ids = [s['service_id'] for s in servicios if s['service_id']] + svc_map = {x.id: x for x in request.env['skeen.servicio'].sudo().browse(svc_ids)} + for s in servicios: + svc = svc_map.get(s['service_id']) + s['servicio'] = svc.name if svc else 'Sin servicio' + code = svc.category if svc else '' + label = self._dir_category_label(svc, code) if svc else '' + cat = categorias.setdefault(code or 'sin', {'categoria': label or 'Sin categoría', + 'total': 0.0, 'cantidad': 0.0}) + cat['total'] += s['total'] + cat['cantidad'] += s['cantidad'] + servicios.sort(key=lambda x: -x['total']) + # Por sucursal (la sucursal sale de la cita origen de la venta) + cr.execute( + "SELECT c.branch, SUM(v.total), COUNT(*) FROM skeen_venta v " + "LEFT JOIN skeen_cita c ON c.id = v.cita_id " + "WHERE v.state <> 'cancelled' AND v.date >= %s AND v.date <= %s" + + suc_sql + " GROUP BY c.branch", params) + por_sucursal = {} + for branch, total, n in cr.fetchall(): + por_sucursal[branch or 'sin_sucursal'] = { + 'total': float(total or 0.0), 'ventas': n or 0} + # Por método de pago (igual que /reports/cash) + pdom = [('payment_date', '>=', f'{start} 00:00:00'), + ('payment_date', '<=', f'{end} 23:59:59'), + ('state', '=', 'completed')] + pdom += self._dir_domain_sucursal('skeen.pago', sucursal) + por_metodo = {} + for g in P.read_group(pdom, ['payment_method', 'amount:sum'], ['payment_method']): + por_metodo[g['payment_method'] or 'otro'] = round(g.get('amount') or 0.0, 2) + # Membresías + Memb = request.env['skeen.membresia'].sudo() + activas_grp = Memb.read_group([('estado', 'in', ('activa', 'congelada'))], + ['plan_id'], ['plan_id']) + FACTOR = {'semanal': 52 / 12.0, 'mensual': 1.0, 'trimestral': 1 / 3.0, + 'semestral': 1 / 6.0, 'anual': 1 / 12.0} + mrr = 0.0 + for g in activas_grp: + if not g.get('plan_id'): + continue + plan = request.env['skeen.membresia.plan'].sudo().browse(g['plan_id'][0]) + n = g.get('__count') or g.get('plan_id_count') or 0 + mrr += n * (plan.precio or 0.0) * FACTOR.get(plan.periodicidad, 1.0) + Periodo = request.env['skeen.membresia.periodo'].sudo() + recurrentes = Periodo.read_group([ + ('estado', '=', 'pagado'), ('tipo', '=', 'periodo'), + ('fecha_pago', '>=', f'{start} 00:00:00'), ('fecha_pago', '<=', f'{end} 23:59:59')], + ['monto:sum'], []) + membresias = { + 'activas': sum((g.get('__count') or g.get('plan_id_count') or 0) + for g in activas_grp), + 'mrr_estimado': round(mrr, 2), + 'renovaciones': sum((g.get('__count') or 0) for g in recurrentes), + 'ingreso_recurrente_periodo': round((recurrentes[0].get('monto') or 0.0) + if recurrentes else 0.0, 2), + 'altas': Memb.search_count([('fecha_inicio', '>=', start), + ('fecha_inicio', '<=', end)]), + 'cancelaciones': Memb.search_count([ + ('estado', '=', 'cancelada'), + ('fecha_cancelacion', '>=', f'{start} 00:00:00'), + ('fecha_cancelacion', '<=', f'{end} 23:59:59')]), + } + # Paquetes vendidos y sesiones pendientes (compromiso del negocio) + PC = request.env['skeen.paquete.compra'].sudo() + pv = PC.read_group([('date', '>=', start), ('date', '<=', end), + ('state', '!=', 'cancelled')], ['price:sum'], []) + ses = PC.read_group([('state', '!=', 'cancelled')], + ['sessions_total:sum', 'sessions_used:sum'], []) + paquetes = { + 'vendidos': (PC.search_count([('date', '>=', start), ('date', '<=', end), + ('state', '!=', 'cancelled')])), + 'total_vendido': round((pv[0].get('price') or 0.0) if pv else 0.0, 2), + 'activos': PC.search_count([('state', '=', 'active')]), + 'sesiones_pendientes': int(((ses[0].get('sessions_total') or 0) - + (ses[0].get('sessions_used') or 0)) if ses else 0), + } + # Lealtad: pasivo en pesos (igual que /reports/lealtad) + cfg_l = get_lealtad(request.env) + valor = float(cfg_l.get('valor_punto') or 1.0) + mon = request.env['skeen.monedero'].sudo().read_group([], ['points:sum'], []) + puntos = int((mon[0].get('points') or 0) if mon else 0) + Tx = request.env['skeen.monedero.transaction'].sudo() + by_type = {} + for g in Tx.read_group([('create_date', '>=', f'{start} 00:00:00'), + ('create_date', '<=', f'{end} 23:59:59')], + ['type', 'points:sum'], ['type']): + by_type[g['type']] = g['points'] + lealtad = { + 'valor_punto': valor, + 'puntos_activos': puntos, + 'pasivo_mxn': round(puntos * valor, 2), + 'otorgados': by_type.get('accrual', 0) + by_type.get('deposito', 0), + 'canjeados': abs(by_type.get('redemption', 0)), + 'expirados': abs(by_type.get('expiration', 0)), + } + return { + 'por_servicio': servicios, + 'por_categoria': sorted(categorias.values(), key=lambda x: -x['total']), + 'por_sucursal': [{'sucursal': k, 'total': round(v['total'], 2), + 'ventas': v['ventas']} for k, v in por_sucursal.items()], + 'por_metodo_pago': por_metodo, + 'membresias': membresias, + 'paquetes': paquetes, + 'lealtad': lealtad, + } + + def _dir_metas(self, start, end, sucursal, kpis=None): + """Metas por sucursal y por empleado, avance del mes y proyección.""" + cfg = get_direccion(request.env) + V = request.env['skeen.venta'].sudo() + Lineas = request.env['skeen.venta.line'].sudo() + hoy = date.today() + d_fin = datetime.strptime(end, '%Y-%m-%d').date() + mes_ini = d_fin.replace(day=1) + mes_fin = d_fin.replace(day=calendar.monthrange(d_fin.year, d_fin.month)[1]) + dias_mes = (mes_fin - mes_ini).days + 1 + dias_transcurridos = min((min(hoy, mes_fin) - mes_ini).days + 1, dias_mes) + if dias_transcurridos < 1: + dias_transcurridos = 1 + mes_cerrado = hoy > mes_fin + + def proyectar(actual): + return round(actual, 2) if mes_cerrado else round(actual / dias_transcurridos * dias_mes, 2) + + # Metas por sucursal + metas = {m['sucursal']: m['meta_mensual'] for m in cfg['metas_sucursal']} + sucursales = request.env['skeen.sucursal'].sudo().search([], order='sequence, name') + codigos = [s.code for s in sucursales if s.code] + for code in metas: + if code not in codigos: + codigos.append(code) + filas_suc = [] + for code in codigos: + grp = V.read_group([('state', '!=', 'cancelled'), + ('date', '>=', mes_ini.strftime('%Y-%m-%d')), + ('date', '<=', mes_fin.strftime('%Y-%m-%d')), + ('cita_id.branch', '=', code)], ['total:sum'], []) + actual = round((grp[0].get('total') or 0.0) if grp else 0.0, 2) + meta = metas.get(code, 0.0) + filas_suc.append({ + 'sucursal': code, + 'meta_mensual': round(meta, 2), + 'ventas_mes': actual, + 'pct': round(actual / meta, 4) if meta else None, + 'proyeccion': proyectar(actual), + }) + # Metas por empleado (ventas del mes atribuidas por línea recetada) + mes_ventas = V.search([('state', '!=', 'cancelled'), + ('date', '>=', mes_ini.strftime('%Y-%m-%d')), + ('date', '<=', mes_fin.strftime('%Y-%m-%d'))]) + vendido_emp = {} + if mes_ventas: + for g in Lineas.read_group([('venta_id', 'in', mes_ventas.ids), + ('prescribed_by_id', '!=', False)], + ['prescribed_by_id', 'subtotal:sum'], + ['prescribed_by_id']): + vendido_emp[g['prescribed_by_id'][0]] = g.get('subtotal') or 0.0 + Emp = request.env['hr.employee'].sudo() + empleados = Emp.search(['|', ('monthly_goal', '>', 0), + ('id', 'in', list(vendido_emp.keys()))]) + filas_emp = [] + for e in empleados: + actual = round(vendido_emp.get(e.id, 0.0), 2) + meta = e.monthly_goal or 0.0 + filas_emp.append({ + 'employee_id': e.id, + 'empleado': e.name, + 'meta_mensual': round(meta, 2), + 'ventas_mes': actual, + 'pct': round(actual / meta, 4) if meta else None, + 'proyeccion': proyectar(actual), + }) + filas_emp.sort(key=lambda x: -(x['ventas_mes'])) + # Proyección del periodo seleccionado + p1 = datetime.strptime(start, '%Y-%m-%d').date() + p2 = d_fin + dias_periodo = (p2 - p1).days + 1 + dias_trans_p = min((min(hoy, p2) - p1).days + 1, dias_periodo) + if dias_trans_p < 1: + dias_trans_p = 1 + if kpis is None: + kpis = self._dir_kpis(start, end, sucursal) + periodo_cerrado = hoy > p2 + proy_ventas = (kpis['ventas_total'] if periodo_cerrado + else round(kpis['ventas_total'] / dias_trans_p * dias_periodo, 2)) + proy_cobros = (kpis['ingresos_cobrados'] if periodo_cerrado + else round(kpis['ingresos_cobrados'] / dias_trans_p * dias_periodo, 2)) + # Mes actual (para metas) + mes_grp = V.read_group([('state', '!=', 'cancelled'), + ('date', '>=', mes_ini.strftime('%Y-%m-%d')), + ('date', '<=', mes_fin.strftime('%Y-%m-%d'))], + ['total:sum'], []) + ventas_mes = round((mes_grp[0].get('total') or 0.0) if mes_grp else 0.0, 2) + meta_global = cfg['meta_mensual_global'] or sum(metas.values()) + return { + 'por_sucursal': filas_suc, + 'por_empleado': filas_emp, + 'periodo': { + 'dias': dias_periodo, + 'dias_transcurridos': dias_trans_p, + 'ventas': kpis['ventas_total'], + 'cobros': kpis['ingresos_cobrados'], + 'proyeccion_ventas': proy_ventas, + 'proyeccion_cobros': proy_cobros, + }, + 'mes': { + 'mes': self._dir_mes_key(mes_ini), + 'dias_mes': dias_mes, + 'dias_transcurridos': dias_transcurridos, + 'ventas': ventas_mes, + 'meta': round(meta_global, 2), + 'pct': round(ventas_mes / meta_global, 4) if meta_global else None, + 'proyeccion': proyectar(ventas_mes), + 'proyeccion_vs_meta': (round(proyectar(ventas_mes) / meta_global, 4) + if meta_global else None), + }, + } + + @http.route('/skeen/frontend/v1/direccion', type='http', auth='none', + methods=['GET', 'OPTIONS'], csrf=False) + @require_role('lectura') + @require_perm('direccion.ver') + def direccion_tablero(self, **kw): + """Tablero ejecutivo del dueño. Solo lectura.""" + try: + if request.httprequest.method == 'OPTIONS': + return json_response({}, 200) + hoy = date.today().strftime('%Y-%m-%d') + start = kw.get('start') or hoy + end = kw.get('end') or hoy + try: + d1 = datetime.strptime(start, '%Y-%m-%d').date() + d2 = datetime.strptime(end, '%Y-%m-%d').date() + except (TypeError, ValueError): + return json_response({'status': 'error', 'message': 'Fecha inválida'}, 400) + if d2 < d1: + d1, d2 = d2, d1 + start, end = d1.strftime('%Y-%m-%d'), d2.strftime('%Y-%m-%d') + if (d2 - d1).days > 4000: + return json_response({'status': 'error', + 'message': 'El rango no puede pasar de 4000 días'}, 400) + sucursal = (kw.get('sucursal') or '').strip() or None + cfg = get_direccion(request.env) + comparar = (kw.get('comparar') or cfg['comparativo_default'] or 'anterior').lower() + if comparar not in ('anterior', 'anio', 'ninguno'): + comparar = 'anterior' + t0 = datetime.now() + kpis = self._dir_kpis(start, end, sucursal) + # Serie diaria de ventas (mismo criterio que /reports/sales.by_day) + params = [start, end] + suc_sql = ' AND c.branch = %s' if sucursal else '' + if sucursal: + params.append(sucursal) + request.env.cr.execute( + "SELECT v.date, SUM(v.total) FROM skeen_venta v " + "LEFT JOIN skeen_cita c ON c.id = v.cita_id " + "WHERE v.state <> 'cancelled' AND v.date >= %s AND v.date <= %s" + + suc_sql + " GROUP BY v.date ORDER BY v.date", params) + by_day = {str(d)[:10]: round(float(t or 0.0), 2) + for d, t in request.env.cr.fetchall()} + kpis['por_dia'] = [{'date': k, 'total': by_day[k]} for k in sorted(by_day)] + resumen = kpis + comparativo = None + if comparar != 'ninguno': + if comparar == 'anio': + cs, ce = self._dir_periodo_anio(start, end) + else: + cs, ce = self._dir_periodo_anterior(start, end) + ck = self._dir_kpis(cs, ce, sucursal) + variaciones = {} + for campo in ('ingresos_cobrados', 'ventas_total', 'ticket_promedio', + 'visitas_atendidas', 'pacientes_nuevos', 'pacientes_recurrentes', + 'tasa_no_show', 'tasa_cancelacion'): + variaciones[campo] = self._dir_variacion(kpis.get(campo, 0), ck.get(campo, 0)) + comparativo = {'modo': comparar, 'start': cs, 'end': ce, + 'kpis': ck, 'variacion': variaciones} + t_sec = {'resumen': int((datetime.now() - t0).total_seconds() * 1000)} + t_a = datetime.now() + clientes = self._dir_clientes(start, end, sucursal) + t_b = datetime.now() + ocupacion = self._dir_ocupacion(start, end, sucursal) + t_c = datetime.now() + ingresos = self._dir_ingresos(start, end, sucursal) + t_d = datetime.now() + metas = self._dir_metas(start, end, sucursal, kpis=kpis) + t_e = datetime.now() + t_sec['clientes'] = int((t_b - t_a).total_seconds() * 1000) + t_sec['ocupacion'] = int((t_c - t_b).total_seconds() * 1000) + t_sec['ingresos'] = int((t_d - t_c).total_seconds() * 1000) + t_sec['metas'] = int((t_e - t_d).total_seconds() * 1000) + no_disponible = [ + {'clave': 'entregas', 'etiqueta': 'Entregas', + 'motivo': 'El sistema no registra entregas de productos, así que no hay dato.'}, + {'clave': 'whatsapp_resultados', 'etiqueta': 'Resultados de WhatsApp', + 'motivo': 'No hay una métrica de resultados de WhatsApp disponible en este tablero.'}, + ] + ms = int((datetime.now() - t0).total_seconds() * 1000) + respuesta = { + 'status': 'success', + 'start': start, + 'end': end, + 'sucursal': sucursal, + 'comparativo': comparativo, + 'resumen': resumen, + 'clientes': clientes, + 'ocupacion': ocupacion, + 'ingresos': ingresos, + 'metas': metas, + 'no_disponible': no_disponible, + 'ms': ms, + } + if kw.get('debug') in ('1', 'true'): + respuesta['_ms_secciones'] = t_sec + return json_response(respuesta) + except Exception as e: + return json_response({'status': 'error', 'message': str(e)}, 500) + # ============================================================ # Sucursales (configurables) # ============================================================ diff --git a/odoo-addons/skeen_whatsapp/permissions.py b/odoo-addons/skeen_whatsapp/permissions.py index 1e19c4f..3d300cd 100644 --- a/odoo-addons/skeen_whatsapp/permissions.py +++ b/odoo-addons/skeen_whatsapp/permissions.py @@ -85,6 +85,9 @@ PERMISSION_GROUPS = [ ('caja.ver', 'Ver cortes de caja'), ('caja.cerrar', 'Cerrar caja'), ]), + ('direccion', 'Tablero de dirección', [ + ('direccion.ver', 'Ver el tablero de dirección'), + ]), ('whatsapp', 'WhatsApp y leads', [ ('whatsapp.ver', 'Ver conversaciones y leads'), ('whatsapp.responder', 'Responder conversaciones'), @@ -272,6 +275,10 @@ DEFAULT_PERM_ROLES = { 'reportes.exportar': ALL, 'caja.ver': ALL, 'caja.cerrar': MONEY, + # Tablero de dirección: solo dueño/administración y marketing. NO entra el + # rol médico (ni los roles de solo lectura); su visibilidad de menú también + # queda excluida para 'medico' y 'lectura' en roles.py. + 'direccion.ver': ('admin', 'gerencia', 'marketing'), # WhatsApp y leads (menú WACRM: admin, recepcion, gerencia, marketing) 'whatsapp.ver': W, 'whatsapp.responder': W, diff --git a/odoo-addons/skeen_whatsapp/roles.py b/odoo-addons/skeen_whatsapp/roles.py index d403385..8c7056d 100644 --- a/odoo-addons/skeen_whatsapp/roles.py +++ b/odoo-addons/skeen_whatsapp/roles.py @@ -174,6 +174,9 @@ DEFAULT_MENU_ROLES = { '/compras': ['admin', 'recepcion', 'lectura', 'gerencia'], '/cortes': ['admin', 'recepcion', 'gerencia'], '/reportes': ['admin', 'recepcion', 'lectura', 'gerencia'], + # Tablero de dirección: solo dueño/administración y marketing. El rol médico + # no lo ve (tampoco 'lectura'). Su acceso a la API lo cierra `direccion.ver`. + '/direccion': ['admin', 'gerencia', 'marketing'], '/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura', 'gerencia'], @@ -202,7 +205,7 @@ DEFAULT_MENU_ROLES = { # Marketing NO hereda los menús de 'lectura': sólo estos (mínimo privilegio). MARKETING_MENUS = ( '/', '/agenda', '/pacientes', '/cumpleanos', '/visitas', - '/wacrm/messages', '/wacrm/leads', '/campanas', + '/wacrm/messages', '/wacrm/leads', '/campanas', '/direccion', ) # Roles nuevos que por default ven lo mismo que 'lectura'.