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:
@@ -615,6 +615,80 @@ todo respeta el **modo prueba** y queda en la bitácora con su campaña o encues
|
||||
de origen.
|
||||
|
||||
|
||||
## 14 quater. Tablero de dirección (indicadores del dueño)
|
||||
|
||||
Pantalla **Dirección** (menú, ruta `/direccion`) con los indicadores del
|
||||
negocio en una sola vista. Es de **solo lectura** y **no depende de ningún
|
||||
interruptor**: se ve quien tenga el permiso.
|
||||
|
||||
- **Permisos**: `direccion.ver` — por defecto **admin, gerencia y marketing**.
|
||||
El rol **médico no lo ve** (ni la sección de menú ni la API: el endpoint
|
||||
responde 403). Se administra en **Configuración → Roles → Permisos**.
|
||||
- **Filtros**: rango de fechas con atajos (hoy, esta semana, este mes, mes
|
||||
pasado, este año, personalizado), **sucursal** y un **comparativo**
|
||||
(periodo anterior equivalente, mismo periodo del año pasado o ninguno).
|
||||
Todo el tablero se **exporta a Excel**.
|
||||
- **Secciones**:
|
||||
1. **Resumen del periodo**: ingresos cobrados, ventas, ticket promedio,
|
||||
visitas atendidas, pacientes nuevos y recurrentes, tasa de no presentados
|
||||
y de cancelación, con la variación contra el comparativo elegido.
|
||||
2. **Clientes**: pacientes activos, nuevos por mes, **retención por
|
||||
cohortes** (de quienes vinieron por primera vez en un mes, cuántos
|
||||
volvieron en 1, 3, 6 y 12 meses), **frecuencia promedio** de visita,
|
||||
**valor por cliente** (ingreso histórico promedio y mediana) y **clientes
|
||||
en riesgo** (los que solían venir con regularidad y llevan más de X días
|
||||
sin aparecer), con un botón directo para **armar una campaña** con ellos.
|
||||
3. **Ocupación y agenda**: horas vendidas contra disponibles por médico, por
|
||||
sucursal y por recurso, porcentaje de ocupación, franjas horarias más y
|
||||
menos ocupadas y no-show por médico.
|
||||
4. **Ingresos por concepto**: por servicio, categoría, sucursal y método de
|
||||
pago, **propinas**, membresías (activas, ingreso recurrente mensual
|
||||
estimado, renovaciones y cancelaciones), paquetes (vendidos y sesiones
|
||||
pendientes de usar, que son un compromiso del negocio) y el **pasivo de
|
||||
lealtad** en pesos.
|
||||
5. **Metas y proyección**: metas por sucursal y por empleado, avance del mes
|
||||
y **proyección de cierre** con el ritmo actual.
|
||||
- **Indicadores no disponibles**: los que no se pueden calcular con los datos
|
||||
del sistema (p. ej. entregas de productos o resultados de WhatsApp) se
|
||||
muestran como **no disponible** con su motivo; nunca se estiman.
|
||||
- **Criterios de cálculo** (coinciden con los reportes existentes):
|
||||
- *Ingresos cobrados*: pagos con estado **completado** del periodo (igual que
|
||||
**Reportes → Cortes/Caja** y el *Concentrado*).
|
||||
- *Ventas*: ventas **no canceladas** del periodo (las devoluciones se
|
||||
reportan aparte); *ventas cobradas* = suma de lo pagado en esas ventas.
|
||||
- *Pacientes nuevos*: citas del periodo cuya fecha es la **primera cita
|
||||
histórica** no cancelada del paciente (igual que **Reportes → KPIs**).
|
||||
- *No-show y ocupación por médico*: mismas reglas que **KPIs**; *franjas
|
||||
horarias* igual que **Horas agenda**; *recursos* igual que **Recursos**.
|
||||
- *Pasivo de lealtad* y *propinas*: mismos criterios que sus reportes.
|
||||
|
||||
### 14 quater.1 Bloque de ajustes `tablero`
|
||||
|
||||
**Configuración → Dirección**. Guarda los parámetros del tablero; con los
|
||||
valores por defecto el tablero funciona igual y solo deja de mostrar el avance
|
||||
de metas. Claves (defaults):
|
||||
|
||||
- `metas_sucursal`: lista `[{sucursal: <código>, meta_mensual: <monto>}]`
|
||||
(default: **vacía**). Es la **meta mensual de venta de cada sucursal**.
|
||||
- `meta_mensual_global`: meta de venta mensual de toda la clínica; **0** usa la
|
||||
suma de las metas por sucursal (default: **0**).
|
||||
- `dias_inactividad`: días sin venir para considerar a un cliente **en riesgo**
|
||||
(default: **90**).
|
||||
- `visitas_minimas_riesgo`: visitas históricas mínimas para entrar a *en
|
||||
riesgo*; un paciente de una sola visita no se marca (default: **3**).
|
||||
- `comparativo_default`: `anterior` | `anio` | `ninguno` (default:
|
||||
**anterior**).
|
||||
|
||||
La clave se llama **`tablero`** y no `direccion` para no chocar con el campo
|
||||
homónimo de ajustes que guarda la **dirección postal** de la clínica. Se guarda
|
||||
dentro del JSON de `skeen.clinic.settings` (`GET`/`PUT
|
||||
/skeen/frontend/v1/clinic-settings`), con **merge anidado**: un PUT parcial no
|
||||
borra el resto.
|
||||
|
||||
El endpoint de datos es **`GET /skeen/frontend/v1/direccion`** (parámetros
|
||||
`start`, `end`, `sucursal`, `comparar`), de solo lectura y con las cifras ya
|
||||
agrupadas en el servidor.
|
||||
|
||||
## 15. Tickets y documentos
|
||||
|
||||
**Configuración → Tickets**: ancho (58/80 mm), fuente, logo y qué secciones se
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PatientDrawerProvider } from './components/PatientDrawer';
|
||||
import ChatNotifications from './lib/chat-notifications';
|
||||
import { useClinicSettings, useClinicSettingsCargado } from './lib/clinic-settings';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Direccion from './pages/Direccion';
|
||||
import Agenda from './pages/Agenda';
|
||||
import Pacientes from './pages/Pacientes';
|
||||
import Pos from './pages/Pos';
|
||||
@@ -211,6 +212,7 @@ const App: FC = () => {
|
||||
<Route path="/mi-cuenta" element={<MiCuenta />} />
|
||||
<Route path="/mi-cuenta/acceso/:token" element={<MiCuenta />} />
|
||||
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
|
||||
<Route path="/direccion" element={<RequireAuth href="/direccion"><Direccion /></RequireAuth>} />
|
||||
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
|
||||
<Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></RequireAuth>} />
|
||||
<Route path="/pacientes" element={<RequireAuth href="/pacientes"><Pacientes /></RequireAuth>} />
|
||||
|
||||
133
frontend/src/components/DireccionPanel.tsx
Normal file
133
frontend/src/components/DireccionPanel.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Plus, Trash2, LayoutDashboard } from 'lucide-react';
|
||||
import { Button, Input, toast } from './ui';
|
||||
import { odooApi, TABLERO_DEFAULT, type ClinicSettings, type TableroSettings } from '../services/odoo';
|
||||
import { useSucursales } from '../lib/sucursales';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
settings: ClinicSettings;
|
||||
onSaved: (settings: ClinicSettings) => void;
|
||||
}
|
||||
|
||||
// Configuración → Dirección: bloque `tablero` de clinic-settings. Solo guarda
|
||||
// los parámetros del tablero (metas por sucursal y criterios de riesgo); con
|
||||
// los defaults (sin metas) el tablero funciona igual.
|
||||
const DireccionPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||
const { t } = useI18n();
|
||||
const { sucursales } = useSucursales();
|
||||
const [form, setForm] = useState<TableroSettings>(settings.tablero || TABLERO_DEFAULT);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setForm(settings.tablero || TABLERO_DEFAULT);
|
||||
}, [settings.tablero]);
|
||||
|
||||
const set = <K extends keyof TableroSettings>(k: K, v: TableroSettings[K]) =>
|
||||
setForm((prev) => ({ ...prev, [k]: v }));
|
||||
|
||||
const setMeta = (i: number, campo: 'sucursal' | 'meta_mensual', valor: string | number) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
metas_sucursal: prev.metas_sucursal.map((m, idx) => (idx === i ? { ...m, [campo]: valor } : m)),
|
||||
}));
|
||||
|
||||
const addMeta = () => {
|
||||
const usados = new Set(form.metas_sucursal.map((m) => m.sucursal));
|
||||
const libre = sucursales.find((s) => !usados.has(s.code));
|
||||
set('metas_sucursal', [...form.metas_sucursal, { sucursal: libre?.code || '', meta_mensual: 0 }]);
|
||||
};
|
||||
|
||||
const delMeta = (i: number) =>
|
||||
set('metas_sucursal', form.metas_sucursal.filter((_, idx) => idx !== i));
|
||||
|
||||
const guardar = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const metas = form.metas_sucursal.filter((m) => m.sucursal);
|
||||
const res = await odooApi.putClinicSettings({
|
||||
tablero: { ...form, metas_sucursal: metas },
|
||||
motivo: 'Parámetros del tablero de dirección',
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
onSaved(res.settings);
|
||||
toast.success(t('dir.cfg.saved'));
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || t('dir.cfg.error'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const opcionesSucursal = sucursales.map((s) => ({ value: s.code, label: s.name }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
|
||||
<LayoutDashboard size={18} /> {t('dir.cfg.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-theme-muted mt-1">{t('dir.cfg.hint')}</p>
|
||||
</div>
|
||||
<Button onClick={guardar} loading={saving}><Save size={16} className="mr-1.5" />{t('common.save')}</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Input label={t('dir.cfg.metaGlobal')} type="number" min={0} value={String(form.meta_mensual_global)}
|
||||
onChange={(e) => set('meta_mensual_global', parseFloat(e.target.value) || 0)} />
|
||||
<Input label={t('dir.cfg.dias')} type="number" min={1} value={String(form.dias_inactividad)}
|
||||
onChange={(e) => set('dias_inactividad', parseInt(e.target.value, 10) || 1)} />
|
||||
<Input label={t('dir.cfg.visitas')} type="number" min={0} value={String(form.visitas_minimas_riesgo)}
|
||||
onChange={(e) => set('visitas_minimas_riesgo', parseInt(e.target.value, 10) || 0)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1.5">{t('dir.cfg.comparativo')}</label>
|
||||
<select value={form.comparativo_default}
|
||||
onChange={(e) => set('comparativo_default', e.target.value as TableroSettings['comparativo_default'])}
|
||||
className="w-full sm:max-w-xs rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading">
|
||||
<option value="anterior">{t('dir.cmp.anterior')}</option>
|
||||
<option value="anio">{t('dir.cmp.anio')}</option>
|
||||
<option value="ninguno">{t('dir.cmp.ninguno')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-theme-heading">{t('dir.cfg.metas')}</h4>
|
||||
<Button size="sm" variant="ghost" onClick={addMeta}><Plus size={14} className="mr-1" />{t('dir.cfg.addMeta')}</Button>
|
||||
</div>
|
||||
{form.metas_sucursal.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">{t('dir.cfg.sinMetas')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{form.metas_sucursal.map((m, i) => (
|
||||
<div key={i} className="flex flex-col sm:flex-row gap-2 sm:items-end">
|
||||
<div className="flex-1">
|
||||
<select value={m.sucursal} onChange={(e) => setMeta(i, 'sucursal', e.target.value)}
|
||||
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading">
|
||||
<option value="">{t('dir.sucursal')}</option>
|
||||
{opcionesSucursal.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:w-48">
|
||||
<Input label={t('dir.metas.meta')} type="number" min={0} value={String(m.meta_mensual)}
|
||||
onChange={(e) => setMeta(i, 'meta_mensual', parseFloat(e.target.value) || 0)} />
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => delMeta(i)} aria-label={t('common.delete')}>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DireccionPanel;
|
||||
@@ -53,6 +53,8 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
|
||||
'/compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/cortes': ['admin', 'recepcion', 'gerencia'],
|
||||
'/reportes': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
// Tablero de dirección: solo dueño/administración y marketing (el médico no).
|
||||
'/direccion': ['admin', 'gerencia', 'marketing'],
|
||||
'/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LEALTAD_DEFAULT, PROPINAS_DEFAULT, CAMPANAS_DEFAULT, ENCUESTAS_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT, PORTAL_CLIENTE_DEFAULT } from '../services/odoo';
|
||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LEALTAD_DEFAULT, PROPINAS_DEFAULT, CAMPANAS_DEFAULT, ENCUESTAS_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT, PORTAL_CLIENTE_DEFAULT, TABLERO_DEFAULT } from '../services/odoo';
|
||||
|
||||
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
|
||||
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
|
||||
@@ -33,6 +33,11 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
|
||||
limites: { ...LICENCIA_LIMITES_DEFAULT, ...((settings?.licencia || {}).limites || {}) },
|
||||
},
|
||||
portal_cliente: { ...PORTAL_CLIENTE_DEFAULT, ...(settings?.portal_cliente || {}) },
|
||||
tablero: {
|
||||
...TABLERO_DEFAULT,
|
||||
...(settings?.tablero || {}),
|
||||
metas_sucursal: settings?.tablero?.metas_sucursal ?? TABLERO_DEFAULT.metas_sucursal,
|
||||
},
|
||||
});
|
||||
|
||||
let actual: ClinicSettings = normalizar(null);
|
||||
|
||||
@@ -969,6 +969,128 @@ export const es: Dict = {
|
||||
'nps.processed': 'Encuestas procesadas.',
|
||||
'nps.replySent': 'Respuesta enviada.',
|
||||
'nps.replySimulated': 'Modo prueba: la respuesta quedó registrada como simulada.',
|
||||
// --- Tablero de dirección ---
|
||||
'dir.title': 'Dirección',
|
||||
'dir.subtitle': 'Tablero ejecutivo del negocio',
|
||||
'dir.periodo': 'Periodo',
|
||||
'dir.inicio': 'Inicio',
|
||||
'dir.fin': 'Fin',
|
||||
'dir.sucursal': 'Sucursal',
|
||||
'dir.todasSucursales': 'Todas las sucursales',
|
||||
'dir.comparar': 'Comparar con',
|
||||
'dir.cmp.anterior': 'Periodo anterior',
|
||||
'dir.cmp.anio': 'Mismo periodo del año pasado',
|
||||
'dir.cmp.ninguno': 'Sin comparativo',
|
||||
'dir.preset.hoy': 'Hoy',
|
||||
'dir.preset.semana': 'Esta semana',
|
||||
'dir.preset.mes': 'Este mes',
|
||||
'dir.preset.mesPasado': 'Mes pasado',
|
||||
'dir.preset.anio': 'Este año',
|
||||
'dir.preset.custom': 'Personalizado',
|
||||
'dir.exportar': 'Exportar',
|
||||
'dir.cargando': 'Cargando…',
|
||||
'dir.sinDatos': 'Sin datos en el periodo.',
|
||||
'dir.error': 'No se pudo cargar el tablero.',
|
||||
'dir.exportOk': 'Excel descargado',
|
||||
'dir.exportError': 'Error al exportar',
|
||||
'dir.noDisponible': 'No disponible',
|
||||
'dir.noDisponibleHint': 'Estos indicadores no se pueden calcular con los datos del sistema.',
|
||||
'dir.vsAnterior': 'vs. periodo anterior',
|
||||
'dir.vsAnio': 'vs. año pasado',
|
||||
'dir.section.resumen': 'Resumen del periodo',
|
||||
'dir.kpi.ingresos': 'Ingresos cobrados',
|
||||
'dir.kpi.ventas': 'Ventas',
|
||||
'dir.kpi.ticket': 'Ticket promedio',
|
||||
'dir.kpi.visitas': 'Visitas atendidas',
|
||||
'dir.kpi.nuevos': 'Pacientes nuevos',
|
||||
'dir.kpi.recurrentes': 'Pacientes recurrentes',
|
||||
'dir.kpi.noShow': 'Tasa de no presentados',
|
||||
'dir.kpi.cancelacion': 'Tasa de cancelación',
|
||||
'dir.section.clientes': 'Clientes',
|
||||
'dir.kpi.activos': 'Pacientes activos',
|
||||
'dir.kpi.frecuencia': 'Frecuencia promedio',
|
||||
'dir.kpi.frecuenciaHint': 'Visitas por paciente en el periodo',
|
||||
'dir.kpi.valor': 'Valor por cliente',
|
||||
'dir.kpi.valorHint': 'Ingreso histórico promedio por paciente',
|
||||
'dir.clientes.nuevosMes': 'Pacientes nuevos por mes',
|
||||
'dir.clientes.retencion': 'Retención por cohortes',
|
||||
'dir.clientes.retencionHint': 'De los pacientes que vinieron por primera vez en un mes, cuántos volvieron en los siguientes 1, 3, 6 y 12 meses.',
|
||||
'dir.clientes.cohorte': 'Cohorte',
|
||||
'dir.clientes.tamano': 'Pacientes',
|
||||
'dir.clientes.m1': '1 mes',
|
||||
'dir.clientes.m3': '3 meses',
|
||||
'dir.clientes.m6': '6 meses',
|
||||
'dir.clientes.m12': '12 meses',
|
||||
'dir.clientes.riesgo': 'Clientes en riesgo',
|
||||
'dir.clientes.riesgoHint': 'Con {visitas} o más visitas y más de {dias} días sin venir',
|
||||
'dir.clientes.crearCampana': 'Armar campaña',
|
||||
'dir.clientes.valorMediana': 'Mediana',
|
||||
'dir.clientes.valorTotal': 'Ingreso histórico',
|
||||
'dir.clientes.ultimaVisita': 'Última visita',
|
||||
'dir.clientes.visitas': 'Visitas',
|
||||
'dir.clientes.diasSinVenir': 'Días sin venir',
|
||||
'dir.clientes.ventanaPendiente': 'Aún no se cumple la ventana',
|
||||
'dir.section.ocupacion': 'Ocupación y agenda',
|
||||
'dir.ocup.porMedico': 'Ocupación por médico',
|
||||
'dir.ocup.porSucursal': 'Ocupación por sucursal',
|
||||
'dir.ocup.porRecurso': 'Ocupación por recurso',
|
||||
'dir.ocup.franjas': 'Franjas horarias',
|
||||
'dir.ocup.franjasHint': 'Citas por hora del día (canceladas, no-show y lista de espera no cuentan).',
|
||||
'dir.ocup.masOcupadas': 'Franjas más ocupadas',
|
||||
'dir.ocup.menosOcupadas': 'Franjas menos ocupadas',
|
||||
'dir.ocup.horasVendidas': 'Horas vendidas',
|
||||
'dir.ocup.horasDisponibles': 'Horas disponibles',
|
||||
'dir.ocup.horasLibres': 'Horas libres',
|
||||
'dir.ocup.ocupacion': 'Ocupación',
|
||||
'dir.ocup.noShow': 'No-show por médico',
|
||||
'dir.ocup.citas': 'Citas',
|
||||
'dir.section.ingresos': 'Ingresos por concepto',
|
||||
'dir.ing.porServicio': 'Por servicio',
|
||||
'dir.ing.porCategoria': 'Por categoría',
|
||||
'dir.ing.porSucursal': 'Por sucursal',
|
||||
'dir.ing.porMetodo': 'Por método de pago',
|
||||
'dir.ing.propinas': 'Propinas',
|
||||
'dir.ing.membresias': 'Membresías',
|
||||
'dir.ing.paquetes': 'Paquetes',
|
||||
'dir.ing.lealtad': 'Lealtad',
|
||||
'dir.ing.concepto': 'Concepto',
|
||||
'dir.ing.cantidad': 'Cantidad',
|
||||
'dir.ing.total': 'Total',
|
||||
'dir.ing.activas': 'Membresías activas',
|
||||
'dir.ing.mrr': 'Ingreso recurrente mensual (estimado)',
|
||||
'dir.ing.renovaciones': 'Renovaciones del periodo',
|
||||
'dir.ing.cancelaciones': 'Cancelaciones del periodo',
|
||||
'dir.ing.paquetesVendidos': 'Paquetes vendidos',
|
||||
'dir.ing.sesionesPendientes': 'Sesiones pendientes de usar',
|
||||
'dir.ing.pasivoLealtad': 'Pasivo de lealtad',
|
||||
'dir.ing.puntosActivos': 'Puntos activos',
|
||||
'dir.section.metas': 'Metas y proyección',
|
||||
'dir.metas.porSucursal': 'Metas por sucursal (mes en curso)',
|
||||
'dir.metas.porEmpleado': 'Metas por empleado (mes en curso)',
|
||||
'dir.metas.meta': 'Meta',
|
||||
'dir.metas.avance': 'Ventas del mes',
|
||||
'dir.metas.pct': 'Avance',
|
||||
'dir.metas.proyeccion': 'Proyección de cierre',
|
||||
'dir.metas.mes': 'Mes en curso',
|
||||
'dir.metas.periodo': 'Proyección del periodo',
|
||||
'dir.metas.sinMeta': 'Sin meta',
|
||||
'dir.metas.concepto': 'Concepto',
|
||||
'dir.metas.empleado': 'Empleado',
|
||||
'dir.metas.global': 'Global',
|
||||
'dir.metas.ventasPeriodo': 'Ventas del periodo',
|
||||
'dir.metas.cobrosPeriodo': 'Cobros del periodo',
|
||||
'dir.ms': 'Respuesta',
|
||||
'dir.cfg.title': 'Parámetros del tablero',
|
||||
'dir.cfg.hint': 'Metas por sucursal y criterios de "clientes en riesgo". Con las metas en cero el tablero funciona igual y solo no muestra avance.',
|
||||
'dir.cfg.metas': 'Metas mensuales por sucursal',
|
||||
'dir.cfg.metaGlobal': 'Meta mensual global',
|
||||
'dir.cfg.dias': 'Días sin venir para "en riesgo"',
|
||||
'dir.cfg.visitas': 'Visitas mínimas para "en riesgo"',
|
||||
'dir.cfg.comparativo': 'Comparativo por defecto',
|
||||
'dir.cfg.addMeta': 'Agregar sucursal',
|
||||
'dir.cfg.sinMetas': 'Sin metas configuradas.',
|
||||
'dir.cfg.saved': 'Parámetros del tablero guardados',
|
||||
'dir.cfg.error': 'No se pudieron guardar los parámetros',
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------
|
||||
@@ -1893,6 +2015,128 @@ export const en: Dict = {
|
||||
'nps.processed': 'Surveys processed.',
|
||||
'nps.replySent': 'Reply sent.',
|
||||
'nps.replySimulated': 'Test mode: the reply was logged as simulated.',
|
||||
// --- Executive dashboard (Dirección) ---
|
||||
'dir.title': 'Management',
|
||||
'dir.subtitle': 'Business executive dashboard',
|
||||
'dir.periodo': 'Period',
|
||||
'dir.inicio': 'Start',
|
||||
'dir.fin': 'End',
|
||||
'dir.sucursal': 'Branch',
|
||||
'dir.todasSucursales': 'All branches',
|
||||
'dir.comparar': 'Compare with',
|
||||
'dir.cmp.anterior': 'Previous period',
|
||||
'dir.cmp.anio': 'Same period last year',
|
||||
'dir.cmp.ninguno': 'No comparison',
|
||||
'dir.preset.hoy': 'Today',
|
||||
'dir.preset.semana': 'This week',
|
||||
'dir.preset.mes': 'This month',
|
||||
'dir.preset.mesPasado': 'Last month',
|
||||
'dir.preset.anio': 'This year',
|
||||
'dir.preset.custom': 'Custom',
|
||||
'dir.exportar': 'Export',
|
||||
'dir.cargando': 'Loading…',
|
||||
'dir.sinDatos': 'No data for the period.',
|
||||
'dir.error': 'The dashboard could not be loaded.',
|
||||
'dir.exportOk': 'Excel downloaded',
|
||||
'dir.exportError': 'Export failed',
|
||||
'dir.noDisponible': 'Not available',
|
||||
'dir.noDisponibleHint': 'These indicators cannot be computed with the available data.',
|
||||
'dir.vsAnterior': 'vs. previous period',
|
||||
'dir.vsAnio': 'vs. last year',
|
||||
'dir.section.resumen': 'Period summary',
|
||||
'dir.kpi.ingresos': 'Collected revenue',
|
||||
'dir.kpi.ventas': 'Sales',
|
||||
'dir.kpi.ticket': 'Average ticket',
|
||||
'dir.kpi.visitas': 'Visits attended',
|
||||
'dir.kpi.nuevos': 'New patients',
|
||||
'dir.kpi.recurrentes': 'Returning patients',
|
||||
'dir.kpi.noShow': 'No-show rate',
|
||||
'dir.kpi.cancelacion': 'Cancellation rate',
|
||||
'dir.section.clientes': 'Customers',
|
||||
'dir.kpi.activos': 'Active patients',
|
||||
'dir.kpi.frecuencia': 'Average frequency',
|
||||
'dir.kpi.frecuenciaHint': 'Visits per patient in the period',
|
||||
'dir.kpi.valor': 'Value per customer',
|
||||
'dir.kpi.valorHint': 'Average historical revenue per patient',
|
||||
'dir.clientes.nuevosMes': 'New patients by month',
|
||||
'dir.clientes.retencion': 'Cohort retention',
|
||||
'dir.clientes.retencionHint': 'Of the patients who came for the first time in a month, how many returned within 1, 3, 6 and 12 months.',
|
||||
'dir.clientes.cohorte': 'Cohort',
|
||||
'dir.clientes.tamano': 'Patients',
|
||||
'dir.clientes.m1': '1 month',
|
||||
'dir.clientes.m3': '3 months',
|
||||
'dir.clientes.m6': '6 months',
|
||||
'dir.clientes.m12': '12 months',
|
||||
'dir.clientes.riesgo': 'Customers at risk',
|
||||
'dir.clientes.riesgoHint': 'With {visitas}+ visits and more than {dias} days without coming',
|
||||
'dir.clientes.crearCampana': 'Build campaign',
|
||||
'dir.clientes.valorMediana': 'Median',
|
||||
'dir.clientes.valorTotal': 'Historical revenue',
|
||||
'dir.clientes.ultimaVisita': 'Last visit',
|
||||
'dir.clientes.visitas': 'Visits',
|
||||
'dir.clientes.diasSinVenir': 'Days since last visit',
|
||||
'dir.clientes.ventanaPendiente': 'Window not reached yet',
|
||||
'dir.section.ocupacion': 'Occupancy and schedule',
|
||||
'dir.ocup.porMedico': 'Occupancy by doctor',
|
||||
'dir.ocup.porSucursal': 'Occupancy by branch',
|
||||
'dir.ocup.porRecurso': 'Occupancy by resource',
|
||||
'dir.ocup.franjas': 'Time slots',
|
||||
'dir.ocup.franjasHint': 'Appointments by hour (cancelled, no-show and waitlist do not count).',
|
||||
'dir.ocup.masOcupadas': 'Busiest slots',
|
||||
'dir.ocup.menosOcupadas': 'Least busy slots',
|
||||
'dir.ocup.horasVendidas': 'Hours sold',
|
||||
'dir.ocup.horasDisponibles': 'Hours available',
|
||||
'dir.ocup.horasLibres': 'Hours free',
|
||||
'dir.ocup.ocupacion': 'Occupancy',
|
||||
'dir.ocup.noShow': 'No-show by doctor',
|
||||
'dir.ocup.citas': 'Appointments',
|
||||
'dir.section.ingresos': 'Revenue by concept',
|
||||
'dir.ing.porServicio': 'By service',
|
||||
'dir.ing.porCategoria': 'By category',
|
||||
'dir.ing.porSucursal': 'By branch',
|
||||
'dir.ing.porMetodo': 'By payment method',
|
||||
'dir.ing.propinas': 'Tips',
|
||||
'dir.ing.membresias': 'Memberships',
|
||||
'dir.ing.paquetes': 'Packages',
|
||||
'dir.ing.lealtad': 'Loyalty',
|
||||
'dir.ing.concepto': 'Concept',
|
||||
'dir.ing.cantidad': 'Quantity',
|
||||
'dir.ing.total': 'Total',
|
||||
'dir.ing.activas': 'Active memberships',
|
||||
'dir.ing.mrr': 'Estimated monthly recurring revenue',
|
||||
'dir.ing.renovaciones': 'Renewals in the period',
|
||||
'dir.ing.cancelaciones': 'Cancellations in the period',
|
||||
'dir.ing.paquetesVendidos': 'Packages sold',
|
||||
'dir.ing.sesionesPendientes': 'Sessions pending use',
|
||||
'dir.ing.pasivoLealtad': 'Loyalty liability',
|
||||
'dir.ing.puntosActivos': 'Active points',
|
||||
'dir.section.metas': 'Goals and projection',
|
||||
'dir.metas.porSucursal': 'Goals by branch (current month)',
|
||||
'dir.metas.porEmpleado': 'Goals by employee (current month)',
|
||||
'dir.metas.meta': 'Goal',
|
||||
'dir.metas.avance': 'Month sales',
|
||||
'dir.metas.pct': 'Progress',
|
||||
'dir.metas.proyeccion': 'Closing projection',
|
||||
'dir.metas.mes': 'Current month',
|
||||
'dir.metas.periodo': 'Period projection',
|
||||
'dir.metas.sinMeta': 'No goal',
|
||||
'dir.metas.concepto': 'Concept',
|
||||
'dir.metas.empleado': 'Employee',
|
||||
'dir.metas.global': 'Global',
|
||||
'dir.metas.ventasPeriodo': 'Period sales',
|
||||
'dir.metas.cobrosPeriodo': 'Period collections',
|
||||
'dir.ms': 'Response',
|
||||
'dir.cfg.title': 'Dashboard parameters',
|
||||
'dir.cfg.hint': 'Goals by branch and "customers at risk" criteria. With zeroed goals the dashboard still works, it just shows no progress.',
|
||||
'dir.cfg.metas': 'Monthly goals by branch',
|
||||
'dir.cfg.metaGlobal': 'Global monthly goal',
|
||||
'dir.cfg.dias': 'Days without coming for "at risk"',
|
||||
'dir.cfg.visitas': 'Minimum visits for "at risk"',
|
||||
'dir.cfg.comparativo': 'Default comparison',
|
||||
'dir.cfg.addMeta': 'Add branch',
|
||||
'dir.cfg.sinMetas': 'No goals configured.',
|
||||
'dir.cfg.saved': 'Dashboard parameters saved',
|
||||
'dir.cfg.error': 'Could not save the parameters',
|
||||
};
|
||||
|
||||
export const DICT: Record<Locale, Dict> = { es, en };
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface MenuItem {
|
||||
|
||||
export const MENU_ITEMS: MenuItem[] = [
|
||||
{ label: 'Dashboard', href: '/' },
|
||||
// Tablero ejecutivo del dueño (solo admin, gerencia y marketing).
|
||||
{ label: 'Dirección', href: '/direccion' },
|
||||
{ label: 'Agenda', href: '/agenda' },
|
||||
{ label: 'Pacientes', href: '/pacientes', termKey: 'patient_plural' },
|
||||
{ label: 'Visitas', href: '/visitas', termKey: 'visit_plural' },
|
||||
|
||||
@@ -16,6 +16,7 @@ export const PERM_KEYS: string[] = [
|
||||
'inventario.ver', 'inventario.ajustar', 'inventario.mover', 'inventario.costos',
|
||||
'servicios.ver', 'servicios.editar',
|
||||
'reportes.ver', 'reportes.exportar', 'caja.ver', 'caja.cerrar',
|
||||
'direccion.ver',
|
||||
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
|
||||
'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar',
|
||||
'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar',
|
||||
@@ -84,6 +85,7 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||
'inventario.costos': WN,
|
||||
'servicios.ver': ALL, 'servicios.editar': AD,
|
||||
'reportes.ver': ALL, 'reportes.exportar': ALL, 'caja.ver': ALL, 'caja.cerrar': MONEY,
|
||||
'direccion.ver': ['admin', 'gerencia', 'marketing'],
|
||||
'whatsapp.ver': W, 'whatsapp.responder': W, 'whatsapp.asignar': W,
|
||||
'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG,
|
||||
'formularios.ver': ALL, 'formularios.crear': FORM_WRITE, 'formularios.editar': AG,
|
||||
|
||||
@@ -26,6 +26,7 @@ import LealtadPanel from '../components/LealtadPanel';
|
||||
import PropinasPanel from '../components/PropinasPanel';
|
||||
import CampanasPanel from '../components/CampanasPanel';
|
||||
import EncuestasPanel from '../components/EncuestasPanel';
|
||||
import DireccionPanel from '../components/DireccionPanel';
|
||||
import LicenciaPanel from '../components/LicenciaPanel';
|
||||
import ApiKeysPanel from '../components/ApiKeysPanel';
|
||||
import WebhooksPanel from '../components/WebhooksPanel';
|
||||
@@ -38,6 +39,7 @@ import { LanguageSelect } from '../components/LanguageSelect';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'clinica', label: 'Clínica' },
|
||||
{ key: 'direccion', label: 'Dirección' },
|
||||
{ key: 'reservas', label: 'Reservas en línea' },
|
||||
{ key: 'portal', label: 'Portal del cliente' },
|
||||
{ key: 'notificaciones', label: 'Notificaciones' },
|
||||
@@ -66,7 +68,7 @@ const TABS = [
|
||||
] as const;
|
||||
|
||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
||||
const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'lealtad', 'propinas', 'campanas', 'encuestas', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
|
||||
const TABS_GERENCIA = ['direccion', 'reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'lealtad', 'propinas', 'campanas', 'encuestas', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
|
||||
|
||||
const SUBCATALOGOS = [
|
||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||
@@ -87,6 +89,7 @@ const Configuracion: FC = () => {
|
||||
const esAdmin = user?.role === 'admin';
|
||||
// Cada pestaña requiere su permiso por acción (Configuración › Roles › Permisos).
|
||||
const TABS_PERM: Record<string, string> = {
|
||||
direccion: 'config.editar',
|
||||
usuarios: 'config.usuarios',
|
||||
roles: 'config.roles',
|
||||
auditoria: 'config.auditoria',
|
||||
@@ -518,6 +521,12 @@ const Configuracion: FC = () => {
|
||||
<ApiKeysPanel />
|
||||
) : tab === 'webhooks' ? (
|
||||
<WebhooksPanel />
|
||||
) : tab === 'direccion' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<DireccionPanel settings={settings} onSaved={setSettings} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card className="mb-4 sm:mb-6">
|
||||
|
||||
694
frontend/src/pages/Direccion.tsx
Normal file
694
frontend/src/pages/Direccion.tsx
Normal file
@@ -0,0 +1,694 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
Minus,
|
||||
Users,
|
||||
CalendarCheck,
|
||||
DollarSign,
|
||||
Receipt,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Gauge,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, EmptyState, Input, Select, Skeleton, PageHeader, toast } from '../components/ui';
|
||||
import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { exportToExcel } from '../lib/exporter';
|
||||
import { odooApi, type DireccionTablero, type TableroComparativo } from '../services/odoo';
|
||||
import { useClinicSettings } from '../lib/clinic-settings';
|
||||
import { useSucursales } from '../lib/sucursales';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import { rangeForPreset, isoDate } from '../lib/dateRange';
|
||||
|
||||
type Preset = 'hoy' | 'semana' | 'mes' | 'mes_pasado' | 'anio' | 'custom';
|
||||
|
||||
const PRESETS: { value: Preset; key: string }[] = [
|
||||
{ value: 'hoy', key: 'dir.preset.hoy' },
|
||||
{ value: 'semana', key: 'dir.preset.semana' },
|
||||
{ value: 'mes', key: 'dir.preset.mes' },
|
||||
{ value: 'mes_pasado', key: 'dir.preset.mesPasado' },
|
||||
{ value: 'anio', key: 'dir.preset.anio' },
|
||||
{ value: 'custom', key: 'dir.preset.custom' },
|
||||
];
|
||||
|
||||
const money = (v: number | null | undefined) =>
|
||||
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const num = (v: number | null | undefined, d = 0) =>
|
||||
(v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
const pct = (v: number | null | undefined) =>
|
||||
v === null || v === undefined ? '—' : `${Math.round(v * 100)}%`;
|
||||
|
||||
const rangoPreset = (p: Preset): { start: string; end: string } => {
|
||||
if (p === 'anio') {
|
||||
const y = new Date().getFullYear();
|
||||
return { start: `${y}-01-01`, end: isoDate(new Date()) };
|
||||
}
|
||||
return rangeForPreset((p === 'custom' ? 'hoy' : p) as 'hoy' | 'semana' | 'mes' | 'mes_pasado');
|
||||
};
|
||||
|
||||
const Kpi: FC<{ label: string; value: ReactNode; hint?: ReactNode; icon?: ReactNode; variant?: 'a' | 'b' | 'c' }> = ({
|
||||
label, value, hint, icon, variant,
|
||||
}) => (
|
||||
<Card className={`p-4 border-0 ${variant === 'a' ? 'bg-theme-bg' : variant === 'b' ? 'bg-theme-surface' : 'bg-theme-bg'}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{icon && <span className="text-theme-muted">{icon}</span>}
|
||||
<p className="text-xs text-theme-muted">{label}</p>
|
||||
</div>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{value}</p>
|
||||
{hint && <p className="text-[11px] text-theme-muted mt-0.5">{hint}</p>}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const Variacion: FC<{ v: number | null; label: string }> = ({ v, label }) => {
|
||||
if (v === null || v === undefined) {
|
||||
return <span className="inline-flex items-center gap-1 text-[11px] text-theme-muted"><Minus size={12} /> {label}</span>;
|
||||
}
|
||||
const up = v >= 0;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-[11px] font-medium ${up ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{up ? <ArrowUpRight size={12} /> : <ArrowDownRight size={12} />}
|
||||
{(v * 100).toFixed(1)}% <span className="text-theme-muted font-normal">{label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const Direccion: FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { term } = useGlosario();
|
||||
const { sucursales, labelFor } = useSucursales();
|
||||
const { tablero } = useClinicSettings();
|
||||
const navigate = useNavigate();
|
||||
const [sp] = useSearchParams();
|
||||
|
||||
const [preset, setPreset] = useState<Preset>('mes');
|
||||
const [start, setStart] = useState(() => rangoPreset('mes').start);
|
||||
const [end, setEnd] = useState(() => rangoPreset('mes').end);
|
||||
const [sucursal, setSucursal] = useState<string>(sp.get('sucursal') || '');
|
||||
const [comparar, setComparar] = useState<TableroComparativo>(tablero.comparativo_default);
|
||||
const [data, setData] = useState<DireccionTablero | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getDireccion({ start, end, sucursal: sucursal || undefined, comparar });
|
||||
if (res.status === 'success') setData(res);
|
||||
else toast.error(res.message || t('dir.error'));
|
||||
} catch (err) {
|
||||
toast.error(t('dir.error'));
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end, sucursal, comparar, t]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const cambiarPreset = (p: Preset) => {
|
||||
setPreset(p);
|
||||
if (p !== 'custom') {
|
||||
const r = rangoPreset(p);
|
||||
setStart(r.start);
|
||||
setEnd(r.end);
|
||||
}
|
||||
};
|
||||
|
||||
const cmpLabel = (comparar as TableroComparativo) === 'anio' ? t('dir.vsAnio') : t('dir.vsAnterior');
|
||||
|
||||
const opcionesSucursal = useMemo(
|
||||
() => [{ value: '', label: t('dir.todasSucursales') },
|
||||
...sucursales.map((s) => ({ value: s.code, label: s.name }))],
|
||||
[sucursales, t],
|
||||
);
|
||||
|
||||
const exportar = async () => {
|
||||
if (!data) return;
|
||||
try {
|
||||
setExporting(true);
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
const push = (sec: string, concepto: string, valor: number | string | null, detalle = '') =>
|
||||
rows.push({ seccion: sec, concepto, valor, detalle });
|
||||
const r = data.resumen;
|
||||
const secRes = t('dir.section.resumen');
|
||||
push(secRes, t('dir.kpi.ingresos'), r.ingresos_cobrados);
|
||||
push(secRes, t('dir.kpi.ventas'), r.ventas_total);
|
||||
push(secRes, t('dir.kpi.ticket'), r.ticket_promedio);
|
||||
push(secRes, t('dir.kpi.visitas'), r.visitas_atendidas);
|
||||
push(secRes, t('dir.kpi.nuevos'), r.pacientes_nuevos);
|
||||
push(secRes, t('dir.kpi.recurrentes'), r.pacientes_recurrentes);
|
||||
push(secRes, t('dir.kpi.noShow'), r.tasa_no_show);
|
||||
push(secRes, t('dir.kpi.cancelacion'), r.tasa_cancelacion);
|
||||
const secCli = t('dir.section.clientes');
|
||||
push(secCli, t('dir.kpi.activos'), data.clientes.activos);
|
||||
push(secCli, t('dir.kpi.frecuencia'), data.clientes.frecuencia_promedio);
|
||||
push(secCli, t('dir.kpi.valor'), data.clientes.valor_por_cliente.promedio);
|
||||
for (const c of data.clientes.retencion_cohortes) {
|
||||
push(secCli, `${t('dir.clientes.cohorte')} ${c.cohorte} (${t('dir.clientes.tamano')} ${c.tamano})`,
|
||||
c.ret1_tasa, `1m=${pct(c.ret1_tasa)} 3m=${pct(c.ret3_tasa)} 6m=${pct(c.ret6_tasa)} 12m=${pct(c.ret12_tasa)}`);
|
||||
}
|
||||
const secOc = t('dir.section.ocupacion');
|
||||
for (const m of data.ocupacion.por_medico) {
|
||||
push(secOc, `${t('dir.ocup.porMedico')}: ${m.medico}`, m.horas_vendidas, `${pct(m.ocupacion)} (${num(m.horas_disponibles, 1)}h)`);
|
||||
}
|
||||
for (const s of data.ocupacion.por_sucursal) {
|
||||
push(secOc, `${t('dir.ocup.porSucursal')}: ${labelFor(s.sucursal)}`, s.horas_vendidas, pct(s.ocupacion));
|
||||
}
|
||||
for (const fr of data.ocupacion.franjas) push(secOc, `${t('dir.ocup.franjas')} ${fr.hora}`, fr.total);
|
||||
const secIn = t('dir.section.ingresos');
|
||||
for (const s of data.ingresos.por_servicio) push(secIn, `${t('dir.ing.porServicio')}: ${s.servicio}`, s.total, `${num(s.cantidad, 1)}`);
|
||||
for (const c of data.ingresos.por_categoria) push(secIn, `${t('dir.ing.porCategoria')}: ${c.categoria}`, c.total);
|
||||
for (const s of data.ingresos.por_sucursal) push(secIn, `${t('dir.ing.porSucursal')}: ${labelFor(s.sucursal)}`, s.total);
|
||||
for (const [k, v] of Object.entries(data.ingresos.por_metodo_pago)) push(secIn, `${t('dir.ing.porMetodo')}: ${k}`, v);
|
||||
push(secIn, t('dir.ing.propinas'), r.propinas);
|
||||
const secMe = t('dir.section.metas');
|
||||
push(secMe, `${t('dir.metas.mes')} ${data.metas.mes.mes}`, data.metas.mes.ventas, `${pct(data.metas.mes.pct)} · ${money(data.metas.mes.meta)}`);
|
||||
for (const m of data.metas.por_sucursal) push(secMe, `${t('dir.metas.porSucursal')}: ${labelFor(m.sucursal)}`, m.ventas_mes, `${pct(m.pct)} · ${money(m.meta_mensual)}`);
|
||||
for (const m of data.metas.por_empleado) push(secMe, `${t('dir.metas.porEmpleado')}: ${m.empleado}`, m.ventas_mes, `${pct(m.pct)} · ${money(m.meta_mensual)}`);
|
||||
await exportToExcel({
|
||||
filename: `direccion-${start}_${end}`,
|
||||
sheetName: t('dir.title'),
|
||||
title: `${t('dir.title')} · ${data.start} a ${data.end}`,
|
||||
columns: [
|
||||
{ header: t('dir.metas.concepto'), key: 'seccion' },
|
||||
{ header: t('dir.ing.concepto'), key: 'concepto' },
|
||||
{ header: t('dir.ing.total'), key: 'valor', format: 'number' },
|
||||
{ header: t('dir.ms'), key: 'detalle' },
|
||||
],
|
||||
rows,
|
||||
});
|
||||
toast.success(t('dir.exportOk'));
|
||||
} catch (err) {
|
||||
toast.error(t('dir.exportError'));
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title={t('dir.title')} subtitle={t('dir.subtitle')}>
|
||||
<Card className="mb-6">
|
||||
<Card.Body>
|
||||
<div className="flex flex-col lg:flex-row gap-3 lg:items-end">
|
||||
<Select
|
||||
label={t('dir.periodo')}
|
||||
value={preset}
|
||||
onChange={(e) => cambiarPreset(e.target.value as Preset)}
|
||||
options={PRESETS.map((p) => ({ value: p.value, label: t(p.key) }))}
|
||||
className="lg:max-w-[170px]"
|
||||
/>
|
||||
<Input label={t('dir.inicio')} type="date" value={start}
|
||||
onChange={(e) => { setStart(e.target.value); setPreset('custom'); }} className="lg:max-w-[150px]" />
|
||||
<Input label={t('dir.fin')} type="date" value={end}
|
||||
onChange={(e) => { setEnd(e.target.value); setPreset('custom'); }} className="lg:max-w-[150px]" />
|
||||
<Select
|
||||
label={t('dir.sucursal')}
|
||||
value={sucursal}
|
||||
onChange={(e) => setSucursal(e.target.value)}
|
||||
options={opcionesSucursal}
|
||||
className="lg:max-w-[200px]"
|
||||
/>
|
||||
<Select
|
||||
label={t('dir.comparar')}
|
||||
value={comparar}
|
||||
onChange={(e) => setComparar(e.target.value as TableroComparativo)}
|
||||
options={[
|
||||
{ value: 'anterior', label: t('dir.cmp.anterior') },
|
||||
{ value: 'anio', label: t('dir.cmp.anio') },
|
||||
{ value: 'ninguno', label: t('dir.cmp.ninguno') },
|
||||
]}
|
||||
className="lg:max-w-[210px]"
|
||||
/>
|
||||
<div className="lg:ml-auto">
|
||||
<ExportButton onClick={exportar} loading={exporting} />
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : !data ? (
|
||||
<EmptyState title={t('dir.title')} subtitle={t('dir.error')} />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* ---- Resumen ---- */}
|
||||
<section>
|
||||
<PageHeader title={t('dir.section.resumen')} />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Kpi label={t('dir.kpi.ingresos')} value={money(data.resumen.ingresos_cobrados)}
|
||||
icon={<DollarSign size={15} />}
|
||||
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.ingresos_cobrados} label={cmpLabel} /> : undefined} />
|
||||
<Kpi label={t('dir.kpi.ventas')} value={money(data.resumen.ventas_total)}
|
||||
icon={<Receipt size={15} />}
|
||||
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.ventas_total} label={cmpLabel} /> : undefined} />
|
||||
<Kpi label={t('dir.kpi.ticket')} value={money(data.resumen.ticket_promedio)}
|
||||
icon={<Activity size={15} />}
|
||||
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.ticket_promedio} label={cmpLabel} /> : undefined} />
|
||||
<Kpi label={t('dir.kpi.visitas')} value={num(data.resumen.visitas_atendidas)}
|
||||
icon={<CalendarCheck size={15} />}
|
||||
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.visitas_atendidas} label={cmpLabel} /> : undefined} />
|
||||
<Kpi label={t('dir.kpi.nuevos')} value={num(data.resumen.pacientes_nuevos)}
|
||||
icon={<Users size={15} />}
|
||||
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.pacientes_nuevos} label={cmpLabel} /> : undefined} />
|
||||
<Kpi label={t('dir.kpi.recurrentes')} value={num(data.resumen.pacientes_recurrentes)}
|
||||
icon={<Users size={15} />}
|
||||
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.pacientes_recurrentes} label={cmpLabel} /> : undefined} />
|
||||
<Kpi label={t('dir.kpi.noShow')} value={pct(data.resumen.tasa_no_show)}
|
||||
icon={<AlertTriangle size={15} />}
|
||||
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.tasa_no_show} label={cmpLabel} /> : undefined} />
|
||||
<Kpi label={t('dir.kpi.cancelacion')} value={pct(data.resumen.tasa_cancelacion)}
|
||||
icon={<AlertTriangle size={15} />}
|
||||
hint={data.comparativo ? <Variacion v={data.comparativo.variacion.tasa_cancelacion} label={cmpLabel} /> : undefined} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- Clientes ---- */}
|
||||
<section>
|
||||
<PageHeader title={t('dir.section.clientes')} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<Kpi label={t('dir.kpi.activos')} value={num(data.clientes.activos)} icon={<Users size={15} />} />
|
||||
<Kpi label={t('dir.kpi.frecuencia')} value={num(data.clientes.frecuencia_promedio, 2)} hint={t('dir.kpi.frecuenciaHint')} />
|
||||
<Kpi label={t('dir.kpi.valor')} value={money(data.clientes.valor_por_cliente.promedio)}
|
||||
hint={`${t('dir.kpi.valorHint')} · ${t('dir.clientes.valorMediana')}: ${money(data.clientes.valor_por_cliente.mediana)}`} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.clientes.nuevosMes')}</h3>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data.clientes.nuevos_por_mes.map((m) => ({ mes: m.mes.slice(5), total: m.total }))}
|
||||
margin={{ top: 4, right: 8, left: -18, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
|
||||
<XAxis dataKey="mes" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 11 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 11 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
|
||||
<Bar dataKey="total" fill="#57534e" radius={[4, 4, 0, 0]} name={t('dir.clientes.tamano')} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-1">{t('dir.clientes.retencion')}</h3>
|
||||
<p className="text-xs text-theme-muted mb-3">{t('dir.clientes.retencionHint')}</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">{t('dir.clientes.cohorte')}</th>
|
||||
<th className="text-right px-3 py-2">{t('dir.clientes.tamano')}</th>
|
||||
<th className="text-right px-3 py-2">{t('dir.clientes.m1')}</th>
|
||||
<th className="text-right px-3 py-2">{t('dir.clientes.m3')}</th>
|
||||
<th className="text-right px-3 py-2">{t('dir.clientes.m6')}</th>
|
||||
<th className="text-right px-3 py-2">{t('dir.clientes.m12')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{data.clientes.retencion_cohortes.map((c) => (
|
||||
<tr key={c.cohorte} className="hover:bg-theme-bg">
|
||||
<td className="px-3 py-2 font-medium text-theme-heading">{c.cohorte}</td>
|
||||
<td className="px-3 py-2 text-right text-theme-muted">{c.tamano}</td>
|
||||
<td className="px-3 py-2 text-right text-theme-heading">{pct(c.ret1_tasa)}</td>
|
||||
<td className="px-3 py-2 text-right text-theme-heading">{pct(c.ret3_tasa)}</td>
|
||||
<td className="px-3 py-2 text-right text-theme-heading">{pct(c.ret6_tasa)}</td>
|
||||
<td className="px-3 py-2 text-right text-theme-heading">{pct(c.ret12_tasa)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mt-4">
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
|
||||
<div>
|
||||
<h3 className="font-heading text-lg text-theme-heading">{t('dir.clientes.riesgo')}</h3>
|
||||
<p className="text-xs text-theme-muted">
|
||||
{t('dir.clientes.riesgoHint', {
|
||||
visitas: data.clientes.en_riesgo.visitas_minimas,
|
||||
dias: data.clientes.en_riesgo.dias_inactividad,
|
||||
})} · {num(data.clientes.en_riesgo.total)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`${data.clientes.en_riesgo.link_campana}?nuevo_segmento=1&ultima_visita_dias=${data.clientes.en_riesgo.filtro_campana.ultima_visita_dias}`)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium bg-theme-accent text-theme-inverse hover:opacity-90 transition"
|
||||
>
|
||||
{t('dir.clientes.crearCampana')}
|
||||
</button>
|
||||
</div>
|
||||
{data.clientes.en_riesgo.pacientes.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">{t('dir.sinDatos')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">{term('patient_singular')}</th>
|
||||
<th className="text-left px-3 py-2">{t('common.phone')}</th>
|
||||
<th className="text-right px-3 py-2">{t('dir.clientes.visitas')}</th>
|
||||
<th className="text-left px-3 py-2">{t('dir.clientes.ultimaVisita')}</th>
|
||||
<th className="text-right px-3 py-2">{t('dir.clientes.diasSinVenir')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{data.clientes.en_riesgo.pacientes.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-theme-bg">
|
||||
<td className="px-3 py-2 font-medium text-theme-heading">{p.name}</td>
|
||||
<td className="px-3 py-2 text-theme-muted">{p.phone || '—'}</td>
|
||||
<td className="px-3 py-2 text-right text-theme-muted">{p.visitas}</td>
|
||||
<td className="px-3 py-2 text-theme-muted">{p.ultima_visita || '—'}</td>
|
||||
<td className="px-3 py-2 text-right font-medium text-rose-600">{p.dias_sin_venir ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ---- Ocupación ---- */}
|
||||
<section>
|
||||
<PageHeader title={t('dir.section.ocupacion')}
|
||||
subtitle={`${num(data.ocupacion.dias_habiles)} ${t('common.date').toLowerCase()} · ${num(data.ocupacion.jornada_horas, 1)}h`} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.ocup.porMedico')}</h3>
|
||||
{data.ocupacion.por_medico.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">{t('dir.sinDatos')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">{term('doctor_singular')}</th>
|
||||
<th className="text-right px-3 py-2">{t('dir.ocup.horasVendidas')}</th>
|
||||
<th className="text-right px-3 py-2">{t('dir.ocup.horasDisponibles')}</th>
|
||||
<th className="text-right px-3 py-2">{t('dir.ocup.ocupacion')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{data.ocupacion.por_medico.map((m) => (
|
||||
<tr key={m.medico} className="hover:bg-theme-bg">
|
||||
<td className="px-3 py-2 font-medium text-theme-heading">{m.medico}</td>
|
||||
<td className="px-3 py-2 text-right text-theme-muted">{num(m.horas_vendidas, 1)}</td>
|
||||
<td className="px-3 py-2 text-right text-theme-muted">{num(m.horas_disponibles, 1)}</td>
|
||||
<td className="px-3 py-2 text-right font-medium text-theme-heading">{pct(m.ocupacion)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-1">{t('dir.ocup.franjas')}</h3>
|
||||
<p className="text-xs text-theme-muted mb-3">{t('dir.ocup.franjasHint')}</p>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={data.ocupacion.franjas.map((f) => ({ hora: f.hora.slice(0, 2), total: f.total }))}
|
||||
margin={{ top: 4, right: 8, left: -18, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
|
||||
<XAxis dataKey="hora" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 11 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 11 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
|
||||
<Bar dataKey="total" fill="#a8a29e" radius={[4, 4, 0, 0]} name={t('dir.ocup.citas')} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="grid grid-cols-2 gap-3 mt-3 text-xs">
|
||||
<div>
|
||||
<p className="text-theme-muted mb-1 flex items-center gap-1"><Gauge size={12} /> {t('dir.ocup.masOcupadas')}</p>
|
||||
{data.ocupacion.franjas_mas_ocupadas.map((f) => (
|
||||
<p key={`mx-${f.hora}`} className="text-theme-heading">{f.hora} · {f.total}</p>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-theme-muted mb-1">{t('dir.ocup.menosOcupadas')}</p>
|
||||
{data.ocupacion.franjas_menos_ocupadas.map((f) => (
|
||||
<p key={`mn-${f.hora}`} className="text-theme-heading">{f.hora} · {f.total}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.ocup.porSucursal')}</h3>
|
||||
{data.ocupacion.por_sucursal.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">{t('dir.sinDatos')}</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{data.ocupacion.por_sucursal.map((s) => (
|
||||
<tr key={s.sucursal} className="hover:bg-theme-bg">
|
||||
<td className="py-2 font-medium text-theme-heading">{labelFor(s.sucursal)}</td>
|
||||
<td className="py-2 text-right text-theme-muted">{num(s.horas_vendidas, 1)}h</td>
|
||||
<td className="py-2 text-right font-medium text-theme-heading">{pct(s.ocupacion)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{data.ocupacion.por_recurso.length > 0 && (
|
||||
<>
|
||||
<h3 className="font-heading text-base text-theme-heading mt-4 mb-2">{t('dir.ocup.porRecurso')}</h3>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{data.ocupacion.por_recurso.map((r) => (
|
||||
<tr key={r.recurso_id} className="hover:bg-theme-bg">
|
||||
<td className="py-2 font-medium text-theme-heading">{r.recurso}</td>
|
||||
<td className="py-2 text-right text-theme-muted">{num(r.horas_ocupadas, 1)}h</td>
|
||||
<td className="py-2 text-right font-medium text-theme-heading">{num(r.ocupacion_pct, 1)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.ocup.noShow')}</h3>
|
||||
{data.ocupacion.no_show_por_medico.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">{t('dir.sinDatos')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">{term('doctor_singular')}</th>
|
||||
<th className="text-right px-3 py-2">{t('dir.ocup.citas')}</th>
|
||||
<th className="text-right px-3 py-2">{t('dir.kpi.noShow')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{data.ocupacion.no_show_por_medico.map((m) => (
|
||||
<tr key={m.medico} className="hover:bg-theme-bg">
|
||||
<td className="px-3 py-2 font-medium text-theme-heading">{m.medico}</td>
|
||||
<td className="px-3 py-2 text-right text-theme-muted">{m.citas}</td>
|
||||
<td className={`px-3 py-2 text-right font-medium ${m.tasa > 0.15 ? 'text-rose-600' : 'text-theme-heading'}`}>{pct(m.tasa)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- Ingresos ---- */}
|
||||
<section>
|
||||
<PageHeader title={t('dir.section.ingresos')} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.ing.porServicio')}</h3>
|
||||
<TablaSimple
|
||||
cols={[t('dir.ing.concepto'), t('dir.ing.cantidad'), t('dir.ing.total')]}
|
||||
rows={data.ingresos.por_servicio.slice(0, 12).map((s) => [s.servicio, num(s.cantidad, 1), money(s.total)])}
|
||||
empty={t('dir.sinDatos')}
|
||||
/>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.ing.porCategoria')}</h3>
|
||||
<TablaSimple
|
||||
cols={[t('dir.ing.concepto'), t('dir.ing.total')]}
|
||||
rows={data.ingresos.por_categoria.map((c) => [c.categoria, money(c.total)])}
|
||||
empty={t('dir.sinDatos')}
|
||||
/>
|
||||
<h3 className="font-heading text-base text-theme-heading mt-4 mb-2">{t('dir.ing.porSucursal')}</h3>
|
||||
<TablaSimple
|
||||
cols={[t('dir.ing.concepto'), t('dir.ing.total')]}
|
||||
rows={data.ingresos.por_sucursal.map((s) => [labelFor(s.sucursal), money(s.total)])}
|
||||
empty={t('dir.sinDatos')}
|
||||
/>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.ing.porMetodo')}</h3>
|
||||
<TablaSimple
|
||||
cols={[t('dir.ing.concepto'), t('dir.ing.total')]}
|
||||
rows={Object.entries(data.ingresos.por_metodo_pago).map(([k, v]) => [k, money(v)])}
|
||||
empty={t('dir.sinDatos')}
|
||||
/>
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<span className="text-theme-muted">{t('dir.ing.propinas')}</span>
|
||||
<span className="font-medium text-theme-heading">{money(data.resumen.propinas)}</span>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.ing.membresias')}</h3>
|
||||
<Linea label={t('dir.ing.activas')} value={num(data.ingresos.membresias.activas)} />
|
||||
<Linea label={t('dir.ing.mrr')} value={money(data.ingresos.membresias.mrr_estimado)} />
|
||||
<Linea label={t('dir.ing.renovaciones')} value={num(data.ingresos.membresias.renovaciones)} />
|
||||
<Linea label={t('dir.ing.cancelaciones')} value={num(data.ingresos.membresias.cancelaciones)} />
|
||||
<h3 className="font-heading text-base text-theme-heading mt-4 mb-2">{t('dir.ing.paquetes')}</h3>
|
||||
<Linea label={t('dir.ing.paquetesVendidos')} value={num(data.ingresos.paquetes.vendidos)} />
|
||||
<Linea label={t('dir.ing.sesionesPendientes')} value={num(data.ingresos.paquetes.sesiones_pendientes)} />
|
||||
<h3 className="font-heading text-base text-theme-heading mt-4 mb-2">{t('dir.ing.lealtad')}</h3>
|
||||
<Linea label={t('dir.ing.puntosActivos')} value={num(data.ingresos.lealtad.puntos_activos)} />
|
||||
<Linea label={t('dir.ing.pasivoLealtad')} value={money(data.ingresos.lealtad.pasivo_mxn)} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- Metas ---- */}
|
||||
<section>
|
||||
<PageHeader title={t('dir.section.metas')} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<Kpi label={`${t('dir.metas.mes')} ${data.metas.mes.mes}`} value={money(data.metas.mes.ventas)}
|
||||
hint={`${t('dir.metas.meta')}: ${money(data.metas.mes.meta)} · ${pct(data.metas.mes.pct)}`} />
|
||||
<Kpi label={t('dir.metas.proyeccion')} value={money(data.metas.mes.proyeccion)}
|
||||
hint={`${t('dir.metas.proyeccion')} vs ${t('dir.metas.meta')}: ${pct(data.metas.mes.proyeccion_vs_meta)}`} />
|
||||
<Kpi label={t('dir.metas.periodo')} value={money(data.metas.periodo.proyeccion_ventas)}
|
||||
hint={`${t('dir.metas.cobrosPeriodo')}: ${money(data.metas.periodo.proyeccion_cobros)}`} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.metas.porSucursal')}</h3>
|
||||
<TablaSimple
|
||||
cols={[t('dir.sucursal'), t('dir.metas.meta'), t('dir.metas.avance'), t('dir.metas.pct'), t('dir.metas.proyeccion')]}
|
||||
rows={data.metas.por_sucursal.map((m) => [
|
||||
labelFor(m.sucursal), money(m.meta_mensual), money(m.ventas_mes),
|
||||
m.pct === null ? t('dir.metas.sinMeta') : pct(m.pct), money(m.proyeccion),
|
||||
])}
|
||||
empty={t('dir.sinDatos')}
|
||||
/>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-lg text-theme-heading mb-3">{t('dir.metas.porEmpleado')}</h3>
|
||||
<TablaSimple
|
||||
cols={[t('dir.metas.empleado'), t('dir.metas.meta'), t('dir.metas.avance'), t('dir.metas.pct')]}
|
||||
rows={data.metas.por_empleado.slice(0, 15).map((m) => [
|
||||
m.empleado, money(m.meta_mensual), money(m.ventas_mes),
|
||||
m.pct === null ? t('dir.metas.sinMeta') : pct(m.pct),
|
||||
])}
|
||||
empty={t('dir.sinDatos')}
|
||||
/>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- No disponible ---- */}
|
||||
{data.no_disponible.length > 0 && (
|
||||
<section>
|
||||
<Card className="border border-amber-200 bg-amber-50/50">
|
||||
<Card.Body>
|
||||
<h3 className="font-heading text-base text-theme-heading mb-1 flex items-center gap-2">
|
||||
<AlertTriangle size={15} /> {t('dir.noDisponible')}
|
||||
</h3>
|
||||
<p className="text-xs text-theme-muted mb-2">{t('dir.noDisponibleHint')}</p>
|
||||
<ul className="text-sm text-theme-heading space-y-1">
|
||||
{data.no_disponible.map((n) => (
|
||||
<li key={n.clave}>
|
||||
<span className="font-medium">{n.etiqueta}</span>
|
||||
<span className="text-theme-muted"> — {n.motivo}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-theme-muted text-right">{t('dir.ms')}: {data.ms} ms</p>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const TablaSimple: FC<{ cols: string[]; rows: (string | number)[][]; empty: string }> = ({ cols, rows, empty }) => (
|
||||
rows.length === 0 ? <p className="text-sm text-theme-muted">{empty}</p> : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
{cols.map((c, i) => (
|
||||
<th key={c} className={`px-3 py-2 ${i === 0 ? 'text-left' : 'text-right'}`}>{c}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{rows.map((r, ri) => (
|
||||
<tr key={ri} className="hover:bg-theme-bg">
|
||||
{r.map((cell, ci) => (
|
||||
<td key={ci} className={`px-3 py-2 ${ci === 0 ? 'font-medium text-theme-heading' : 'text-right text-theme-muted'}`}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
const Linea: FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||
<div className="flex items-center justify-between text-sm py-1">
|
||||
<span className="text-theme-muted">{label}</span>
|
||||
<span className="font-medium text-theme-heading">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Direccion;
|
||||
@@ -1099,6 +1099,192 @@ export interface ClinicSettings {
|
||||
portal_cliente: PortalClienteSettings;
|
||||
campanas: CampanasSettings;
|
||||
encuestas: EncuestasSettings;
|
||||
/** Parámetros del tablero de dirección y metas por sucursal. */
|
||||
tablero: TableroSettings;
|
||||
}
|
||||
|
||||
// ---- Tablero de dirección (bloque `tablero` de clinic-settings) ----
|
||||
export type TableroComparativo = 'anterior' | 'anio' | 'ninguno';
|
||||
|
||||
export interface TableroMetaSucursal {
|
||||
sucursal: string;
|
||||
meta_mensual: number;
|
||||
}
|
||||
|
||||
export interface TableroSettings {
|
||||
metas_sucursal: TableroMetaSucursal[];
|
||||
meta_mensual_global: number;
|
||||
dias_inactividad: number;
|
||||
visitas_minimas_riesgo: number;
|
||||
comparativo_default: TableroComparativo;
|
||||
}
|
||||
|
||||
export const TABLERO_DEFAULT: TableroSettings = {
|
||||
metas_sucursal: [],
|
||||
meta_mensual_global: 0,
|
||||
dias_inactividad: 90,
|
||||
visitas_minimas_riesgo: 3,
|
||||
comparativo_default: 'anterior',
|
||||
};
|
||||
|
||||
// ---- Respuesta del endpoint del tablero de dirección ----
|
||||
export interface DireccionKpis {
|
||||
ingresos_cobrados: number;
|
||||
ventas_total: number;
|
||||
ventas_count: number;
|
||||
pagado_en_ventas: number;
|
||||
ticket_promedio: number;
|
||||
propinas: number;
|
||||
visitas_atendidas: number;
|
||||
citas_total: number;
|
||||
citas_activas: number;
|
||||
pacientes_activos: number;
|
||||
pacientes_nuevos: number;
|
||||
pacientes_recurrentes: number;
|
||||
tasa_no_show: number;
|
||||
tasa_cancelacion: number;
|
||||
por_dia?: { date: string; total: number }[];
|
||||
}
|
||||
|
||||
export interface DireccionComparativo {
|
||||
modo: TableroComparativo;
|
||||
start: string;
|
||||
end: string;
|
||||
kpis: DireccionKpis;
|
||||
variacion: Record<string, number | null>;
|
||||
}
|
||||
|
||||
export interface DireccionCohorte {
|
||||
cohorte: string;
|
||||
tamano: number;
|
||||
ret1: number; ret1_tasa: number | null;
|
||||
ret3: number; ret3_tasa: number | null;
|
||||
ret6: number; ret6_tasa: number | null;
|
||||
ret12: number; ret12_tasa: number | null;
|
||||
}
|
||||
|
||||
export interface DireccionClienteRiesgo {
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
ultima_visita: string | null;
|
||||
visitas: number;
|
||||
dias_sin_venir: number | null;
|
||||
}
|
||||
|
||||
export interface DireccionClientes {
|
||||
activos: number;
|
||||
frecuencia_promedio: number;
|
||||
frecuencia_historica: number;
|
||||
nuevos_por_mes: { mes: string; total: number }[];
|
||||
retencion_cohortes: DireccionCohorte[];
|
||||
valor_por_cliente: {
|
||||
promedio: number; mediana: number; total: number; pacientes_con_ventas: number;
|
||||
};
|
||||
en_riesgo: {
|
||||
total: number;
|
||||
dias_inactividad: number;
|
||||
visitas_minimas: number;
|
||||
pacientes: DireccionClienteRiesgo[];
|
||||
filtro_campana: { ultima_visita_dias: number };
|
||||
link_campana: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DireccionMedico {
|
||||
medico: string;
|
||||
horas_vendidas: number;
|
||||
horas_disponibles: number;
|
||||
horas_libres: number;
|
||||
ocupacion: number;
|
||||
}
|
||||
|
||||
export interface DireccionFranja {
|
||||
hora: string;
|
||||
total: number;
|
||||
by_state: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface DireccionOcupacion {
|
||||
dias_habiles: number;
|
||||
jornada_horas: number;
|
||||
por_medico: DireccionMedico[];
|
||||
no_show_por_medico: { medico: string; citas: number; no_shows: number; tasa: number }[];
|
||||
por_sucursal: { sucursal: string; horas_vendidas: number; horas_disponibles: number; ocupacion: number }[];
|
||||
por_recurso: {
|
||||
recurso_id: number; recurso: string; citas: number;
|
||||
horas_ocupadas: number; horas_disponibles: number; ocupacion_pct: number;
|
||||
}[];
|
||||
franjas: DireccionFranja[];
|
||||
franjas_mas_ocupadas: DireccionFranja[];
|
||||
franjas_menos_ocupadas: DireccionFranja[];
|
||||
total_horas_vendidas: number;
|
||||
total_horas_ocupadas_recursos: number;
|
||||
}
|
||||
|
||||
export interface DireccionIngresos {
|
||||
por_servicio: { service_id: number; servicio: string; cantidad: number; total: number }[];
|
||||
por_categoria: { categoria: string; total: number; cantidad: number }[];
|
||||
por_sucursal: { sucursal: string; total: number; ventas: number }[];
|
||||
por_metodo_pago: Record<string, number>;
|
||||
membresias: {
|
||||
activas: number; mrr_estimado: number; renovaciones: number;
|
||||
ingreso_recurrente_periodo: number; altas: number; cancelaciones: number;
|
||||
};
|
||||
paquetes: { vendidos: number; total_vendido: number; activos: number; sesiones_pendientes: number };
|
||||
lealtad: {
|
||||
valor_punto: number; puntos_activos: number; pasivo_mxn: number;
|
||||
otorgados: number; canjeados: number; expirados: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DireccionMetaSucursalFila {
|
||||
sucursal: string;
|
||||
meta_mensual: number;
|
||||
ventas_mes: number;
|
||||
pct: number | null;
|
||||
proyeccion: number;
|
||||
}
|
||||
|
||||
export interface DireccionMetaEmpleado {
|
||||
employee_id: number;
|
||||
empleado: string;
|
||||
meta_mensual: number;
|
||||
ventas_mes: number;
|
||||
pct: number | null;
|
||||
proyeccion: number;
|
||||
}
|
||||
|
||||
export interface DireccionMetas {
|
||||
por_sucursal: DireccionMetaSucursalFila[];
|
||||
por_empleado: DireccionMetaEmpleado[];
|
||||
periodo: {
|
||||
dias: number; dias_transcurridos: number; ventas: number; cobros: number;
|
||||
proyeccion_ventas: number; proyeccion_cobros: number;
|
||||
};
|
||||
mes: {
|
||||
mes: string; dias_mes: number; dias_transcurridos: number; ventas: number;
|
||||
meta: number; pct: number | null; proyeccion: number;
|
||||
proyeccion_vs_meta: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DireccionNoDisponible { clave: string; etiqueta: string; motivo: string; }
|
||||
|
||||
export interface DireccionTablero {
|
||||
status: string;
|
||||
start: string;
|
||||
end: string;
|
||||
sucursal: string | null;
|
||||
comparativo: DireccionComparativo | null;
|
||||
resumen: DireccionKpis;
|
||||
clientes: DireccionClientes;
|
||||
ocupacion: DireccionOcupacion;
|
||||
ingresos: DireccionIngresos;
|
||||
metas: DireccionMetas;
|
||||
no_disponible: DireccionNoDisponible[];
|
||||
ms: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ---- Licencia por plan (bloque `licencia` de clinic-settings) ----
|
||||
@@ -2801,6 +2987,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = { nombre: BRAND_DEFAULT.
|
||||
portal_cliente: PORTAL_CLIENTE_DEFAULT,
|
||||
campanas: CAMPANAS_DEFAULT,
|
||||
encuestas: ENCUESTAS_DEFAULT,
|
||||
tablero: TABLERO_DEFAULT,
|
||||
};
|
||||
|
||||
export interface FrontendUser {
|
||||
@@ -5053,6 +5240,12 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// Tablero de dirección (indicadores del dueño). Solo lectura.
|
||||
async getDireccion(params: { start?: string; end?: string; sucursal?: string; comparar?: TableroComparativo }): Promise<DireccionTablero> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/direccion`, { params });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getNotifications(): Promise<{ status: string; notifications: AppNotification[]; total: number }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/notifications`);
|
||||
return data;
|
||||
|
||||
@@ -1730,6 +1730,134 @@ def get_encuestas(env):
|
||||
"""Bloque de encuestas vigente (copia). Apagado por defecto."""
|
||||
return normalizar_encuestas(get_settings(env).get('encuestas'))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tablero de dirección (indicadores del dueño).
|
||||
#
|
||||
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La clave es
|
||||
# `tablero` (no `direccion`, que ya existe a nivel raíz para la dirección
|
||||
# postal de la clínica). No enciende ni apaga nada por sí solo: son los
|
||||
# PARÁMETROS del tablero y las metas por sucursal. Con los defaults (sin metas,
|
||||
# 90 días de inactividad y 3 visitas mínimas) el tablero funciona y no altera
|
||||
# ninguna cifra existente.
|
||||
#
|
||||
# metas_sucursal: [{sucursal: <code>, meta_mensual: <monto>}]
|
||||
# meta_mensual_global: meta de venta mensual de toda la clínica (0 = sin meta)
|
||||
# dias_inactividad: días sin aparecer para considerar a un cliente "en riesgo"
|
||||
# visitas_minimas_riesgo: visitas históricas mínimas para entrar a "en riesgo"
|
||||
# (un paciente de una sola visita no se considera en riesgo)
|
||||
# comparativo_default: anterior | anio | ninguno (interruptor del tablero)
|
||||
# ---------------------------------------------------------------------------
|
||||
DIRECCION_DEFAULT = {
|
||||
'metas_sucursal': [],
|
||||
'meta_mensual_global': 0.0,
|
||||
'dias_inactividad': 90,
|
||||
'visitas_minimas_riesgo': 3,
|
||||
'comparativo_default': 'anterior',
|
||||
}
|
||||
|
||||
DIRECCION_COMPARATIVOS = ('anterior', 'anio', 'ninguno')
|
||||
DIRECCION_DIAS_MAX = 3650
|
||||
DIRECCION_VISITAS_MAX = 1000
|
||||
DIRECCION_METAS_MAX = 100
|
||||
|
||||
|
||||
def _normalizar_metas_sucursal(raw):
|
||||
"""Lista de metas por sucursal normalizada (una por código)."""
|
||||
out = []
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return out
|
||||
vistos = set()
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
codigo = str(item.get('sucursal') or '').strip()[:40]
|
||||
if not codigo or codigo in vistos:
|
||||
continue
|
||||
vistos.add(codigo)
|
||||
out.append({
|
||||
'sucursal': codigo,
|
||||
'meta_mensual': max(0.0, _num(item.get('meta_mensual'), 0.0)),
|
||||
})
|
||||
return out[:DIRECCION_METAS_MAX]
|
||||
|
||||
|
||||
def normalizar_direccion(raw):
|
||||
"""Bloque `direccion` completo, con defaults. Tolerante: nunca lanza."""
|
||||
out = dict(DIRECCION_DEFAULT)
|
||||
out['metas_sucursal'] = []
|
||||
if not isinstance(raw, dict):
|
||||
return out
|
||||
out['metas_sucursal'] = _normalizar_metas_sucursal(raw.get('metas_sucursal'))
|
||||
out['meta_mensual_global'] = max(0.0, _num(
|
||||
raw.get('meta_mensual_global'), DIRECCION_DEFAULT['meta_mensual_global']))
|
||||
out['dias_inactividad'] = min(max(_int(
|
||||
raw.get('dias_inactividad'), DIRECCION_DEFAULT['dias_inactividad']),
|
||||
1), DIRECCION_DIAS_MAX)
|
||||
out['visitas_minimas_riesgo'] = min(max(_int(
|
||||
raw.get('visitas_minimas_riesgo'), DIRECCION_DEFAULT['visitas_minimas_riesgo']),
|
||||
0), DIRECCION_VISITAS_MAX)
|
||||
comp = str(raw.get('comparativo_default') or '').strip().lower()
|
||||
out['comparativo_default'] = (
|
||||
comp if comp in DIRECCION_COMPARATIVOS else DIRECCION_DEFAULT['comparativo_default'])
|
||||
return out
|
||||
|
||||
|
||||
def validar_direccion(raw):
|
||||
"""Valida el bloque `direccion` de un PUT. Lanza ValueError si algo no
|
||||
cuadra. Devuelve SOLO las claves presentes y normalizadas (merge)."""
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError('El bloque de dirección debe ser un objeto')
|
||||
out = {}
|
||||
if 'metas_sucursal' in raw:
|
||||
valor = raw['metas_sucursal']
|
||||
if not isinstance(valor, list):
|
||||
raise ValueError('Las metas por sucursal deben ser una lista')
|
||||
for item in valor:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError('Cada meta de sucursal debe ser un objeto')
|
||||
if not str(item.get('sucursal') or '').strip():
|
||||
raise ValueError('Cada meta de sucursal necesita una sucursal')
|
||||
n = _num(item.get('meta_mensual'), None)
|
||||
if n is None or n < 0:
|
||||
raise ValueError('La meta mensual debe ser un número no negativo')
|
||||
out['metas_sucursal'] = _normalizar_metas_sucursal(valor)
|
||||
if 'meta_mensual_global' in raw:
|
||||
n = _num(raw['meta_mensual_global'], None)
|
||||
if n is None or n < 0:
|
||||
raise ValueError('La meta mensual global debe ser un número no negativo')
|
||||
out['meta_mensual_global'] = n
|
||||
if 'dias_inactividad' in raw:
|
||||
if isinstance(raw['dias_inactividad'], bool) or not isinstance(raw['dias_inactividad'], int):
|
||||
raise ValueError('Los días de inactividad deben ser un número entero')
|
||||
if not (1 <= raw['dias_inactividad'] <= DIRECCION_DIAS_MAX):
|
||||
raise ValueError('Los días de inactividad deben estar entre 1 y %d' % DIRECCION_DIAS_MAX)
|
||||
out['dias_inactividad'] = raw['dias_inactividad']
|
||||
if 'visitas_minimas_riesgo' in raw:
|
||||
if isinstance(raw['visitas_minimas_riesgo'], bool) or \
|
||||
not isinstance(raw['visitas_minimas_riesgo'], int):
|
||||
raise ValueError('Las visitas mínimas deben ser un número entero')
|
||||
if not (0 <= raw['visitas_minimas_riesgo'] <= DIRECCION_VISITAS_MAX):
|
||||
raise ValueError('Las visitas mínimas deben estar entre 0 y %d'
|
||||
% DIRECCION_VISITAS_MAX)
|
||||
out['visitas_minimas_riesgo'] = raw['visitas_minimas_riesgo']
|
||||
if 'comparativo_default' in raw:
|
||||
comp = str(raw['comparativo_default'] or '').strip().lower()
|
||||
if comp not in DIRECCION_COMPARATIVOS:
|
||||
raise ValueError("El comparativo debe ser 'anterior', 'anio' o 'ninguno'")
|
||||
out['comparativo_default'] = comp
|
||||
return out
|
||||
|
||||
|
||||
def get_direccion(env):
|
||||
"""Bloque del tablero de dirección vigente (copia).
|
||||
|
||||
La clave se llama `tablero` (no `direccion`) para no chocar con el campo
|
||||
`direccion` de nivel raíz, que guarda la DIRECCIÓN POSTAL de la clínica.
|
||||
"""
|
||||
return normalizar_direccion(get_settings(env).get('tablero'))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Licencia por plan (producto comercial).
|
||||
#
|
||||
@@ -2163,6 +2291,7 @@ def get_settings(env, aplicar_licencia=True):
|
||||
'propinas': dict(PROPINAS_DEFAULT),
|
||||
'campanas': dict(CAMPANAS_DEFAULT),
|
||||
'encuestas': dict(ENCUESTAS_DEFAULT),
|
||||
'tablero': normalizar_direccion(None),
|
||||
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
|
||||
'portal_cliente': normalizar_portal_cliente(None),
|
||||
}
|
||||
@@ -2187,6 +2316,7 @@ def get_settings(env, aplicar_licencia=True):
|
||||
out['propinas'] = normalizar_propinas(guardado.get('propinas'))
|
||||
out['campanas'] = normalizar_campanas(guardado.get('campanas'))
|
||||
out['encuestas'] = normalizar_encuestas(guardado.get('encuestas'))
|
||||
out['tablero'] = normalizar_direccion(guardado.get('tablero'))
|
||||
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
|
||||
out['portal_cliente'] = normalizar_portal_cliente(
|
||||
guardado.get('portal_cliente'))
|
||||
@@ -2268,6 +2398,11 @@ def set_settings(env, vals):
|
||||
if bloque is not None:
|
||||
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
||||
actual['encuestas'] = {**actual['encuestas'], **bloque}
|
||||
if 'tablero' in vals:
|
||||
bloque = validar_direccion(vals['tablero'])
|
||||
if bloque is not None:
|
||||
# Merge anidado: un PUT parcial (p. ej. solo las metas) no borra el resto.
|
||||
actual['tablero'] = {**actual['tablero'], **bloque}
|
||||
if 'licencia' in vals:
|
||||
bloque = validar_licencia(vals['licencia'])
|
||||
if bloque is not None:
|
||||
|
||||
@@ -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)
|
||||
# ============================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'.
|
||||
|
||||
Reference in New Issue
Block a user