From a6d6df4ebda63ddfdad1e97ac9fef844850046e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Mon, 12 Oct 2026 02:34:34 +0000 Subject: [PATCH] bloque 4: campanas por segmentos con medicion (modulo skeen_campanas: segmentos reutilizables con conteo verificado, campanas por WhatsApp y correo con programacion, prueba previa, limite por minuto, pausa por errores, consentimiento por canal y atribucion de citas) + encuesta al salir con NPS (campo propio, envio automatico y recordatorio, reporte con tendencia y desglose); apagado por defecto --- docs/producto/configuracion-cliente.md | 113 ++- frontend/src/App.tsx | 25 +- frontend/src/components/CampanasPanel.tsx | 101 +++ frontend/src/components/EncuestasPanel.tsx | 131 ++++ frontend/src/components/HomeNestLayout.tsx | 18 +- frontend/src/components/SkeenLayout.tsx | 19 +- .../components/formulario/CampoFormulario.tsx | 27 + frontend/src/components/formulario/tipos.ts | 8 + frontend/src/lib/auth.tsx | 4 + frontend/src/lib/clinic-settings.ts | 37 +- frontend/src/lib/i18n.tsx | 244 ++++++ frontend/src/lib/menu-items.ts | 2 + frontend/src/lib/permissions.ts | 7 + frontend/src/pages/Campanas.tsx | 730 ++++++++++++++++++ frontend/src/pages/Configuracion.tsx | 20 +- frontend/src/pages/FormularioPublico.tsx | 3 + frontend/src/pages/Satisfaccion.tsx | 309 ++++++++ frontend/src/services/odoo.ts | 342 +++++++- odoo-addons/skeen_campanas/__init__.py | 4 + odoo-addons/skeen_campanas/__manifest__.py | 40 + .../skeen_campanas/controllers/__init__.py | 4 + .../skeen_campanas/controllers/campanas.py | 576 ++++++++++++++ .../skeen_campanas/controllers/encuestas.py | 258 +++++++ .../skeen_campanas/data/campanas_cron.xml | 31 + odoo-addons/skeen_campanas/models/__init__.py | 6 + odoo-addons/skeen_campanas/models/campana.py | 561 ++++++++++++++ .../skeen_campanas/models/consentimiento.py | 63 ++ odoo-addons/skeen_campanas/models/encuesta.py | 255 ++++++ odoo-addons/skeen_campanas/models/segmento.py | 362 +++++++++ .../security/ir.model.access.csv | 3 + odoo-addons/skeen_citas/clinic_settings.py | 209 +++++ .../skeen_formularios/controllers/comun.py | 18 + .../controllers/public_formulario.py | 16 +- .../models/formulario_plantilla.py | 2 +- .../skeen_whatsapp/models/notificacion.py | 84 +- odoo-addons/skeen_whatsapp/permissions.py | 24 + odoo-addons/skeen_whatsapp/roles.py | 7 +- 37 files changed, 4619 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/CampanasPanel.tsx create mode 100644 frontend/src/components/EncuestasPanel.tsx create mode 100644 frontend/src/pages/Campanas.tsx create mode 100644 frontend/src/pages/Satisfaccion.tsx create mode 100644 odoo-addons/skeen_campanas/__init__.py create mode 100644 odoo-addons/skeen_campanas/__manifest__.py create mode 100644 odoo-addons/skeen_campanas/controllers/__init__.py create mode 100644 odoo-addons/skeen_campanas/controllers/campanas.py create mode 100644 odoo-addons/skeen_campanas/controllers/encuestas.py create mode 100644 odoo-addons/skeen_campanas/data/campanas_cron.xml create mode 100644 odoo-addons/skeen_campanas/models/__init__.py create mode 100644 odoo-addons/skeen_campanas/models/campana.py create mode 100644 odoo-addons/skeen_campanas/models/consentimiento.py create mode 100644 odoo-addons/skeen_campanas/models/encuesta.py create mode 100644 odoo-addons/skeen_campanas/models/segmento.py create mode 100644 odoo-addons/skeen_campanas/security/ir.model.access.csv diff --git a/docs/producto/configuracion-cliente.md b/docs/producto/configuracion-cliente.md index 1ac6fb0..b2191bd 100644 --- a/docs/producto/configuracion-cliente.md +++ b/docs/producto/configuracion-cliente.md @@ -503,10 +503,12 @@ formularios. requerir firma y/o foto, duración estimada y asociación opcional a sucursales y servicios. Las plantillas se **versionan**: cada respuesta guarda la versión con la que se llenó. -- **Tipos de campo** (12): texto, texto largo, número, fecha, selección, - multiselección, casilla, escala, firma, foto, archivo y consentimiento. Los - campos de foto/archivo pueden marcarse como antes / después / otro, lo que - arma la **galería antes/después** del expediente. +- **Tipos de campo** (13): texto, texto largo, número, fecha, selección, + multiselección, casilla, escala, **recomendación (NPS 0-10)**, firma, foto, + archivo y consentimiento. Los campos de foto/archivo pueden marcarse como + antes / después / otro, lo que arma la **galería antes/después** del + expediente. El campo de recomendación (NPS) es un tipo propio: se puede + analizar sin depender del texto (ver la sección de encuestas de salida). - **Envío**: por WhatsApp, correo o enlace. El enlace es un token firmado (HMAC, vigencia 180 días) y deja de servir al llenarse. - **Llenado por el paciente**: página pública `/formulario/` (también @@ -515,6 +517,104 @@ formularios. y fotos requeridas. - **Historial**: respuestas por paciente con canal, fecha, firma y fotos. +## 14 bis. Campañas por segmentos — apagado por defecto + +**Configuración → Campañas** (interruptor) y **Campañas** (menú, sección +"Comunicación"), con dos pestañas: **Campañas** y **Segmentos**. Con el bloque +apagado (`campanas.activo = false`, el default) no aparece ninguna pantalla ni +enlace, no corre ningún proceso automático y no se envía nada. + +- **Segmentos** (`skeen.segmento`): un segmento guarda nombre, criterios y una + **previsualización** (cuántos pacientes y una muestra de 5) que se calcula + *antes* de usarlo. Criterios disponibles (todos opcionales): + - última visita hace más de N días, o sin visita en N meses (incluye a quien + nunca ha venido); + - cumpleaños del mes en curso o en los próximos N días; + - nivel de lealtad; + - gasto acumulado (suma de ventas no canceladas) mayor o igual a X; + - sucursal (con al menos una cita ahí); + - servicio que **sí** ha tomado y servicio que **nunca** ha tomado; + - con o sin membresía activa; + - con o sin paquetes con sesiones disponibles; + - con o sin saldo pendiente; + - con o sin consentimiento de marketing. + La evaluación corre en el servidor y su conteo coincide con lo que responde + la base de datos. +- **Campañas** (`skeen.campana`): nombre, segmento, canal (WhatsApp, correo o + ambos), mensaje con variables (`{nombre}`, `{servicio}`, `{sucursal}`, + `{enlace}`, `{doctor}`, `{fecha}`, `{hora}`, `{telefono}`), programación + (una sola vez en una fecha, mensual, semanal o **según el propio segmento**, + p. ej. cumpleaños) y estado (borrador, programada, en ejecución, pausada, + terminada, cancelada). +- **Envío de prueba**: manda el mensaje a un número o correo propio antes de + lanzar. Queda registrado en la bitácora marcado como prueba (no cuenta en la + medición). +- **Medición por campaña**: pacientes alcanzados, enviados, errores y + pendientes; **citas generadas como consecuencia** (atribuidas por paciente y + dentro de una **ventana configurable**, por defecto 7 días) y su valor en + dinero; y la tasa de conversión. Lo que no se puede medir con lo que hay + (entrega real y respuestas del paciente) se muestra como **no disponible** en + lugar de inventarse. +- **Cuidado de WhatsApp**: **límite de envíos por minuto** configurable, + **pausa automática** si se acumulan errores seguidos y **respeto al + consentimiento** (ver abajo). +- **Opciones del bloque** (defaults): `activo` (no), `envios_por_minuto` (20), + `pausar_tras_errores` (5), `ventana_atribucion_dias` (7), + `exigir_consentimiento` (sí) y `hora_envio` (`10:00`, para las recurrentes). + +### 14 bis.1 Consentimiento de marketing por canal + +En el paciente (`res.partner`) hay un interruptor **general** y uno por canal: + +- `consentimiento_marketing` (general), `consentimiento_whatsapp` y + `consentimiento_email`, más la fecha y el origen del consentimiento. + +Un paciente **sin consentimiento de marketing nunca recibe una campaña**, aunque +cumpla el criterio del segmento. Los **avisos transaccionales** (recordatorio de +cita, confirmación, encuesta de salida) **no dependen** de esto: son parte del +servicio que el paciente ya contrató. El consentimiento nace **apagado**. + +### 14 bis.2 Permisos + +- `campanas.ver`: ver campañas y segmentos (por defecto: todos los roles). +- `campanas.gestionar`: crear y editar campañas y segmentos (admin, gerencia y + marketing). +- `campanas.enviar`: lanzar campañas y enviar pruebas (admin, gerencia y + marketing). +- `campanas.medir`: ver la medición (por defecto: todos los roles). + +## 14 ter. Encuestas de salida y satisfacción (NPS) — apagado por defecto + +**Configuración → Encuestas** (interruptor) y **Satisfacción** (menú). Con el +bloque apagado (`encuestas.activo = false`, el default) no se envía ninguna +encuesta tras completar una cita y la pantalla de Satisfacción no aparece. + +- **Envío automático**: cuando una cita pasa a completada, tras **N horas** + (default 2) se envía al paciente la encuesta configurada, por WhatsApp, correo + o ambos. Hay **recordatorio opcional** si no responde (default 48 h) y una + opción de **enviarla solo a la primera visita**. +- **Captura**: reutiliza la página pública de formularios `/formulario/` + (no hay otro mecanismo de captura). El mensaje de agradecimiento es + configurable. +- **Pregunta de recomendación (NPS)**: tipo de campo propio `nps` (puntaje de 0 a + 10) más un comentario abierto (texto largo). Se calcula de forma estándar: + **promotores 9-10**, pasivos 7-8, **detractores 0-6**, y + **NPS = % promotores − % detractores** (de −100 a 100). +- **Reporte de satisfacción**: resultado del periodo, **tendencia por mes**, + desglose por **sucursal, médico y servicio**, y la lista de respuestas con su + comentario y un enlace a la ficha del paciente para dar seguimiento (incluida + la posibilidad de **responderle por WhatsApp** desde ahí). Exportable a Excel. +- **Opciones del bloque** (defaults): `activo` (no), `enviar_tras_horas` (2), + `plantilla_id` (ninguna), `canal` (`whatsapp`), `recordatorio_horas` (48), + `solo_primera_visita` (no) y `agradecimiento` (vacío). +- **Permisos**: `encuestas.ver` y `encuestas.medir` (todos los roles); + `encuestas.gestionar` (admin y gerencia). + +Ambas piezas **pasan por el motor de notificaciones**: nada se envía en silencio, +todo respeta el **modo prueba** y queda en la bitácora con su campaña o encuesta +de origen. + + ## 15. Tickets y documentos **Configuración → Tickets**: ancho (58/80 mm), fuente, logo y qué secciones se @@ -600,6 +700,9 @@ El panel es una **aplicación web instalable** (PWA), sin tienda de aplicaciones - [ ] Compras y proveedores (si controla abasto y margen). - [ ] Comisiones (reglas, metas y liquidaciones). - [ ] Formularios y consentimientos. +- [ ] Campañas por segmentos (si hará marketing): activar y registrar el + consentimiento de los pacientes. +- [ ] Encuestas de salida: crear la plantilla con la pregunta NPS y activar. - [ ] Reservas en línea (activar y difundir el enlace). - [ ] Portal del cliente (si se ofrece cuenta): activar y difundir `/mi-cuenta`. - [ ] Notificaciones en modo prueba → revisar textos → encender. @@ -628,6 +731,8 @@ Todas las fases están **entregadas**. | 3.5 | Licenciamiento por plan, API keys gestionables y webhooks | Entregado | | 3.6 | i18n español/inglés | Entregado | | 3.7 | Cuenta del cliente (portal de autoservicio) | Entregado | +| 3.8 | Campañas por segmentos con medición y consentimiento de marketing | Entregado | +| 3.9 | Encuestas de salida y satisfacción (NPS) | Entregado | | Operación | Backups automáticos verificados y panel/CLI multi-instancia | Entregado | Los pendientes y limitaciones conocidos (los que aún no están cerrados) viven en diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f0c814e..09c4709 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,7 +13,7 @@ import { RecursosProvider } from './lib/recursos'; import { QuickLinksProvider } from './lib/quicklinks'; import { PatientDrawerProvider } from './components/PatientDrawer'; import ChatNotifications from './lib/chat-notifications'; -import { useClinicSettings } from './lib/clinic-settings'; +import { useClinicSettings, useClinicSettingsCargado } from './lib/clinic-settings'; import Dashboard from './pages/Dashboard'; import Agenda from './pages/Agenda'; import Pacientes from './pages/Pacientes'; @@ -32,6 +32,8 @@ import Cortes from './pages/Cortes'; import Reportes from './pages/Reportes'; import Configuracion from './pages/Configuracion'; import Cumpleanos from './pages/Cumpleanos'; +import Campanas from './pages/Campanas'; +import Satisfaccion from './pages/Satisfaccion'; import Inventario from './pages/Inventario'; import Compras from './pages/Compras'; import Liquidaciones from './pages/Liquidaciones'; @@ -146,6 +148,24 @@ const LiquidacionesRoute: FC = () => { return ; }; +// Campañas vive detrás de `campanas.activo`. Apagado, la ruta rebota a "/". +const CampanasRoute: FC = () => { + const { campanas } = useClinicSettings(); + const listo = useClinicSettingsCargado(); + if (!listo) return ; + if (!campanas.activo) return ; + return ; +}; + +// Satisfacción (encuestas de salida) vive detrás de `encuestas.activo`. +const SatisfaccionRoute: FC = () => { + const { encuestas } = useClinicSettings(); + const listo = useClinicSettingsCargado(); + if (!listo) return ; + if (!encuestas.activo) return ; + return ; +}; + const HomeRoute: FC = () => { const { user, canSee } = useAuth(); const { t } = useI18n(); @@ -214,6 +234,9 @@ const App: FC = () => { } /> } /> } /> + {/* Campañas y Satisfacción: solo con campanas.activo / encuestas.activo. */} + } /> + } /> } /> {/* Compras solo cuando la clínica la activa (compras.activo). */} } /> diff --git a/frontend/src/components/CampanasPanel.tsx b/frontend/src/components/CampanasPanel.tsx new file mode 100644 index 0000000..8c462ac --- /dev/null +++ b/frontend/src/components/CampanasPanel.tsx @@ -0,0 +1,101 @@ +import type { FC } from 'react'; +import { useEffect, useState } from 'react'; +import { Save, Megaphone } from 'lucide-react'; +import { Button, Input, toast } from './ui'; +import { + odooApi, CAMPANAS_DEFAULT, type ClinicSettings, type CampanasSettings, +} from '../services/odoo'; + +interface Props { + settings: ClinicSettings; + onSaved: (settings: ClinicSettings) => void; +} + +const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => ( + +); + +// Configuración → Campañas: bloque `campanas`. Apagado (default) no aparece la +// sección, no corre el cron y no se envía nada. +const CampanasPanel: FC = ({ settings, onSaved }) => { + const [form, setForm] = useState(settings.campanas || CAMPANAS_DEFAULT); + const [saving, setSaving] = useState(false); + + useEffect(() => setForm(settings.campanas || CAMPANAS_DEFAULT), [settings.campanas]); + + const set = (k: K, v: CampanasSettings[K]) => + setForm((prev) => ({ ...prev, [k]: v })); + + const guardar = async () => { + setSaving(true); + try { + const res = await odooApi.putClinicSettings({ + campanas: { ...form }, motivo: 'Configuración de campañas', + }); + if (res.status === 'success') { + onSaved(res.settings); + toast.success('Configuración de campañas guardada'); + } + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'No se pudo guardar la configuración de campañas'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+

+ Campañas por segmentos +

+

+ Apagado (default) no existe la sección Campañas, no corre ningún proceso y no se envía + nada. Encendido, aparecen las pantallas de Campañas y Segmentos. +

+
+ +
+ +
+ set('activo', v)} + ayuda="Muestra la sección Campañas y habilita el proceso automático cada 5 minutos." /> + set('exigir_consentimiento', v)} + ayuda="Un paciente sin consentimiento nunca recibe una campaña (los avisos de cita sí siguen)." /> + +
+ set('envios_por_minuto', Number(e.target.value) || 1)} /> + set('pausar_tras_errores', Number(e.target.value) || 1)} /> + set('ventana_atribucion_dias', Number(e.target.value) || 1)} /> + set('hora_envio', e.target.value)} /> +
+
+
+ ); +}; + +export default CampanasPanel; diff --git a/frontend/src/components/EncuestasPanel.tsx b/frontend/src/components/EncuestasPanel.tsx new file mode 100644 index 0000000..8c94d66 --- /dev/null +++ b/frontend/src/components/EncuestasPanel.tsx @@ -0,0 +1,131 @@ +import type { FC } from 'react'; +import { useEffect, useState } from 'react'; +import { Save, Smile } from 'lucide-react'; +import { Button, Input, TextArea, toast } from './ui'; +import { + odooApi, ENCUESTAS_DEFAULT, type ClinicSettings, type EncuestasSettings, +} from '../services/odoo'; + +interface Props { + settings: ClinicSettings; + onSaved: (settings: ClinicSettings) => void; +} + +const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => ( + +); + +// Configuración → Encuestas: bloque `encuestas`. Apagado (default) no se envía +// ninguna encuesta tras completar una cita. +const EncuestasPanel: FC = ({ settings, onSaved }) => { + const [form, setForm] = useState(settings.encuestas || ENCUESTAS_DEFAULT); + const [plantillas, setPlantillas] = useState<{ id: number; name: string; activo: boolean; tiene_nps: boolean }[]>([]); + const [saving, setSaving] = useState(false); + + useEffect(() => setForm(settings.encuestas || ENCUESTAS_DEFAULT), [settings.encuestas]); + useEffect(() => { + odooApi.getEncuestasConfig() + .then((res) => { if (res.status === 'success') setPlantillas(res.plantillas || []); }) + .catch(() => {}); + }, []); + + const set = (k: K, v: EncuestasSettings[K]) => + setForm((prev) => ({ ...prev, [k]: v })); + + const guardar = async () => { + setSaving(true); + try { + const res = await odooApi.putClinicSettings({ + encuestas: { ...form }, motivo: 'Configuración de encuestas', + }); + if (res.status === 'success') { + onSaved(res.settings); + toast.success('Configuración de encuestas guardada'); + } + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'No se pudo guardar la configuración de encuestas'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+

+ Encuestas de salida (NPS) +

+

+ Apagado (default) no se envía ninguna encuesta al completar una cita. Crea la plantilla + (tipo Encuesta) con una pregunta de recomendación (0-10) en Formularios. +

+
+ +
+ +
+ set('activo', v)} + ayuda="Envía la encuesta tras completarse una cita y habilita el reporte de Satisfacción." /> + set('solo_primera_visita', v)} + ayuda="Solo se envía a quien tiene una única visita completada." /> + +
+
+ + +
+
+ + +
+ set('enviar_tras_horas', Number(e.target.value) || 0)} /> + set('recordatorio_horas', Number(e.target.value) || 0)} /> +
+ +