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
This commit is contained in:
@@ -503,10 +503,12 @@ formularios.
|
|||||||
requerir firma y/o foto, duración estimada y asociación opcional a sucursales
|
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
|
y servicios. Las plantillas se **versionan**: cada respuesta guarda la versión
|
||||||
con la que se llenó.
|
con la que se llenó.
|
||||||
- **Tipos de campo** (12): texto, texto largo, número, fecha, selección,
|
- **Tipos de campo** (13): texto, texto largo, número, fecha, selección,
|
||||||
multiselección, casilla, escala, firma, foto, archivo y consentimiento. Los
|
multiselección, casilla, escala, **recomendación (NPS 0-10)**, firma, foto,
|
||||||
campos de foto/archivo pueden marcarse como antes / después / otro, lo que
|
archivo y consentimiento. Los campos de foto/archivo pueden marcarse como
|
||||||
arma la **galería antes/después** del expediente.
|
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,
|
- **Envío**: por WhatsApp, correo o enlace. El enlace es un token firmado (HMAC,
|
||||||
vigencia 180 días) y deja de servir al llenarse.
|
vigencia 180 días) y deja de servir al llenarse.
|
||||||
- **Llenado por el paciente**: página pública `/formulario/<token>` (también
|
- **Llenado por el paciente**: página pública `/formulario/<token>` (también
|
||||||
@@ -515,6 +517,104 @@ formularios.
|
|||||||
y fotos requeridas.
|
y fotos requeridas.
|
||||||
- **Historial**: respuestas por paciente con canal, fecha, firma y fotos.
|
- **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/<token>`
|
||||||
|
(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
|
## 15. Tickets y documentos
|
||||||
|
|
||||||
**Configuración → Tickets**: ancho (58/80 mm), fuente, logo y qué secciones se
|
**Configuración → Tickets**: ancho (58/80 mm), fuente, logo y qué secciones se
|
||||||
@@ -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).
|
- [ ] Compras y proveedores (si controla abasto y margen).
|
||||||
- [ ] Comisiones (reglas, metas y liquidaciones).
|
- [ ] Comisiones (reglas, metas y liquidaciones).
|
||||||
- [ ] Formularios y consentimientos.
|
- [ ] 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).
|
- [ ] Reservas en línea (activar y difundir el enlace).
|
||||||
- [ ] Portal del cliente (si se ofrece cuenta): activar y difundir `/mi-cuenta`.
|
- [ ] Portal del cliente (si se ofrece cuenta): activar y difundir `/mi-cuenta`.
|
||||||
- [ ] Notificaciones en modo prueba → revisar textos → encender.
|
- [ ] 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.5 | Licenciamiento por plan, API keys gestionables y webhooks | Entregado |
|
||||||
| 3.6 | i18n español/inglés | Entregado |
|
| 3.6 | i18n español/inglés | Entregado |
|
||||||
| 3.7 | Cuenta del cliente (portal de autoservicio) | 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 |
|
| 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
|
Los pendientes y limitaciones conocidos (los que aún no están cerrados) viven en
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { RecursosProvider } from './lib/recursos';
|
|||||||
import { QuickLinksProvider } from './lib/quicklinks';
|
import { QuickLinksProvider } from './lib/quicklinks';
|
||||||
import { PatientDrawerProvider } from './components/PatientDrawer';
|
import { PatientDrawerProvider } from './components/PatientDrawer';
|
||||||
import ChatNotifications from './lib/chat-notifications';
|
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 Dashboard from './pages/Dashboard';
|
||||||
import Agenda from './pages/Agenda';
|
import Agenda from './pages/Agenda';
|
||||||
import Pacientes from './pages/Pacientes';
|
import Pacientes from './pages/Pacientes';
|
||||||
@@ -32,6 +32,8 @@ import Cortes from './pages/Cortes';
|
|||||||
import Reportes from './pages/Reportes';
|
import Reportes from './pages/Reportes';
|
||||||
import Configuracion from './pages/Configuracion';
|
import Configuracion from './pages/Configuracion';
|
||||||
import Cumpleanos from './pages/Cumpleanos';
|
import Cumpleanos from './pages/Cumpleanos';
|
||||||
|
import Campanas from './pages/Campanas';
|
||||||
|
import Satisfaccion from './pages/Satisfaccion';
|
||||||
import Inventario from './pages/Inventario';
|
import Inventario from './pages/Inventario';
|
||||||
import Compras from './pages/Compras';
|
import Compras from './pages/Compras';
|
||||||
import Liquidaciones from './pages/Liquidaciones';
|
import Liquidaciones from './pages/Liquidaciones';
|
||||||
@@ -146,6 +148,24 @@ const LiquidacionesRoute: FC = () => {
|
|||||||
return <Liquidaciones />;
|
return <Liquidaciones />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 <FullScreenLoader />;
|
||||||
|
if (!campanas.activo) return <Navigate to="/" replace />;
|
||||||
|
return <Campanas />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Satisfacción (encuestas de salida) vive detrás de `encuestas.activo`.
|
||||||
|
const SatisfaccionRoute: FC = () => {
|
||||||
|
const { encuestas } = useClinicSettings();
|
||||||
|
const listo = useClinicSettingsCargado();
|
||||||
|
if (!listo) return <FullScreenLoader />;
|
||||||
|
if (!encuestas.activo) return <Navigate to="/" replace />;
|
||||||
|
return <Satisfaccion />;
|
||||||
|
};
|
||||||
|
|
||||||
const HomeRoute: FC = () => {
|
const HomeRoute: FC = () => {
|
||||||
const { user, canSee } = useAuth();
|
const { user, canSee } = useAuth();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -214,6 +234,9 @@ const App: FC = () => {
|
|||||||
<Route path="/configuracion" element={<RequireAuth href="/configuracion"><Configuracion /></RequireAuth>} />
|
<Route path="/configuracion" element={<RequireAuth href="/configuracion"><Configuracion /></RequireAuth>} />
|
||||||
<Route path="/usuarios" element={<Navigate to="/configuracion?tab=usuarios" replace />} />
|
<Route path="/usuarios" element={<Navigate to="/configuracion?tab=usuarios" replace />} />
|
||||||
<Route path="/cumpleanos" element={<RequireAuth href="/cumpleanos"><Cumpleanos /></RequireAuth>} />
|
<Route path="/cumpleanos" element={<RequireAuth href="/cumpleanos"><Cumpleanos /></RequireAuth>} />
|
||||||
|
{/* Campañas y Satisfacción: solo con campanas.activo / encuestas.activo. */}
|
||||||
|
<Route path="/campanas" element={<RequireAuth href="/campanas"><CampanasRoute /></RequireAuth>} />
|
||||||
|
<Route path="/satisfaccion" element={<RequireAuth href="/satisfaccion"><SatisfaccionRoute /></RequireAuth>} />
|
||||||
<Route path="/inventario" element={<RequireAuth href="/inventario"><Inventario /></RequireAuth>} />
|
<Route path="/inventario" element={<RequireAuth href="/inventario"><Inventario /></RequireAuth>} />
|
||||||
{/* Compras solo cuando la clínica la activa (compras.activo). */}
|
{/* Compras solo cuando la clínica la activa (compras.activo). */}
|
||||||
<Route path="/compras" element={<RequireAuth href="/compras"><ComprasRoute /></RequireAuth>} />
|
<Route path="/compras" element={<RequireAuth href="/compras"><ComprasRoute /></RequireAuth>} />
|
||||||
|
|||||||
101
frontend/src/components/CampanasPanel.tsx
Normal file
101
frontend/src/components/CampanasPanel.tsx
Normal file
@@ -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 }) => (
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-theme-heading">{label}</span>
|
||||||
|
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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<Props> = ({ settings, onSaved }) => {
|
||||||
|
const [form, setForm] = useState<CampanasSettings>(settings.campanas || CAMPANAS_DEFAULT);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => setForm(settings.campanas || CAMPANAS_DEFAULT), [settings.campanas]);
|
||||||
|
|
||||||
|
const set = <K extends keyof CampanasSettings>(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 (
|
||||||
|
<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">
|
||||||
|
<Megaphone size={18} /> Campañas por segmentos
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-theme-muted mt-1">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={guardar} loading={saving}><Save size={16} className="mr-1.5" />Guardar</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Toggle label="Activar campañas" checked={form.activo} onChange={(v) => set('activo', v)}
|
||||||
|
ayuda="Muestra la sección Campañas y habilita el proceso automático cada 5 minutos." />
|
||||||
|
<Toggle label="Exigir consentimiento de marketing" checked={form.exigir_consentimiento}
|
||||||
|
onChange={(v) => set('exigir_consentimiento', v)}
|
||||||
|
ayuda="Un paciente sin consentimiento nunca recibe una campaña (los avisos de cita sí siguen)." />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<Input type="number" min={1} max={600} label="Envíos por minuto"
|
||||||
|
value={form.envios_por_minuto}
|
||||||
|
onChange={(e) => set('envios_por_minuto', Number(e.target.value) || 1)} />
|
||||||
|
<Input type="number" min={1} max={100} label="Errores seguidos para pausar"
|
||||||
|
value={form.pausar_tras_errores}
|
||||||
|
onChange={(e) => set('pausar_tras_errores', Number(e.target.value) || 1)} />
|
||||||
|
<Input type="number" min={1} max={90} label="Ventana de atribución (días)"
|
||||||
|
value={form.ventana_atribucion_dias}
|
||||||
|
onChange={(e) => set('ventana_atribucion_dias', Number(e.target.value) || 1)} />
|
||||||
|
<Input type="time" label="Hora de envío (recurrentes)" value={form.hora_envio}
|
||||||
|
onChange={(e) => set('hora_envio', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CampanasPanel;
|
||||||
131
frontend/src/components/EncuestasPanel.tsx
Normal file
131
frontend/src/components/EncuestasPanel.tsx
Normal file
@@ -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 }) => (
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-theme-heading">{label}</span>
|
||||||
|
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Configuración → Encuestas: bloque `encuestas`. Apagado (default) no se envía
|
||||||
|
// ninguna encuesta tras completar una cita.
|
||||||
|
const EncuestasPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||||
|
const [form, setForm] = useState<EncuestasSettings>(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 extends keyof EncuestasSettings>(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 (
|
||||||
|
<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">
|
||||||
|
<Smile size={18} /> Encuestas de salida (NPS)
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-theme-muted mt-1">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={guardar} loading={saving}><Save size={16} className="mr-1.5" />Guardar</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Toggle label="Activar encuestas de salida" checked={form.activo} onChange={(v) => set('activo', v)}
|
||||||
|
ayuda="Envía la encuesta tras completarse una cita y habilita el reporte de Satisfacción." />
|
||||||
|
<Toggle label="Enviar solo a la primera visita" checked={form.solo_primera_visita}
|
||||||
|
onChange={(v) => set('solo_primera_visita', v)}
|
||||||
|
ayuda="Solo se envía a quien tiene una única visita completada." />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1.5">Plantilla de encuesta</label>
|
||||||
|
<select
|
||||||
|
value={form.plantilla_id == null ? '' : String(form.plantilla_id)}
|
||||||
|
onChange={(e) => set('plantilla_id', e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading"
|
||||||
|
>
|
||||||
|
<option value="">— Ninguna —</option>
|
||||||
|
{plantillas.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}{p.tiene_nps ? ' · NPS' : ''}{p.activo ? '' : ' (inactiva)'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1.5">Canal</label>
|
||||||
|
<select value={form.canal} onChange={(e) => set('canal', e.target.value as EncuestasSettings['canal'])}
|
||||||
|
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading">
|
||||||
|
<option value="whatsapp">WhatsApp</option>
|
||||||
|
<option value="email">Correo</option>
|
||||||
|
<option value="ambos">Ambos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Input type="number" min={0} max={720} label="Enviar tras (horas de completada la cita)"
|
||||||
|
value={form.enviar_tras_horas}
|
||||||
|
onChange={(e) => set('enviar_tras_horas', Number(e.target.value) || 0)} />
|
||||||
|
<Input type="number" min={0} max={720} label="Recordatorio si no responde (horas, 0 = sin recordatorio)"
|
||||||
|
value={form.recordatorio_horas}
|
||||||
|
onChange={(e) => set('recordatorio_horas', Number(e.target.value) || 0)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextArea rows={3} label="Agradecimiento (se muestra al terminar la encuesta)"
|
||||||
|
value={form.agradecimiento}
|
||||||
|
onChange={(e) => set('agradecimiento', e.target.value)}
|
||||||
|
placeholder="¡Gracias por tu opinión!" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EncuestasPanel;
|
||||||
@@ -38,6 +38,8 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Repeat,
|
Repeat,
|
||||||
Truck,
|
Truck,
|
||||||
|
Megaphone,
|
||||||
|
Smile,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button, Modal, Input } from './ui';
|
import { Button, Modal, Input } from './ui';
|
||||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||||
@@ -118,6 +120,8 @@ const MENU_SECTIONS = [
|
|||||||
const HOME_MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
|
const HOME_MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
|
||||||
const HOME_COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
|
const HOME_COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
|
||||||
const HOME_COMISIONES_ITEM = { label: 'Liquidaciones', icon: Percent, href: '/comisiones/liquidaciones' };
|
const HOME_COMISIONES_ITEM = { label: 'Liquidaciones', icon: Percent, href: '/comisiones/liquidaciones' };
|
||||||
|
const HOME_CAMPANAS_ITEM = { label: 'Campañas', icon: Megaphone, href: '/campanas' };
|
||||||
|
const HOME_SATISFACCION_ITEM = { label: 'Satisfacción', icon: Smile, href: '/satisfaccion' };
|
||||||
|
|
||||||
export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
|
export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -126,7 +130,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
|||||||
const { term } = useGlosario();
|
const { term } = useGlosario();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const navLabel = useNavLabel();
|
const navLabel = useNavLabel();
|
||||||
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
|
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg, campanas: campanasCfg, encuestas: encuestasCfg } = useClinicSettings();
|
||||||
const topNavItems = useMemo(
|
const topNavItems = useMemo(
|
||||||
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: navLabel(traducirEtiqueta(it.label, term)) })),
|
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: navLabel(traducirEtiqueta(it.label, term)) })),
|
||||||
[term, navLabel]
|
[term, navLabel]
|
||||||
@@ -148,6 +152,16 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
|||||||
? { ...s, items: [...s.items, HOME_COMISIONES_ITEM] }
|
? { ...s, items: [...s.items, HOME_COMISIONES_ITEM] }
|
||||||
: s));
|
: s));
|
||||||
}
|
}
|
||||||
|
if (campanasCfg.activo) {
|
||||||
|
base = base.map((s) => (s.label === 'Analítica y más'
|
||||||
|
? { ...s, items: [...s.items, HOME_CAMPANAS_ITEM] }
|
||||||
|
: s));
|
||||||
|
}
|
||||||
|
if (encuestasCfg.activo) {
|
||||||
|
base = base.map((s) => (s.label === 'Analítica y más'
|
||||||
|
? { ...s, items: [...s.items, HOME_SATISFACCION_ITEM] }
|
||||||
|
: s));
|
||||||
|
}
|
||||||
// Licencia en modo bloqueo sin WhatsApp: se ocultan el chat y la sección WACRM.
|
// Licencia en modo bloqueo sin WhatsApp: se ocultan el chat y la sección WACRM.
|
||||||
if (!licenciaModuloIncluido(licenciaCfg, 'whatsapp')) {
|
if (!licenciaModuloIncluido(licenciaCfg, 'whatsapp')) {
|
||||||
base = base
|
base = base
|
||||||
@@ -163,7 +177,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
|||||||
label: navLabel(s.label),
|
label: navLabel(s.label),
|
||||||
items: s.items.map((it) => ({ ...it, label: navLabel(it.label) })),
|
items: s.items.map((it) => ({ ...it, label: navLabel(it.label) })),
|
||||||
}));
|
}));
|
||||||
}, [term, navLabel, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
|
}, [term, navLabel, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, campanasCfg.activo, encuestasCfg.activo, licenciaCfg]);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
||||||
const chatUnread = useChatUnread();
|
const chatUnread = useChatUnread();
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Repeat,
|
Repeat,
|
||||||
Truck,
|
Truck,
|
||||||
|
Megaphone,
|
||||||
|
Smile,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button, Modal, Input } from './ui';
|
import { Button, Modal, Input } from './ui';
|
||||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||||
@@ -131,6 +133,9 @@ const MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias'
|
|||||||
const COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
|
const COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
|
||||||
// La sección Liquidaciones solo existe con `comisiones.activo=true`.
|
// La sección Liquidaciones solo existe con `comisiones.activo=true`.
|
||||||
const COMISIONES_ITEM = { label: 'Liquidaciones', icon: Percent, href: '/comisiones/liquidaciones' };
|
const COMISIONES_ITEM = { label: 'Liquidaciones', icon: Percent, href: '/comisiones/liquidaciones' };
|
||||||
|
// Campañas y Satisfacción solo existen con `campanas.activo` / `encuestas.activo`.
|
||||||
|
const CAMPANAS_ITEM = { label: 'Campañas', icon: Megaphone, href: '/campanas' };
|
||||||
|
const SATISFACCION_ITEM = { label: 'Satisfacción', icon: Smile, href: '/satisfaccion' };
|
||||||
|
|
||||||
export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -139,7 +144,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
const { term } = useGlosario();
|
const { term } = useGlosario();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const navLabel = useNavLabel();
|
const navLabel = useNavLabel();
|
||||||
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
|
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg, campanas: campanasCfg, encuestas: encuestasCfg } = useClinicSettings();
|
||||||
const menuGroupsT = useMemo(() => {
|
const menuGroupsT = useMemo(() => {
|
||||||
let base = menuGroups;
|
let base = menuGroups;
|
||||||
if (membresiasCfg.activo) {
|
if (membresiasCfg.activo) {
|
||||||
@@ -157,6 +162,16 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
? { ...g, items: [...g.items, COMISIONES_ITEM] }
|
? { ...g, items: [...g.items, COMISIONES_ITEM] }
|
||||||
: g));
|
: g));
|
||||||
}
|
}
|
||||||
|
if (campanasCfg.activo) {
|
||||||
|
base = base.map((g) => (g.label === 'Comunicación'
|
||||||
|
? { ...g, items: [...g.items, CAMPANAS_ITEM] }
|
||||||
|
: g));
|
||||||
|
}
|
||||||
|
if (encuestasCfg.activo) {
|
||||||
|
base = base.map((g) => (g.label === 'Analítica'
|
||||||
|
? { ...g, items: [...g.items, SATISFACCION_ITEM] }
|
||||||
|
: g));
|
||||||
|
}
|
||||||
// Licencia en modo bloqueo sin WhatsApp: se ocultan el chat y la sección WACRM.
|
// Licencia en modo bloqueo sin WhatsApp: se ocultan el chat y la sección WACRM.
|
||||||
if (!licenciaModuloIncluido(licenciaCfg, 'whatsapp')) {
|
if (!licenciaModuloIncluido(licenciaCfg, 'whatsapp')) {
|
||||||
base = base
|
base = base
|
||||||
@@ -169,7 +184,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
label: navLabel(g.label),
|
label: navLabel(g.label),
|
||||||
items: g.items.map((it) => ({ ...it, label: navLabel(it.label) })),
|
items: g.items.map((it) => ({ ...it, label: navLabel(it.label) })),
|
||||||
}));
|
}));
|
||||||
}, [term, navLabel, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
|
}, [term, navLabel, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, campanasCfg.activo, encuestasCfg.activo, licenciaCfg]);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const chatUnread = useChatUnread();
|
const chatUnread = useChatUnread();
|
||||||
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
|
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
|
||||||
|
|||||||
@@ -241,6 +241,33 @@ const CampoFormulario: FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case 'nps': {
|
||||||
|
const n = Number(valor);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="grid grid-cols-6 gap-2">
|
||||||
|
{Array.from({ length: 11 }, (_, i) => i).map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
data-value={v}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(v)}
|
||||||
|
className={`rounded-xl border py-3 text-base font-medium ${
|
||||||
|
n === v ? 'border-theme-border-strong bg-theme-accent text-theme-inverse' : 'border-theme-border bg-theme-surface text-theme-heading'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex justify-between text-[11px] text-theme-muted">
|
||||||
|
<span>0</span>
|
||||||
|
<span>10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
case 'firma':
|
case 'firma':
|
||||||
return (
|
return (
|
||||||
<FirmaCanvas value={firma ?? null} onChange={(v) => onFirma?.(v)} disabled={disabled} />
|
<FirmaCanvas value={firma ?? null} onChange={(v) => onFirma?.(v)} disabled={disabled} />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const CAMPO_TIPOS: { tipo: FormularioCampoTipo; label: string; descripcio
|
|||||||
{ tipo: 'multi_seleccion', label: 'Varias opciones', descripcion: 'Elegir varias' },
|
{ tipo: 'multi_seleccion', label: 'Varias opciones', descripcion: 'Elegir varias' },
|
||||||
{ tipo: 'checkbox', label: 'Casilla', descripcion: 'Sí / no' },
|
{ tipo: 'checkbox', label: 'Casilla', descripcion: 'Sí / no' },
|
||||||
{ tipo: 'escala', label: 'Escala 1-10', descripcion: 'Nivel o intensidad' },
|
{ tipo: 'escala', label: 'Escala 1-10', descripcion: 'Nivel o intensidad' },
|
||||||
|
{ tipo: 'nps', label: 'Recomendación (0-10)', descripcion: 'Pregunta NPS de la encuesta de satisfacción' },
|
||||||
{ tipo: 'firma', label: 'Firma', descripcion: 'Firma con el dedo' },
|
{ tipo: 'firma', label: 'Firma', descripcion: 'Firma con el dedo' },
|
||||||
{ tipo: 'foto', label: 'Foto', descripcion: 'Cámara o galería' },
|
{ tipo: 'foto', label: 'Foto', descripcion: 'Cámara o galería' },
|
||||||
{ tipo: 'archivo', label: 'Archivo', descripcion: 'Documento o estudio' },
|
{ tipo: 'archivo', label: 'Archivo', descripcion: 'Documento o estudio' },
|
||||||
@@ -97,6 +98,12 @@ export function validarLocal(
|
|||||||
if (!(n >= 1 && n <= 10)) errores[campo.clave] = 'Debe ser del 1 al 10';
|
if (!(n >= 1 && n <= 10)) errores[campo.clave] = 'Debe ser del 1 al 10';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'nps': {
|
||||||
|
if (vacio) { if (campo.obligatorio) errores[campo.clave] = 'Elige un valor del 0 al 10'; break; }
|
||||||
|
const n = Number(v);
|
||||||
|
if (!(n >= 0 && n <= 10)) errores[campo.clave] = 'Debe ser del 0 al 10';
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'numero':
|
case 'numero':
|
||||||
if (vacio) { if (campo.obligatorio) errores[campo.clave] = 'Falta este dato'; break; }
|
if (vacio) { if (campo.obligatorio) errores[campo.clave] = 'Falta este dato'; break; }
|
||||||
if (Number.isNaN(Number(v))) errores[campo.clave] = 'Escribe un número';
|
if (Number.isNaN(Number(v))) errores[campo.clave] = 'Escribe un número';
|
||||||
@@ -118,5 +125,6 @@ export function valorLegible(campo: FormularioCampo, valor: unknown): string {
|
|||||||
if (Array.isArray(valor)) return valor.length ? valor.join(', ') : '—';
|
if (Array.isArray(valor)) return valor.length ? valor.join(', ') : '—';
|
||||||
if (typeof valor === 'boolean') return valor ? 'Sí' : 'No';
|
if (typeof valor === 'boolean') return valor ? 'Sí' : 'No';
|
||||||
if (campo.tipo === 'escala') return `${valor} / 10`;
|
if (campo.tipo === 'escala') return `${valor} / 10`;
|
||||||
|
if (campo.tipo === 'nps') return `${valor} / 10 (NPS)`;
|
||||||
return String(valor);
|
return String(valor);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
|
|||||||
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||||
'/configuracion': ['admin', 'gerencia'],
|
'/configuracion': ['admin', 'gerencia'],
|
||||||
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||||
|
// Campañas y satisfacción (apagadas por defecto: solo se pintan con
|
||||||
|
// campanas.activo / encuestas.activo). Se listan aquí para canSee.
|
||||||
|
'/campanas': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||||
|
'/satisfaccion': ['admin', 'recepcion', 'gerencia', 'medico'],
|
||||||
'/wacrm/messages': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
'/wacrm/messages': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||||
'/wacrm/leads': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
'/wacrm/leads': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||||
// El chat se lista explícitamente para dejar fuera a marketing (antes, al no
|
// El chat se lista explícitamente para dejar fuera a marketing (antes, al no
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LEALTAD_DEFAULT, PROPINAS_DEFAULT, 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 } from '../services/odoo';
|
||||||
|
|
||||||
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
|
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
|
||||||
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
|
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
|
||||||
@@ -25,6 +25,8 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
|
|||||||
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
|
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
|
||||||
lealtad: { ...LEALTAD_DEFAULT, ...(settings?.lealtad || {}) },
|
lealtad: { ...LEALTAD_DEFAULT, ...(settings?.lealtad || {}) },
|
||||||
propinas: { ...PROPINAS_DEFAULT, ...(settings?.propinas || {}) },
|
propinas: { ...PROPINAS_DEFAULT, ...(settings?.propinas || {}) },
|
||||||
|
campanas: { ...CAMPANAS_DEFAULT, ...(settings?.campanas || {}) },
|
||||||
|
encuestas: { ...ENCUESTAS_DEFAULT, ...(settings?.encuestas || {}) },
|
||||||
licencia: {
|
licencia: {
|
||||||
...LICENCIA_DEFAULT,
|
...LICENCIA_DEFAULT,
|
||||||
...(settings?.licencia || {}),
|
...(settings?.licencia || {}),
|
||||||
@@ -37,6 +39,19 @@ let actual: ClinicSettings = normalizar(null);
|
|||||||
let peticion: Promise<ClinicSettings> | null = null;
|
let peticion: Promise<ClinicSettings> | null = null;
|
||||||
const oyentes = new Set<(s: ClinicSettings) => void>();
|
const oyentes = new Set<(s: ClinicSettings) => void>();
|
||||||
|
|
||||||
|
// Bandera de "ya se intentó cargar" para las rutas detrás de un interruptor de
|
||||||
|
// módulo (`campanas.activo`, `encuestas.activo`): sin ella, una carga directa
|
||||||
|
// por URL vería los defaults (apagados) y rebotaría a "/" antes de que los
|
||||||
|
// ajustes llegaran del servidor.
|
||||||
|
let cargado = false;
|
||||||
|
const oyentesCargado = new Set<(v: boolean) => void>();
|
||||||
|
|
||||||
|
const marcarCargado = () => {
|
||||||
|
if (cargado) return;
|
||||||
|
cargado = true;
|
||||||
|
oyentesCargado.forEach((f) => f(true));
|
||||||
|
};
|
||||||
|
|
||||||
const publicar = (s: ClinicSettings) => {
|
const publicar = (s: ClinicSettings) => {
|
||||||
actual = s;
|
actual = s;
|
||||||
oyentes.forEach((f) => f(s));
|
oyentes.forEach((f) => f(s));
|
||||||
@@ -47,6 +62,7 @@ export const cargarClinicSettings = (): Promise<ClinicSettings> => {
|
|||||||
// público (reservas, pago, formularios) no lo tiene. Se devuelven los
|
// público (reservas, pago, formularios) no lo tiene. Se devuelven los
|
||||||
// defaults para no ensuciar la consola con un 401.
|
// defaults para no ensuciar la consola con un 401.
|
||||||
if (typeof window !== 'undefined' && !window.localStorage.getItem('skeen_token')) {
|
if (typeof window !== 'undefined' && !window.localStorage.getItem('skeen_token')) {
|
||||||
|
marcarCargado();
|
||||||
return Promise.resolve(actual);
|
return Promise.resolve(actual);
|
||||||
}
|
}
|
||||||
if (peticion) return peticion;
|
if (peticion) return peticion;
|
||||||
@@ -56,7 +72,7 @@ export const cargarClinicSettings = (): Promise<ClinicSettings> => {
|
|||||||
return actual;
|
return actual;
|
||||||
})
|
})
|
||||||
.catch(() => actual)
|
.catch(() => actual)
|
||||||
.finally(() => { peticion = null; });
|
.finally(() => { peticion = null; marcarCargado(); });
|
||||||
return peticion;
|
return peticion;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,3 +96,20 @@ export const useClinicSettings = (): ClinicSettings => {
|
|||||||
}, []);
|
}, []);
|
||||||
return s;
|
return s;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True cuando los ajustes ya se pidieron al servidor una vez (con éxito o no).
|
||||||
|
* Las rutas que viven detrás de un interruptor de módulo lo usan para no
|
||||||
|
* rebotar a "/" con los defaults mientras la petición está en vuelo.
|
||||||
|
*/
|
||||||
|
export const useClinicSettingsCargado = (): boolean => {
|
||||||
|
const [v, setV] = useState(cargado);
|
||||||
|
useEffect(() => {
|
||||||
|
if (cargado) { setV(true); return () => {}; }
|
||||||
|
const f = (x: boolean) => setV(x);
|
||||||
|
oyentesCargado.add(f);
|
||||||
|
void cargarClinicSettings();
|
||||||
|
return () => { oyentesCargado.delete(f); };
|
||||||
|
}, []);
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|||||||
@@ -327,6 +327,8 @@ export const es: Dict = {
|
|||||||
'config.tabs.comisiones': 'Comisiones',
|
'config.tabs.comisiones': 'Comisiones',
|
||||||
'config.tabs.lealtad': 'Lealtad',
|
'config.tabs.lealtad': 'Lealtad',
|
||||||
'config.tabs.propinas': 'Propinas',
|
'config.tabs.propinas': 'Propinas',
|
||||||
|
'config.tabs.campanas': 'Campañas',
|
||||||
|
'config.tabs.encuestas': 'Encuestas',
|
||||||
'config.tabs.terminologia': 'Terminología',
|
'config.tabs.terminologia': 'Terminología',
|
||||||
'config.tabs.usuarios': 'Usuarios',
|
'config.tabs.usuarios': 'Usuarios',
|
||||||
'config.tabs.roles': 'Roles',
|
'config.tabs.roles': 'Roles',
|
||||||
@@ -848,6 +850,125 @@ export const es: Dict = {
|
|||||||
'portal.common.currency': 'Moneda',
|
'portal.common.currency': 'Moneda',
|
||||||
'portal.common.status': 'Estado',
|
'portal.common.status': 'Estado',
|
||||||
'portal.common.date': 'Fecha',
|
'portal.common.date': 'Fecha',
|
||||||
|
// --- campañas por segmentos ---
|
||||||
|
'camp.title': 'Campañas',
|
||||||
|
'camp.subtitle': 'Segmentos de pacientes y campañas con medición',
|
||||||
|
'camp.tab.campanas': 'Campañas',
|
||||||
|
'camp.tab.segmentos': 'Segmentos',
|
||||||
|
'camp.new': 'Nueva campaña',
|
||||||
|
'camp.newSegmento': 'Nuevo segmento',
|
||||||
|
'camp.editSegmento': 'Editar segmento',
|
||||||
|
'camp.edit': 'Editar campaña',
|
||||||
|
'camp.empty': 'Aún no hay campañas.',
|
||||||
|
'camp.emptySegmentos': 'Aún no hay segmentos.',
|
||||||
|
'camp.off': 'El módulo de campañas está apagado. Actívalo en Configuración.',
|
||||||
|
'camp.field.name': 'Nombre',
|
||||||
|
'camp.field.descripcion': 'Descripción',
|
||||||
|
'camp.field.activo': 'Activa',
|
||||||
|
'camp.field.segmento': 'Segmento',
|
||||||
|
'camp.field.canal': 'Canal',
|
||||||
|
'camp.field.plantilla': 'Mensaje',
|
||||||
|
'camp.field.enlace': 'Enlace',
|
||||||
|
'camp.field.programacion': 'Programación',
|
||||||
|
'camp.field.fecha': 'Fecha programada',
|
||||||
|
'camp.field.hora': 'Hora',
|
||||||
|
'camp.field.estado': 'Estado',
|
||||||
|
'camp.field.notas': 'Notas',
|
||||||
|
'camp.field.criterios': 'Criterios',
|
||||||
|
'camp.vars': 'Variables disponibles',
|
||||||
|
'camp.consentNote': 'Un paciente sin consentimiento de marketing nunca recibe la campaña (los avisos de cita sí siguen).',
|
||||||
|
'camp.preview': 'Vista previa',
|
||||||
|
'camp.previewCount': '{n} pacientes',
|
||||||
|
'camp.previewSample': 'Muestra',
|
||||||
|
'camp.previewRecalc': 'Recalcular',
|
||||||
|
'camp.previewEmpty': 'Ajusta criterios para ver la vista previa.',
|
||||||
|
'camp.criterio.ultimaVisita': 'Última visita hace más de N días',
|
||||||
|
'camp.criterio.sinVisita': 'Sin visita en N meses',
|
||||||
|
'camp.criterio.cumpleMes': 'Cumpleaños este mes',
|
||||||
|
'camp.criterio.cumpleProx': 'Cumpleaños en los próximos N días',
|
||||||
|
'camp.criterio.nivel': 'Nivel de lealtad',
|
||||||
|
'camp.criterio.gasto': 'Gasto acumulado ≥',
|
||||||
|
'camp.criterio.sucursal': 'Sucursal',
|
||||||
|
'camp.criterio.servicio': 'Servicio que SÍ ha tomado',
|
||||||
|
'camp.criterio.servicioNunca': 'Servicio que NUNCA ha tomado',
|
||||||
|
'camp.criterio.membresia': 'Con membresía activa',
|
||||||
|
'camp.criterio.paquetes': 'Con paquetes con sesiones',
|
||||||
|
'camp.criterio.saldo': 'Con saldo pendiente',
|
||||||
|
'camp.criterio.consentimiento': 'Consentimiento de marketing',
|
||||||
|
'camp.opt.cualquiera': 'Cualquiera',
|
||||||
|
'camp.opt.con': 'Con',
|
||||||
|
'camp.opt.sin': 'Sin',
|
||||||
|
'camp.opt.ninguno': '—',
|
||||||
|
'camp.action.lanzar': 'Lanzar',
|
||||||
|
'camp.action.pausar': 'Pausar',
|
||||||
|
'camp.action.reanudar': 'Reanudar',
|
||||||
|
'camp.action.cancelar': 'Cancelar',
|
||||||
|
'camp.action.procesar': 'Procesar cola',
|
||||||
|
'camp.action.prueba': 'Envío de prueba',
|
||||||
|
'camp.action.metricas': 'Medición',
|
||||||
|
'camp.action.envios': 'Envíos',
|
||||||
|
'camp.prueba.title': 'Envío de prueba',
|
||||||
|
'camp.prueba.telefono': 'Número',
|
||||||
|
'camp.prueba.correo': 'Correo',
|
||||||
|
'camp.prueba.send': 'Enviar prueba',
|
||||||
|
'camp.prueba.hint': 'Sin credenciales reales o en modo prueba el envío queda como simulado en la bitácora.',
|
||||||
|
'camp.metric.alcanzados': 'Alcanzados',
|
||||||
|
'camp.metric.enviados': 'Enviados',
|
||||||
|
'camp.metric.entregados': 'Entregados',
|
||||||
|
'camp.metric.respuestas': 'Respuestas',
|
||||||
|
'camp.metric.citas': 'Citas atribuidas',
|
||||||
|
'camp.metric.valor': 'Valor atribuido',
|
||||||
|
'camp.metric.conversion': 'Conversión',
|
||||||
|
'camp.metric.noDisponible': 'No disponible',
|
||||||
|
'camp.metric.ventana': 'Ventana de atribución: {n} días',
|
||||||
|
'camp.estado.borrador': 'Borrador',
|
||||||
|
'camp.estado.programada': 'Programada',
|
||||||
|
'camp.estado.en_ejecucion': 'En ejecución',
|
||||||
|
'camp.estado.pausada': 'Pausada',
|
||||||
|
'camp.estado.terminada': 'Terminada',
|
||||||
|
'camp.estado.cancelada': 'Cancelada',
|
||||||
|
'camp.saved': 'Campaña guardada.',
|
||||||
|
'camp.segmentoSaved': 'Segmento guardado.',
|
||||||
|
'camp.deleted': 'Eliminado.',
|
||||||
|
'camp.processing': 'Cola procesada.',
|
||||||
|
'camp.confirmDelete': '¿Eliminar definitivamente?',
|
||||||
|
'camp.envios.title': 'Bitácora de envíos',
|
||||||
|
'camp.envios.esPrueba': 'Prueba',
|
||||||
|
'camp.export.metricas': 'Exportar medición',
|
||||||
|
'camp.export.segmento': 'Exportar muestra',
|
||||||
|
'nav.campanas': 'Campañas',
|
||||||
|
'nav.satisfaccion': 'Satisfacción',
|
||||||
|
// --- satisfacción (NPS) ---
|
||||||
|
'nps.title': 'Satisfacción',
|
||||||
|
'nps.subtitle': 'Encuestas de salida y recomendación (NPS)',
|
||||||
|
'nps.off': 'Las encuestas de salida están apagadas. Actívalas en Configuración.',
|
||||||
|
'nps.period': 'Periodo',
|
||||||
|
'nps.general': 'NPS del periodo',
|
||||||
|
'nps.promotores': 'Promotores (9-10)',
|
||||||
|
'nps.pasivos': 'Pasivos (7-8)',
|
||||||
|
'nps.detractores': 'Detractores (0-6)',
|
||||||
|
'nps.respuestas': 'Respuestas',
|
||||||
|
'nps.trend': 'Tendencia por mes',
|
||||||
|
'nps.byBranch': 'Por sucursal',
|
||||||
|
'nps.byDoctor': 'Por médico',
|
||||||
|
'nps.byService': 'Por servicio',
|
||||||
|
'nps.responses': 'Respuestas con comentario',
|
||||||
|
'nps.comment': 'Comentario',
|
||||||
|
'nps.reply': 'Responder por WhatsApp',
|
||||||
|
'nps.replyPlaceholder': 'Escribe el mensaje al paciente…',
|
||||||
|
'nps.replySend': 'Enviar',
|
||||||
|
'nps.openRecord': 'Abrir ficha',
|
||||||
|
'nps.noData': 'Sin respuestas en el periodo.',
|
||||||
|
'nps.score': 'Puntaje',
|
||||||
|
'nps.category.promotor': 'Promotor',
|
||||||
|
'nps.category.pasivo': 'Pasivo',
|
||||||
|
'nps.category.detractor': 'Detractor',
|
||||||
|
'nps.export': 'Exportar reporte',
|
||||||
|
'nps.exportRespuestas': 'Exportar respuestas',
|
||||||
|
'nps.process': 'Procesar encuestas ahora',
|
||||||
|
'nps.processed': 'Encuestas procesadas.',
|
||||||
|
'nps.replySent': 'Respuesta enviada.',
|
||||||
|
'nps.replySimulated': 'Modo prueba: la respuesta quedó registrada como simulada.',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
@@ -1135,6 +1256,8 @@ export const en: Dict = {
|
|||||||
'config.tabs.comisiones': 'Commissions',
|
'config.tabs.comisiones': 'Commissions',
|
||||||
'config.tabs.lealtad': 'Loyalty',
|
'config.tabs.lealtad': 'Loyalty',
|
||||||
'config.tabs.propinas': 'Tips',
|
'config.tabs.propinas': 'Tips',
|
||||||
|
'config.tabs.campanas': 'Campaigns',
|
||||||
|
'config.tabs.encuestas': 'Surveys',
|
||||||
'config.tabs.terminologia': 'Terminology',
|
'config.tabs.terminologia': 'Terminology',
|
||||||
'config.tabs.usuarios': 'Users',
|
'config.tabs.usuarios': 'Users',
|
||||||
'config.tabs.roles': 'Roles',
|
'config.tabs.roles': 'Roles',
|
||||||
@@ -1651,6 +1774,125 @@ export const en: Dict = {
|
|||||||
'portal.common.currency': 'Currency',
|
'portal.common.currency': 'Currency',
|
||||||
'portal.common.status': 'Status',
|
'portal.common.status': 'Status',
|
||||||
'portal.common.date': 'Date',
|
'portal.common.date': 'Date',
|
||||||
|
// --- campaigns by segment ---
|
||||||
|
'camp.title': 'Campaigns',
|
||||||
|
'camp.subtitle': 'Patient segments and campaigns with measurement',
|
||||||
|
'camp.tab.campanas': 'Campaigns',
|
||||||
|
'camp.tab.segmentos': 'Segments',
|
||||||
|
'camp.new': 'New campaign',
|
||||||
|
'camp.newSegmento': 'New segment',
|
||||||
|
'camp.editSegmento': 'Edit segment',
|
||||||
|
'camp.edit': 'Edit campaign',
|
||||||
|
'camp.empty': 'No campaigns yet.',
|
||||||
|
'camp.emptySegmentos': 'No segments yet.',
|
||||||
|
'camp.off': 'The campaigns module is off. Enable it in Settings.',
|
||||||
|
'camp.field.name': 'Name',
|
||||||
|
'camp.field.descripcion': 'Description',
|
||||||
|
'camp.field.activo': 'Active',
|
||||||
|
'camp.field.segmento': 'Segment',
|
||||||
|
'camp.field.canal': 'Channel',
|
||||||
|
'camp.field.plantilla': 'Message',
|
||||||
|
'camp.field.enlace': 'Link',
|
||||||
|
'camp.field.programacion': 'Schedule',
|
||||||
|
'camp.field.fecha': 'Scheduled date',
|
||||||
|
'camp.field.hora': 'Time',
|
||||||
|
'camp.field.estado': 'Status',
|
||||||
|
'camp.field.notas': 'Notes',
|
||||||
|
'camp.field.criterios': 'Criteria',
|
||||||
|
'camp.vars': 'Available variables',
|
||||||
|
'camp.consentNote': 'A patient without marketing consent never receives the campaign (appointment notices still do).',
|
||||||
|
'camp.preview': 'Preview',
|
||||||
|
'camp.previewCount': '{n} patients',
|
||||||
|
'camp.previewSample': 'Sample',
|
||||||
|
'camp.previewRecalc': 'Recalculate',
|
||||||
|
'camp.previewEmpty': 'Adjust criteria to see the preview.',
|
||||||
|
'camp.criterio.ultimaVisita': 'Last visit more than N days ago',
|
||||||
|
'camp.criterio.sinVisita': 'No visit in N months',
|
||||||
|
'camp.criterio.cumpleMes': 'Birthday this month',
|
||||||
|
'camp.criterio.cumpleProx': 'Birthday in the next N days',
|
||||||
|
'camp.criterio.nivel': 'Loyalty level',
|
||||||
|
'camp.criterio.gasto': 'Accumulated spend ≥',
|
||||||
|
'camp.criterio.sucursal': 'Branch',
|
||||||
|
'camp.criterio.servicio': 'Service they HAVE had',
|
||||||
|
'camp.criterio.servicioNunca': 'Service they NEVER had',
|
||||||
|
'camp.criterio.membresia': 'With active membership',
|
||||||
|
'camp.criterio.paquetes': 'With packages with sessions',
|
||||||
|
'camp.criterio.saldo': 'With pending balance',
|
||||||
|
'camp.criterio.consentimiento': 'Marketing consent',
|
||||||
|
'camp.opt.cualquiera': 'Any',
|
||||||
|
'camp.opt.con': 'With',
|
||||||
|
'camp.opt.sin': 'Without',
|
||||||
|
'camp.opt.ninguno': '—',
|
||||||
|
'camp.action.lanzar': 'Launch',
|
||||||
|
'camp.action.pausar': 'Pause',
|
||||||
|
'camp.action.reanudar': 'Resume',
|
||||||
|
'camp.action.cancelar': 'Cancel',
|
||||||
|
'camp.action.procesar': 'Process queue',
|
||||||
|
'camp.action.prueba': 'Test send',
|
||||||
|
'camp.action.metricas': 'Measurement',
|
||||||
|
'camp.action.envios': 'Sends',
|
||||||
|
'camp.prueba.title': 'Test send',
|
||||||
|
'camp.prueba.telefono': 'Number',
|
||||||
|
'camp.prueba.correo': 'Email',
|
||||||
|
'camp.prueba.send': 'Send test',
|
||||||
|
'camp.prueba.hint': 'Without real credentials or in test mode the send is logged as simulated.',
|
||||||
|
'camp.metric.alcanzados': 'Reached',
|
||||||
|
'camp.metric.enviados': 'Sent',
|
||||||
|
'camp.metric.entregados': 'Delivered',
|
||||||
|
'camp.metric.respuestas': 'Replies',
|
||||||
|
'camp.metric.citas': 'Attributed appointments',
|
||||||
|
'camp.metric.valor': 'Attributed value',
|
||||||
|
'camp.metric.conversion': 'Conversion',
|
||||||
|
'camp.metric.noDisponible': 'Not available',
|
||||||
|
'camp.metric.ventana': 'Attribution window: {n} days',
|
||||||
|
'camp.estado.borrador': 'Draft',
|
||||||
|
'camp.estado.programada': 'Scheduled',
|
||||||
|
'camp.estado.en_ejecucion': 'Running',
|
||||||
|
'camp.estado.pausada': 'Paused',
|
||||||
|
'camp.estado.terminada': 'Finished',
|
||||||
|
'camp.estado.cancelada': 'Cancelled',
|
||||||
|
'camp.saved': 'Campaign saved.',
|
||||||
|
'camp.segmentoSaved': 'Segment saved.',
|
||||||
|
'camp.deleted': 'Deleted.',
|
||||||
|
'camp.processing': 'Queue processed.',
|
||||||
|
'camp.confirmDelete': 'Delete permanently?',
|
||||||
|
'camp.envios.title': 'Send log',
|
||||||
|
'camp.envios.esPrueba': 'Test',
|
||||||
|
'camp.export.metricas': 'Export measurement',
|
||||||
|
'camp.export.segmento': 'Export sample',
|
||||||
|
'nav.campanas': 'Campaigns',
|
||||||
|
'nav.satisfaccion': 'Satisfaction',
|
||||||
|
// --- satisfaction (NPS) ---
|
||||||
|
'nps.title': 'Satisfaction',
|
||||||
|
'nps.subtitle': 'Exit surveys and recommendation (NPS)',
|
||||||
|
'nps.off': 'Exit surveys are off. Enable them in Settings.',
|
||||||
|
'nps.period': 'Period',
|
||||||
|
'nps.general': 'NPS for the period',
|
||||||
|
'nps.promotores': 'Promoters (9-10)',
|
||||||
|
'nps.pasivos': 'Passives (7-8)',
|
||||||
|
'nps.detractores': 'Detractors (0-6)',
|
||||||
|
'nps.respuestas': 'Responses',
|
||||||
|
'nps.trend': 'Monthly trend',
|
||||||
|
'nps.byBranch': 'By branch',
|
||||||
|
'nps.byDoctor': 'By doctor',
|
||||||
|
'nps.byService': 'By service',
|
||||||
|
'nps.responses': 'Responses with comment',
|
||||||
|
'nps.comment': 'Comment',
|
||||||
|
'nps.reply': 'Reply on WhatsApp',
|
||||||
|
'nps.replyPlaceholder': 'Write the message to the patient…',
|
||||||
|
'nps.replySend': 'Send',
|
||||||
|
'nps.openRecord': 'Open record',
|
||||||
|
'nps.noData': 'No responses in the period.',
|
||||||
|
'nps.score': 'Score',
|
||||||
|
'nps.category.promotor': 'Promoter',
|
||||||
|
'nps.category.pasivo': 'Passive',
|
||||||
|
'nps.category.detractor': 'Detractor',
|
||||||
|
'nps.export': 'Export report',
|
||||||
|
'nps.exportRespuestas': 'Export responses',
|
||||||
|
'nps.process': 'Process surveys now',
|
||||||
|
'nps.processed': 'Surveys processed.',
|
||||||
|
'nps.replySent': 'Reply sent.',
|
||||||
|
'nps.replySimulated': 'Test mode: the reply was logged as simulated.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DICT: Record<Locale, Dict> = { es, en };
|
export const DICT: Record<Locale, Dict> = { es, en };
|
||||||
@@ -1725,6 +1967,8 @@ export const MENU_LABEL_KEYS: Record<string, string> = { // grupos
|
|||||||
'Leads': 'nav.leads',
|
'Leads': 'nav.leads',
|
||||||
'Membresías': 'nav.memberships',
|
'Membresías': 'nav.memberships',
|
||||||
'Liquidaciones': 'nav.liquidations',
|
'Liquidaciones': 'nav.liquidations',
|
||||||
|
'Campañas': 'nav.campanas',
|
||||||
|
'Satisfacción': 'nav.satisfaccion',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Etiquetas del tema "clásico" -> clave i18n (reutiliza nav.* cuando coincide).
|
// Etiquetas del tema "clásico" -> clave i18n (reutiliza nav.* cuando coincide).
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||||||
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },
|
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },
|
||||||
{ label: 'Exportar', href: '/reportes?tab=exportar' },
|
{ label: 'Exportar', href: '/reportes?tab=exportar' },
|
||||||
{ label: 'Cumpleañeros', href: '/cumpleanos' },
|
{ label: 'Cumpleañeros', href: '/cumpleanos' },
|
||||||
|
{ label: 'Campañas', href: '/campanas' },
|
||||||
|
{ label: 'Satisfacción', href: '/satisfaccion' },
|
||||||
{ label: 'Mis ventas', href: '/mis-ventas' },
|
{ label: 'Mis ventas', href: '/mis-ventas' },
|
||||||
{ label: 'Chat', href: '/chat' },
|
{ label: 'Chat', href: '/chat' },
|
||||||
{ label: 'Configuración', href: '/configuracion' },
|
{ label: 'Configuración', href: '/configuracion' },
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export const PERM_KEYS: string[] = [
|
|||||||
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
|
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
|
||||||
'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar',
|
'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar',
|
||||||
'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar',
|
'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar',
|
||||||
|
'campanas.ver', 'campanas.gestionar', 'campanas.enviar', 'campanas.medir',
|
||||||
|
'encuestas.ver', 'encuestas.gestionar', 'encuestas.medir',
|
||||||
'membresias.ver', 'membresias.crear', 'membresias.cobrar', 'membresias.editar',
|
'membresias.ver', 'membresias.crear', 'membresias.cobrar', 'membresias.editar',
|
||||||
'membresias.cancelar',
|
'membresias.cancelar',
|
||||||
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
|
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
|
||||||
@@ -53,6 +55,8 @@ const READ_PERM_KEYS = new Set([
|
|||||||
'whatsapp.ver',
|
'whatsapp.ver',
|
||||||
'notificaciones.ver',
|
'notificaciones.ver',
|
||||||
'formularios.ver',
|
'formularios.ver',
|
||||||
|
'campanas.ver', 'campanas.medir',
|
||||||
|
'encuestas.ver', 'encuestas.medir',
|
||||||
'membresias.ver',
|
'membresias.ver',
|
||||||
'compras.ver',
|
'compras.ver',
|
||||||
'proveedores.ver',
|
'proveedores.ver',
|
||||||
@@ -84,6 +88,9 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
|||||||
'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG,
|
'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG,
|
||||||
'formularios.ver': ALL, 'formularios.crear': FORM_WRITE, 'formularios.editar': AG,
|
'formularios.ver': ALL, 'formularios.crear': FORM_WRITE, 'formularios.editar': AG,
|
||||||
'formularios.enviar': FORM_WRITE,
|
'formularios.enviar': FORM_WRITE,
|
||||||
|
'campanas.ver': ALL, 'campanas.gestionar': ['admin', 'gerencia', 'marketing'],
|
||||||
|
'campanas.enviar': ['admin', 'gerencia', 'marketing'], 'campanas.medir': ALL,
|
||||||
|
'encuestas.ver': ALL, 'encuestas.gestionar': AG, 'encuestas.medir': ALL,
|
||||||
'membresias.ver': ALL, 'membresias.crear': ['admin', 'recepcion', 'gerencia'],
|
'membresias.ver': ALL, 'membresias.crear': ['admin', 'recepcion', 'gerencia'],
|
||||||
'membresias.cobrar': ['admin', 'recepcion', 'gerencia'], 'membresias.editar': AG,
|
'membresias.cobrar': ['admin', 'recepcion', 'gerencia'], 'membresias.editar': AG,
|
||||||
'membresias.cancelar': AG,
|
'membresias.cancelar': AG,
|
||||||
|
|||||||
730
frontend/src/pages/Campanas.tsx
Normal file
730
frontend/src/pages/Campanas.tsx
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Megaphone, Users, Pencil, Trash2, Plus, Eye, PlayCircle, PauseCircle,
|
||||||
|
RotateCcw, XCircle, FlaskConical, BarChart3, Download,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { Card, PageHeader, Button, Input, Select, TextArea, Modal, Badge, EmptyState, toast } from '../components/ui';
|
||||||
|
import { odooApi } from '../services/odoo';
|
||||||
|
import type {
|
||||||
|
Campana, CampanaEstado, CampanaMetricas, CampanaEnvio, Segmento,
|
||||||
|
SegmentoCriterios, SegmentoMuestraPaciente, Service,
|
||||||
|
} from '../services/odoo';
|
||||||
|
import { SEGMENTO_CRITERIOS_DEFAULT } from '../services/odoo';
|
||||||
|
import { useAuth } from '../lib/auth';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
import { useGlosario } from '../lib/glosario';
|
||||||
|
import { useClinicSettings } from '../lib/clinic-settings';
|
||||||
|
import { useSucursales } from '../lib/sucursales';
|
||||||
|
import { exportToExcel } from '../lib/exporter';
|
||||||
|
|
||||||
|
type Tab = 'campanas' | 'segmentos';
|
||||||
|
|
||||||
|
const ESTADO_STYLE: Record<CampanaEstado, string> = {
|
||||||
|
borrador: 'bg-theme-bg text-theme-muted',
|
||||||
|
programada: 'bg-sky-100 text-sky-700',
|
||||||
|
en_ejecucion: 'bg-amber-100 text-amber-700',
|
||||||
|
pausada: 'bg-orange-100 text-orange-700',
|
||||||
|
terminada: 'bg-emerald-100 text-emerald-700',
|
||||||
|
cancelada: 'bg-rose-100 text-rose-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtMoney = (n: number) =>
|
||||||
|
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
const fmtDt = (s?: string | null) => {
|
||||||
|
if (!s) return '—';
|
||||||
|
const d = new Date(s.replace(' ', 'T'));
|
||||||
|
if (Number.isNaN(d.getTime())) return s;
|
||||||
|
return d.toLocaleString('es-MX', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const tri = (v: boolean | null): string => (v === null ? '' : v ? 'con' : 'sin');
|
||||||
|
|
||||||
|
const resumenCriterios = (c: SegmentoCriterios, term: (k: 'branch_singular' | 'service_singular') => string): string[] => {
|
||||||
|
const out: string[] = [];
|
||||||
|
if (c.ultima_visita_dias > 0) out.push(`Última visita > ${c.ultima_visita_dias} d`);
|
||||||
|
if (c.sin_visita_meses > 0) out.push(`Sin visita ${c.sin_visita_meses} m`);
|
||||||
|
if (c.cumple_mes) out.push('Cumpleaños del mes');
|
||||||
|
if (c.cumple_proximos_dias > 0) out.push(`Cumple en ${c.cumple_proximos_dias} d`);
|
||||||
|
if (c.nivel_codigo) out.push(`Nivel ${c.nivel_codigo}`);
|
||||||
|
if (c.gasto_minimo > 0) out.push(`Gasto ≥ ${fmtMoney(c.gasto_minimo)}`);
|
||||||
|
if (c.sucursal) out.push(`Sucursal ${c.sucursal}`);
|
||||||
|
if (c.servicio_id) out.push(`${term('service_singular')} tomado`);
|
||||||
|
if (c.servicio_nunca_id) out.push(`${term('service_singular')} nunca tomado`);
|
||||||
|
if (c.con_membresia !== null) out.push(c.con_membresia ? 'Con membresía' : 'Sin membresía');
|
||||||
|
if (c.con_paquetes !== null) out.push(c.con_paquetes ? 'Con paquetes' : 'Sin paquetes');
|
||||||
|
if (c.con_saldo !== null) out.push(c.con_saldo ? 'Con saldo' : 'Sin saldo');
|
||||||
|
if (c.consentimiento) out.push(c.consentimiento === 'con' ? 'Con consentimiento' : 'Sin consentimiento');
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Campanas: FC = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { term } = useGlosario();
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { sucursales } = useSucursales();
|
||||||
|
const { lealtad } = useClinicSettings();
|
||||||
|
|
||||||
|
const puedeGestionar = can('campanas.gestionar');
|
||||||
|
const puedeEnviar = can('campanas.enviar');
|
||||||
|
const puedeMedir = can('campanas.medir');
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<Tab>('campanas');
|
||||||
|
const [campanas, setCampanas] = useState<Campana[]>([]);
|
||||||
|
const [segmentos, setSegmentos] = useState<Segmento[]>([]);
|
||||||
|
const [servicios, setServicios] = useState<Service[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const [segModal, setSegModal] = useState(false);
|
||||||
|
const [segEdit, setSegEdit] = useState<Segmento | null>(null);
|
||||||
|
const [segName, setSegName] = useState('');
|
||||||
|
const [segDesc, setSegDesc] = useState('');
|
||||||
|
const [criterios, setCriterios] = useState<SegmentoCriterios>({ ...SEGMENTO_CRITERIOS_DEFAULT });
|
||||||
|
const [preview, setPreview] = useState<{ total: number; muestra: SegmentoMuestraPaciente[] } | null>(null);
|
||||||
|
const [previewing, setPreviewing] = useState(false);
|
||||||
|
|
||||||
|
const [campModal, setCampModal] = useState(false);
|
||||||
|
const [campEdit, setCampEdit] = useState<Campana | null>(null);
|
||||||
|
const [campForm, setCampForm] = useState({
|
||||||
|
name: '', descripcion: '', segmento_id: 0, canal: 'whatsapp' as Campana['canal'],
|
||||||
|
plantilla: '', enlace: '', programacion: 'una_vez' as Campana['programacion'],
|
||||||
|
fecha_programada: '', hora: '10:00',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [testModal, setTestModal] = useState<Campana | null>(null);
|
||||||
|
const [testTel, setTestTel] = useState('');
|
||||||
|
const [testCorreo, setTestCorreo] = useState('');
|
||||||
|
|
||||||
|
const [metricasModal, setMetricasModal] = useState(false);
|
||||||
|
const [metricas, setMetricas] = useState<CampanaMetricas[]>([]);
|
||||||
|
const [envios, setEnvios] = useState<CampanaEnvio[]>([]);
|
||||||
|
const [enviosCampana, setEnviosCampana] = useState<Campana | null>(null);
|
||||||
|
|
||||||
|
const niveles = useMemo(
|
||||||
|
() => (lealtad?.niveles || []).map((n) => ({ value: n.codigo, label: n.nombre || n.codigo })),
|
||||||
|
[lealtad],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cargar = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [c, s] = await Promise.all([odooApi.listCampanas(), odooApi.listSegmentos(true)]);
|
||||||
|
if (c.status === 'success') setCampanas(c.campanas || []);
|
||||||
|
if (s.status === 'success') setSegmentos(s.segmentos || []);
|
||||||
|
} catch {
|
||||||
|
toast.error(t('camp.title'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => { void cargar(); }, [cargar]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
odooApi.getServices({ page_size: 200 })
|
||||||
|
.then((res) => { if (res.status === 'success') setServicios(res.services || []); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- Vista previa del segmento (con retardo) ---
|
||||||
|
const previewTimer = useRef<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!segModal) return;
|
||||||
|
if (previewTimer.current) window.clearTimeout(previewTimer.current);
|
||||||
|
previewTimer.current = window.setTimeout(() => {
|
||||||
|
setPreviewing(true);
|
||||||
|
odooApi.previewSegmento(criterios)
|
||||||
|
.then((res) => { if (res.status === 'success') setPreview({ total: res.total, muestra: res.muestra }); })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setPreviewing(false));
|
||||||
|
}, 350);
|
||||||
|
return () => { if (previewTimer.current) window.clearTimeout(previewTimer.current); };
|
||||||
|
}, [criterios, segModal]);
|
||||||
|
|
||||||
|
const abrirNuevoSegmento = () => {
|
||||||
|
setSegEdit(null);
|
||||||
|
setSegName('');
|
||||||
|
setSegDesc('');
|
||||||
|
setCriterios({ ...SEGMENTO_CRITERIOS_DEFAULT });
|
||||||
|
setPreview(null);
|
||||||
|
setSegModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const abrirEditarSegmento = (s: Segmento) => {
|
||||||
|
setSegEdit(s);
|
||||||
|
setSegName(s.name);
|
||||||
|
setSegDesc(s.descripcion || '');
|
||||||
|
setCriterios({ ...SEGMENTO_CRITERIOS_DEFAULT, ...(s.criterios || {}) });
|
||||||
|
setPreview({ total: s.ultimo_conteo, muestra: s.muestra || [] });
|
||||||
|
setSegModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const guardarSegmento = async () => {
|
||||||
|
if (!segName.trim()) { toast.error(t('camp.field.name')); return; }
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const payload = { name: segName.trim(), descripcion: segDesc, criterios };
|
||||||
|
const res = segEdit
|
||||||
|
? await odooApi.actualizarSegmento(segEdit.id, payload)
|
||||||
|
: await odooApi.crearSegmento(payload);
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast.success(t('camp.segmentoSaved'));
|
||||||
|
setSegModal(false);
|
||||||
|
await cargar();
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t('camp.segmentoSaved'));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const borrarSegmento = async (s: Segmento) => {
|
||||||
|
if (!window.confirm(t('camp.confirmDelete'))) return;
|
||||||
|
try {
|
||||||
|
const res = await odooApi.borrarSegmento(s.id);
|
||||||
|
toast.success(res.message || t('camp.deleted'));
|
||||||
|
await cargar();
|
||||||
|
} catch { toast.error(t('camp.deleted')); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const abrirNuevaCampana = () => {
|
||||||
|
setCampEdit(null);
|
||||||
|
setCampForm({
|
||||||
|
name: '', descripcion: '', segmento_id: segmentos[0]?.id || 0, canal: 'whatsapp',
|
||||||
|
plantilla: 'Hola {nombre}, te esperamos en {sucursal}. Agenda tu {servicio}: {enlace}',
|
||||||
|
enlace: '', programacion: 'una_vez', fecha_programada: '', hora: '10:00',
|
||||||
|
});
|
||||||
|
setCampModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const abrirEditarCampana = (c: Campana) => {
|
||||||
|
setCampEdit(c);
|
||||||
|
setCampForm({
|
||||||
|
name: c.name, descripcion: c.descripcion || '', segmento_id: c.segmento_id || 0,
|
||||||
|
canal: c.canal, plantilla: c.plantilla || '', enlace: c.enlace || '',
|
||||||
|
programacion: c.programacion, fecha_programada: c.fecha_programada || '', hora: c.hora || '10:00',
|
||||||
|
});
|
||||||
|
setCampModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const guardarCampana = async () => {
|
||||||
|
if (!campForm.name.trim()) { toast.error(t('camp.field.name')); return; }
|
||||||
|
setBusy(true);
|
||||||
|
const payload = {
|
||||||
|
name: campForm.name.trim(),
|
||||||
|
descripcion: campForm.descripcion,
|
||||||
|
segmento_id: campForm.segmento_id || null,
|
||||||
|
canal: campForm.canal,
|
||||||
|
plantilla: campForm.plantilla,
|
||||||
|
enlace: campForm.enlace,
|
||||||
|
programacion: campForm.programacion,
|
||||||
|
fecha_programada: campForm.fecha_programada || null,
|
||||||
|
hora: campForm.hora,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = campEdit
|
||||||
|
? await odooApi.actualizarCampana(campEdit.id, payload)
|
||||||
|
: await odooApi.crearCampana(payload);
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast.success(t('camp.saved'));
|
||||||
|
setCampModal(false);
|
||||||
|
await cargar();
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t('camp.saved'));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const accion = async (c: Campana, a: 'lanzar' | 'pausar' | 'reanudar' | 'cancelar') => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.accionCampana(c.id, a);
|
||||||
|
if (res.status === 'success') { toast.success(t('common.save')); await cargar(); }
|
||||||
|
else toast.error(res.message || '');
|
||||||
|
} catch { toast.error(t('common.save')); } finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const procesar = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.procesarCampanas();
|
||||||
|
toast.success(`${t('camp.processing')} (${res.encoladas}/${res.enviadas + res.simuladas})`);
|
||||||
|
await cargar();
|
||||||
|
} catch { toast.error(t('camp.processing')); } finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const enviarPrueba = async () => {
|
||||||
|
if (!testModal) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.pruebaCampana(testModal.id, {
|
||||||
|
telefono: testTel.trim() || undefined,
|
||||||
|
correo: testCorreo.trim() || undefined,
|
||||||
|
});
|
||||||
|
if (res.ok) toast.success(res.message);
|
||||||
|
else toast.error(res.message);
|
||||||
|
setTestModal(null);
|
||||||
|
} catch { toast.error(t('camp.action.prueba')); } finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const abrirMetricas = async (c?: Campana) => {
|
||||||
|
setMetricasModal(true);
|
||||||
|
try {
|
||||||
|
if (c) {
|
||||||
|
const res = await odooApi.metricasCampana(c.id);
|
||||||
|
if (res.status === 'success') setMetricas([res.metricas]);
|
||||||
|
} else {
|
||||||
|
const res = await odooApi.metricasCampanas();
|
||||||
|
if (res.status === 'success') setMetricas(res.metricas || []);
|
||||||
|
}
|
||||||
|
} catch { toast.error(t('camp.action.metricas')); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const abrirEnvios = async (c: Campana) => {
|
||||||
|
setEnviosCampana(c);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.enviosCampana(c.id, true);
|
||||||
|
if (res.status === 'success') setEnvios(res.envios || []);
|
||||||
|
} catch { toast.error(''); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportarMetricas = async () => {
|
||||||
|
if (!metricas.length) return;
|
||||||
|
try {
|
||||||
|
await exportToExcel({
|
||||||
|
filename: `campanas-medicion-${new Date().toISOString().split('T')[0]}`,
|
||||||
|
sheetName: 'Medición',
|
||||||
|
title: `Medición de ${t('camp.title')}`,
|
||||||
|
columns: [
|
||||||
|
{ header: t('camp.field.name'), key: 'campana' },
|
||||||
|
{ header: t('camp.field.estado'), key: 'estado' },
|
||||||
|
{ header: t('camp.metric.alcanzados'), key: 'alcanzados', format: 'number' },
|
||||||
|
{ header: t('camp.metric.enviados'), key: 'enviados', format: 'number' },
|
||||||
|
{ header: t('camp.metric.entregados'), key: 'entregados_txt' },
|
||||||
|
{ header: t('camp.metric.respuestas'), key: 'respuestas_txt' },
|
||||||
|
{ header: t('camp.metric.citas'), key: 'citas_atribuidas', format: 'number' },
|
||||||
|
{ header: t('camp.metric.valor'), key: 'valor_atribuido', format: 'currency' },
|
||||||
|
{ header: t('camp.metric.conversion'), key: 'conversion_pct', format: 'number' },
|
||||||
|
],
|
||||||
|
rows: metricas.map((m) => ({
|
||||||
|
...m,
|
||||||
|
entregados_txt: m.entregados_disponible ? String(m.entregados) : t('camp.metric.noDisponible'),
|
||||||
|
respuestas_txt: m.respuestas_disponible ? String(m.respuestas) : t('camp.metric.noDisponible'),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
toast.success(t('common.export'));
|
||||||
|
} catch { toast.error(t('common.export')); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportarMuestra = async (s: Segmento) => {
|
||||||
|
try {
|
||||||
|
await exportToExcel({
|
||||||
|
filename: `segmento-${s.name}-${new Date().toISOString().split('T')[0]}`,
|
||||||
|
sheetName: 'Segmento',
|
||||||
|
title: s.name,
|
||||||
|
subtitle: t('camp.previewCount', { n: s.ultimo_conteo }),
|
||||||
|
columns: [
|
||||||
|
{ header: t('common.name'), key: 'name' },
|
||||||
|
{ header: t('common.phone'), key: 'phone' },
|
||||||
|
{ header: t('common.email'), key: 'email' },
|
||||||
|
{ header: 'Última visita', key: 'last_visit', format: 'date' },
|
||||||
|
{ header: t('camp.criterio.nivel'), key: 'nivel' },
|
||||||
|
],
|
||||||
|
rows: (s.muestra || []) as unknown as Record<string, unknown>[],
|
||||||
|
});
|
||||||
|
toast.success(t('common.export'));
|
||||||
|
} catch { toast.error(t('common.export')); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const segmentosOpts = segmentos.map((s) => ({ value: String(s.id), label: s.name }));
|
||||||
|
const serviciosOpts = [{ value: '', label: t('camp.opt.ninguno') },
|
||||||
|
...servicios.map((s) => ({ value: String(s.id), label: s.name }))];
|
||||||
|
const sucursalesOpts = [{ value: '', label: t('camp.opt.ninguno') },
|
||||||
|
...sucursales.map((s) => ({ value: s.code, label: s.name }))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title={t('camp.title')} subtitle={t('camp.subtitle')}>
|
||||||
|
<PageHeader title={t('camp.title')} subtitle={t('camp.subtitle')}>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{puedeMedir && (
|
||||||
|
<Button variant="outline" onClick={() => void abrirMetricas()}>
|
||||||
|
<BarChart3 size={16} className="mr-2" />{t('camp.action.metricas')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{puedeEnviar && (
|
||||||
|
<Button variant="outline" onClick={() => void procesar()} disabled={busy}>
|
||||||
|
<PlayCircle size={16} className="mr-2" />{t('camp.action.procesar')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{puedeGestionar && (
|
||||||
|
<Button onClick={() => (tab === 'campanas' ? abrirNuevaCampana() : abrirNuevoSegmento())}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
{tab === 'campanas' ? t('camp.new') : t('camp.newSegmento')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="mb-4 flex gap-2">
|
||||||
|
{(['campanas', 'segmentos'] as Tab[]).map((x) => (
|
||||||
|
<button
|
||||||
|
key={x}
|
||||||
|
onClick={() => setTab(x)}
|
||||||
|
className={`rounded-full px-4 py-2 text-sm font-medium transition ${
|
||||||
|
tab === x ? 'bg-theme-heading text-theme-inverse' : 'bg-theme-bg text-theme-muted hover:bg-theme-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{x === 'campanas' ? t('camp.tab.campanas') : t('camp.tab.segmentos')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Card><Card.Body><p className="p-6 text-sm text-theme-muted">{t('common.loading')}</p></Card.Body></Card>
|
||||||
|
) : tab === 'segmentos' ? (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{segmentos.length === 0 ? (
|
||||||
|
<Card><Card.Body>
|
||||||
|
<EmptyState icon={<Users size={28} />} title={t('camp.emptySegmentos')} />
|
||||||
|
</Card.Body></Card>
|
||||||
|
) : segmentos.map((s) => (
|
||||||
|
<Card key={s.id}><Card.Body>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-heading text-base text-theme-heading">{s.name}</h3>
|
||||||
|
{!s.activo && <Badge>{t('common.no')}</Badge>}
|
||||||
|
<span className="text-xs text-theme-muted">{t('camp.previewCount', { n: s.ultimo_conteo })}</span>
|
||||||
|
</div>
|
||||||
|
{s.descripcion && <p className="mt-1 text-sm text-theme-muted">{s.descripcion}</p>}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{resumenCriterios(s.criterios, term).map((r) => (
|
||||||
|
<span key={r} className="rounded-full bg-theme-bg px-2 py-0.5 text-[11px] text-theme-muted">{r}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => void odooApi.recalcularSegmento(s.id).then(() => cargar())}>
|
||||||
|
<Eye size={14} className="mr-1" />{t('camp.previewRecalc')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void exportarMuestra(s)}>
|
||||||
|
<Download size={14} className="mr-1" />{t('common.export')}
|
||||||
|
</Button>
|
||||||
|
{puedeGestionar && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => abrirEditarSegmento(s)}>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => void borrarSegmento(s)}>
|
||||||
|
<Trash2 size={14} className="text-rose-600" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Body></Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{campanas.length === 0 ? (
|
||||||
|
<Card><Card.Body>
|
||||||
|
<EmptyState icon={<Megaphone size={28} />} title={t('camp.empty')} />
|
||||||
|
</Card.Body></Card>
|
||||||
|
) : campanas.map((c) => (
|
||||||
|
<Card key={c.id}><Card.Body>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="font-heading text-base text-theme-heading">{c.name}</h3>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${ESTADO_STYLE[c.estado]}`}>
|
||||||
|
{t(`camp.estado.${c.estado}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-theme-muted">
|
||||||
|
{c.segmento || '—'} · {c.canal} · {c.programacion}
|
||||||
|
{c.proxima_ejecucion ? ` · ${fmtDt(c.proxima_ejecucion)}` : ''}
|
||||||
|
</p>
|
||||||
|
{c.motivo_pausa && <p className="mt-1 text-xs text-orange-600">{c.motivo_pausa}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
|
{puedeEnviar && c.estado === 'borrador' && (
|
||||||
|
<Button size="sm" onClick={() => void accion(c, 'lanzar')}><PlayCircle size={14} className="mr-1" />{t('camp.action.lanzar')}</Button>
|
||||||
|
)}
|
||||||
|
{puedeEnviar && (c.estado === 'programada' || c.estado === 'en_ejecucion') && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void accion(c, 'pausar')}><PauseCircle size={14} className="mr-1" />{t('camp.action.pausar')}</Button>
|
||||||
|
)}
|
||||||
|
{puedeEnviar && c.estado === 'pausada' && (
|
||||||
|
<Button size="sm" onClick={() => void accion(c, 'reanudar')}><RotateCcw size={14} className="mr-1" />{t('camp.action.reanudar')}</Button>
|
||||||
|
)}
|
||||||
|
{puedeEnviar && c.estado !== 'cancelada' && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => void accion(c, 'cancelar')}><XCircle size={14} className="mr-1" />{t('camp.action.cancelar')}</Button>
|
||||||
|
)}
|
||||||
|
{puedeEnviar && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setTestModal(c); setTestTel(''); setTestCorreo(''); }}>
|
||||||
|
<FlaskConical size={14} className="mr-1" />{t('camp.action.prueba')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{puedeMedir && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => void abrirMetricas(c)}><BarChart3 size={14} /></Button>
|
||||||
|
)}
|
||||||
|
{puedeMedir && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => void abrirEnvios(c)}>{t('camp.action.envios')}</Button>
|
||||||
|
)}
|
||||||
|
{puedeGestionar && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => abrirEditarCampana(c)}><Pencil size={14} /></Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => void odooApi.borrarCampana(c.id).then(() => { toast.success(t('camp.deleted')); return cargar(); })}>
|
||||||
|
<Trash2 size={14} className="text-rose-600" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Body></Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- Modal segmento ---- */}
|
||||||
|
<Modal
|
||||||
|
isOpen={segModal}
|
||||||
|
onClose={() => setSegModal(false)}
|
||||||
|
title={segEdit ? t('camp.editSegmento') : t('camp.newSegmento')}
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setSegModal(false)}>{t('common.cancel')}</Button>
|
||||||
|
<Button onClick={() => void guardarSegmento()} loading={busy}>{t('common.save')}</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input label={t('camp.field.name')} value={segName} onChange={(e) => setSegName(e.target.value)} />
|
||||||
|
<Input label={t('camp.field.descripcion')} value={segDesc} onChange={(e) => setSegDesc(e.target.value)} />
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-theme-border bg-theme-bg/50 p-3">
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase text-theme-muted">{t('camp.field.criterios')}</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Input type="number" min={0} label={t('camp.criterio.ultimaVisita')}
|
||||||
|
value={criterios.ultima_visita_dias || ''}
|
||||||
|
onChange={(e) => setCriterios({ ...criterios, ultima_visita_dias: Number(e.target.value) || 0 })} />
|
||||||
|
<Input type="number" min={0} label={t('camp.criterio.sinVisita')}
|
||||||
|
value={criterios.sin_visita_meses || ''}
|
||||||
|
onChange={(e) => setCriterios({ ...criterios, sin_visita_meses: Number(e.target.value) || 0 })} />
|
||||||
|
<Input type="number" min={0} label={t('camp.criterio.cumpleProx')}
|
||||||
|
value={criterios.cumple_proximos_dias || ''}
|
||||||
|
onChange={(e) => setCriterios({ ...criterios, cumple_proximos_dias: Number(e.target.value) || 0 })} />
|
||||||
|
<label className="flex items-center gap-2 pt-5 text-sm text-theme-heading">
|
||||||
|
<input type="checkbox" className="h-4 w-4 accent-theme-accent"
|
||||||
|
checked={criterios.cumple_mes}
|
||||||
|
onChange={(e) => setCriterios({ ...criterios, cumple_mes: e.target.checked })} />
|
||||||
|
{t('camp.criterio.cumpleMes')}
|
||||||
|
</label>
|
||||||
|
<Select label={t('camp.criterio.nivel')} options={[{ value: '', label: t('camp.opt.ninguno') }, ...niveles]}
|
||||||
|
value={criterios.nivel_codigo}
|
||||||
|
onChange={(e) => setCriterios({ ...criterios, nivel_codigo: e.target.value })} />
|
||||||
|
<Input type="number" min={0} label={t('camp.criterio.gasto')}
|
||||||
|
value={criterios.gasto_minimo || ''}
|
||||||
|
onChange={(e) => setCriterios({ ...criterios, gasto_minimo: Number(e.target.value) || 0 })} />
|
||||||
|
<Select label={t('camp.criterio.sucursal')} options={sucursalesOpts}
|
||||||
|
value={criterios.sucursal}
|
||||||
|
onChange={(e) => setCriterios({ ...criterios, sucursal: e.target.value })} />
|
||||||
|
<Select label={t('camp.criterio.servicio')} options={serviciosOpts}
|
||||||
|
value={String(criterios.servicio_id || '')}
|
||||||
|
onChange={(e) => setCriterios({ ...criterios, servicio_id: Number(e.target.value) || 0 })} />
|
||||||
|
<Select label={t('camp.criterio.servicioNunca')} options={serviciosOpts}
|
||||||
|
value={String(criterios.servicio_nunca_id || '')}
|
||||||
|
onChange={(e) => setCriterios({ ...criterios, servicio_nunca_id: Number(e.target.value) || 0 })} />
|
||||||
|
<Select label={t('camp.criterio.membresia')}
|
||||||
|
options={[{ value: '', label: t('camp.opt.cualquiera') }, { value: 'con', label: t('camp.opt.con') }, { value: 'sin', label: t('camp.opt.sin') }]}
|
||||||
|
value={tri(criterios.con_membresia)}
|
||||||
|
onChange={(e) => setCriterios({ ...criterios, con_membresia: e.target.value === '' ? null : e.target.value === 'con' })} />
|
||||||
|
<Select label={t('camp.criterio.paquetes')}
|
||||||
|
options={[{ value: '', label: t('camp.opt.cualquiera') }, { value: 'con', label: t('camp.opt.con') }, { value: 'sin', label: t('camp.opt.sin') }]}
|
||||||
|
value={tri(criterios.con_paquetes)}
|
||||||
|
onChange={(e) => setCriterios({ ...criterios, con_paquetes: e.target.value === '' ? null : e.target.value === 'con' })} />
|
||||||
|
<Select label={t('camp.criterio.saldo')}
|
||||||
|
options={[{ value: '', label: t('camp.opt.cualquiera') }, { value: 'con', label: t('camp.opt.con') }, { value: 'sin', label: t('camp.opt.sin') }]}
|
||||||
|
value={tri(criterios.con_saldo)}
|
||||||
|
onChange={(e) => setCriterios({ ...criterios, con_saldo: e.target.value === '' ? null : e.target.value === 'con' })} />
|
||||||
|
<Select label={t('camp.criterio.consentimiento')}
|
||||||
|
options={[{ value: '', label: t('camp.opt.cualquiera') }, { value: 'con', label: t('camp.opt.con') }, { value: 'sin', label: t('camp.opt.sin') }]}
|
||||||
|
value={criterios.consentimiento}
|
||||||
|
onChange={(e) => setCriterios({ ...criterios, consentimiento: e.target.value as SegmentoCriterios['consentimiento'] })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-theme-border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-semibold uppercase text-theme-muted">{t('camp.preview')}</p>
|
||||||
|
<span className="text-sm font-medium text-theme-heading">
|
||||||
|
{previewing ? t('common.loading') : preview ? t('camp.previewCount', { n: preview.total }) : t('camp.previewEmpty')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{preview && preview.muestra.length > 0 && (
|
||||||
|
<ul className="mt-2 divide-y divide-theme-border text-sm">
|
||||||
|
{preview.muestra.map((p) => (
|
||||||
|
<li key={p.id} className="flex items-center justify-between py-1.5">
|
||||||
|
<span className="text-theme-heading">{p.name}</span>
|
||||||
|
<span className="text-xs text-theme-muted">{p.phone || p.email || ''}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ---- Modal campaña ---- */}
|
||||||
|
<Modal
|
||||||
|
isOpen={campModal}
|
||||||
|
onClose={() => setCampModal(false)}
|
||||||
|
title={campEdit ? t('camp.edit') : t('camp.new')}
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setCampModal(false)}>{t('common.cancel')}</Button>
|
||||||
|
<Button onClick={() => void guardarCampana()} loading={busy}>{t('common.save')}</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input label={t('camp.field.name')} value={campForm.name}
|
||||||
|
onChange={(e) => setCampForm({ ...campForm, name: e.target.value })} />
|
||||||
|
<Select label={t('camp.field.segmento')} options={segmentosOpts}
|
||||||
|
value={String(campForm.segmento_id || '')}
|
||||||
|
onChange={(e) => setCampForm({ ...campForm, segmento_id: Number(e.target.value) || 0 })} />
|
||||||
|
<Select label={t('camp.field.canal')}
|
||||||
|
options={[{ value: 'whatsapp', label: 'WhatsApp' }, { value: 'email', label: 'Correo' }, { value: 'ambos', label: 'WhatsApp + Correo' }]}
|
||||||
|
value={campForm.canal}
|
||||||
|
onChange={(e) => setCampForm({ ...campForm, canal: e.target.value as Campana['canal'] })} />
|
||||||
|
<div>
|
||||||
|
<TextArea rows={4} label={t('camp.field.plantilla')} value={campForm.plantilla}
|
||||||
|
onChange={(e) => setCampForm({ ...campForm, plantilla: e.target.value })} />
|
||||||
|
<p className="mt-1 text-xs text-theme-muted">
|
||||||
|
{t('camp.vars')}: {'{nombre} {servicio} {sucursal} {enlace} {doctor} {fecha} {hora} {telefono}'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Input label={`${t('camp.field.enlace')} ({'{enlace}'})`} value={campForm.enlace}
|
||||||
|
onChange={(e) => setCampForm({ ...campForm, enlace: e.target.value })} />
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<Select label={t('camp.field.programacion')}
|
||||||
|
options={[
|
||||||
|
{ value: 'una_vez', label: 'Una sola vez' },
|
||||||
|
{ value: 'mensual', label: 'Mensual' },
|
||||||
|
{ value: 'semanal', label: 'Semanal' },
|
||||||
|
{ value: 'segmento', label: 'Según el segmento' },
|
||||||
|
]}
|
||||||
|
value={campForm.programacion}
|
||||||
|
onChange={(e) => setCampForm({ ...campForm, programacion: e.target.value as Campana['programacion'] })} />
|
||||||
|
{campForm.programacion === 'una_vez' && (
|
||||||
|
<Input type="datetime-local" label={t('camp.field.fecha')} value={campForm.fecha_programada}
|
||||||
|
onChange={(e) => setCampForm({ ...campForm, fecha_programada: e.target.value })} />
|
||||||
|
)}
|
||||||
|
<Input type="time" label={t('camp.field.hora')} value={campForm.hora}
|
||||||
|
onChange={(e) => setCampForm({ ...campForm, hora: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<p className="rounded-xl bg-theme-bg p-3 text-xs text-theme-muted">{t('camp.consentNote')}</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ---- Modal envío de prueba ---- */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!testModal}
|
||||||
|
onClose={() => setTestModal(null)}
|
||||||
|
title={t('camp.prueba.title')}
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setTestModal(null)}>{t('common.cancel')}</Button>
|
||||||
|
<Button onClick={() => void enviarPrueba()} loading={busy}>{t('camp.prueba.send')}</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input label={t('camp.prueba.telefono')} value={testTel} onChange={(e) => setTestTel(e.target.value)} placeholder="+52 664 000 0000" />
|
||||||
|
<Input label={t('camp.prueba.correo')} value={testCorreo} onChange={(e) => setTestCorreo(e.target.value)} placeholder="correo@ejemplo.com" />
|
||||||
|
<p className="text-xs text-theme-muted">{t('camp.prueba.hint')}</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ---- Modal medición ---- */}
|
||||||
|
<Modal isOpen={metricasModal} onClose={() => setMetricasModal(false)} title={t('camp.action.metricas')}
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => void exportarMetricas()}><Download size={16} className="mr-2" />{t('common.export')}</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setMetricasModal(false)}>{t('common.close')}</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{metricas.length === 0 ? (
|
||||||
|
<p className="text-sm text-theme-muted">{t('common.loading')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{metricas.map((m) => (
|
||||||
|
<div key={m.campana_id} className="rounded-2xl border border-theme-border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-medium text-theme-heading">{m.campana}</h4>
|
||||||
|
<span className="text-xs text-theme-muted">{t(`camp.estado.${m.estado}`)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
<Metric label={t('camp.metric.alcanzados')} value={String(m.alcanzados)} />
|
||||||
|
<Metric label={t('camp.metric.enviados')} value={String(m.enviados)} />
|
||||||
|
<Metric label={t('camp.metric.entregados')} value={m.entregados_disponible ? String(m.entregados) : t('camp.metric.noDisponible')} muted={!m.entregados_disponible} />
|
||||||
|
<Metric label={t('camp.metric.respuestas')} value={m.respuestas_disponible ? String(m.respuestas) : t('camp.metric.noDisponible')} muted={!m.respuestas_disponible} />
|
||||||
|
<Metric label={t('camp.metric.citas')} value={String(m.citas_atribuidas)} />
|
||||||
|
<Metric label={t('camp.metric.valor')} value={fmtMoney(m.valor_atribuido)} />
|
||||||
|
<Metric label={t('camp.metric.conversion')} value={`${m.conversion_pct}%`} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] text-theme-muted">{t('camp.metric.ventana', { n: m.ventana_atribucion_dias })}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ---- Modal bitácora de envíos ---- */}
|
||||||
|
<Modal isOpen={!!enviosCampana} onClose={() => setEnviosCampana(null)} title={t('camp.envios.title')}
|
||||||
|
footer={<Button variant="ghost" onClick={() => setEnviosCampana(null)}>{t('common.close')}</Button>}>
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs uppercase text-theme-muted">
|
||||||
|
<tr><th className="text-left py-2">Paciente</th><th className="text-left py-2">Canal</th><th className="text-left py-2">{t('camp.field.estado')}</th><th className="text-right py-2">{t('common.date')}</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{envios.map((e) => (
|
||||||
|
<tr key={e.id}>
|
||||||
|
<td className="py-1.5 text-theme-heading">
|
||||||
|
{e.patient || e.destino || '—'}
|
||||||
|
{e.es_prueba && <span className="ml-1 text-[10px] text-theme-muted">({t('camp.envios.esPrueba')})</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-theme-muted">{e.canal}</td>
|
||||||
|
<td className="py-1.5 text-theme-muted">{e.estado}{e.error ? ` · ${e.error.slice(0, 40)}` : ''}</td>
|
||||||
|
<td className="py-1.5 text-right text-theme-muted">{fmtDt(e.enviada_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Metric: FC<{ label: string; value: string; muted?: boolean }> = ({ label, value, muted }) => (
|
||||||
|
<div className="rounded-xl bg-theme-bg p-2">
|
||||||
|
<p className="text-[10px] uppercase text-theme-muted">{label}</p>
|
||||||
|
<p className={`text-sm font-medium ${muted ? 'text-theme-muted italic' : 'text-theme-heading'}`}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Importa el hook de sucursales con respaldo (evita dependencia circular dura).
|
||||||
|
export default Campanas;
|
||||||
@@ -24,6 +24,8 @@ import ComprasPanel from '../components/ComprasPanel';
|
|||||||
import ComisionesPanel from '../components/ComisionesPanel';
|
import ComisionesPanel from '../components/ComisionesPanel';
|
||||||
import LealtadPanel from '../components/LealtadPanel';
|
import LealtadPanel from '../components/LealtadPanel';
|
||||||
import PropinasPanel from '../components/PropinasPanel';
|
import PropinasPanel from '../components/PropinasPanel';
|
||||||
|
import CampanasPanel from '../components/CampanasPanel';
|
||||||
|
import EncuestasPanel from '../components/EncuestasPanel';
|
||||||
import LicenciaPanel from '../components/LicenciaPanel';
|
import LicenciaPanel from '../components/LicenciaPanel';
|
||||||
import ApiKeysPanel from '../components/ApiKeysPanel';
|
import ApiKeysPanel from '../components/ApiKeysPanel';
|
||||||
import WebhooksPanel from '../components/WebhooksPanel';
|
import WebhooksPanel from '../components/WebhooksPanel';
|
||||||
@@ -49,6 +51,8 @@ const TABS = [
|
|||||||
{ key: 'comisiones', label: 'Comisiones' },
|
{ key: 'comisiones', label: 'Comisiones' },
|
||||||
{ key: 'lealtad', label: 'Lealtad' },
|
{ key: 'lealtad', label: 'Lealtad' },
|
||||||
{ key: 'propinas', label: 'Propinas' },
|
{ key: 'propinas', label: 'Propinas' },
|
||||||
|
{ key: 'campanas', label: 'Campañas' },
|
||||||
|
{ key: 'encuestas', label: 'Encuestas' },
|
||||||
{ key: 'terminologia', label: 'Terminología' },
|
{ key: 'terminologia', label: 'Terminología' },
|
||||||
{ key: 'usuarios', label: 'Usuarios' },
|
{ key: 'usuarios', label: 'Usuarios' },
|
||||||
{ key: 'roles', label: 'Roles' },
|
{ key: 'roles', label: 'Roles' },
|
||||||
@@ -62,7 +66,7 @@ const TABS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
// Secciones que ve gerencia (admin del sistema ve todas).
|
||||||
const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'lealtad', 'propinas', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
|
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 SUBCATALOGOS = [
|
const SUBCATALOGOS = [
|
||||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||||
@@ -94,6 +98,8 @@ const Configuracion: FC = () => {
|
|||||||
comisiones: 'comisiones.editar',
|
comisiones: 'comisiones.editar',
|
||||||
lealtad: 'lealtad.configurar',
|
lealtad: 'lealtad.configurar',
|
||||||
propinas: 'propinas.configurar',
|
propinas: 'propinas.configurar',
|
||||||
|
campanas: 'campanas.gestionar',
|
||||||
|
encuestas: 'encuestas.gestionar',
|
||||||
recursos: 'recursos.editar',
|
recursos: 'recursos.editar',
|
||||||
licencia: 'config.editar',
|
licencia: 'config.editar',
|
||||||
api: 'api.claves',
|
api: 'api.claves',
|
||||||
@@ -458,6 +464,18 @@ const Configuracion: FC = () => {
|
|||||||
<PropinasPanel settings={settings} onSaved={setSettings} />
|
<PropinasPanel settings={settings} onSaved={setSettings} />
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : tab === 'campanas' ? (
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<CampanasPanel settings={settings} onSaved={setSettings} />
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
) : tab === 'encuestas' ? (
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<EncuestasPanel settings={settings} onSaved={setSettings} />
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
) : tab === 'terminologia' ? (
|
) : tab === 'terminologia' ? (
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ const FormularioPublico: FC = () => {
|
|||||||
<p className="mt-2 text-sm text-theme-muted">
|
<p className="mt-2 text-sm text-theme-muted">
|
||||||
{enviado ? t('public.form.registered') : t('public.form.alreadyFilled')}
|
{enviado ? t('public.form.registered') : t('public.form.alreadyFilled')}
|
||||||
</p>
|
</p>
|
||||||
|
{config.agradecimiento && (
|
||||||
|
<p className="mt-3 whitespace-pre-wrap text-sm text-theme-heading">{config.agradecimiento}</p>
|
||||||
|
)}
|
||||||
<p className="mt-4 text-xs text-theme-muted">{t('public.form.canClose')}</p>
|
<p className="mt-4 text-xs text-theme-muted">{t('public.form.canClose')}</p>
|
||||||
</div>
|
</div>
|
||||||
</Centro>
|
</Centro>
|
||||||
|
|||||||
309
frontend/src/pages/Satisfaccion.tsx
Normal file
309
frontend/src/pages/Satisfaccion.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Smile, Download, RefreshCw, MessageCircle, UserRound } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { Card, PageHeader, Button, Modal, TextArea, EmptyState, toast } from '../components/ui';
|
||||||
|
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||||
|
import { odooApi } from '../services/odoo';
|
||||||
|
import type { EncuestaRespuesta, EncuestasReporte, NpsDesglose } from '../services/odoo';
|
||||||
|
import { useAuth } from '../lib/auth';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
import { useGlosario } from '../lib/glosario';
|
||||||
|
import { exportToExcel } from '../lib/exporter';
|
||||||
|
|
||||||
|
const iso = (d: Date) => {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inicioMes = () => {
|
||||||
|
const d = new Date();
|
||||||
|
return iso(new Date(d.getFullYear(), d.getMonth(), 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const npsColor = (n: number | null): string => {
|
||||||
|
if (n === null) return 'text-theme-muted';
|
||||||
|
if (n >= 50) return 'text-emerald-600';
|
||||||
|
if (n >= 0) return 'text-amber-600';
|
||||||
|
return 'text-rose-600';
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORIA_STYLE: Record<string, string> = {
|
||||||
|
promotor: 'bg-emerald-100 text-emerald-700',
|
||||||
|
pasivo: 'bg-amber-100 text-amber-700',
|
||||||
|
detractor: 'bg-rose-100 text-rose-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Satisfaccion: FC = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { term } = useGlosario();
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { openPatient } = usePatientDrawer();
|
||||||
|
|
||||||
|
const [start, setStart] = useState(inicioMes());
|
||||||
|
const [end, setEnd] = useState(iso(new Date()));
|
||||||
|
const [reporte, setReporte] = useState<EncuestasReporte | null>(null);
|
||||||
|
const [respuestas, setRespuestas] = useState<EncuestaRespuesta[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [reply, setReply] = useState<EncuestaRespuesta | null>(null);
|
||||||
|
const [replyText, setReplyText] = useState('');
|
||||||
|
|
||||||
|
const cargar = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [rep, res] = await Promise.all([
|
||||||
|
odooApi.getEncuestasReporte(start, end),
|
||||||
|
odooApi.getEncuestasRespuestas(start, end),
|
||||||
|
]);
|
||||||
|
if (rep.status === 'success') setReporte(rep);
|
||||||
|
if (res.status === 'success') setRespuestas(res.respuestas || []);
|
||||||
|
} catch {
|
||||||
|
toast.error(t('nps.title'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [start, end, t]);
|
||||||
|
|
||||||
|
useEffect(() => { void cargar(); }, [cargar]);
|
||||||
|
|
||||||
|
const procesar = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.procesarEncuestas();
|
||||||
|
if (res.status === 'success') { toast.success(t('nps.processed')); await cargar(); }
|
||||||
|
else toast.error(res.message || '');
|
||||||
|
} catch { toast.error(t('nps.processed')); } finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const enviarRespuesta = async () => {
|
||||||
|
if (!reply || !replyText.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.responderEncuestaWhatsapp(reply.id, replyText.trim());
|
||||||
|
if (res.status === 'success') {
|
||||||
|
toast.success(res.simulado ? t('nps.replySimulated') : t('nps.replySent'));
|
||||||
|
setReply(null);
|
||||||
|
setReplyText('');
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '');
|
||||||
|
}
|
||||||
|
} catch { toast.error(t('nps.reply')); } finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportar = async () => {
|
||||||
|
if (!reporte) return;
|
||||||
|
try {
|
||||||
|
await exportToExcel({
|
||||||
|
filename: `satisfaccion-${start}_${end}`,
|
||||||
|
sheetName: 'Satisfacción',
|
||||||
|
title: `Satisfacción (NPS) ${start} → ${end}`,
|
||||||
|
subtitle: `NPS ${reporte.general.nps ?? '—'} · ${reporte.general.total} respuestas`,
|
||||||
|
columns: [
|
||||||
|
{ header: t('nps.period'), key: 'bloque' },
|
||||||
|
{ header: t('common.name'), key: 'nombre' },
|
||||||
|
{ header: t('nps.respuestas'), key: 'total', format: 'number' },
|
||||||
|
{ header: t('nps.promotores'), key: 'promotores', format: 'number' },
|
||||||
|
{ header: t('nps.pasivos'), key: 'pasivos', format: 'number' },
|
||||||
|
{ header: t('nps.detractores'), key: 'detractores', format: 'number' },
|
||||||
|
{ header: 'NPS', key: 'nps', format: 'number' },
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{ bloque: t('nps.general'), nombre: '—', ...reporte.general },
|
||||||
|
...reporte.por_sucursal.map((r) => ({ bloque: t('nps.byBranch'), ...r })),
|
||||||
|
...reporte.por_doctor.map((r) => ({ bloque: t('nps.byDoctor'), ...r })),
|
||||||
|
...reporte.por_servicio.map((r) => ({ bloque: t('nps.byService'), ...r })),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
toast.success(t('common.export'));
|
||||||
|
} catch { toast.error(t('common.export')); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportarRespuestas = async () => {
|
||||||
|
try {
|
||||||
|
await exportToExcel({
|
||||||
|
filename: `satisfaccion-respuestas-${start}_${end}`,
|
||||||
|
sheetName: 'Respuestas',
|
||||||
|
title: `Respuestas de satisfacción ${start} → ${end}`,
|
||||||
|
columns: [
|
||||||
|
{ header: term('patient_singular'), key: 'patient' },
|
||||||
|
{ header: 'NPS', key: 'nps_puntaje', format: 'number' },
|
||||||
|
{ header: t('common.status'), key: 'nps_categoria' },
|
||||||
|
{ header: t('nps.comment'), key: 'comentario' },
|
||||||
|
{ header: term('branch_singular'), key: 'sucursal' },
|
||||||
|
{ header: term('doctor_singular'), key: 'doctor' },
|
||||||
|
{ header: term('service_singular'), key: 'servicio' },
|
||||||
|
{ header: t('common.date'), key: 'llenado_at', format: 'date' },
|
||||||
|
],
|
||||||
|
rows: respuestas as unknown as Record<string, unknown>[],
|
||||||
|
});
|
||||||
|
toast.success(t('common.export'));
|
||||||
|
} catch { toast.error(t('common.export')); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const g = reporte?.general;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title={t('nps.title')} subtitle={t('nps.subtitle')}>
|
||||||
|
<PageHeader title={t('nps.title')} subtitle={t('nps.subtitle')}>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input type="date" value={start} onChange={(e) => setStart(e.target.value)}
|
||||||
|
className="rounded-xl border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading" />
|
||||||
|
<input type="date" value={end} onChange={(e) => setEnd(e.target.value)}
|
||||||
|
className="rounded-xl border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading" />
|
||||||
|
{can('encuestas.gestionar') && (
|
||||||
|
<Button variant="outline" onClick={() => void procesar()} loading={busy}>
|
||||||
|
<RefreshCw size={16} className="mr-2" />{t('nps.process')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" onClick={() => void exportar()} disabled={!reporte}>
|
||||||
|
<Download size={16} className="mr-2" />{t('nps.export')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Card><Card.Body><p className="p-6 text-sm text-theme-muted">{t('common.loading')}</p></Card.Body></Card>
|
||||||
|
) : !g || g.total === 0 ? (
|
||||||
|
<Card><Card.Body><EmptyState icon={<Smile size={28} />} title={t('nps.noData')} /></Card.Body></Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs uppercase text-theme-muted">{t('nps.general')}</p>
|
||||||
|
<p className={`mt-1 font-heading text-3xl ${npsColor(g.nps)}`}>{g.nps}</p>
|
||||||
|
<p className="text-xs text-theme-muted">{t('nps.respuestas')}: {g.total}</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs uppercase text-theme-muted">{t('nps.promotores')}</p>
|
||||||
|
<p className="mt-1 font-heading text-2xl text-emerald-600">{g.promotores}</p>
|
||||||
|
<p className="text-xs text-theme-muted">{g.promotores_pct}%</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs uppercase text-theme-muted">{t('nps.pasivos')}</p>
|
||||||
|
<p className="mt-1 font-heading text-2xl text-amber-600">{g.pasivos}</p>
|
||||||
|
<p className="text-xs text-theme-muted">{g.pasivos_pct}%</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs uppercase text-theme-muted">{t('nps.detractores')}</p>
|
||||||
|
<p className="mt-1 font-heading text-2xl text-rose-600">{g.detractores}</p>
|
||||||
|
<p className="text-xs text-theme-muted">{g.detractores_pct}%</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card><Card.Body>
|
||||||
|
<h3 className="mb-3 font-heading text-base text-theme-heading">{t('nps.trend')}</h3>
|
||||||
|
<div className="flex items-end gap-1 overflow-x-auto pb-2">
|
||||||
|
{reporte?.tendencia.map((m) => {
|
||||||
|
const alto = Math.max(4, Math.min(100, Math.abs(m.nps ?? 0)));
|
||||||
|
return (
|
||||||
|
<div key={m.mes} className="flex w-12 shrink-0 flex-col items-center gap-1">
|
||||||
|
<span className={`text-[10px] ${npsColor(m.nps)}`}>{m.nps ?? '—'}</span>
|
||||||
|
<div className="flex h-24 w-6 items-end rounded bg-theme-bg">
|
||||||
|
<div className={`w-full rounded ${(m.nps ?? 0) >= 0 ? 'bg-emerald-400' : 'bg-rose-400'}`} style={{ height: `${alto}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-theme-muted">{m.mes.slice(5)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card.Body></Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
<DesgloseCard title={t('nps.byBranch')} rows={reporte?.por_sucursal || []} />
|
||||||
|
<DesgloseCard title={t('nps.byDoctor')} rows={reporte?.por_doctor || []} />
|
||||||
|
<DesgloseCard title={t('nps.byService')} rows={reporte?.por_servicio || []} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card><Card.Body>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="font-heading text-base text-theme-heading">{t('nps.responses')}</h3>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void exportarRespuestas()} disabled={!respuestas.length}>
|
||||||
|
<Download size={14} className="mr-2" />{t('nps.exportRespuestas')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-theme-border">
|
||||||
|
{respuestas.map((r) => (
|
||||||
|
<div key={r.id} className="flex flex-wrap items-start justify-between gap-3 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium text-theme-heading">{r.patient}</span>
|
||||||
|
<span className="rounded-full bg-theme-bg px-2 py-0.5 text-[11px] font-medium text-theme-heading">
|
||||||
|
{r.nps_puntaje}/10
|
||||||
|
</span>
|
||||||
|
{r.nps_categoria && (
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${CATEGORIA_STYLE[r.nps_categoria] || 'bg-theme-bg text-theme-muted'}`}>
|
||||||
|
{t(`nps.category.${r.nps_categoria}`)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{r.comentario && <p className="mt-1 text-sm text-theme-muted">{r.comentario}</p>}
|
||||||
|
<p className="mt-1 text-[11px] text-theme-muted">
|
||||||
|
{[r.sucursal, r.doctor, r.servicio].filter(Boolean).join(' · ')} · {r.llenado_at || ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => openPatient(r.partner_id)}>
|
||||||
|
<UserRound size={14} className="mr-1" />{t('nps.openRecord')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setReply(r); setReplyText(''); }}>
|
||||||
|
<MessageCircle size={14} className="mr-1" />WhatsApp
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card.Body></Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal isOpen={!!reply} onClose={() => setReply(null)} title={t('nps.reply')}
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setReply(null)}>{t('common.cancel')}</Button>
|
||||||
|
<Button onClick={() => void enviarRespuesta()} loading={busy}>{t('nps.replySend')}</Button>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{reply && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-theme-muted">{reply.patient} · {reply.phone || '—'}</p>
|
||||||
|
<TextArea rows={4} value={replyText} onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
placeholder={t('nps.replyPlaceholder')} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DesgloseCard: FC<{ title: string; rows: NpsDesglose[] }> = ({ title, rows }) => (
|
||||||
|
<Card><Card.Body>
|
||||||
|
<h3 className="mb-3 font-heading text-base text-theme-heading">{title}</h3>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-sm text-theme-muted">—</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs uppercase text-theme-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-1.5">{title}</th>
|
||||||
|
<th className="text-right py-1.5">NPS</th>
|
||||||
|
<th className="text-right py-1.5">n</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.nombre}>
|
||||||
|
<td className="py-1.5 text-theme-heading">{r.nombre}</td>
|
||||||
|
<td className={`py-1.5 text-right font-medium ${npsColor(r.nps)}`}>{r.nps ?? '—'}</td>
|
||||||
|
<td className="py-1.5 text-right text-theme-muted">{r.total}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card.Body></Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Satisfaccion;
|
||||||
@@ -1097,6 +1097,8 @@ export interface ClinicSettings {
|
|||||||
propinas: PropinasSettings;
|
propinas: PropinasSettings;
|
||||||
licencia: LicenciaSettings;
|
licencia: LicenciaSettings;
|
||||||
portal_cliente: PortalClienteSettings;
|
portal_cliente: PortalClienteSettings;
|
||||||
|
campanas: CampanasSettings;
|
||||||
|
encuestas: EncuestasSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Licencia por plan (bloque `licencia` de clinic-settings) ----
|
// ---- Licencia por plan (bloque `licencia` de clinic-settings) ----
|
||||||
@@ -1700,6 +1702,48 @@ export const PROPINAS_DEFAULT: PropinasSettings = {
|
|||||||
cuenta_propinas: '',
|
cuenta_propinas: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Campañas por segmentos (bloque `campanas` de clinic-settings) ----
|
||||||
|
export interface CampanasSettings {
|
||||||
|
activo: boolean;
|
||||||
|
envios_por_minuto: number;
|
||||||
|
pausar_tras_errores: number;
|
||||||
|
ventana_atribucion_dias: number;
|
||||||
|
exigir_consentimiento: boolean;
|
||||||
|
hora_envio: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CAMPANAS_DEFAULT: CampanasSettings = {
|
||||||
|
activo: false,
|
||||||
|
envios_por_minuto: 20,
|
||||||
|
pausar_tras_errores: 5,
|
||||||
|
ventana_atribucion_dias: 7,
|
||||||
|
exigir_consentimiento: true,
|
||||||
|
hora_envio: '10:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Encuestas de salida (bloque `encuestas` de clinic-settings) ----
|
||||||
|
export type EncuestasCanal = 'whatsapp' | 'email' | 'ambos';
|
||||||
|
|
||||||
|
export interface EncuestasSettings {
|
||||||
|
activo: boolean;
|
||||||
|
enviar_tras_horas: number;
|
||||||
|
plantilla_id: number | null;
|
||||||
|
canal: EncuestasCanal;
|
||||||
|
recordatorio_horas: number;
|
||||||
|
solo_primera_visita: boolean;
|
||||||
|
agradecimiento: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ENCUESTAS_DEFAULT: EncuestasSettings = {
|
||||||
|
activo: false,
|
||||||
|
enviar_tras_horas: 2,
|
||||||
|
plantilla_id: null,
|
||||||
|
canal: 'whatsapp',
|
||||||
|
recordatorio_horas: 48,
|
||||||
|
solo_primera_visita: false,
|
||||||
|
agradecimiento: '',
|
||||||
|
};
|
||||||
|
|
||||||
export interface PropinaRepartoFila {
|
export interface PropinaRepartoFila {
|
||||||
employee_id: number | null;
|
employee_id: number | null;
|
||||||
empleado: string;
|
empleado: string;
|
||||||
@@ -2456,7 +2500,7 @@ export interface Cobro {
|
|||||||
// ---- Formularios digitales y consentimientos ----
|
// ---- Formularios digitales y consentimientos ----
|
||||||
export type FormularioCampoTipo =
|
export type FormularioCampoTipo =
|
||||||
| 'texto' | 'texto_largo' | 'numero' | 'fecha' | 'seleccion' | 'multi_seleccion'
|
| 'texto' | 'texto_largo' | 'numero' | 'fecha' | 'seleccion' | 'multi_seleccion'
|
||||||
| 'checkbox' | 'escala' | 'firma' | 'foto' | 'archivo' | 'consentimiento';
|
| 'checkbox' | 'escala' | 'nps' | 'firma' | 'foto' | 'archivo' | 'consentimiento';
|
||||||
export type FormularioPlantillaTipo = 'consentimiento' | 'expediente' | 'encuesta' | 'autorizacion';
|
export type FormularioPlantillaTipo = 'consentimiento' | 'expediente' | 'encuesta' | 'autorizacion';
|
||||||
export type FormularioEstado = 'borrador' | 'enviado' | 'llenado' | 'cancelado';
|
export type FormularioEstado = 'borrador' | 'enviado' | 'llenado' | 'cancelado';
|
||||||
export type FormularioMomento = 'antes' | 'despues' | 'otro';
|
export type FormularioMomento = 'antes' | 'despues' | 'otro';
|
||||||
@@ -2548,6 +2592,7 @@ export interface FormularioPublicoConfig {
|
|||||||
cita: { fecha: string | null; hora: string | null; servicio: string | null } | null;
|
cita: { fecha: string | null; hora: string | null; servicio: string | null } | null;
|
||||||
estado: FormularioEstado;
|
estado: FormularioEstado;
|
||||||
ya_llenado: boolean;
|
ya_llenado: boolean;
|
||||||
|
agradecimiento?: string;
|
||||||
plantilla: {
|
plantilla: {
|
||||||
nombre: string;
|
nombre: string;
|
||||||
tipo: FormularioPlantillaTipo;
|
tipo: FormularioPlantillaTipo;
|
||||||
@@ -2561,8 +2606,176 @@ export interface FormularioPublicoConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
// ---- Campañas y segmentos ----
|
||||||
nombre: BRAND_DEFAULT.nombre,
|
export interface SegmentoCriterios {
|
||||||
|
ultima_visita_dias: number;
|
||||||
|
sin_visita_meses: number;
|
||||||
|
cumple_mes: boolean;
|
||||||
|
cumple_proximos_dias: number;
|
||||||
|
nivel_codigo: string;
|
||||||
|
gasto_minimo: number;
|
||||||
|
sucursal: string;
|
||||||
|
servicio_id: number;
|
||||||
|
servicio_nunca_id: number;
|
||||||
|
con_membresia: boolean | null;
|
||||||
|
con_paquetes: boolean | null;
|
||||||
|
con_saldo: boolean | null;
|
||||||
|
consentimiento: '' | 'con' | 'sin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SEGMENTO_CRITERIOS_DEFAULT: SegmentoCriterios = {
|
||||||
|
ultima_visita_dias: 0,
|
||||||
|
sin_visita_meses: 0,
|
||||||
|
cumple_mes: false,
|
||||||
|
cumple_proximos_dias: 0,
|
||||||
|
nivel_codigo: '',
|
||||||
|
gasto_minimo: 0,
|
||||||
|
sucursal: '',
|
||||||
|
servicio_id: 0,
|
||||||
|
servicio_nunca_id: 0,
|
||||||
|
con_membresia: null,
|
||||||
|
con_paquetes: null,
|
||||||
|
con_saldo: null,
|
||||||
|
consentimiento: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SegmentoMuestraPaciente {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
last_visit: string | null;
|
||||||
|
nivel: string;
|
||||||
|
consentimiento: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Segmento {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
descripcion: string;
|
||||||
|
activo: boolean;
|
||||||
|
criterios: SegmentoCriterios;
|
||||||
|
ultimo_conteo: number;
|
||||||
|
muestra: SegmentoMuestraPaciente[];
|
||||||
|
preview_at: string | null;
|
||||||
|
creado_por: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CampanaCanal = 'whatsapp' | 'email' | 'ambos';
|
||||||
|
export type CampanaProgramacion = 'una_vez' | 'mensual' | 'semanal' | 'segmento';
|
||||||
|
export type CampanaEstado = 'borrador' | 'programada' | 'en_ejecucion'
|
||||||
|
| 'pausada' | 'terminada' | 'cancelada';
|
||||||
|
|
||||||
|
export interface Campana {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
descripcion: string;
|
||||||
|
activo: boolean;
|
||||||
|
segmento_id: number | null;
|
||||||
|
segmento: string;
|
||||||
|
canal: CampanaCanal;
|
||||||
|
canales: string[];
|
||||||
|
plantilla: string;
|
||||||
|
enlace: string;
|
||||||
|
programacion: CampanaProgramacion;
|
||||||
|
fecha_programada: string | null;
|
||||||
|
hora: string;
|
||||||
|
estado: CampanaEstado;
|
||||||
|
ultima_ejecucion: string | null;
|
||||||
|
proxima_ejecucion: string | null;
|
||||||
|
motivo_pausa: string;
|
||||||
|
creado_por: string;
|
||||||
|
notas: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampanaMetricas {
|
||||||
|
campana_id: number;
|
||||||
|
campana: string;
|
||||||
|
estado: CampanaEstado;
|
||||||
|
alcanzados: number;
|
||||||
|
enviados: number;
|
||||||
|
enviados_reales: number;
|
||||||
|
simulados: number;
|
||||||
|
errores: number;
|
||||||
|
cancelados: number;
|
||||||
|
pendientes: number;
|
||||||
|
entregados: number | null;
|
||||||
|
entregados_disponible: boolean;
|
||||||
|
respuestas: number | null;
|
||||||
|
respuestas_disponible: boolean;
|
||||||
|
citas_atribuidas: number;
|
||||||
|
valor_atribuido: number;
|
||||||
|
ventana_atribucion_dias: number;
|
||||||
|
conversion_pct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampanaEnvio {
|
||||||
|
id: number;
|
||||||
|
partner_id: number | null;
|
||||||
|
patient: string;
|
||||||
|
canal: string;
|
||||||
|
estado: string;
|
||||||
|
es_prueba: boolean;
|
||||||
|
destino: string;
|
||||||
|
enviada_at: string | null;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsentimientoPaciente {
|
||||||
|
partner_id: number;
|
||||||
|
name: string;
|
||||||
|
general: boolean;
|
||||||
|
whatsapp: boolean;
|
||||||
|
email: boolean;
|
||||||
|
at: string | null;
|
||||||
|
origen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Satisfacción (NPS) ----
|
||||||
|
export interface NpsResumen {
|
||||||
|
total: number;
|
||||||
|
promotores: number;
|
||||||
|
pasivos: number;
|
||||||
|
detractores: number;
|
||||||
|
promotores_pct: number;
|
||||||
|
detractores_pct: number;
|
||||||
|
pasivos_pct: number;
|
||||||
|
nps: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NpsDesglose extends NpsResumen {
|
||||||
|
nombre: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncuestaRespuesta {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
partner_id: number;
|
||||||
|
patient: string;
|
||||||
|
phone: string;
|
||||||
|
nps_puntaje: number;
|
||||||
|
nps_categoria: string;
|
||||||
|
comentario: string;
|
||||||
|
llenado_at: string | null;
|
||||||
|
cita_id: number | null;
|
||||||
|
sucursal: string;
|
||||||
|
doctor: string;
|
||||||
|
servicio: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncuestasReporte {
|
||||||
|
status: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
general: NpsResumen;
|
||||||
|
tendencia: { mes: string; total: number; nps: number | null; promotores: number; detractores: number }[];
|
||||||
|
por_sucursal: NpsDesglose[];
|
||||||
|
por_doctor: NpsDesglose[];
|
||||||
|
por_servicio: NpsDesglose[];
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = { nombre: BRAND_DEFAULT.nombre,
|
||||||
telefono: '',
|
telefono: '',
|
||||||
email: '',
|
email: '',
|
||||||
direccion: '',
|
direccion: '',
|
||||||
@@ -2586,6 +2799,8 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
|||||||
propinas: PROPINAS_DEFAULT,
|
propinas: PROPINAS_DEFAULT,
|
||||||
licencia: LICENCIA_DEFAULT,
|
licencia: LICENCIA_DEFAULT,
|
||||||
portal_cliente: PORTAL_CLIENTE_DEFAULT,
|
portal_cliente: PORTAL_CLIENTE_DEFAULT,
|
||||||
|
campanas: CAMPANAS_DEFAULT,
|
||||||
|
encuestas: ENCUESTAS_DEFAULT,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FrontendUser {
|
export interface FrontendUser {
|
||||||
@@ -3297,6 +3512,127 @@ export const odooApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- Campañas y segmentos ----
|
||||||
|
async listSegmentos(includeInactive = false): Promise<{ status: string; segmentos: Segmento[]; total: number }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/campanas/segmentos`, {
|
||||||
|
params: includeInactive ? { include_inactive: 1 } : {},
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async crearSegmento(payload: { name: string; descripcion?: string; activo?: boolean; criterios: SegmentoCriterios }): Promise<{ status: string; segmento: Segmento; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/campanas/segmentos`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async actualizarSegmento(id: number, payload: Partial<{ name: string; descripcion: string; activo: boolean; criterios: SegmentoCriterios }>): Promise<{ status: string; segmento: Segmento; message?: string }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/campanas/segmentos/${id}`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async borrarSegmento(id: number): Promise<{ status: string; desactivado: boolean; message: string }> {
|
||||||
|
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/campanas/segmentos/${id}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async previewSegmento(criterios: SegmentoCriterios): Promise<{ status: string; total: number; muestra: SegmentoMuestraPaciente[]; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/campanas/segmentos/preview`, { criterios });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async recalcularSegmento(id: number): Promise<{ status: string; segmento: Segmento; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/campanas/segmentos/${id}/preview`, {});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listCampanas(estado?: string): Promise<{ status: string; campanas: Campana[]; total: number }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/campanas`, { params: estado ? { estado } : {} });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async crearCampana(payload: Partial<Campana>): Promise<{ status: string; campana: Campana; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/campanas`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async actualizarCampana(id: number, payload: Partial<Campana>): Promise<{ status: string; campana: Campana; message?: string }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/campanas/${id}`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async borrarCampana(id: number): Promise<{ status: string; message: string }> {
|
||||||
|
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/campanas/${id}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async accionCampana(id: number, accion: 'lanzar' | 'pausar' | 'reanudar' | 'cancelar' | 'borrador' | 'procesar'): Promise<{ status: string; campana?: Campana; encoladas?: number; enviadas?: number; simuladas?: number; fallidas?: number; pausadas?: number; limite_alcanzado?: boolean; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/campanas/${id}/${accion}`, {});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async procesarCampanas(): Promise<{ status: string; encoladas: number; enviadas: number; simuladas: number; fallidas: number; pausadas: number; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/campanas/procesar`, {});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async pruebaCampana(id: number, payload: { partner_id?: number; telefono?: string; correo?: string }): Promise<{ status: string; ok: boolean; simulado?: boolean; message: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/campanas/${id}/prueba`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async metricasCampana(id: number, start?: string, end?: string): Promise<{ status: string; metricas: CampanaMetricas; message?: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/campanas/${id}/metricas`, { params: { start, end } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async metricasCampanas(start?: string, end?: string): Promise<{ status: string; metricas: CampanaMetricas[]; totales: { alcanzados: number; enviados: number; citas_atribuidas: number; valor_atribuido: number }; message?: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/campanas/metricas`, { params: { start, end } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async enviosCampana(id: number, incluirPruebas = false): Promise<{ status: string; envios: CampanaEnvio[]; total: number; message?: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/campanas/${id}/envios`, {
|
||||||
|
params: incluirPruebas ? { incluir_pruebas: 1 } : {},
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getConsentimiento(partnerId: number): Promise<{ status: string; consentimiento: ConsentimientoPaciente; message?: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/campanas/consentimiento/${partnerId}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async setConsentimiento(partnerId: number, payload: { general?: boolean; whatsapp?: boolean; email?: boolean }): Promise<{ status: string; consentimiento: ConsentimientoPaciente; message?: string }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/campanas/consentimiento/${partnerId}`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Encuestas de salida (satisfacción) ----
|
||||||
|
async getEncuestasConfig(): Promise<{ status: string; config: EncuestasSettings; plantilla: { id: number; name: string; activo: boolean } | null; plantillas: { id: number; name: string; activo: boolean; tiene_nps: boolean }[]; message?: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/encuestas`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getEncuestasReporte(start?: string, end?: string): Promise<EncuestasReporte> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/encuestas/reporte`, { params: { start, end } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getEncuestasRespuestas(start?: string, end?: string): Promise<{ status: string; respuestas: EncuestaRespuesta[]; total: number; message?: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/encuestas/respuestas`, { params: { start, end } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async procesarEncuestas(): Promise<{ status: string; encoladas: number; recordatorios: number; enviadas: number; simuladas: number; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/encuestas/procesar`, {});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async responderEncuestaWhatsapp(id: number, mensaje: string): Promise<{ status: string; simulado: boolean; notificaciones: number; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/encuestas/respuestas/${id}/whatsapp`, { mensaje });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
// ---- Lealtad: puntos y niveles ----
|
// ---- Lealtad: puntos y niveles ----
|
||||||
async getPatientLealtad(patientId: number): Promise<LealtadPaciente> {
|
async getPatientLealtad(patientId: number): Promise<LealtadPaciente> {
|
||||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/lealtad`);
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/lealtad`);
|
||||||
|
|||||||
4
odoo-addons/skeen_campanas/__init__.py
Normal file
4
odoo-addons/skeen_campanas/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
40
odoo-addons/skeen_campanas/__manifest__.py
Normal file
40
odoo-addons/skeen_campanas/__manifest__.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': 'SKEEN Campañas y Encuestas',
|
||||||
|
'version': '1.0.0',
|
||||||
|
'category': 'Healthcare',
|
||||||
|
'summary': 'Campañas por segmentos con medición y encuestas de salida (NPS)',
|
||||||
|
'description': """
|
||||||
|
Dos piezas APAGADAS por defecto (`campanas.activo=false` y
|
||||||
|
`encuestas.activo=false`):
|
||||||
|
|
||||||
|
1. Campañas por segmentos: un constructor de criterios reutilizable
|
||||||
|
sobre la base de pacientes, campañas por WhatsApp/correo con
|
||||||
|
plantilla, programación (única o recurrente), envío de prueba y
|
||||||
|
medición por campaña (alcanzados, enviados, respuestas, citas
|
||||||
|
generadas en una ventana y su valor).
|
||||||
|
|
||||||
|
2. Encuestas de salida y satisfacción (NPS): envío automático tras
|
||||||
|
completar una cita (con recordatorio opcional y opción de solo la
|
||||||
|
primera visita) y reporte de satisfacción con tendencia y desglose.
|
||||||
|
|
||||||
|
Todo el envío pasa por el motor de notificaciones existente
|
||||||
|
(`skeen.notificacion.service`), respeta el modo de prueba y queda en la
|
||||||
|
bitácora con su campaña o encuesta de origen. Un paciente sin
|
||||||
|
consentimiento de marketing nunca recibe una campaña.
|
||||||
|
""",
|
||||||
|
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||||
|
'website': 'https://skeen.mx',
|
||||||
|
'depends': [
|
||||||
|
'base', 'skeen_citas', 'skeen_pacientes', 'skeen_visitas',
|
||||||
|
'skeen_ventas', 'skeen_membresias', 'skeen_whatsapp', 'skeen_formularios',
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/campanas_cron.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'auto_install': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
4
odoo-addons/skeen_campanas/controllers/__init__.py
Normal file
4
odoo-addons/skeen_campanas/controllers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import campanas
|
||||||
|
from . import encuestas
|
||||||
576
odoo-addons/skeen_campanas/controllers/campanas.py
Normal file
576
odoo-addons/skeen_campanas/controllers/campanas.py
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""API autenticada de campañas y segmentos.
|
||||||
|
|
||||||
|
Rutas bajo `/skeen/frontend/v1/campanas/*`. Mismo guard doble que el resto del
|
||||||
|
frontend: `@require_role` para el rango del rol y `@require_perm` para la acción
|
||||||
|
concreta (`campanas.ver|gestionar|enviar|medir`).
|
||||||
|
|
||||||
|
El módulo nace APAGADO (`campanas.activo=false`): las acciones que envían
|
||||||
|
mensajes responden con un aviso claro y no tocan nada. Leer y administrar
|
||||||
|
segmentos/campañas sigue disponible para poder configurarlas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from odoo import fields, http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
from odoo.addons.skeen_whatsapp.controllers.auth import (
|
||||||
|
_json, current_user, require_role, require_perm,
|
||||||
|
)
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import get_campanas
|
||||||
|
|
||||||
|
from ..models.segmento import normalizar_criterios, validar_criterios
|
||||||
|
|
||||||
|
PREFIX = '/skeen/frontend/v1/campanas'
|
||||||
|
|
||||||
|
|
||||||
|
def _body():
|
||||||
|
try:
|
||||||
|
return json.loads(request.httprequest.get_data(as_text=True) or '{}')
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _error(message, status=400):
|
||||||
|
return _json({'status': 'error', 'message': message}, status)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dt(valor):
|
||||||
|
"""Acepta 'YYYY-MM-DD HH:MM', 'YYYY-MM-DDTHH:MM' o vacío."""
|
||||||
|
texto = str(valor or '').strip().replace('T', ' ')
|
||||||
|
if not texto:
|
||||||
|
return False
|
||||||
|
if len(texto) == 10:
|
||||||
|
texto += ' 00:00:00'
|
||||||
|
try:
|
||||||
|
return fields.Datetime.to_datetime(texto)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError('La fecha no es válida (usa AAAA-MM-DD HH:MM)')
|
||||||
|
|
||||||
|
|
||||||
|
def _seg_dict(seg):
|
||||||
|
return {
|
||||||
|
'id': seg.id,
|
||||||
|
'name': seg.name,
|
||||||
|
'descripcion': seg.descripcion or '',
|
||||||
|
'activo': seg.activo,
|
||||||
|
'criterios': seg.criterios_dict(),
|
||||||
|
'ultimo_conteo': seg.ultimo_conteo,
|
||||||
|
'muestra': seg.muestra_lista(),
|
||||||
|
'preview_at': seg.preview_at.strftime('%Y-%m-%d %H:%M') if seg.preview_at else None,
|
||||||
|
'creado_por': seg.creado_por or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _camp_dict(c):
|
||||||
|
return {
|
||||||
|
'id': c.id,
|
||||||
|
'name': c.name,
|
||||||
|
'descripcion': c.descripcion or '',
|
||||||
|
'activo': c.activo,
|
||||||
|
'segmento_id': c.segmento_id.id if c.segmento_id else None,
|
||||||
|
'segmento': c.segmento_id.name if c.segmento_id else '',
|
||||||
|
'canal': c.canal,
|
||||||
|
'canales': c.canales_lista(),
|
||||||
|
'plantilla': c.plantilla or '',
|
||||||
|
'enlace': c.enlace or '',
|
||||||
|
'programacion': c.programacion,
|
||||||
|
'fecha_programada': c.fecha_programada.strftime('%Y-%m-%d %H:%M')
|
||||||
|
if c.fecha_programada else None,
|
||||||
|
'hora': c.hora or '',
|
||||||
|
'estado': c.estado,
|
||||||
|
'ultima_ejecucion': c.ultima_ejecucion.strftime('%Y-%m-%d %H:%M')
|
||||||
|
if c.ultima_ejecucion else None,
|
||||||
|
'proxima_ejecucion': c.proxima_ejecucion.strftime('%Y-%m-%d %H:%M')
|
||||||
|
if c.proxima_ejecucion else None,
|
||||||
|
'motivo_pausa': c.motivo_pausa or '',
|
||||||
|
'creado_por': c.creado_por or '',
|
||||||
|
'notas': c.notas or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenCampanasController(http.Controller):
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Utilidades
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _cfg(self):
|
||||||
|
return get_campanas(request.env)
|
||||||
|
|
||||||
|
def _requiere_activo(self):
|
||||||
|
if not self._cfg()['activo']:
|
||||||
|
return _error('El módulo de campañas está apagado. Actívalo en '
|
||||||
|
'Configuración para enviar.', 400)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _vals_segmento(self, data, crear):
|
||||||
|
vals = {}
|
||||||
|
if 'name' in data or crear:
|
||||||
|
name = str(data.get('name') or '').strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError('El nombre del segmento es obligatorio')
|
||||||
|
vals['name'] = name[:120]
|
||||||
|
if 'descripcion' in data:
|
||||||
|
vals['descripcion'] = str(data.get('descripcion') or '')[:2000]
|
||||||
|
if 'activo' in data:
|
||||||
|
vals['activo'] = bool(data['activo'])
|
||||||
|
if 'criterios' in data or crear:
|
||||||
|
vals['criterios'] = json.dumps(
|
||||||
|
validar_criterios(data.get('criterios') or {}), ensure_ascii=False)
|
||||||
|
return vals
|
||||||
|
|
||||||
|
def _vals_campana(self, data, crear):
|
||||||
|
vals = {}
|
||||||
|
if 'name' in data or crear:
|
||||||
|
name = str(data.get('name') or '').strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError('El nombre de la campaña es obligatorio')
|
||||||
|
vals['name'] = name[:200]
|
||||||
|
if 'descripcion' in data:
|
||||||
|
vals['descripcion'] = str(data.get('descripcion') or '')[:2000]
|
||||||
|
if 'activo' in data:
|
||||||
|
vals['activo'] = bool(data['activo'])
|
||||||
|
if 'segmento_id' in data:
|
||||||
|
sid = data.get('segmento_id')
|
||||||
|
if sid in (None, '', 0):
|
||||||
|
vals['segmento_id'] = False
|
||||||
|
else:
|
||||||
|
seg = request.env['skeen.segmento'].sudo().browse(int(sid))
|
||||||
|
if not seg.exists():
|
||||||
|
raise ValueError('El segmento no existe')
|
||||||
|
vals['segmento_id'] = seg.id
|
||||||
|
if 'canal' in data:
|
||||||
|
canal = str(data.get('canal') or '').strip()
|
||||||
|
if canal not in ('whatsapp', 'email', 'ambos'):
|
||||||
|
raise ValueError('El canal debe ser whatsapp, email o ambos')
|
||||||
|
vals['canal'] = canal
|
||||||
|
if 'plantilla' in data:
|
||||||
|
vals['plantilla'] = str(data.get('plantilla') or '')[:2000]
|
||||||
|
if 'enlace' in data:
|
||||||
|
vals['enlace'] = str(data.get('enlace') or '').strip()[:500]
|
||||||
|
if 'programacion' in data or crear:
|
||||||
|
prog = str(data.get('programacion') or 'una_vez').strip()
|
||||||
|
if prog not in ('una_vez', 'mensual', 'semanal', 'segmento'):
|
||||||
|
raise ValueError('Programación no válida')
|
||||||
|
vals['programacion'] = prog
|
||||||
|
if 'fecha_programada' in data:
|
||||||
|
vals['fecha_programada'] = _parse_dt(data.get('fecha_programada'))
|
||||||
|
if 'hora' in data:
|
||||||
|
hora = str(data.get('hora') or '').strip()
|
||||||
|
partes = hora.split(':')
|
||||||
|
if len(partes) != 2:
|
||||||
|
raise ValueError('La hora debe tener el formato HH:MM')
|
||||||
|
try:
|
||||||
|
h, m = int(partes[0]), int(partes[1])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError('La hora debe tener el formato HH:MM')
|
||||||
|
if not (0 <= h <= 23 and 0 <= m <= 59):
|
||||||
|
raise ValueError('La hora debe tener el formato HH:MM')
|
||||||
|
vals['hora'] = '%02d:%02d' % (h, m)
|
||||||
|
if 'notas' in data:
|
||||||
|
vals['notas'] = str(data.get('notas') or '')[:2000]
|
||||||
|
return vals
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Segmentos
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX + '/segmentos', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('campanas.ver')
|
||||||
|
def listar_segmentos(self, **kw):
|
||||||
|
try:
|
||||||
|
incluir_inactivos = str(kw.get('include_inactive') or '') in ('1', 'true', 'True')
|
||||||
|
dominio = [] if incluir_inactivos else [('activo', '=', True)]
|
||||||
|
segmentos = request.env['skeen.segmento'].sudo().search(dominio, order='name, id')
|
||||||
|
return _json({'status': 'success',
|
||||||
|
'segmentos': [_seg_dict(s) for s in segmentos],
|
||||||
|
'total': len(segmentos)})
|
||||||
|
except Exception as e:
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/segmentos/preview', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('campanas.ver')
|
||||||
|
def preview_criterios(self, **kw):
|
||||||
|
"""Vista previa (conteo + muestra) SIN guardar el segmento."""
|
||||||
|
try:
|
||||||
|
data = _body()
|
||||||
|
resultado = request.env['skeen.segmento'].sudo().previsualizar(
|
||||||
|
data.get('criterios') or {})
|
||||||
|
return _json({'status': 'success', **resultado})
|
||||||
|
except Exception as e:
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/segmentos/<int:segmento_id>', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('campanas.ver')
|
||||||
|
def get_segmento(self, segmento_id, **kw):
|
||||||
|
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
|
||||||
|
if not seg.exists():
|
||||||
|
return _error('Segmento no encontrado', 404)
|
||||||
|
return _json({'status': 'success', 'segmento': _seg_dict(seg)})
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/segmentos', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
@require_perm('campanas.gestionar')
|
||||||
|
def crear_segmento(self, **kw):
|
||||||
|
data = _body()
|
||||||
|
try:
|
||||||
|
vals = self._vals_segmento(data, crear=True)
|
||||||
|
usuario = current_user(request.env)
|
||||||
|
vals['creado_por'] = usuario.name if usuario else ''
|
||||||
|
seg = request.env['skeen.segmento'].sudo().create(vals)
|
||||||
|
seg.guardar_preview()
|
||||||
|
return _json({'status': 'success', 'segmento': _seg_dict(seg)}, 201)
|
||||||
|
except ValueError as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/segmentos/<int:segmento_id>', type='http', auth='none',
|
||||||
|
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
@require_perm('campanas.gestionar')
|
||||||
|
def actualizar_segmento(self, segmento_id, **kw):
|
||||||
|
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
|
||||||
|
if not seg.exists():
|
||||||
|
return _error('Segmento no encontrado', 404)
|
||||||
|
data = _body()
|
||||||
|
try:
|
||||||
|
seg.write(self._vals_segmento(data, crear=False))
|
||||||
|
if 'criterios' in data:
|
||||||
|
seg.guardar_preview()
|
||||||
|
return _json({'status': 'success', 'segmento': _seg_dict(seg)})
|
||||||
|
except ValueError as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/segmentos/<int:segmento_id>', type='http', auth='none',
|
||||||
|
methods=['DELETE'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
@require_perm('campanas.gestionar')
|
||||||
|
def borrar_segmento(self, segmento_id, **kw):
|
||||||
|
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
|
||||||
|
if not seg.exists():
|
||||||
|
return _error('Segmento no encontrado', 404)
|
||||||
|
try:
|
||||||
|
usadas = request.env['skeen.campana'].sudo().search_count(
|
||||||
|
[('segmento_id', '=', seg.id)])
|
||||||
|
if usadas:
|
||||||
|
seg.write({'activo': False})
|
||||||
|
return _json({'status': 'success', 'desactivado': True,
|
||||||
|
'message': 'El segmento está en uso: se desactivó '
|
||||||
|
'en lugar de borrarse.'})
|
||||||
|
seg.unlink()
|
||||||
|
return _json({'status': 'success', 'desactivado': False,
|
||||||
|
'message': 'Segmento eliminado'})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/segmentos/<int:segmento_id>/preview', type='http',
|
||||||
|
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('campanas.ver')
|
||||||
|
def recalcular_segmento(self, segmento_id, **kw):
|
||||||
|
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
|
||||||
|
if not seg.exists():
|
||||||
|
return _error('Segmento no encontrado', 404)
|
||||||
|
try:
|
||||||
|
seg.guardar_preview()
|
||||||
|
return _json({'status': 'success', 'segmento': _seg_dict(seg)})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Consentimiento
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX + '/consentimiento/<int:partner_id>', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('campanas.ver')
|
||||||
|
def get_consentimiento(self, partner_id, **kw):
|
||||||
|
partner = request.env['res.partner'].sudo().browse(partner_id)
|
||||||
|
if not partner.exists():
|
||||||
|
return _error('Paciente no encontrado', 404)
|
||||||
|
return _json({'status': 'success', 'consentimiento': _consent_dict(partner)})
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/consentimiento/<int:partner_id>', type='http', auth='none',
|
||||||
|
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
@require_perm('campanas.gestionar')
|
||||||
|
def set_consentimiento(self, partner_id, **kw):
|
||||||
|
partner = request.env['res.partner'].sudo().browse(partner_id)
|
||||||
|
if not partner.exists() or not partner.is_patient:
|
||||||
|
return _error('Paciente no encontrado', 404)
|
||||||
|
data = _body()
|
||||||
|
try:
|
||||||
|
usuario = current_user(request.env)
|
||||||
|
request.env['res.partner']._marcar_consentimiento(
|
||||||
|
partner,
|
||||||
|
general=data.get('general'),
|
||||||
|
whatsapp=data.get('whatsapp'),
|
||||||
|
email=data.get('email'),
|
||||||
|
origen=(usuario.name if usuario else '') or str(data.get('origen') or ''))
|
||||||
|
return _json({'status': 'success',
|
||||||
|
'consentimiento': _consent_dict(partner)})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Campañas
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX, type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('campanas.ver')
|
||||||
|
def listar_campanas(self, **kw):
|
||||||
|
try:
|
||||||
|
dominio = []
|
||||||
|
if kw.get('estado'):
|
||||||
|
dominio.append(('estado', '=', kw['estado']))
|
||||||
|
campanas = request.env['skeen.campana'].sudo().search(
|
||||||
|
dominio, order='create_date desc, id desc', limit=500)
|
||||||
|
return _json({'status': 'success',
|
||||||
|
'campanas': [_camp_dict(c) for c in campanas],
|
||||||
|
'total': len(campanas)})
|
||||||
|
except Exception as e:
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
@http.route(PREFIX, type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
@require_perm('campanas.gestionar')
|
||||||
|
def crear_campana(self, **kw):
|
||||||
|
data = _body()
|
||||||
|
try:
|
||||||
|
vals = self._vals_campana(data, crear=True)
|
||||||
|
usuario = current_user(request.env)
|
||||||
|
vals['creado_por'] = usuario.name if usuario else ''
|
||||||
|
campana = request.env['skeen.campana'].sudo().create(vals)
|
||||||
|
return _json({'status': 'success', 'campana': _camp_dict(campana)}, 201)
|
||||||
|
except ValueError as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/<int:campana_id>', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('campanas.ver')
|
||||||
|
def get_campana(self, campana_id, **kw):
|
||||||
|
campana = request.env['skeen.campana'].sudo().browse(campana_id)
|
||||||
|
if not campana.exists():
|
||||||
|
return _error('Campaña no encontrada', 404)
|
||||||
|
return _json({'status': 'success', 'campana': _camp_dict(campana)})
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/<int:campana_id>', type='http', auth='none',
|
||||||
|
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
@require_perm('campanas.gestionar')
|
||||||
|
def actualizar_campana(self, campana_id, **kw):
|
||||||
|
campana = request.env['skeen.campana'].sudo().browse(campana_id)
|
||||||
|
if not campana.exists():
|
||||||
|
return _error('Campaña no encontrada', 404)
|
||||||
|
data = _body()
|
||||||
|
try:
|
||||||
|
campana.write(self._vals_campana(data, crear=False))
|
||||||
|
return _json({'status': 'success', 'campana': _camp_dict(campana)})
|
||||||
|
except ValueError as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/<int:campana_id>', type='http', auth='none',
|
||||||
|
methods=['DELETE'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
@require_perm('campanas.gestionar')
|
||||||
|
def borrar_campana(self, campana_id, **kw):
|
||||||
|
campana = request.env['skeen.campana'].sudo().browse(campana_id)
|
||||||
|
if not campana.exists():
|
||||||
|
return _error('Campaña no encontrada', 404)
|
||||||
|
try:
|
||||||
|
campana.write({'activo': False, 'estado': 'cancelada'})
|
||||||
|
return _json({'status': 'success',
|
||||||
|
'message': 'La campaña se archivó (cancelada y desactivada).'})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Acciones
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _accion(self, campana_id, accion, **kw):
|
||||||
|
campana = request.env['skeen.campana'].sudo().browse(campana_id)
|
||||||
|
if not campana.exists():
|
||||||
|
return _error('Campaña no encontrada', 404)
|
||||||
|
try:
|
||||||
|
if accion == 'lanzar':
|
||||||
|
if not campana.segmento_id:
|
||||||
|
return _error('La campaña necesita un segmento para lanzarse')
|
||||||
|
if not (campana.plantilla or '').strip():
|
||||||
|
return _error('La campaña necesita un mensaje para lanzarse')
|
||||||
|
if campana.programacion == 'una_vez' and not campana.fecha_programada:
|
||||||
|
campana.fecha_programada = fields.Datetime.now()
|
||||||
|
campana.action_lanzar()
|
||||||
|
elif accion == 'pausar':
|
||||||
|
campana.action_pausar()
|
||||||
|
elif accion == 'reanudar':
|
||||||
|
campana.action_reanudar()
|
||||||
|
elif accion == 'cancelar':
|
||||||
|
campana.action_cancelar()
|
||||||
|
elif accion == 'borrador':
|
||||||
|
campana.action_borrador()
|
||||||
|
elif accion == 'procesar':
|
||||||
|
servicio = request.env['skeen.campana.service'].sudo()
|
||||||
|
resultado = servicio.ejecutar()
|
||||||
|
return _json({'status': 'success', **resultado,
|
||||||
|
'campana': _camp_dict(campana)})
|
||||||
|
return _json({'status': 'success', 'campana': _camp_dict(campana)})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/<int:campana_id>/<string:accion>', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
@require_perm('campanas.enviar')
|
||||||
|
def accion_campana(self, campana_id, accion, **kw):
|
||||||
|
bloqueo = self._requiere_activo()
|
||||||
|
if bloqueo:
|
||||||
|
return bloqueo
|
||||||
|
if accion not in ('lanzar', 'pausar', 'reanudar', 'cancelar', 'borrador',
|
||||||
|
'procesar'):
|
||||||
|
return _error('Acción no válida', 404)
|
||||||
|
return self._accion(campana_id, accion, **kw)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/procesar', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
@require_perm('campanas.enviar')
|
||||||
|
def procesar_campanas(self, **kw):
|
||||||
|
bloqueo = self._requiere_activo()
|
||||||
|
if bloqueo:
|
||||||
|
return bloqueo
|
||||||
|
try:
|
||||||
|
resultado = request.env['skeen.campana.service'].sudo().ejecutar()
|
||||||
|
return _json({'status': 'success', **resultado})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/<int:campana_id>/prueba', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
@require_perm('campanas.enviar')
|
||||||
|
def prueba_campana(self, campana_id, **kw):
|
||||||
|
bloqueo = self._requiere_activo()
|
||||||
|
if bloqueo:
|
||||||
|
return bloqueo
|
||||||
|
campana = request.env['skeen.campana'].sudo().browse(campana_id)
|
||||||
|
if not campana.exists():
|
||||||
|
return _error('Campaña no encontrada', 404)
|
||||||
|
data = _body()
|
||||||
|
partner = None
|
||||||
|
if data.get('partner_id'):
|
||||||
|
partner = request.env['res.partner'].sudo().browse(int(data['partner_id']))
|
||||||
|
if not partner.exists() or not partner.is_patient:
|
||||||
|
return _error('El paciente indicado no existe', 404)
|
||||||
|
try:
|
||||||
|
resultado = request.env['skeen.campana.service'].sudo().enviar_prueba(
|
||||||
|
campana, partner=partner,
|
||||||
|
telefono=str(data.get('telefono') or '').strip(),
|
||||||
|
correo=str(data.get('correo') or '').strip())
|
||||||
|
return _json({'status': 'success' if resultado.get('ok') else 'error',
|
||||||
|
**resultado}, 200 if resultado.get('ok') else 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Medición
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX + '/<int:campana_id>/metricas', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('campanas.medir')
|
||||||
|
def metricas_campana(self, campana_id, **kw):
|
||||||
|
campana = request.env['skeen.campana'].sudo().browse(campana_id)
|
||||||
|
if not campana.exists():
|
||||||
|
return _error('Campaña no encontrada', 404)
|
||||||
|
try:
|
||||||
|
servicio = request.env['skeen.campana.service'].sudo()
|
||||||
|
campana_res = campana.with_context(lang=campana.env.user.lang)
|
||||||
|
datos = campana_res.metricas(start=kw.get('start'), end=kw.get('end'))
|
||||||
|
return _json({'status': 'success', 'metricas': datos})
|
||||||
|
except Exception as e:
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/metricas', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('campanas.medir')
|
||||||
|
def metricas_todas(self, **kw):
|
||||||
|
try:
|
||||||
|
servicio = request.env['skeen.campana.service'].sudo()
|
||||||
|
campanas = request.env['skeen.campana'].sudo().search([], limit=500)
|
||||||
|
datos = [campana.metricas(start=kw.get('start'), end=kw.get('end'))
|
||||||
|
for campana in campanas]
|
||||||
|
total = {
|
||||||
|
'alcanzados': sum(d['alcanzados'] for d in datos),
|
||||||
|
'enviados': sum(d['enviados'] for d in datos),
|
||||||
|
'citas_atribuidas': sum(d['citas_atribuidas'] for d in datos),
|
||||||
|
'valor_atribuido': round(sum(d['valor_atribuido'] for d in datos), 2),
|
||||||
|
}
|
||||||
|
return _json({'status': 'success', 'metricas': datos, 'totales': total})
|
||||||
|
except Exception as e:
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/<int:campana_id>/envios', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('campanas.medir')
|
||||||
|
def envios_campana(self, campana_id, **kw):
|
||||||
|
campana = request.env['skeen.campana'].sudo().browse(campana_id)
|
||||||
|
if not campana.exists():
|
||||||
|
return _error('Campaña no encontrada', 404)
|
||||||
|
try:
|
||||||
|
servicio = request.env['skeen.campana.service'].sudo()
|
||||||
|
dominio = [('campana_id', '=', campana.id)]
|
||||||
|
if str(kw.get('incluir_pruebas') or '') not in ('1', 'true', 'True'):
|
||||||
|
dominio.append(('es_prueba', '=', False))
|
||||||
|
notifs = request.env['skeen.notificacion'].sudo().search(
|
||||||
|
dominio, order='id desc', limit=500)
|
||||||
|
return _json({'status': 'success',
|
||||||
|
'envios': [servicio._notif_dict(n) for n in notifs],
|
||||||
|
'total': len(notifs)})
|
||||||
|
except Exception as e:
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
def _consent_dict(partner):
|
||||||
|
return {
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'name': partner.name,
|
||||||
|
'general': bool(partner.consentimiento_marketing),
|
||||||
|
'whatsapp': bool(partner.consentimiento_whatsapp),
|
||||||
|
'email': bool(partner.consentimiento_email),
|
||||||
|
'at': partner.consentimiento_at.strftime('%Y-%m-%d %H:%M')
|
||||||
|
if partner.consentimiento_at else None,
|
||||||
|
'origen': partner.consentimiento_origen or '',
|
||||||
|
}
|
||||||
258
odoo-addons/skeen_campanas/controllers/encuestas.py
Normal file
258
odoo-addons/skeen_campanas/controllers/encuestas.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""API autenticada de encuestas de satisfacción (NPS).
|
||||||
|
|
||||||
|
Rutas bajo `/skeen/frontend/v1/encuestas/*`. Guard doble `@require_role` +
|
||||||
|
`@require_perm` (`encuestas.ver|gestionar|medir`).
|
||||||
|
|
||||||
|
Con `encuestas.activo=false` (default) no se encola ni se envía nada; el
|
||||||
|
reporte simplemente muestra lo que haya (cero en una instancia recién
|
||||||
|
instalada).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from odoo import fields, http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
from odoo.addons.skeen_whatsapp.controllers.auth import (
|
||||||
|
_json, current_user, require_role, require_perm,
|
||||||
|
)
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import get_encuestas
|
||||||
|
|
||||||
|
PREFIX = '/skeen/frontend/v1/encuestas'
|
||||||
|
|
||||||
|
|
||||||
|
def _error(message, status=400):
|
||||||
|
return _json({'status': 'error', 'message': message}, status)
|
||||||
|
|
||||||
|
|
||||||
|
def _body():
|
||||||
|
try:
|
||||||
|
return json.loads(request.httprequest.get_data(as_text=True) or '{}')
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _rango(kw):
|
||||||
|
hoy = date.today()
|
||||||
|
start = kw.get('start') or (hoy.replace(day=1)).strftime('%Y-%m-%d')
|
||||||
|
end = kw.get('end') or hoy.strftime('%Y-%m-%d')
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _respuesta_dict(r, sucursales=None):
|
||||||
|
cita = r.cita_id
|
||||||
|
sucursal = ''
|
||||||
|
if cita and cita.branch:
|
||||||
|
sucursal = (sucursales or {}).get(cita.branch, cita.branch)
|
||||||
|
return {
|
||||||
|
'id': r.id,
|
||||||
|
'name': r.name,
|
||||||
|
'partner_id': r.partner_id.id,
|
||||||
|
'patient': r.partner_id.name,
|
||||||
|
'phone': r.partner_id.whatsapp or r.partner_id.phone or '',
|
||||||
|
'nps_puntaje': r.nps_puntaje,
|
||||||
|
'nps_categoria': r.nps_categoria or '',
|
||||||
|
'comentario': r.nps_comentario or '',
|
||||||
|
'llenado_at': r.llenado_at.strftime('%Y-%m-%d %H:%M') if r.llenado_at else None,
|
||||||
|
'cita_id': cita.id if cita else None,
|
||||||
|
'sucursal': sucursal,
|
||||||
|
'doctor': cita.doctor_id.name if cita and cita.doctor_id else '',
|
||||||
|
'servicio': cita.servicio_id.name if cita and cita.servicio_id else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenEncuestasController(http.Controller):
|
||||||
|
|
||||||
|
def _cfg(self):
|
||||||
|
return get_encuestas(request.env)
|
||||||
|
|
||||||
|
def _plantilla(self):
|
||||||
|
pid = self._cfg().get('plantilla_id')
|
||||||
|
if not pid:
|
||||||
|
return request.env['skeen.formulario.plantilla'].sudo().browse()
|
||||||
|
p = request.env['skeen.formulario.plantilla'].sudo().browse(int(pid))
|
||||||
|
return p if p.exists() else request.env['skeen.formulario.plantilla'].sudo().browse()
|
||||||
|
|
||||||
|
def _respuestas(self, start, end, solo_nps=False):
|
||||||
|
plantilla = self._plantilla()
|
||||||
|
if not plantilla:
|
||||||
|
return request.env['skeen.formulario.respuesta'].sudo().browse()
|
||||||
|
dominio = [
|
||||||
|
('plantilla_id', '=', plantilla.id),
|
||||||
|
('estado', '=', 'llenado'),
|
||||||
|
('llenado_at', '>=', '%s 00:00:00' % start),
|
||||||
|
('llenado_at', '<=', '%s 23:59:59' % end),
|
||||||
|
]
|
||||||
|
if solo_nps:
|
||||||
|
dominio.append(('nps_puntaje', '>=', 0))
|
||||||
|
return request.env['skeen.formulario.respuesta'].sudo().search(
|
||||||
|
dominio, order='llenado_at desc, id desc')
|
||||||
|
|
||||||
|
def _sucursales(self):
|
||||||
|
return {s.code: s.name for s in
|
||||||
|
request.env['skeen.sucursal'].sudo().search([])}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Configuración
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX, type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('encuestas.ver')
|
||||||
|
def config_encuestas(self, **kw):
|
||||||
|
try:
|
||||||
|
cfg = self._cfg()
|
||||||
|
plantillas = request.env['skeen.formulario.plantilla'].sudo().search(
|
||||||
|
[('tipo', '=', 'encuesta')], order='name')
|
||||||
|
plantilla = self._plantilla()
|
||||||
|
return _json({
|
||||||
|
'status': 'success',
|
||||||
|
'config': cfg,
|
||||||
|
'plantilla': {
|
||||||
|
'id': plantilla.id, 'name': plantilla.name,
|
||||||
|
'activo': plantilla.activo,
|
||||||
|
} if plantilla else None,
|
||||||
|
'plantillas': [{
|
||||||
|
'id': p.id, 'name': p.name, 'activo': p.activo,
|
||||||
|
'tiene_nps': any(c.get('tipo') == 'nps' for c in p.campos_lista()),
|
||||||
|
} for p in plantillas],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Reporte de satisfacción
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX + '/reporte', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('encuestas.medir')
|
||||||
|
def reporte_satisfaccion(self, **kw):
|
||||||
|
try:
|
||||||
|
start, end = _rango(kw)
|
||||||
|
Respuesta = request.env['skeen.formulario.respuesta'].sudo()
|
||||||
|
respuestas = self._respuestas(start, end, solo_nps=True)
|
||||||
|
general = Respuesta.nps_de(respuestas)
|
||||||
|
sucursales = self._sucursales()
|
||||||
|
|
||||||
|
# Tendencia por mes (últimos 12 meses, independiente del rango).
|
||||||
|
hoy = date.today()
|
||||||
|
tendencia = []
|
||||||
|
for i in range(11, -1, -1):
|
||||||
|
m = (hoy.replace(day=1) - timedelta(days=i * 31)).replace(day=1)
|
||||||
|
fin = (m.replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)
|
||||||
|
del_mes = Respuesta.search([
|
||||||
|
('plantilla_id', '=', self._plantilla().id),
|
||||||
|
('estado', '=', 'llenado'),
|
||||||
|
('nps_puntaje', '>=', 0),
|
||||||
|
('llenado_at', '>=', '%s 00:00:00' % m.strftime('%Y-%m-%d')),
|
||||||
|
('llenado_at', '<=', '%s 23:59:59' % fin.strftime('%Y-%m-%d')),
|
||||||
|
])
|
||||||
|
datos = Respuesta.nps_de(del_mes)
|
||||||
|
tendencia.append({
|
||||||
|
'mes': m.strftime('%Y-%m'),
|
||||||
|
'total': datos['total'],
|
||||||
|
'nps': datos['nps'],
|
||||||
|
'promotores': datos['promotores'],
|
||||||
|
'detractores': datos['detractores'],
|
||||||
|
})
|
||||||
|
|
||||||
|
def desglose(etiqueta):
|
||||||
|
grupos = {}
|
||||||
|
for r in respuestas:
|
||||||
|
valor = etiqueta(r) or 'Sin dato'
|
||||||
|
grupos.setdefault(valor, Respuesta.browse())
|
||||||
|
grupos[valor] |= r
|
||||||
|
salida = []
|
||||||
|
for valor, resps in grupos.items():
|
||||||
|
d = Respuesta.nps_de(resps)
|
||||||
|
salida.append({'nombre': valor, **d})
|
||||||
|
salida.sort(key=lambda x: (-(x['nps'] if x['nps'] is not None else -999),
|
||||||
|
-x['total']))
|
||||||
|
return salida
|
||||||
|
|
||||||
|
return _json({
|
||||||
|
'status': 'success',
|
||||||
|
'start': start,
|
||||||
|
'end': end,
|
||||||
|
'general': general,
|
||||||
|
'tendencia': tendencia,
|
||||||
|
'por_sucursal': desglose(lambda r: (r.cita_id.branch
|
||||||
|
and sucursales.get(r.cita_id.branch))
|
||||||
|
or (r.cita_id.branch if r.cita_id else '')),
|
||||||
|
'por_doctor': desglose(
|
||||||
|
lambda r: r.cita_id.doctor_id.name if r.cita_id and r.cita_id.doctor_id else ''),
|
||||||
|
'por_servicio': desglose(
|
||||||
|
lambda r: r.cita_id.servicio_id.name if r.cita_id and r.cita_id.servicio_id else ''),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/respuestas', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('encuestas.medir')
|
||||||
|
def listar_respuestas(self, **kw):
|
||||||
|
try:
|
||||||
|
start, end = _rango(kw)
|
||||||
|
respuestas = self._respuestas(start, end, solo_nps=False)
|
||||||
|
sucursales = self._sucursales()
|
||||||
|
return _json({
|
||||||
|
'status': 'success',
|
||||||
|
'respuestas': [_respuesta_dict(r, sucursales) for r in respuestas[:500]],
|
||||||
|
'total': len(respuestas),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Acciones
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX + '/procesar', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
@require_perm('encuestas.gestionar')
|
||||||
|
def procesar_encuestas(self, **kw):
|
||||||
|
if not self._cfg()['activo']:
|
||||||
|
return _error('Las encuestas de salida están apagadas. Actívalas en '
|
||||||
|
'Configuración.', 400)
|
||||||
|
try:
|
||||||
|
resultado = request.env['skeen.encuesta.service'].sudo().ejecutar()
|
||||||
|
return _json({'status': 'success', **resultado})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 500)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/respuestas/<int:respuesta_id>/whatsapp', type='http',
|
||||||
|
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
@require_perm('encuestas.medir')
|
||||||
|
def responder_whatsapp(self, respuesta_id, **kw):
|
||||||
|
"""Responde por WhatsApp al paciente desde el reporte (vía el motor)."""
|
||||||
|
respuesta = request.env['skeen.formulario.respuesta'].sudo().browse(respuesta_id)
|
||||||
|
if not respuesta.exists():
|
||||||
|
return _error('Respuesta no encontrada', 404)
|
||||||
|
data = _body()
|
||||||
|
mensaje = str(data.get('mensaje') or '').strip()
|
||||||
|
if not mensaje:
|
||||||
|
return _error('Escribe un mensaje para enviar')
|
||||||
|
partner = respuesta.partner_id
|
||||||
|
servicio = request.env['skeen.notificacion.service'].sudo()
|
||||||
|
try:
|
||||||
|
import uuid as _uuid
|
||||||
|
notifs = servicio.encolar(
|
||||||
|
partner=partner, tipo='manual', canal='whatsapp',
|
||||||
|
cuando=fields.Datetime.now(), mensaje=mensaje[:1000],
|
||||||
|
clave_unica='encuesta-wa:r%s:%s' % (respuesta.id, _uuid.uuid4().hex[:10]))
|
||||||
|
if not notifs:
|
||||||
|
return _error('No se pudo enviar. Revisa que las notificaciones '
|
||||||
|
'estén activas y el paciente tenga teléfono.', 400)
|
||||||
|
cfg = servicio._cfg()
|
||||||
|
estados = [servicio._intentar(n, cfg) for n in notifs]
|
||||||
|
return _json({'status': 'success',
|
||||||
|
'simulado': bool(cfg['modo_prueba']),
|
||||||
|
'notificaciones': len(notifs)})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _error(str(e), 500)
|
||||||
31
odoo-addons/skeen_campanas/data/campanas_cron.xml
Normal file
31
odoo-addons/skeen_campanas/data/campanas_cron.xml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<!-- Motor de campañas. Con `campanas.activo=false` (default) el método
|
||||||
|
regresa sin tocar nada: cero filas y cero envíos. Cada 5 minutos
|
||||||
|
encola las campañas vencidas y procesa la cola respetando el límite
|
||||||
|
de envíos por minuto y la pausa por errores seguidos. -->
|
||||||
|
<record id="cron_campanas" model="ir.cron">
|
||||||
|
<field name="name">SKEEN Campañas: encolar y enviar por segmentos</field>
|
||||||
|
<field name="model_id" ref="model_skeen_campana_service"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_tick()</field>
|
||||||
|
<field name="interval_number">5</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
<!-- Encuestas de salida. Con `encuestas.activo=false` (default) no se
|
||||||
|
encola ni se envía nada: el cron regresa sin efectos. -->
|
||||||
|
<record id="cron_encuestas" model="ir.cron">
|
||||||
|
<field name="name">SKEEN Encuestas: enviar tras la cita y recordar</field>
|
||||||
|
<field name="model_id" ref="model_skeen_encuesta_service"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_tick()</field>
|
||||||
|
<field name="interval_number">15</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
6
odoo-addons/skeen_campanas/models/__init__.py
Normal file
6
odoo-addons/skeen_campanas/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import consentimiento
|
||||||
|
from . import segmento
|
||||||
|
from . import campana
|
||||||
|
from . import encuesta
|
||||||
561
odoo-addons/skeen_campanas/models/campana.py
Normal file
561
odoo-addons/skeen_campanas/models/campana.py
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Campañas por segmentos con medición.
|
||||||
|
|
||||||
|
`campanas.activo=false` (default) apaga TODO: no corre el cron, no se encola ni
|
||||||
|
se envía nada y las campañas nuevas nacen en borrador. El envío real siempre
|
||||||
|
pasa por el motor de notificaciones (`skeen.notificacion.service`), que respeta
|
||||||
|
el modo de prueba y registra cada intento en la bitácora con su campaña.
|
||||||
|
|
||||||
|
Dos cuidados propios de WhatsApp:
|
||||||
|
|
||||||
|
* límite de ENVÍOS POR MINUTO configurable (`campanas.envios_por_minuto`), y
|
||||||
|
* PAUSA AUTOMÁTICA si se acumulan errores seguidos
|
||||||
|
(`campanas.pausar_tras_errores`).
|
||||||
|
|
||||||
|
Y una regla dura: un paciente SIN consentimiento de marketing nunca recibe una
|
||||||
|
campaña, aunque cumpla el criterio del segmento. Los avisos transaccionales
|
||||||
|
(recordatorio de cita, encuesta de salida) no dependen del consentimiento.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
import re
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import (
|
||||||
|
get_campanas, get_notificaciones, get_settings as clinic_get_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo'), ('ambos', 'Ambos')]
|
||||||
|
PROGRAMACION = [
|
||||||
|
('una_vez', 'Una sola vez'),
|
||||||
|
('mensual', 'Mensual'),
|
||||||
|
('semanal', 'Semanal'),
|
||||||
|
('segmento', 'Según el segmento (recurrente)'),
|
||||||
|
]
|
||||||
|
ESTADOS = [
|
||||||
|
('borrador', 'Borrador'),
|
||||||
|
('programada', 'Programada'),
|
||||||
|
('en_ejecucion', 'En ejecución'),
|
||||||
|
('pausada', 'Pausada'),
|
||||||
|
('terminada', 'Terminada'),
|
||||||
|
('cancelada', 'Cancelada'),
|
||||||
|
]
|
||||||
|
# Variables disponibles en la plantilla de una campaña.
|
||||||
|
VARIABLES = ('nombre', 'servicio', 'sucursal', 'enlace', 'doctor', 'fecha',
|
||||||
|
'hora', 'telefono')
|
||||||
|
_VARIABLE_RE = re.compile(r'\{([a-zA-Z_]+)\}')
|
||||||
|
PLANTILLA_MAX = 2000
|
||||||
|
MIN_SEGUNDOS_TASA = 60
|
||||||
|
|
||||||
|
|
||||||
|
def variables_de(texto):
|
||||||
|
usadas = []
|
||||||
|
for m in _VARIABLE_RE.finditer(str(texto or '')):
|
||||||
|
if m.group(1) not in usadas:
|
||||||
|
usadas.append(m.group(1))
|
||||||
|
return usadas
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenNotificacion(models.Model):
|
||||||
|
_inherit = 'skeen.notificacion'
|
||||||
|
|
||||||
|
campana_id = fields.Many2one('skeen.campana', string='Campaña', index=True,
|
||||||
|
ondelete='set null')
|
||||||
|
campana_periodo = fields.Char(string='Periodo de campaña', index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenCampana(models.Model):
|
||||||
|
_name = 'skeen.campana'
|
||||||
|
_description = 'Campaña por segmento'
|
||||||
|
_order = 'create_date desc, id desc'
|
||||||
|
|
||||||
|
name = fields.Char(string='Nombre', required=True)
|
||||||
|
descripcion = fields.Text(string='Descripción')
|
||||||
|
activo = fields.Boolean(string='Activa', default=True)
|
||||||
|
segmento_id = fields.Many2one('skeen.segmento', string='Segmento',
|
||||||
|
ondelete='restrict', index=True)
|
||||||
|
canal = fields.Selection(CANALES, string='Canal', default='whatsapp', required=True)
|
||||||
|
plantilla = fields.Text(string='Mensaje', default='')
|
||||||
|
enlace = fields.Char(string='Enlace para {enlace}')
|
||||||
|
programacion = fields.Selection(PROGRAMACION, string='Programación',
|
||||||
|
default='una_vez', required=True)
|
||||||
|
fecha_programada = fields.Datetime(string='Fecha programada')
|
||||||
|
hora = fields.Char(string='Hora (HH:MM)', default='10:00')
|
||||||
|
estado = fields.Selection(ESTADOS, string='Estado', default='borrador',
|
||||||
|
required=True, index=True)
|
||||||
|
ultima_ejecucion = fields.Datetime(string='Última ejecución', readonly=True)
|
||||||
|
proxima_ejecucion = fields.Datetime(string='Próxima ejecución', readonly=True)
|
||||||
|
ultimo_periodo = fields.Char(string='Último periodo', readonly=True)
|
||||||
|
motivo_pausa = fields.Text(string='Motivo de pausa', readonly=True)
|
||||||
|
creado_por = fields.Char(string='Creada por', copy=False)
|
||||||
|
notas = fields.Text(string='Notas')
|
||||||
|
|
||||||
|
def _periodo_actual(self, hoy=None):
|
||||||
|
"""Clave del periodo vigente (idempotencia de los envíos recurrentes)."""
|
||||||
|
self.ensure_one()
|
||||||
|
hoy = hoy or fields.Date.today()
|
||||||
|
if self.programacion == 'semanal':
|
||||||
|
semana = hoy.isocalendar()
|
||||||
|
return '%04d-W%02d' % (semana[0], semana[1])
|
||||||
|
if self.programacion in ('mensual', 'segmento'):
|
||||||
|
return '%04d-%02d' % (hoy.year, hoy.month)
|
||||||
|
return hoy.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
def _canales(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.canal == 'ambos':
|
||||||
|
return ['whatsapp', 'email']
|
||||||
|
return [self.canal]
|
||||||
|
|
||||||
|
def canales_lista(self):
|
||||||
|
return self._canales()
|
||||||
|
|
||||||
|
def _esta_vencida(self, ahora=None):
|
||||||
|
self.ensure_one()
|
||||||
|
ahora = ahora or fields.Datetime.now()
|
||||||
|
if self.programacion == 'una_vez':
|
||||||
|
if self.ultima_ejecucion:
|
||||||
|
return False
|
||||||
|
objetivo = self.proxima_ejecucion or self.fecha_programada
|
||||||
|
return bool(objetivo and objetivo <= ahora)
|
||||||
|
if self.proxima_ejecucion:
|
||||||
|
return self.proxima_ejecucion <= ahora
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _siguiente_ejecucion(self, desde=None):
|
||||||
|
"""Próxima fecha de disparo de una campaña recurrente."""
|
||||||
|
self.ensure_one()
|
||||||
|
desde = desde or fields.Datetime.now()
|
||||||
|
try:
|
||||||
|
h, m = [int(x) for x in (self.hora or '10:00').split(':')]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
h, m = 10, 0
|
||||||
|
base = desde.replace(hour=h, minute=m, second=0, microsecond=0)
|
||||||
|
if self.programacion == 'semanal':
|
||||||
|
if base <= desde:
|
||||||
|
base = base + timedelta(days=7)
|
||||||
|
return base
|
||||||
|
# mensual / segmento
|
||||||
|
if base <= desde:
|
||||||
|
anio = base.year + (1 if base.month == 12 else 0)
|
||||||
|
mes = 1 if base.month == 12 else base.month + 1
|
||||||
|
dia = min(base.day, calendar.monthrange(anio, mes)[1])
|
||||||
|
base = base.replace(year=anio, month=mes, day=dia)
|
||||||
|
return base
|
||||||
|
|
||||||
|
def action_lanzar(self, inmediato=True):
|
||||||
|
"""Pasa la campaña a 'programada' y (opcional) la agenda para ya."""
|
||||||
|
self.ensure_one()
|
||||||
|
vals = {'estado': 'programada', 'motivo_pausa': False}
|
||||||
|
if self.programacion == 'una_vez':
|
||||||
|
vals['proxima_ejecucion'] = self.fecha_programada or fields.Datetime.now()
|
||||||
|
else:
|
||||||
|
vals['proxima_ejecucion'] = self._siguiente_ejecucion(
|
||||||
|
fields.Datetime.now() - timedelta(minutes=1)) if inmediato \
|
||||||
|
else self._siguiente_ejecucion()
|
||||||
|
self.write(vals)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_pausar(self, motivo=''):
|
||||||
|
self.write({'estado': 'pausada', 'motivo_pausa': motivo or 'Pausada a mano'})
|
||||||
|
|
||||||
|
def action_reanudar(self):
|
||||||
|
self.write({'estado': 'programada', 'motivo_pausa': False})
|
||||||
|
|
||||||
|
def action_cancelar(self):
|
||||||
|
self.write({'estado': 'cancelada'})
|
||||||
|
|
||||||
|
def action_borrador(self):
|
||||||
|
self.write({'estado': 'borrador'})
|
||||||
|
|
||||||
|
def metricas(self, start=None, end=None):
|
||||||
|
"""Métricas de la campaña (delega en el servicio)."""
|
||||||
|
self.ensure_one()
|
||||||
|
return self.env['skeen.campana.service']._metricas(self, start=start, end=end)
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenCampanaService(models.AbstractModel):
|
||||||
|
"""Motor de campañas: encola, respeta consentimiento y mide."""
|
||||||
|
_name = 'skeen.campana.service'
|
||||||
|
_description = 'Servicio de campañas de SKEEN'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cfg(self):
|
||||||
|
return get_campanas(self.env)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Render de la plantilla
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _contexto_paciente(self, partner):
|
||||||
|
settings = clinic_get_settings(self.env)
|
||||||
|
ctx = {
|
||||||
|
'nombre': partner.name or '',
|
||||||
|
'servicio': '',
|
||||||
|
'sucursal': '',
|
||||||
|
'doctor': '',
|
||||||
|
'fecha': fields.Date.today().strftime('%d/%m/%Y'),
|
||||||
|
'hora': '',
|
||||||
|
'telefono': (settings.get('telefono')
|
||||||
|
or get_notificaciones(self.env).get('whatsapp_numero') or ''),
|
||||||
|
}
|
||||||
|
cita = self.env['skeen.cita'].sudo().search([
|
||||||
|
('partner_id', '=', partner.id), ('state', '=', 'done'),
|
||||||
|
], order='date desc, id desc', limit=1)
|
||||||
|
if cita:
|
||||||
|
ctx['servicio'] = cita.servicio_id.name if cita.servicio_id else ''
|
||||||
|
ctx['doctor'] = cita.doctor_id.name if cita.doctor_id else ''
|
||||||
|
ctx['fecha'] = cita.date.strftime('%d/%m/%Y') if cita.date else ctx['fecha']
|
||||||
|
ctx['hora'] = cita.time_str or ''
|
||||||
|
if cita.branch:
|
||||||
|
sucursal = self.env['skeen.sucursal'].sudo().search(
|
||||||
|
[('code', '=', cita.branch)], limit=1)
|
||||||
|
ctx['sucursal'] = sucursal.name or cita.branch
|
||||||
|
if not ctx['sucursal']:
|
||||||
|
sucursal = self.env['skeen.sucursal'].sudo().search(
|
||||||
|
[('is_default', '=', True)], limit=1) \
|
||||||
|
or self.env['skeen.sucursal'].sudo().search([], order='sequence', limit=1)
|
||||||
|
ctx['sucursal'] = sucursal.name or ''
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def render(self, campana, partner):
|
||||||
|
ctx = self._contexto_paciente(partner)
|
||||||
|
ctx['enlace'] = campana.enlace or ''
|
||||||
|
texto = str(campana.plantilla or '')
|
||||||
|
for clave, valor in ctx.items():
|
||||||
|
texto = texto.replace('{%s}' % clave, str(valor or ''))
|
||||||
|
return texto
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Encolado
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _encolar_campana(self, campana, periodo=None, ahora=None):
|
||||||
|
"""Encola la campaña para su segmento vigente. Devuelve el # de creadas."""
|
||||||
|
ahora = ahora or fields.Datetime.now()
|
||||||
|
periodo = periodo or campana._periodo_actual()
|
||||||
|
cfg = self._cfg()
|
||||||
|
service = self.env['skeen.notificacion.service'].sudo()
|
||||||
|
segmento = campana.segmento_id
|
||||||
|
if not segmento:
|
||||||
|
return 0
|
||||||
|
pacientes = segmento.pacientes()
|
||||||
|
creadas = 0
|
||||||
|
for partner in pacientes:
|
||||||
|
for canal in campana._canales():
|
||||||
|
if not service._destino_disponible(partner, canal):
|
||||||
|
continue
|
||||||
|
# Consentimiento: un paciente sin autorización nunca recibe
|
||||||
|
# marketing, aunque cumpla el criterio.
|
||||||
|
if cfg['exigir_consentimiento'] and not partner._marketing_permitido(canal):
|
||||||
|
continue
|
||||||
|
mensaje = self.render(campana, partner)
|
||||||
|
clave = 'campana:c%s:p%s:%s:%s' % (
|
||||||
|
campana.id, partner.id, periodo, canal)
|
||||||
|
notifs = service.encolar(
|
||||||
|
partner=partner, tipo='campana', canal=canal,
|
||||||
|
cuando=ahora, mensaje=mensaje,
|
||||||
|
clave_unica=clave, forzar=True,
|
||||||
|
vals_extra={'campana_id': campana.id, 'campana_periodo': periodo})
|
||||||
|
creadas += len(notifs)
|
||||||
|
return creadas
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _campanas_vencidas(self, ahora=None):
|
||||||
|
ahora = ahora or fields.Datetime.now()
|
||||||
|
campanas = self.env['skeen.campana'].sudo().search([
|
||||||
|
('estado', 'in', ('programada', 'en_ejecucion')),
|
||||||
|
('activo', '=', True),
|
||||||
|
])
|
||||||
|
return campanas.filtered(lambda c: c._esta_vencida(ahora))
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _procesar_campanas(self, cfg, ahora=None):
|
||||||
|
"""Encola las campañas vencidas (no manda todavía)."""
|
||||||
|
ahora = ahora or fields.Datetime.now()
|
||||||
|
encoladas = 0
|
||||||
|
for campana in self._campanas_vencidas(ahora):
|
||||||
|
periodo = campana._periodo_actual()
|
||||||
|
creadas = self._encolar_campana(campana, periodo=periodo, ahora=ahora)
|
||||||
|
encoladas += creadas
|
||||||
|
vals = {'ultima_ejecucion': ahora, 'ultimo_periodo': periodo,
|
||||||
|
'estado': 'en_ejecucion'}
|
||||||
|
if campana.programacion != 'una_vez':
|
||||||
|
vals['proxima_ejecucion'] = campana._siguiente_ejecucion(ahora)
|
||||||
|
campana.write(vals)
|
||||||
|
return encoladas
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Envío con límite por minuto y pausa por errores
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _enviadas_ultimo_minuto(self, ahora=None):
|
||||||
|
ahora = ahora or fields.Datetime.now()
|
||||||
|
desde = ahora - timedelta(seconds=MIN_SEGUNDOS_TASA)
|
||||||
|
return self.env['skeen.notificacion'].sudo().search_count([
|
||||||
|
('tipo', '=', 'campana'),
|
||||||
|
('estado', 'in', ('enviada', 'simulada')),
|
||||||
|
('enviada_at', '>=', desde),
|
||||||
|
])
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _procesar_cola(self, cfg, limite=None):
|
||||||
|
"""Manda la cola de campañas respetando el límite por minuto."""
|
||||||
|
servicio = self.env['skeen.notificacion.service'].sudo()
|
||||||
|
notif_cfg = servicio._cfg()
|
||||||
|
ahora = fields.Datetime.now()
|
||||||
|
presupuesto = cfg['envios_por_minuto'] - self._enviadas_ultimo_minuto(ahora)
|
||||||
|
if presupuesto <= 0:
|
||||||
|
return {'enviadas': 0, 'simuladas': 0, 'fallidas': 0, 'pausadas': 0,
|
||||||
|
'limite_alcanzado': True}
|
||||||
|
if limite:
|
||||||
|
presupuesto = min(presupuesto, limite)
|
||||||
|
pendientes = self.env['skeen.notificacion'].sudo().search([
|
||||||
|
('tipo', '=', 'campana'),
|
||||||
|
('estado', '=', 'pendiente'),
|
||||||
|
('programada_para', '<=', ahora),
|
||||||
|
], order='programada_para asc, id asc', limit=presupuesto)
|
||||||
|
|
||||||
|
resultado = {'enviadas': 0, 'simuladas': 0, 'fallidas': 0, 'pausadas': 0,
|
||||||
|
'limite_alcanzado': False}
|
||||||
|
seguidos = 0
|
||||||
|
for notif in pendientes:
|
||||||
|
# Guarda de consentimiento al momento del envío (pudo revocarse
|
||||||
|
# entre el encolado y ahora).
|
||||||
|
partner = notif.partner_id
|
||||||
|
if cfg['exigir_consentimiento'] and partner \
|
||||||
|
and not partner._marketing_permitido(notif.canal):
|
||||||
|
notif.write({'estado': 'cancelada',
|
||||||
|
'error': 'Sin consentimiento de marketing'})
|
||||||
|
continue
|
||||||
|
estado = servicio._intentar(notif, notif_cfg)
|
||||||
|
if estado == 'enviada':
|
||||||
|
resultado['enviadas'] += 1
|
||||||
|
seguidos = 0
|
||||||
|
elif estado == 'simulada':
|
||||||
|
resultado['simuladas'] += 1
|
||||||
|
seguidos = 0
|
||||||
|
elif estado == 'fallida':
|
||||||
|
resultado['fallidas'] += 1
|
||||||
|
seguidos += 1
|
||||||
|
if seguidos >= cfg['pausar_tras_errores']:
|
||||||
|
campana = notif.campana_id
|
||||||
|
if campana:
|
||||||
|
campana.action_pausar(
|
||||||
|
'Pausada sola tras %d errores seguidos.' % seguidos)
|
||||||
|
resultado['pausadas'] += 1
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
seguidos = 0
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cerrar_campanas(self):
|
||||||
|
"""Marca como terminadas las campañas de una sola vez ya sin cola."""
|
||||||
|
pendientes = self.env['skeen.notificacion'].sudo().search([
|
||||||
|
('tipo', '=', 'campana'), ('estado', '=', 'pendiente')])
|
||||||
|
con_cola = set(pendientes.mapped('campana_id').ids)
|
||||||
|
abiertas = self.env['skeen.campana'].sudo().search([
|
||||||
|
('estado', '=', 'en_ejecucion'), ('programacion', '=', 'una_vez')])
|
||||||
|
for campana in abiertas:
|
||||||
|
if campana.id not in con_cola:
|
||||||
|
campana.write({'estado': 'terminada'})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Entrada del cron
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def ejecutar(self, limite=None):
|
||||||
|
cfg = self._cfg()
|
||||||
|
resultado = {'activo': cfg['activo'], 'encoladas': 0, 'enviadas': 0,
|
||||||
|
'simuladas': 0, 'fallidas': 0, 'pausadas': 0,
|
||||||
|
'limite_alcanzado': False}
|
||||||
|
if not cfg['activo']:
|
||||||
|
return resultado
|
||||||
|
resultado['encoladas'] = self._procesar_campanas(cfg)
|
||||||
|
envio = self._procesar_cola(cfg, limite=limite)
|
||||||
|
resultado.update(envio)
|
||||||
|
self._cerrar_campanas()
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_tick(self):
|
||||||
|
return self.ejecutar()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Envío de prueba
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def enviar_prueba(self, campana, partner=None, telefono='', correo=''):
|
||||||
|
"""Manda la plantilla a un número/correo propio. Nunca lanza.
|
||||||
|
|
||||||
|
Queda registrado en la bitácora marcado como prueba (no cuenta en la
|
||||||
|
medición). El envío real pasa por el motor de notificaciones.
|
||||||
|
"""
|
||||||
|
cfg = self._cfg()
|
||||||
|
if not cfg['activo']:
|
||||||
|
return {'ok': False, 'message': 'El módulo de campañas está apagado.'}
|
||||||
|
if not campana.plantilla or not str(campana.plantilla).strip():
|
||||||
|
return {'ok': False, 'message': 'La campaña no tiene mensaje todavía.'}
|
||||||
|
servicio = self.env['skeen.notificacion.service'].sudo()
|
||||||
|
notif_cfg = servicio._cfg()
|
||||||
|
canales = campana._canales()
|
||||||
|
creadas = self.env['skeen.notificacion'].sudo()
|
||||||
|
for canal in canales:
|
||||||
|
if canal == 'whatsapp' and not telefono and not partner:
|
||||||
|
return {'ok': False, 'message': 'Indica un número para la prueba.'}
|
||||||
|
if canal == 'email' and not correo and not partner:
|
||||||
|
return {'ok': False, 'message': 'Indica un correo para la prueba.'}
|
||||||
|
if partner:
|
||||||
|
mensaje = self.render(campana, partner)
|
||||||
|
destino = ''
|
||||||
|
else:
|
||||||
|
mensaje = (str(campana.plantilla or '')
|
||||||
|
.replace('{nombre}', 'Prueba')
|
||||||
|
.replace('{servicio}', 'Servicio de ejemplo')
|
||||||
|
.replace('{sucursal}', 'Sucursal')
|
||||||
|
.replace('{doctor}', 'Doctor')
|
||||||
|
.replace('{fecha}', fields.Date.today().strftime('%d/%m/%Y'))
|
||||||
|
.replace('{hora}', '10:00')
|
||||||
|
.replace('{enlace}', campana.enlace or '')
|
||||||
|
.replace('{telefono}', ''))
|
||||||
|
destino = telefono if canal == 'whatsapp' else correo
|
||||||
|
clave = 'campana-prueba:c%s:%s:%s' % (
|
||||||
|
campana.id, canal, fields.Datetime.now().strftime('%Y%m%d%H%M%S%f'))
|
||||||
|
notifs = servicio.encolar(
|
||||||
|
partner=partner, tipo='campana', canal=canal,
|
||||||
|
cuando=fields.Datetime.now(), mensaje=mensaje, clave_unica=clave,
|
||||||
|
forzar=True, destino=destino or None,
|
||||||
|
vals_extra={'campana_id': campana.id, 'es_prueba': True})
|
||||||
|
for notif in notifs:
|
||||||
|
servicio._intentar(notif, notif_cfg)
|
||||||
|
creadas |= notifs
|
||||||
|
if not creadas:
|
||||||
|
return {'ok': False, 'message': 'No se pudo encolar la prueba.'}
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'simulado': bool(notif_cfg['modo_prueba']),
|
||||||
|
'notificaciones': [self._notif_dict(n) for n in creadas],
|
||||||
|
'message': ('Modo prueba: la campaña quedó registrada como simulada.'
|
||||||
|
if notif_cfg['modo_prueba'] else 'Prueba enviada.'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Medición
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _notif_dict(self, n):
|
||||||
|
return {
|
||||||
|
'id': n.id,
|
||||||
|
'partner_id': n.partner_id.id if n.partner_id else None,
|
||||||
|
'patient': n.partner_id.name if n.partner_id else '',
|
||||||
|
'canal': n.canal,
|
||||||
|
'estado': n.estado,
|
||||||
|
'es_prueba': n.es_prueba,
|
||||||
|
'destino': n.destino or '',
|
||||||
|
'enviada_at': n.enviada_at.strftime('%Y-%m-%d %H:%M') if n.enviada_at else None,
|
||||||
|
'error': n.error or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _respuestas_wacrm(self, partners, desde, hasta):
|
||||||
|
"""Mensajes del paciente (WACRM) en la ventana. None si no hay datos.
|
||||||
|
|
||||||
|
Se detectan por teléfono del contacto. Si la instancia no sincroniza
|
||||||
|
WACRM se devuelve None (el reporte lo muestra como "no disponible")."""
|
||||||
|
if 'skeen.wacrm.message' not in self.env:
|
||||||
|
return None
|
||||||
|
M = self.env['skeen.wacrm.message'].sudo()
|
||||||
|
if not M.search_count([], limit=1):
|
||||||
|
return None
|
||||||
|
total = 0
|
||||||
|
for partner in partners:
|
||||||
|
digitos = re.sub(r'\D', '', (partner.whatsapp or partner.phone or ''))
|
||||||
|
if not digitos:
|
||||||
|
continue
|
||||||
|
cola = digitos[-10:]
|
||||||
|
total += M.search_count([
|
||||||
|
('sender_type', '=', 'customer'),
|
||||||
|
('created_at', '>=', desde), ('created_at', '<=', hasta),
|
||||||
|
('contact_id.phone', 'like', cola),
|
||||||
|
])
|
||||||
|
return total
|
||||||
|
|
||||||
|
def _metricas(self, campana, start=None, end=None):
|
||||||
|
"""Métricas de la campaña. `start`/`end` acotan las fechas de envío."""
|
||||||
|
cfg = self._cfg()
|
||||||
|
Notif = self.env['skeen.notificacion'].sudo()
|
||||||
|
dominio = [('campana_id', '=', campana.id), ('es_prueba', '=', False)]
|
||||||
|
if start:
|
||||||
|
dominio.append(('enviada_at', '>=', '%s 00:00:00' % start))
|
||||||
|
if end:
|
||||||
|
dominio.append(('enviada_at', '<=', '%s 23:59:59' % end))
|
||||||
|
enviadas = Notif.search(dominio + [('estado', 'in', ('enviada', 'simulada'))])
|
||||||
|
errores = Notif.search_count(dominio + [('estado', '=', 'error')])
|
||||||
|
canceladas = Notif.search_count(dominio + [('estado', '=', 'cancelada')])
|
||||||
|
pendientes = Notif.search_count(dominio + [('estado', '=', 'pendiente')])
|
||||||
|
pacientes = enviadas.mapped('partner_id')
|
||||||
|
alcanzados = len(pacientes)
|
||||||
|
|
||||||
|
ventana = cfg['ventana_atribucion_dias']
|
||||||
|
citas_count = 0
|
||||||
|
citas_valor = 0.0
|
||||||
|
citas_ids = set()
|
||||||
|
ventas_ids = set()
|
||||||
|
for partner in pacientes:
|
||||||
|
envios = enviadas.filtered(lambda n: n.partner_id.id == partner.id
|
||||||
|
and n.enviada_at)
|
||||||
|
if not envios:
|
||||||
|
continue
|
||||||
|
desde = min(envios.mapped('enviada_at'))
|
||||||
|
hasta = desde + timedelta(days=ventana)
|
||||||
|
citas = self.env['skeen.cita'].sudo().search([
|
||||||
|
('partner_id', '=', partner.id), ('state', '!=', 'cancelled'),
|
||||||
|
('create_date', '>=', desde), ('create_date', '<=', hasta)])
|
||||||
|
citas_ids |= set(citas.ids)
|
||||||
|
ventas = self.env['skeen.venta'].sudo().search([
|
||||||
|
('partner_id', '=', partner.id), ('state', '!=', 'cancelled'),
|
||||||
|
('create_date', '>=', desde), ('create_date', '<=', hasta)])
|
||||||
|
ventas_ids |= set(ventas.ids)
|
||||||
|
citas_valor += sum(v.total for v in ventas)
|
||||||
|
citas_count = len(citas_ids)
|
||||||
|
|
||||||
|
# Respuestas detectables en WACRM dentro de cada ventana.
|
||||||
|
respuestas = 0
|
||||||
|
respuestas_disponible = True
|
||||||
|
if pacientes:
|
||||||
|
min_envio = min(enviadas.mapped('enviada_at'))
|
||||||
|
max_envio = max(enviadas.mapped('enviada_at'))
|
||||||
|
detectadas = self._respuestas_wacrm(
|
||||||
|
pacientes, min_envio, max_envio + timedelta(days=ventana))
|
||||||
|
if detectadas is None:
|
||||||
|
respuestas_disponible = False
|
||||||
|
else:
|
||||||
|
respuestas = detectadas
|
||||||
|
|
||||||
|
enviadas_reales = len(enviadas.filtered(lambda n: n.estado == 'enviada'))
|
||||||
|
simuladas = len(enviadas.filtered(lambda n: n.estado == 'simulada'))
|
||||||
|
return {
|
||||||
|
'campana_id': campana.id,
|
||||||
|
'campana': campana.name,
|
||||||
|
'estado': campana.estado,
|
||||||
|
'alcanzados': alcanzados,
|
||||||
|
'enviados': len(enviadas),
|
||||||
|
'enviados_reales': enviadas_reales,
|
||||||
|
'simulados': simuladas,
|
||||||
|
'errores': errores,
|
||||||
|
'cancelados': canceladas,
|
||||||
|
'pendientes': pendientes,
|
||||||
|
# La entrega real (ACK del proveedor) no está disponible con el
|
||||||
|
# WACRM actual: se reporta como no disponible en lugar de inventarla.
|
||||||
|
'entregados': None,
|
||||||
|
'entregados_disponible': False,
|
||||||
|
'respuestas': respuestas if respuestas_disponible else None,
|
||||||
|
'respuestas_disponible': respuestas_disponible,
|
||||||
|
'citas_atribuidas': citas_count,
|
||||||
|
'valor_atribuido': round(citas_valor, 2),
|
||||||
|
'ventana_atribucion_dias': ventana,
|
||||||
|
'conversion_pct': round((citas_count * 100.0 / alcanzados), 2)
|
||||||
|
if alcanzados else 0.0,
|
||||||
|
}
|
||||||
63
odoo-addons/skeen_campanas/models/consentimiento.py
Normal file
63
odoo-addons/skeen_campanas/models/consentimiento.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Consentimiento de marketing del paciente, por canal.
|
||||||
|
|
||||||
|
Las campañas (marketing) NUNCA escriben a quien no autorizó recibirlas. 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 es explícito y nace APAGADO: la clínica lo registra cuando el
|
||||||
|
paciente lo autoriza (por WhatsApp, en recepción, etc.). `consentimiento_marketing`
|
||||||
|
es el interruptor general; `consentimiento_whatsapp` / `consentimiento_email`
|
||||||
|
autorizan canal por canal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResPartner(models.Model):
|
||||||
|
_inherit = 'res.partner'
|
||||||
|
|
||||||
|
consentimiento_marketing = fields.Boolean(
|
||||||
|
string='Acepta marketing', default=False, copy=False,
|
||||||
|
help='Autorización general para recibir promociones y campañas.')
|
||||||
|
consentimiento_whatsapp = fields.Boolean(
|
||||||
|
string='Marketing por WhatsApp', default=False, copy=False)
|
||||||
|
consentimiento_email = fields.Boolean(
|
||||||
|
string='Marketing por correo', default=False, copy=False)
|
||||||
|
consentimiento_at = fields.Datetime(
|
||||||
|
string='Consentimiento registrado el', copy=False)
|
||||||
|
consentimiento_origen = fields.Char(
|
||||||
|
string='Origen del consentimiento', copy=False)
|
||||||
|
|
||||||
|
def _marketing_permitido(self, canal):
|
||||||
|
"""True si el paciente autorizó marketing por `canal` (whatsapp|email).
|
||||||
|
|
||||||
|
Exige el interruptor general Y el del canal. Sin autorización explícita
|
||||||
|
devuelve False: el silencio no es consentimiento.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.consentimiento_marketing:
|
||||||
|
return False
|
||||||
|
if canal == 'whatsapp':
|
||||||
|
return bool(self.consentimiento_whatsapp)
|
||||||
|
if canal == 'email':
|
||||||
|
return bool(self.consentimiento_email)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _marcar_consentimiento(self, partner, whatsapp=None, email=None,
|
||||||
|
general=None, origen=''):
|
||||||
|
"""Registra el consentimiento de un paciente (update parcial)."""
|
||||||
|
vals = {}
|
||||||
|
if general is not None:
|
||||||
|
vals['consentimiento_marketing'] = bool(general)
|
||||||
|
if whatsapp is not None:
|
||||||
|
vals['consentimiento_whatsapp'] = bool(whatsapp)
|
||||||
|
if email is not None:
|
||||||
|
vals['consentimiento_email'] = bool(email)
|
||||||
|
if not vals:
|
||||||
|
return partner
|
||||||
|
vals['consentimiento_at'] = fields.Datetime.now()
|
||||||
|
vals['consentimiento_origen'] = (origen or '')[:120]
|
||||||
|
partner.write(vals)
|
||||||
|
return partner
|
||||||
255
odoo-addons/skeen_campanas/models/encuesta.py
Normal file
255
odoo-addons/skeen_campanas/models/encuesta.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Encuestas de salida y medición de satisfacción (NPS).
|
||||||
|
|
||||||
|
Cuando una cita pasa a completada y `encuestas.activo=true`, tras N horas se
|
||||||
|
envía al paciente la encuesta configurada (por WhatsApp o correo) usando el
|
||||||
|
motor de notificaciones y la página pública de formularios que ya existe. Hay
|
||||||
|
recordatorio opcional y una opción de enviarla solo a la primera visita.
|
||||||
|
|
||||||
|
La pregunta de recomendación se guarda con un tipo de campo PROPIO (`nps`,
|
||||||
|
puntaje de 0 a 10) para poder analizarla sin depender del texto. Se calcula de
|
||||||
|
la forma estándar: promotores 9-10, detractores 0-6 y
|
||||||
|
NPS = %promotores − %detractores (de −100 a 100).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import get_encuestas
|
||||||
|
|
||||||
|
ENCUESTA_LOOKBACK_DIAS = 30
|
||||||
|
PROCESAR_POR_DEFECTO = 100
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenFormularioRespuesta(models.Model):
|
||||||
|
_inherit = 'skeen.formulario.respuesta'
|
||||||
|
|
||||||
|
nps_puntaje = fields.Integer(
|
||||||
|
string='NPS (0-10)', compute='_compute_nps', store=True, index=True,
|
||||||
|
help='-1 cuando la respuesta no incluye la pregunta de recomendación.')
|
||||||
|
nps_categoria = fields.Selection([
|
||||||
|
('promotor', 'Promotor'), ('pasivo', 'Pasivo'), ('detractor', 'Detractor'),
|
||||||
|
], string='Categoría NPS', compute='_compute_nps', store=True, index=True)
|
||||||
|
nps_comentario = fields.Text(string='Comentario', compute='_compute_nps', store=True)
|
||||||
|
|
||||||
|
@api.depends('respuestas', 'plantilla_id')
|
||||||
|
def _compute_nps(self):
|
||||||
|
for r in self:
|
||||||
|
puntaje = -1
|
||||||
|
comentario = ''
|
||||||
|
datos = {}
|
||||||
|
try:
|
||||||
|
datos = json.loads(r.respuestas or '{}')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
datos = {}
|
||||||
|
for campo in r.plantilla_id.campos_lista():
|
||||||
|
clave = campo.get('clave')
|
||||||
|
tipo = campo.get('tipo')
|
||||||
|
valor = datos.get(clave)
|
||||||
|
if tipo == 'nps':
|
||||||
|
try:
|
||||||
|
n = int(valor)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if 0 <= n <= 10:
|
||||||
|
puntaje = n
|
||||||
|
elif tipo == 'texto_largo' and not comentario and valor:
|
||||||
|
comentario = str(valor)
|
||||||
|
r.nps_puntaje = puntaje
|
||||||
|
r.nps_comentario = comentario
|
||||||
|
if puntaje < 0:
|
||||||
|
r.nps_categoria = False
|
||||||
|
elif puntaje >= 9:
|
||||||
|
r.nps_categoria = 'promotor'
|
||||||
|
elif puntaje >= 7:
|
||||||
|
r.nps_categoria = 'pasivo'
|
||||||
|
else:
|
||||||
|
r.nps_categoria = 'detractor'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def nps_de(self, respuestas):
|
||||||
|
"""Cálculo estándar del NPS sobre un conjunto de respuestas."""
|
||||||
|
validas = respuestas.filtered(lambda r: r.nps_puntaje >= 0)
|
||||||
|
total = len(validas)
|
||||||
|
if not total:
|
||||||
|
return {'total': 0, 'promotores': 0, 'pasivos': 0, 'detractores': 0,
|
||||||
|
'nps': None, 'promotores_pct': 0.0, 'detractores_pct': 0.0,
|
||||||
|
'pasivos_pct': 0.0}
|
||||||
|
promotores = len(validas.filtered(lambda r: r.nps_categoria == 'promotor'))
|
||||||
|
pasivos = len(validas.filtered(lambda r: r.nps_categoria == 'pasivo'))
|
||||||
|
detractores = len(validas.filtered(lambda r: r.nps_categoria == 'detractor'))
|
||||||
|
pct_p = promotores * 100.0 / total
|
||||||
|
pct_d = detractores * 100.0 / total
|
||||||
|
return {
|
||||||
|
'total': total,
|
||||||
|
'promotores': promotores,
|
||||||
|
'pasivos': pasivos,
|
||||||
|
'detractores': detractores,
|
||||||
|
'promotores_pct': round(pct_p, 1),
|
||||||
|
'detractores_pct': round(pct_d, 1),
|
||||||
|
'pasivos_pct': round(pasivos * 100.0 / total, 1),
|
||||||
|
'nps': round(pct_p - pct_d),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenEncuestaService(models.AbstractModel):
|
||||||
|
"""Envío automático de la encuesta de salida (encolado y procesado)."""
|
||||||
|
_name = 'skeen.encuesta.service'
|
||||||
|
_description = 'Servicio de encuestas de satisfacción'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cfg(self):
|
||||||
|
return get_encuestas(self.env)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _plantilla(self, cfg):
|
||||||
|
pid = cfg.get('plantilla_id')
|
||||||
|
Plantilla = self.env['skeen.formulario.plantilla'].sudo()
|
||||||
|
if not pid:
|
||||||
|
return Plantilla.browse()
|
||||||
|
plantilla = Plantilla.browse(int(pid))
|
||||||
|
return plantilla if plantilla.exists() and plantilla.activo else Plantilla.browse()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _canales(self, cfg):
|
||||||
|
if cfg['canal'] == 'ambos':
|
||||||
|
return ['whatsapp', 'email']
|
||||||
|
return [cfg['canal']]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _enlace(self, token):
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import url_publica
|
||||||
|
base = url_publica(self.env, '')
|
||||||
|
ruta = '/formulario/%s' % token
|
||||||
|
return (base + ruta) if base else ruta
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Encolado
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _encolar_citas(self, cfg, plantilla):
|
||||||
|
from odoo.addons.skeen_formularios.controllers.comun import firmar_token
|
||||||
|
ahora = fields.Datetime.now()
|
||||||
|
limite = ahora - timedelta(hours=cfg['enviar_tras_horas'])
|
||||||
|
desde = ahora - timedelta(days=ENCUESTA_LOOKBACK_DIAS)
|
||||||
|
citas = self.env['skeen.cita'].sudo().search([
|
||||||
|
('state', '=', 'done'),
|
||||||
|
('datetime_end', '<=', limite),
|
||||||
|
('datetime_end', '>=', desde),
|
||||||
|
])
|
||||||
|
Respuesta = self.env['skeen.formulario.respuesta'].sudo()
|
||||||
|
servicio = self.env['skeen.notificacion.service'].sudo()
|
||||||
|
notif_cfg = servicio._cfg()
|
||||||
|
creadas = 0
|
||||||
|
for cita in citas:
|
||||||
|
partner = cita.partner_id
|
||||||
|
if not partner:
|
||||||
|
continue
|
||||||
|
if cfg['solo_primera_visita']:
|
||||||
|
hechas = self.env['skeen.cita'].sudo().search_count([
|
||||||
|
('partner_id', '=', partner.id), ('state', '=', 'done')])
|
||||||
|
if hechas > 1:
|
||||||
|
continue
|
||||||
|
if Respuesta.search([
|
||||||
|
('cita_id', '=', cita.id), ('plantilla_id', '=', plantilla.id)],
|
||||||
|
limit=1):
|
||||||
|
continue
|
||||||
|
respuesta = Respuesta.create({
|
||||||
|
'plantilla_id': plantilla.id,
|
||||||
|
'plantilla_version': plantilla.version,
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'cita_id': cita.id,
|
||||||
|
'estado': 'enviado',
|
||||||
|
'enviado_at': ahora,
|
||||||
|
'canal_envio': cfg['canal'] if cfg['canal'] != 'ambos' else 'whatsapp',
|
||||||
|
'respuestas': '{}',
|
||||||
|
})
|
||||||
|
token = firmar_token(self.env, respuesta)
|
||||||
|
respuesta.write({'token': token})
|
||||||
|
enlace = self._enlace(token)
|
||||||
|
mensaje = servicio.render(
|
||||||
|
notif_cfg.get('plantilla_formulario') or '', cita=cita,
|
||||||
|
partner=partner, cfg=notif_cfg,
|
||||||
|
extra={'enlace': enlace, 'formulario': plantilla.name})
|
||||||
|
for canal in self._canales(cfg):
|
||||||
|
clave = 'encuesta:c%s:%s' % (cita.id, canal)
|
||||||
|
creadas += len(servicio.encolar(
|
||||||
|
cita=cita, tipo='encuesta', canal=canal, cuando=ahora,
|
||||||
|
mensaje=mensaje, clave_unica=clave, forzar=True,
|
||||||
|
vals_extra={'destino': False}))
|
||||||
|
return creadas
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _encolar_recordatorios(self, cfg, plantilla):
|
||||||
|
horas = cfg['recordatorio_horas']
|
||||||
|
if horas <= 0:
|
||||||
|
return 0
|
||||||
|
ahora = fields.Datetime.now()
|
||||||
|
tope = ahora - timedelta(hours=horas)
|
||||||
|
Respuesta = self.env['skeen.formulario.respuesta'].sudo()
|
||||||
|
pendientes = Respuesta.search([
|
||||||
|
('plantilla_id', '=', plantilla.id),
|
||||||
|
('estado', '=', 'enviado'),
|
||||||
|
('enviado_at', '<=', tope),
|
||||||
|
])
|
||||||
|
servicio = self.env['skeen.notificacion.service'].sudo()
|
||||||
|
notif_cfg = servicio._cfg()
|
||||||
|
creados = 0
|
||||||
|
for r in pendientes:
|
||||||
|
enlace = self._enlace(r.token)
|
||||||
|
mensaje = servicio.render(
|
||||||
|
notif_cfg.get('plantilla_formulario') or '', cita=r.cita_id or None,
|
||||||
|
partner=r.partner_id, cfg=notif_cfg,
|
||||||
|
extra={'enlace': enlace, 'formulario': plantilla.name})
|
||||||
|
for canal in self._canales(cfg):
|
||||||
|
clave = 'encuesta-rec:r%s:%s' % (r.id, canal)
|
||||||
|
creados += len(servicio.encolar(
|
||||||
|
cita=r.cita_id or None, partner=r.partner_id, tipo='encuesta',
|
||||||
|
canal=canal, cuando=ahora, mensaje=mensaje,
|
||||||
|
clave_unica=clave, forzar=True))
|
||||||
|
return creados
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Procesado de la cola de encuestas
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _procesar(self, cfg, limite=None):
|
||||||
|
servicio = self.env['skeen.notificacion.service'].sudo()
|
||||||
|
notif_cfg = servicio._cfg()
|
||||||
|
ahora = fields.Datetime.now()
|
||||||
|
pendientes = self.env['skeen.notificacion'].sudo().search([
|
||||||
|
('tipo', '=', 'encuesta'),
|
||||||
|
('estado', '=', 'pendiente'),
|
||||||
|
('programada_para', '<=', ahora),
|
||||||
|
], order='programada_para asc, id asc', limit=limite or PROCESAR_POR_DEFECTO)
|
||||||
|
resultado = {'enviadas': 0, 'simuladas': 0, 'fallidas': 0}
|
||||||
|
for notif in pendientes:
|
||||||
|
estado = servicio._intentar(notif, notif_cfg)
|
||||||
|
if estado == 'enviada':
|
||||||
|
resultado['enviadas'] += 1
|
||||||
|
elif estado == 'simulada':
|
||||||
|
resultado['simuladas'] += 1
|
||||||
|
elif estado == 'fallida':
|
||||||
|
resultado['fallidas'] += 1
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def ejecutar(self, limite=None):
|
||||||
|
cfg = self._cfg()
|
||||||
|
resultado = {'activo': cfg['activo'], 'encoladas': 0, 'recordatorios': 0,
|
||||||
|
'enviadas': 0, 'simuladas': 0, 'fallidas': 0}
|
||||||
|
if not cfg['activo']:
|
||||||
|
return resultado
|
||||||
|
plantilla = self._plantilla(cfg)
|
||||||
|
if not plantilla:
|
||||||
|
return resultado
|
||||||
|
resultado['encoladas'] = self._encolar_citas(cfg, plantilla)
|
||||||
|
resultado['recordatorios'] = self._encolar_recordatorios(cfg, plantilla)
|
||||||
|
resultado.update(self._procesar(cfg, limite))
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_tick(self):
|
||||||
|
return self.ejecutar()
|
||||||
362
odoo-addons/skeen_campanas/models/segmento.py
Normal file
362
odoo-addons/skeen_campanas/models/segmento.py
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Segmentos de pacientes: constructor de criterios reutilizable.
|
||||||
|
|
||||||
|
Un segmento guarda un NOMBRE, unos CRITERIOS (JSON) y una previsualización
|
||||||
|
(cuántos pacientes y una muestra de 5) que se calcula ANTES de usarlo.
|
||||||
|
|
||||||
|
La evaluación corre en el servidor: cada criterio aporta un conjunto de
|
||||||
|
pacientes que se INTERSECTA con el resultado (y se RESTA cuando el criterio es
|
||||||
|
negativo, p. ej. "con membresía = no"). Así el conteo del segmento coincide
|
||||||
|
exactamente con lo que responde la base de datos, sin contar de más.
|
||||||
|
|
||||||
|
Criterios soportados (todos opcionales; vacío = no aplica):
|
||||||
|
|
||||||
|
ultima_visita_dias última visita registrada hace MÁS de N días
|
||||||
|
sin_visita_meses sin visitas en los últimos N meses (incluye a quien
|
||||||
|
nunca ha venido)
|
||||||
|
cumple_mes cumpleaños en el mes en curso
|
||||||
|
cumple_proximos_dias cumpleaños en los próximos N días
|
||||||
|
nivel_codigo nivel de lealtad vigente
|
||||||
|
gasto_minimo gasto acumulado (ventas no canceladas) >= X
|
||||||
|
sucursal con al menos una cita en esa sucursal
|
||||||
|
servicio_id que SÍ ha tomado el servicio (cita completada o venta)
|
||||||
|
servicio_nunca_id que NUNCA ha tomado ese servicio
|
||||||
|
con_membresia con (true) / sin (false) membresía activa
|
||||||
|
con_paquetes con (true) / sin (false) paquetes con sesiones disponibles
|
||||||
|
con_saldo con (true) / sin (false) saldo pendiente
|
||||||
|
consentimiento 'con' / 'sin' consentimiento de marketing
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
CRITERIOS_DEFAULT = {
|
||||||
|
'ultima_visita_dias': 0,
|
||||||
|
'sin_visita_meses': 0,
|
||||||
|
'cumple_mes': False,
|
||||||
|
'cumple_proximos_dias': 0,
|
||||||
|
'nivel_codigo': '',
|
||||||
|
'gasto_minimo': 0.0,
|
||||||
|
'sucursal': '',
|
||||||
|
'servicio_id': 0,
|
||||||
|
'servicio_nunca_id': 0,
|
||||||
|
'con_membresia': None,
|
||||||
|
'con_paquetes': None,
|
||||||
|
'con_saldo': None,
|
||||||
|
'consentimiento': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
DIAS_MAX = 3650
|
||||||
|
MESES_MAX = 120
|
||||||
|
GASTO_MAX = 100000000.0
|
||||||
|
MOSTRAR_MUESTRA = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _int(valor, por_defecto=0):
|
||||||
|
try:
|
||||||
|
return int(valor)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return por_defecto
|
||||||
|
|
||||||
|
|
||||||
|
def _num(valor, por_defecto=0.0):
|
||||||
|
try:
|
||||||
|
n = float(valor)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return por_defecto
|
||||||
|
if n != n or n in (float('inf'), float('-inf')):
|
||||||
|
return por_defecto
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def normalizar_criterios(raw):
|
||||||
|
"""Criterios completos, con defaults. Tolerante: nunca lanza."""
|
||||||
|
out = dict(CRITERIOS_DEFAULT)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return out
|
||||||
|
out['ultima_visita_dias'] = min(max(_int(raw.get('ultima_visita_dias'), 0), 0), DIAS_MAX)
|
||||||
|
out['sin_visita_meses'] = min(max(_int(raw.get('sin_visita_meses'), 0), 0), MESES_MAX)
|
||||||
|
out['cumple_mes'] = bool(raw.get('cumple_mes'))
|
||||||
|
out['cumple_proximos_dias'] = min(max(_int(raw.get('cumple_proximos_dias'), 0), 0), DIAS_MAX)
|
||||||
|
out['nivel_codigo'] = str(raw.get('nivel_codigo') or '').strip()[:40]
|
||||||
|
out['gasto_minimo'] = min(max(_num(raw.get('gasto_minimo'), 0.0), 0.0), GASTO_MAX)
|
||||||
|
out['sucursal'] = str(raw.get('sucursal') or '').strip()[:40]
|
||||||
|
out['servicio_id'] = max(_int(raw.get('servicio_id'), 0), 0)
|
||||||
|
out['servicio_nunca_id'] = max(_int(raw.get('servicio_nunca_id'), 0), 0)
|
||||||
|
for k in ('con_membresia', 'con_paquetes', 'con_saldo'):
|
||||||
|
valor = raw.get(k)
|
||||||
|
out[k] = bool(valor) if isinstance(valor, bool) else None
|
||||||
|
cons = str(raw.get('consentimiento') or '').strip().lower()
|
||||||
|
out['consentimiento'] = cons if cons in ('con', 'sin') else ''
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def validar_criterios(raw):
|
||||||
|
"""Valida los criterios de un payload. Lanza ValueError si algo no cuadra.
|
||||||
|
|
||||||
|
Devuelve SIEMPRE el objeto normalizado (los criterios se reemplazan
|
||||||
|
completos: no hay merge parcial de criterios)."""
|
||||||
|
if raw is None:
|
||||||
|
raw = {}
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError('Los criterios deben ser un objeto')
|
||||||
|
for k in ('ultima_visita_dias', 'sin_visita_meses', 'cumple_proximos_dias'):
|
||||||
|
if k in raw and raw[k] not in (None, ''):
|
||||||
|
if isinstance(raw[k], bool) or not isinstance(raw[k], int):
|
||||||
|
raise ValueError('El campo %s debe ser un número de días entero' % k)
|
||||||
|
if raw[k] < 0:
|
||||||
|
raise ValueError('El campo %s no puede ser negativo' % k)
|
||||||
|
for k in ('servicio_id', 'servicio_nunca_id'):
|
||||||
|
if k in raw and raw[k] not in (None, ''):
|
||||||
|
if isinstance(raw[k], bool) or not isinstance(raw[k], int) or raw[k] < 0:
|
||||||
|
raise ValueError('El campo %s debe ser un identificador válido' % k)
|
||||||
|
for k in ('con_membresia', 'con_paquetes', 'con_saldo'):
|
||||||
|
if k in raw and raw[k] is not None and not isinstance(raw[k], bool):
|
||||||
|
raise ValueError('El campo %s debe ser verdadero, falso o nulo' % k)
|
||||||
|
if 'consentimiento' in raw and raw['consentimiento'] not in (None, '', 'con', 'sin'):
|
||||||
|
raise ValueError("El consentimiento debe ser 'con' o 'sin'")
|
||||||
|
if 'cumple_mes' in raw and not isinstance(raw['cumple_mes'], bool):
|
||||||
|
raise ValueError('cumple_mes debe ser verdadero o falso')
|
||||||
|
return normalizar_criterios(raw)
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenSegmento(models.Model):
|
||||||
|
_name = 'skeen.segmento'
|
||||||
|
_description = 'Segmento de pacientes'
|
||||||
|
_order = 'name, id'
|
||||||
|
|
||||||
|
name = fields.Char(string='Nombre', required=True)
|
||||||
|
descripcion = fields.Text(string='Descripción')
|
||||||
|
activo = fields.Boolean(string='Activo', default=True, index=True)
|
||||||
|
criterios = fields.Text(string='Criterios (JSON)', default='{}')
|
||||||
|
creado_por = fields.Char(string='Creado por', copy=False)
|
||||||
|
|
||||||
|
ultimo_conteo = fields.Integer(string='Pacientes (último cálculo)', default=0)
|
||||||
|
muestra = fields.Text(string='Muestra (JSON)', default='[]')
|
||||||
|
preview_at = fields.Datetime(string='Calculado el')
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('name_uniq', 'unique(name)', 'Ya existe un segmento con ese nombre.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Serialización
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def criterios_dict(self):
|
||||||
|
self.ensure_one()
|
||||||
|
try:
|
||||||
|
data = json.loads(self.criterios or '{}')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return dict(CRITERIOS_DEFAULT)
|
||||||
|
return normalizar_criterios(data)
|
||||||
|
|
||||||
|
def muestra_lista(self):
|
||||||
|
self.ensure_one()
|
||||||
|
try:
|
||||||
|
data = json.loads(self.muestra or '[]')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return []
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Evaluación
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _universo(self):
|
||||||
|
return set(self.env['res.partner'].sudo().search(
|
||||||
|
[('is_patient', '=', True)]).ids)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _ids_ultima_visita(self, dias):
|
||||||
|
corte = fields.Date.today() - timedelta(days=dias)
|
||||||
|
return set(self.env['res.partner'].sudo().search([
|
||||||
|
('is_patient', '=', True),
|
||||||
|
('last_visit', '!=', False),
|
||||||
|
('last_visit', '<=', corte),
|
||||||
|
]).ids)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _ids_sin_visita(self, meses):
|
||||||
|
corte = fields.Date.today() - relativedelta(months=meses)
|
||||||
|
return set(self.env['res.partner'].sudo().search([
|
||||||
|
('is_patient', '=', True),
|
||||||
|
'|', ('last_visit', '=', False), ('last_visit', '<=', corte),
|
||||||
|
]).ids)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _ids_cumple(self, mes_actual=True, proximos_dias=0):
|
||||||
|
hoy = fields.Date.today()
|
||||||
|
if proximos_dias:
|
||||||
|
limite = hoy + timedelta(days=proximos_dias)
|
||||||
|
else:
|
||||||
|
limite = hoy
|
||||||
|
ids = set()
|
||||||
|
pacientes = self.env['res.partner'].sudo().search([
|
||||||
|
('is_patient', '=', True), ('birth_date', '!=', False)])
|
||||||
|
for p in pacientes:
|
||||||
|
b = p.birth_date
|
||||||
|
if mes_actual and not proximos_dias:
|
||||||
|
if b.month == hoy.month:
|
||||||
|
ids.add(p.id)
|
||||||
|
continue
|
||||||
|
# cumpleaños dentro de los próximos N días (mes-día, con 29-feb)
|
||||||
|
for yr in (hoy.year, hoy.year + 1):
|
||||||
|
try:
|
||||||
|
ocurre = b.replace(year=yr)
|
||||||
|
except ValueError:
|
||||||
|
ocurre = b.replace(year=yr, day=28)
|
||||||
|
if hoy <= ocurre <= limite:
|
||||||
|
ids.add(p.id)
|
||||||
|
break
|
||||||
|
return ids
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _ids_nivel(self, codigo):
|
||||||
|
if not codigo:
|
||||||
|
return set()
|
||||||
|
return set(self.env['res.partner'].sudo().search([
|
||||||
|
('is_patient', '=', True),
|
||||||
|
('lealtad_nivel_codigo', '=', codigo),
|
||||||
|
]).ids)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _ids_gasto(self, minimo):
|
||||||
|
if minimo <= 0:
|
||||||
|
return set()
|
||||||
|
grupos = self.env['skeen.venta'].sudo().read_group(
|
||||||
|
[('state', '!=', 'cancelled'), ('partner_id', '!=', False)],
|
||||||
|
['partner_id', 'total:sum'], ['partner_id'])
|
||||||
|
return {g['partner_id'][0] for g in grupos
|
||||||
|
if (g.get('total') or 0.0) >= minimo}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _ids_sucursal(self, code):
|
||||||
|
if not code:
|
||||||
|
return set()
|
||||||
|
citas = self.env['skeen.cita'].sudo().search([
|
||||||
|
('branch', '=', code), ('state', '!=', 'cancelled')])
|
||||||
|
return set(citas.mapped('partner_id').ids)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _ids_servicio(self, servicio_id):
|
||||||
|
"""Pacientes que SÍ tomaron el servicio (cita completada o venta)."""
|
||||||
|
if not servicio_id:
|
||||||
|
return set()
|
||||||
|
ids = set(self.env['skeen.cita'].sudo().search([
|
||||||
|
('servicio_id', '=', servicio_id), ('state', '=', 'done'),
|
||||||
|
]).mapped('partner_id').ids)
|
||||||
|
ventas = self.env['skeen.venta'].sudo().search([
|
||||||
|
('state', '!=', 'cancelled'), ('line_ids.service_id', '=', servicio_id)])
|
||||||
|
ids |= set(ventas.mapped('partner_id').ids)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _ids_membresia_activa(self):
|
||||||
|
Model = self.env.get('skeen.membresia')
|
||||||
|
if Model is None:
|
||||||
|
return set()
|
||||||
|
return set(Model.sudo().search([('estado', '=', 'activa')]).mapped('partner_id').ids)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _ids_paquetes_disponibles(self):
|
||||||
|
compras = self.env['skeen.paquete.compra'].sudo().search([
|
||||||
|
('state', '=', 'active')])
|
||||||
|
return set(compras.filtered(
|
||||||
|
lambda c: (c.sessions_remaining or 0) > 0).mapped('partner_id').ids)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _ids_saldo_pendiente(self):
|
||||||
|
ventas = self.env['skeen.venta'].sudo().search([
|
||||||
|
('state', '!=', 'cancelled'), ('amount_due', '>', 0)])
|
||||||
|
return set(ventas.mapped('partner_id').ids)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _ids_consentimiento(self):
|
||||||
|
return set(self.env['res.partner'].sudo().search([
|
||||||
|
('is_patient', '=', True),
|
||||||
|
('consentimiento_marketing', '=', True),
|
||||||
|
]).ids)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _evaluar(self, criterios):
|
||||||
|
"""Conjunto de ids de pacientes que cumplen los criterios."""
|
||||||
|
c = normalizar_criterios(criterios)
|
||||||
|
resultado = self._universo()
|
||||||
|
if not resultado:
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
if c['ultima_visita_dias'] > 0:
|
||||||
|
resultado &= self._ids_ultima_visita(c['ultima_visita_dias'])
|
||||||
|
if c['sin_visita_meses'] > 0:
|
||||||
|
resultado &= self._ids_sin_visita(c['sin_visita_meses'])
|
||||||
|
if c['cumple_mes']:
|
||||||
|
resultado &= self._ids_cumple(mes_actual=True)
|
||||||
|
if c['cumple_proximos_dias'] > 0:
|
||||||
|
resultado &= self._ids_cumple(proximos_dias=c['cumple_proximos_dias'])
|
||||||
|
if c['nivel_codigo']:
|
||||||
|
resultado &= self._ids_nivel(c['nivel_codigo'])
|
||||||
|
if c['gasto_minimo'] > 0:
|
||||||
|
resultado &= self._ids_gasto(c['gasto_minimo'])
|
||||||
|
if c['sucursal']:
|
||||||
|
resultado &= self._ids_sucursal(c['sucursal'])
|
||||||
|
if c['servicio_id']:
|
||||||
|
resultado &= self._ids_servicio(c['servicio_id'])
|
||||||
|
if c['servicio_nunca_id']:
|
||||||
|
resultado -= self._ids_servicio(c['servicio_nunca_id'])
|
||||||
|
if c['con_membresia'] is not None:
|
||||||
|
con = self._ids_membresia_activa()
|
||||||
|
resultado = (resultado & con) if c['con_membresia'] else (resultado - con)
|
||||||
|
if c['con_paquetes'] is not None:
|
||||||
|
con = self._ids_paquetes_disponibles()
|
||||||
|
resultado = (resultado & con) if c['con_paquetes'] else (resultado - con)
|
||||||
|
if c['con_saldo'] is not None:
|
||||||
|
con = self._ids_saldo_pendiente()
|
||||||
|
resultado = (resultado & con) if c['con_saldo'] else (resultado - con)
|
||||||
|
if c['consentimiento']:
|
||||||
|
con = self._ids_consentimiento()
|
||||||
|
resultado = (resultado & con) if c['consentimiento'] == 'con' \
|
||||||
|
else (resultado - con)
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _muestra_de(self, ids):
|
||||||
|
"""Muestra de hasta 5 pacientes con datos mínimos para la vista previa."""
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
partners = self.env['res.partner'].sudo().browse(sorted(ids)[:MOSTRAR_MUESTRA])
|
||||||
|
return [{
|
||||||
|
'id': p.id,
|
||||||
|
'name': p.name,
|
||||||
|
'phone': p.phone or p.whatsapp or '',
|
||||||
|
'email': p.email or '',
|
||||||
|
'last_visit': p.last_visit.strftime('%Y-%m-%d') if p.last_visit else None,
|
||||||
|
'nivel': p.lealtad_nivel_codigo or '',
|
||||||
|
'consentimiento': bool(p.consentimiento_marketing),
|
||||||
|
} for p in partners]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def previsualizar(self, criterios):
|
||||||
|
"""Conteo + muestra SIN guardar (vista previa del constructor)."""
|
||||||
|
ids = self._evaluar(criterios)
|
||||||
|
return {'total': len(ids), 'muestra': self._muestra_de(ids)}
|
||||||
|
|
||||||
|
def guardar_preview(self, ids=None):
|
||||||
|
"""Recalcula y persiste el conteo y la muestra del segmento."""
|
||||||
|
for seg in self:
|
||||||
|
ids = self._evaluar(seg.criterios_dict()) if ids is None else ids
|
||||||
|
seg.write({
|
||||||
|
'ultimo_conteo': len(ids),
|
||||||
|
'muestra': json.dumps(self._muestra_de(ids), ensure_ascii=False),
|
||||||
|
'preview_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
def pacientes(self):
|
||||||
|
"""Recordset de pacientes del segmento (recalcula al vuelo)."""
|
||||||
|
self.ensure_one()
|
||||||
|
ids = self._evaluar(self.criterios_dict())
|
||||||
|
return self.env['res.partner'].sudo().browse(sorted(ids))
|
||||||
3
odoo-addons/skeen_campanas/security/ir.model.access.csv
Normal file
3
odoo-addons/skeen_campanas/security/ir.model.access.csv
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_skeen_segmento,Acceso a Segmentos SKEEN,model_skeen_segmento,base.group_user,1,1,1,1
|
||||||
|
access_skeen_campana,Acceso a Campañas SKEEN,model_skeen_campana,base.group_user,1,1,1,1
|
||||||
|
@@ -1535,6 +1535,201 @@ def get_propinas(env):
|
|||||||
"""Bloque de propinas vigente (copia). Apagado por defecto."""
|
"""Bloque de propinas vigente (copia). Apagado por defecto."""
|
||||||
return normalizar_propinas(get_settings(env).get('propinas'))
|
return normalizar_propinas(get_settings(env).get('propinas'))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Campañas por segmentos (marketing sobre la base de pacientes).
|
||||||
|
#
|
||||||
|
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
|
||||||
|
# oro: con `activo=False` (default) NADA cambia en la instancia — la sección
|
||||||
|
# Campañas no aparece en el menú, no corre ningún cron, no se encola ni se
|
||||||
|
# envía nada y las tablas propias quedan en cero.
|
||||||
|
#
|
||||||
|
# activo: enciende TODO el módulo de campañas
|
||||||
|
# envios_por_minuto: tope de mensajes que el motor manda por minuto
|
||||||
|
# pausar_tras_errores: errores seguidos que pausan la campaña sola
|
||||||
|
# ventana_atribucion_dias: días tras el envío en que una cita se atribuye
|
||||||
|
# exigir_consentimiento: nunca escribir a quien no autorizó marketing
|
||||||
|
# hora_envio: hora (HH:MM) de los envíos recurrentes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
CAMPANAS_DEFAULT = {
|
||||||
|
'activo': False,
|
||||||
|
'envios_por_minuto': 20,
|
||||||
|
'pausar_tras_errores': 5,
|
||||||
|
'ventana_atribucion_dias': 7,
|
||||||
|
'exigir_consentimiento': True,
|
||||||
|
'hora_envio': '10:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
CAMPANAS_ENVIOS_MIN, CAMPANAS_ENVIOS_MAX = 1, 600
|
||||||
|
CAMPANAS_ERRORES_MIN, CAMPANAS_ERRORES_MAX = 1, 100
|
||||||
|
CAMPANAS_VENTANA_MIN, CAMPANAS_VENTANA_MAX = 1, 90
|
||||||
|
|
||||||
|
|
||||||
|
def normalizar_campanas(raw):
|
||||||
|
"""Bloque `campanas` completo, con defaults. Tolerante: nunca lanza."""
|
||||||
|
out = dict(CAMPANAS_DEFAULT)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return out
|
||||||
|
for k in ('activo', 'exigir_consentimiento'):
|
||||||
|
out[k] = _bool(raw.get(k), CAMPANAS_DEFAULT[k])
|
||||||
|
out['envios_por_minuto'] = min(max(_int(
|
||||||
|
raw.get('envios_por_minuto'), CAMPANAS_DEFAULT['envios_por_minuto']),
|
||||||
|
CAMPANAS_ENVIOS_MIN), CAMPANAS_ENVIOS_MAX)
|
||||||
|
out['pausar_tras_errores'] = min(max(_int(
|
||||||
|
raw.get('pausar_tras_errores'), CAMPANAS_DEFAULT['pausar_tras_errores']),
|
||||||
|
CAMPANAS_ERRORES_MIN), CAMPANAS_ERRORES_MAX)
|
||||||
|
out['ventana_atribucion_dias'] = min(max(_int(
|
||||||
|
raw.get('ventana_atribucion_dias'), CAMPANAS_DEFAULT['ventana_atribucion_dias']),
|
||||||
|
CAMPANAS_VENTANA_MIN), CAMPANAS_VENTANA_MAX)
|
||||||
|
out['hora_envio'] = _hhmm_valida(raw.get('hora_envio'), CAMPANAS_DEFAULT['hora_envio'])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def validar_campanas(raw):
|
||||||
|
"""Valida el bloque `campanas` de un PUT. Lanza ValueError si algo no cuadra.
|
||||||
|
Devuelve SOLO las claves presentes para que el merge no reinicie el resto."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError('El bloque de campañas debe ser un objeto')
|
||||||
|
out = {}
|
||||||
|
for k in ('activo', 'exigir_consentimiento'):
|
||||||
|
if k in raw:
|
||||||
|
if not isinstance(raw[k], bool):
|
||||||
|
raise ValueError('El campo %s debe ser verdadero o falso' % k)
|
||||||
|
out[k] = raw[k]
|
||||||
|
for k, lo, hi in (('envios_por_minuto', CAMPANAS_ENVIOS_MIN, CAMPANAS_ENVIOS_MAX),
|
||||||
|
('pausar_tras_errores', CAMPANAS_ERRORES_MIN, CAMPANAS_ERRORES_MAX),
|
||||||
|
('ventana_atribucion_dias', CAMPANAS_VENTANA_MIN, CAMPANAS_VENTANA_MAX)):
|
||||||
|
if k in raw:
|
||||||
|
if isinstance(raw[k], bool) or not isinstance(raw[k], int):
|
||||||
|
raise ValueError('El campo %s debe ser un número entero' % k)
|
||||||
|
if not (lo <= raw[k] <= hi):
|
||||||
|
raise ValueError('El campo %s debe estar entre %d y %d' % (k, lo, hi))
|
||||||
|
out[k] = raw[k]
|
||||||
|
if 'hora_envio' in raw:
|
||||||
|
partes = str(raw['hora_envio'] or '').strip().split(':')
|
||||||
|
valido = False
|
||||||
|
if len(partes) == 2:
|
||||||
|
try:
|
||||||
|
h, m = int(partes[0]), int(partes[1])
|
||||||
|
valido = 0 <= h <= 23 and 0 <= m <= 59
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
valido = False
|
||||||
|
if not valido:
|
||||||
|
raise ValueError('La hora de envío debe tener el formato HH:MM')
|
||||||
|
out['hora_envio'] = _hhmm_valida(raw['hora_envio'])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_campanas(env):
|
||||||
|
"""Bloque de campañas vigente (copia). Apagado por defecto."""
|
||||||
|
return normalizar_campanas(get_settings(env).get('campanas'))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Encuestas de salida y satisfacción (NPS).
|
||||||
|
#
|
||||||
|
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
|
||||||
|
# oro: con `activo=False` (default) NADA cambia en la instancia — no se encola
|
||||||
|
# ni se envía ninguna encuesta tras completar una cita y no se genera ninguna
|
||||||
|
# respuesta nueva.
|
||||||
|
#
|
||||||
|
# activo: enciende el envío automático de la encuesta al salir
|
||||||
|
# enviar_tras_horas: horas después de completarse la cita en que se envía
|
||||||
|
# plantilla_id: plantilla de formulario (tipo encuesta) a enviar
|
||||||
|
# canal: whatsapp | email | ambos
|
||||||
|
# recordatorio_horas: horas para reenviar si no responde (0 = sin recordatorio)
|
||||||
|
# solo_primera_visita: envía solo a quien tiene una única visita
|
||||||
|
# agradecimiento: mensaje de gracias que ve el paciente al terminar
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ENCUESTAS_DEFAULT = {
|
||||||
|
'activo': False,
|
||||||
|
'enviar_tras_horas': 2,
|
||||||
|
'plantilla_id': None,
|
||||||
|
'canal': 'whatsapp',
|
||||||
|
'recordatorio_horas': 48,
|
||||||
|
'solo_primera_visita': False,
|
||||||
|
'agradecimiento': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
ENCUESTAS_CANALES = ('whatsapp', 'email', 'ambos')
|
||||||
|
ENCUESTAS_HORAS_MAX = 720 # 30 días
|
||||||
|
ENCUESTAS_RECORDATORIO_MAX = 720
|
||||||
|
ENCUESTAS_AGRADECIMIENTO_MAX = 500
|
||||||
|
|
||||||
|
|
||||||
|
def normalizar_encuestas(raw):
|
||||||
|
"""Bloque `encuestas` completo, con defaults. Tolerante: nunca lanza."""
|
||||||
|
out = dict(ENCUESTAS_DEFAULT)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return out
|
||||||
|
for k in ('activo', 'solo_primera_visita'):
|
||||||
|
out[k] = _bool(raw.get(k), ENCUESTAS_DEFAULT[k])
|
||||||
|
out['enviar_tras_horas'] = min(max(_int(
|
||||||
|
raw.get('enviar_tras_horas'), ENCUESTAS_DEFAULT['enviar_tras_horas']),
|
||||||
|
0), ENCUESTAS_HORAS_MAX)
|
||||||
|
out['recordatorio_horas'] = min(max(_int(
|
||||||
|
raw.get('recordatorio_horas'), ENCUESTAS_DEFAULT['recordatorio_horas']),
|
||||||
|
0), ENCUESTAS_RECORDATORIO_MAX)
|
||||||
|
canal = str(raw.get('canal') or '').strip().lower()
|
||||||
|
out['canal'] = canal if canal in ENCUESTAS_CANALES else ENCUESTAS_DEFAULT['canal']
|
||||||
|
pid = raw.get('plantilla_id')
|
||||||
|
if pid in (None, '', False, 0, '0'):
|
||||||
|
out['plantilla_id'] = None
|
||||||
|
else:
|
||||||
|
n = _int(pid, 0)
|
||||||
|
out['plantilla_id'] = n if n > 0 else None
|
||||||
|
out['agradecimiento'] = str(raw.get('agradecimiento') or '')[:ENCUESTAS_AGRADECIMIENTO_MAX]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def validar_encuestas(raw):
|
||||||
|
"""Valida el bloque `encuestas` de un PUT. Lanza ValueError si algo no cuadra.
|
||||||
|
Devuelve SOLO las claves presentes para que el merge no reinicie el resto."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError('El bloque de encuestas debe ser un objeto')
|
||||||
|
out = {}
|
||||||
|
for k in ('activo', 'solo_primera_visita'):
|
||||||
|
if k in raw:
|
||||||
|
if not isinstance(raw[k], bool):
|
||||||
|
raise ValueError('El campo %s debe ser verdadero o falso' % k)
|
||||||
|
out[k] = raw[k]
|
||||||
|
for k, hi in (('enviar_tras_horas', ENCUESTAS_HORAS_MAX),
|
||||||
|
('recordatorio_horas', ENCUESTAS_RECORDATORIO_MAX)):
|
||||||
|
if k in raw:
|
||||||
|
if isinstance(raw[k], bool) or not isinstance(raw[k], int):
|
||||||
|
raise ValueError('El campo %s debe ser un número entero' % k)
|
||||||
|
if not (0 <= raw[k] <= hi):
|
||||||
|
raise ValueError('El campo %s debe estar entre 0 y %d' % (k, hi))
|
||||||
|
out[k] = raw[k]
|
||||||
|
if 'canal' in raw:
|
||||||
|
canal = str(raw['canal'] or '').strip().lower()
|
||||||
|
if canal not in ENCUESTAS_CANALES:
|
||||||
|
raise ValueError('El canal debe ser whatsapp, email o ambos')
|
||||||
|
out['canal'] = canal
|
||||||
|
if 'plantilla_id' in raw:
|
||||||
|
pid = raw['plantilla_id']
|
||||||
|
if pid in (None, '', False, 0):
|
||||||
|
out['plantilla_id'] = None
|
||||||
|
else:
|
||||||
|
if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0:
|
||||||
|
raise ValueError('La plantilla de encuesta debe ser un identificador válido')
|
||||||
|
out['plantilla_id'] = pid
|
||||||
|
if 'agradecimiento' in raw:
|
||||||
|
if not isinstance(raw['agradecimiento'], str):
|
||||||
|
raise ValueError('El agradecimiento debe ser texto')
|
||||||
|
if len(raw['agradecimiento']) > ENCUESTAS_AGRADECIMIENTO_MAX:
|
||||||
|
raise ValueError('El agradecimiento es demasiado largo (máx. %d)'
|
||||||
|
% ENCUESTAS_AGRADECIMIENTO_MAX)
|
||||||
|
out['agradecimiento'] = raw['agradecimiento'].strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_encuestas(env):
|
||||||
|
"""Bloque de encuestas vigente (copia). Apagado por defecto."""
|
||||||
|
return normalizar_encuestas(get_settings(env).get('encuestas'))
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Licencia por plan (producto comercial).
|
# Licencia por plan (producto comercial).
|
||||||
#
|
#
|
||||||
@@ -1966,6 +2161,8 @@ def get_settings(env, aplicar_licencia=True):
|
|||||||
'comisiones': dict(COMISIONES_DEFAULT),
|
'comisiones': dict(COMISIONES_DEFAULT),
|
||||||
'lealtad': normalizar_lealtad(None),
|
'lealtad': normalizar_lealtad(None),
|
||||||
'propinas': dict(PROPINAS_DEFAULT),
|
'propinas': dict(PROPINAS_DEFAULT),
|
||||||
|
'campanas': dict(CAMPANAS_DEFAULT),
|
||||||
|
'encuestas': dict(ENCUESTAS_DEFAULT),
|
||||||
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
|
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
|
||||||
'portal_cliente': normalizar_portal_cliente(None),
|
'portal_cliente': normalizar_portal_cliente(None),
|
||||||
}
|
}
|
||||||
@@ -1988,6 +2185,8 @@ def get_settings(env, aplicar_licencia=True):
|
|||||||
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
|
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
|
||||||
out['lealtad'] = normalizar_lealtad(guardado.get('lealtad'))
|
out['lealtad'] = normalizar_lealtad(guardado.get('lealtad'))
|
||||||
out['propinas'] = normalizar_propinas(guardado.get('propinas'))
|
out['propinas'] = normalizar_propinas(guardado.get('propinas'))
|
||||||
|
out['campanas'] = normalizar_campanas(guardado.get('campanas'))
|
||||||
|
out['encuestas'] = normalizar_encuestas(guardado.get('encuestas'))
|
||||||
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
|
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
|
||||||
out['portal_cliente'] = normalizar_portal_cliente(
|
out['portal_cliente'] = normalizar_portal_cliente(
|
||||||
guardado.get('portal_cliente'))
|
guardado.get('portal_cliente'))
|
||||||
@@ -2059,6 +2258,16 @@ def set_settings(env, vals):
|
|||||||
bloque = validar_propinas(vals['propinas'])
|
bloque = validar_propinas(vals['propinas'])
|
||||||
if bloque is not None:
|
if bloque is not None:
|
||||||
actual['propinas'] = {**actual['propinas'], **bloque}
|
actual['propinas'] = {**actual['propinas'], **bloque}
|
||||||
|
if 'campanas' in vals:
|
||||||
|
bloque = validar_campanas(vals['campanas'])
|
||||||
|
if bloque is not None:
|
||||||
|
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
||||||
|
actual['campanas'] = {**actual['campanas'], **bloque}
|
||||||
|
if 'encuestas' in vals:
|
||||||
|
bloque = validar_encuestas(vals['encuestas'])
|
||||||
|
if bloque is not None:
|
||||||
|
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
||||||
|
actual['encuestas'] = {**actual['encuestas'], **bloque}
|
||||||
if 'licencia' in vals:
|
if 'licencia' in vals:
|
||||||
bloque = validar_licencia(vals['licencia'])
|
bloque = validar_licencia(vals['licencia'])
|
||||||
if bloque is not None:
|
if bloque is not None:
|
||||||
|
|||||||
@@ -163,6 +163,12 @@ def limpiar_respuestas(campos, respuestas):
|
|||||||
out[clave] = int(valor)
|
out[clave] = int(valor)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
out[clave] = None
|
out[clave] = None
|
||||||
|
elif tipo == 'nps':
|
||||||
|
try:
|
||||||
|
n = int(valor)
|
||||||
|
out[clave] = n if 0 <= n <= 10 else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
out[clave] = None
|
||||||
elif tipo in ('firma', 'foto', 'archivo'):
|
elif tipo in ('firma', 'foto', 'archivo'):
|
||||||
out[clave] = bool(valor)
|
out[clave] = bool(valor)
|
||||||
else:
|
else:
|
||||||
@@ -209,6 +215,18 @@ def validar_respuestas(plantilla, campos, respuestas, fotos_claves,
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
errores.append('%s debe ser un número del 1 al 10' % etiqueta)
|
errores.append('%s debe ser un número del 1 al 10' % etiqueta)
|
||||||
continue
|
continue
|
||||||
|
if tipo == 'nps':
|
||||||
|
if valor in (None, ''):
|
||||||
|
if obligatorio:
|
||||||
|
errores.append('Falta responder: %s' % etiqueta)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
n = int(valor)
|
||||||
|
if not (0 <= n <= 10):
|
||||||
|
errores.append('%s debe estar entre 0 y 10' % etiqueta)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
errores.append('%s debe ser un número del 0 al 10' % etiqueta)
|
||||||
|
continue
|
||||||
if tipo == 'numero':
|
if tipo == 'numero':
|
||||||
if valor in (None, ''):
|
if valor in (None, ''):
|
||||||
if obligatorio:
|
if obligatorio:
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ from odoo import fields, http
|
|||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
|
|
||||||
from odoo.addons.skeen_whatsapp.controllers import public_booking as _pb
|
from odoo.addons.skeen_whatsapp.controllers import public_booking as _pb
|
||||||
from odoo.addons.skeen_citas.clinic_settings import brand_dict, get_settings
|
from odoo.addons.skeen_citas.clinic_settings import (
|
||||||
|
brand_dict, get_encuestas, get_settings,
|
||||||
|
)
|
||||||
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
|
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
|
||||||
|
|
||||||
from .comun import (
|
from .comun import (
|
||||||
@@ -86,6 +88,17 @@ def _primer_nombre(partner):
|
|||||||
return nombre.split(' ')[0] if nombre else ''
|
return nombre.split(' ')[0] if nombre else ''
|
||||||
|
|
||||||
|
|
||||||
|
def _agradecimiento(env, plantilla):
|
||||||
|
"""Mensaje de gracias de la encuesta de satisfacción (si es la configurada)."""
|
||||||
|
try:
|
||||||
|
cfg = get_encuestas(env)
|
||||||
|
if cfg.get('plantilla_id') and int(cfg['plantilla_id']) == plantilla.id:
|
||||||
|
return (cfg.get('agradecimiento') or '').strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
class SkeenPublicFormularioController(http.Controller):
|
class SkeenPublicFormularioController(http.Controller):
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -118,6 +131,7 @@ class SkeenPublicFormularioController(http.Controller):
|
|||||||
} if cita else None,
|
} if cita else None,
|
||||||
'estado': respuesta.estado,
|
'estado': respuesta.estado,
|
||||||
'ya_llenado': respuesta.estado == 'llenado',
|
'ya_llenado': respuesta.estado == 'llenado',
|
||||||
|
'agradecimiento': _agradecimiento(request.env, plantilla),
|
||||||
'plantilla': {
|
'plantilla': {
|
||||||
'nombre': plantilla.name,
|
'nombre': plantilla.name,
|
||||||
'tipo': plantilla.tipo,
|
'tipo': plantilla.tipo,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from odoo import api, fields, models
|
|||||||
|
|
||||||
TIPOS_CAMPO = (
|
TIPOS_CAMPO = (
|
||||||
'texto', 'texto_largo', 'numero', 'fecha', 'seleccion',
|
'texto', 'texto_largo', 'numero', 'fecha', 'seleccion',
|
||||||
'multi_seleccion', 'checkbox', 'escala', 'firma', 'foto',
|
'multi_seleccion', 'checkbox', 'escala', 'nps', 'firma', 'foto',
|
||||||
'archivo', 'consentimiento',
|
'archivo', 'consentimiento',
|
||||||
)
|
)
|
||||||
TIPOS_PLANTILLA = (
|
TIPOS_PLANTILLA = (
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ TIPOS = [
|
|||||||
('cobro_membresia', 'Cobro de membresía'),
|
('cobro_membresia', 'Cobro de membresía'),
|
||||||
('acceso_codigo', 'Código de acceso al portal'),
|
('acceso_codigo', 'Código de acceso al portal'),
|
||||||
('acceso_enlace', 'Enlace de acceso al portal'),
|
('acceso_enlace', 'Enlace de acceso al portal'),
|
||||||
|
('campana', 'Campaña'),
|
||||||
|
('encuesta', 'Encuesta'),
|
||||||
('manual', 'Manual'),
|
('manual', 'Manual'),
|
||||||
]
|
]
|
||||||
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
|
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
|
||||||
@@ -98,7 +100,7 @@ class SkeenNotificacion(models.Model):
|
|||||||
_description = 'Notificación de SKEEN'
|
_description = 'Notificación de SKEEN'
|
||||||
_order = 'programada_para desc, id desc'
|
_order = 'programada_para desc, id desc'
|
||||||
|
|
||||||
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
|
partner_id = fields.Many2one('res.partner', string='Paciente', required=False,
|
||||||
index=True, ondelete='cascade')
|
index=True, ondelete='cascade')
|
||||||
cita_id = fields.Many2one('skeen.cita', string='Cita', index=True,
|
cita_id = fields.Many2one('skeen.cita', string='Cita', index=True,
|
||||||
ondelete='cascade')
|
ondelete='cascade')
|
||||||
@@ -108,6 +110,12 @@ class SkeenNotificacion(models.Model):
|
|||||||
default='pendiente', index=True)
|
default='pendiente', index=True)
|
||||||
asunto = fields.Char(string='Asunto')
|
asunto = fields.Char(string='Asunto')
|
||||||
mensaje = fields.Text(string='Mensaje')
|
mensaje = fields.Text(string='Mensaje')
|
||||||
|
# Destino alterno (teléfono o correo) para avisos sin paciente, como el
|
||||||
|
# envío de prueba de una campaña a un número propio. Vacío = usar el del
|
||||||
|
# paciente.
|
||||||
|
destino = fields.Char(string='Destino alterno')
|
||||||
|
es_prueba = fields.Boolean(string='Es prueba', default=False, index=True,
|
||||||
|
help='Prueba de campaña: no cuenta en la medición.')
|
||||||
programada_para = fields.Datetime(string='Programada para', required=True,
|
programada_para = fields.Datetime(string='Programada para', required=True,
|
||||||
default=fields.Datetime.now, index=True)
|
default=fields.Datetime.now, index=True)
|
||||||
enviada_at = fields.Datetime(string='Enviada el')
|
enviada_at = fields.Datetime(string='Enviada el')
|
||||||
@@ -241,22 +249,31 @@ class SkeenNotificacionService(models.AbstractModel):
|
|||||||
@api.model
|
@api.model
|
||||||
def encolar(self, cita=None, tipo='recordatorio', cuando=None, canal=None,
|
def encolar(self, cita=None, tipo='recordatorio', cuando=None, canal=None,
|
||||||
partner=None, ventana=None, clave_unica=None, asunto=None,
|
partner=None, ventana=None, clave_unica=None, asunto=None,
|
||||||
contexto_extra=None):
|
contexto_extra=None, mensaje=None, vals_extra=None, forzar=False,
|
||||||
|
destino=None):
|
||||||
"""Crea las notificaciones pendientes de una cita (una por canal).
|
"""Crea las notificaciones pendientes de una cita (una por canal).
|
||||||
|
|
||||||
Con `activo=False` no hace nada. No duplica: si ya existe la misma
|
Con `activo=False` no hace nada. No duplica: si ya existe la misma
|
||||||
`clave_unica` (misma cita + tipo + ventana + canal) se salta. Devuelve el
|
`clave_unica` (misma cita + tipo + ventana + canal) se salta. Devuelve el
|
||||||
recordset de lo creado. `contexto_extra` alimenta variables adicionales
|
recordset de lo creado. `contexto_extra` alimenta variables adicionales
|
||||||
del render (p. ej. `{enlace}` del formulario digital).
|
del render (p. ej. `{enlace}` del formulario digital).
|
||||||
|
|
||||||
|
`mensaje` permite encolar un texto ya resuelto (campañas y encuestas
|
||||||
|
traen su propia plantilla) en lugar de la del bloque `notificaciones`.
|
||||||
|
`vals_extra` añade campos al registro (p. ej. `campana_id`). `forzar=True`
|
||||||
|
salta el interruptor `notificaciones.activo` para los subsistemas que ya
|
||||||
|
validaron su propio interruptor (campañas, encuestas). `destino` permite
|
||||||
|
mandar a un número/correo explícito SIN paciente (envío de prueba).
|
||||||
"""
|
"""
|
||||||
Notif = self.env['skeen.notificacion'].sudo()
|
Notif = self.env['skeen.notificacion'].sudo()
|
||||||
cfg = self._cfg()
|
cfg = self._cfg()
|
||||||
if not cfg['activo']:
|
if not cfg['activo'] and not forzar:
|
||||||
return Notif
|
return Notif
|
||||||
partner = partner or (cita.partner_id if cita else None)
|
partner = partner or (cita.partner_id if cita else None)
|
||||||
if not partner:
|
if not partner and not destino:
|
||||||
return Notif
|
return Notif
|
||||||
|
|
||||||
|
if mensaje is None:
|
||||||
plantilla = cfg.get(PLANTILLA_POR_TIPO.get(tipo, 'plantilla_recordatorio')) or ''
|
plantilla = cfg.get(PLANTILLA_POR_TIPO.get(tipo, 'plantilla_recordatorio')) or ''
|
||||||
if not plantilla.strip():
|
if not plantilla.strip():
|
||||||
return Notif
|
return Notif
|
||||||
@@ -268,24 +285,28 @@ class SkeenNotificacionService(models.AbstractModel):
|
|||||||
canales = [canal] if canal else self._canales(cfg)
|
canales = [canal] if canal else self._canales(cfg)
|
||||||
creadas = Notif
|
creadas = Notif
|
||||||
for ch in canales:
|
for ch in canales:
|
||||||
if not self._destino_disponible(partner, ch):
|
if not destino and not self._destino_disponible(partner, ch):
|
||||||
continue
|
continue
|
||||||
clave = clave_unica or self._clave(tipo, cita, partner, ch, ventana)
|
clave = clave_unica or self._clave(tipo, cita, partner, ch, ventana)
|
||||||
if clave_unica and len(canales) > 1:
|
if clave_unica and len(canales) > 1:
|
||||||
clave = '%s:%s' % (clave_unica, ch)
|
clave = '%s:%s' % (clave_unica, ch)
|
||||||
if Notif.search([('clave_unica', '=', clave)], limit=1):
|
if Notif.search([('clave_unica', '=', clave)], limit=1):
|
||||||
continue
|
continue
|
||||||
creadas |= Notif.create({
|
vals = {
|
||||||
'partner_id': partner.id,
|
'partner_id': partner.id if partner else False,
|
||||||
'cita_id': cita.id if cita is not None else False,
|
'cita_id': cita.id if cita is not None else False,
|
||||||
'tipo': tipo,
|
'tipo': tipo,
|
||||||
'canal': ch,
|
'canal': ch,
|
||||||
'estado': 'pendiente',
|
'estado': 'pendiente',
|
||||||
'asunto': asunto,
|
'asunto': asunto,
|
||||||
'mensaje': mensaje,
|
'mensaje': mensaje,
|
||||||
|
'destino': destino or False,
|
||||||
'programada_para': cuando or fields.Datetime.now(),
|
'programada_para': cuando or fields.Datetime.now(),
|
||||||
'clave_unica': clave,
|
'clave_unica': clave,
|
||||||
})
|
}
|
||||||
|
if vals_extra:
|
||||||
|
vals.update(vals_extra)
|
||||||
|
creadas |= Notif.create(vals)
|
||||||
return creadas
|
return creadas
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
@@ -476,11 +497,13 @@ class SkeenNotificacionService(models.AbstractModel):
|
|||||||
return 'simulada'
|
return 'simulada'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
destino = notif.destino or None
|
||||||
if notif.canal == 'whatsapp':
|
if notif.canal == 'whatsapp':
|
||||||
self.enviar_whatsapp(notif.partner_id, notif.mensaje, cfg=cfg)
|
self.enviar_whatsapp(notif.partner_id, notif.mensaje, cfg=cfg,
|
||||||
|
destino=destino)
|
||||||
else:
|
else:
|
||||||
self.enviar_email(notif.partner_id, notif.asunto or 'Notificación',
|
self.enviar_email(notif.partner_id, notif.asunto or 'Notificación',
|
||||||
notif.mensaje, cfg=cfg)
|
notif.mensaje, cfg=cfg, destino=destino)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
intentos = notif.intentos + 1
|
intentos = notif.intentos + 1
|
||||||
vals = {'intentos': intentos, 'error': str(exc)[:500]}
|
vals = {'intentos': intentos, 'error': str(exc)[:500]}
|
||||||
@@ -509,16 +532,20 @@ class SkeenNotificacionService(models.AbstractModel):
|
|||||||
return (url or '').rstrip('/'), (key or '')
|
return (url or '').rstrip('/'), (key or '')
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def enviar_whatsapp(self, partner, mensaje, cfg=None):
|
def enviar_whatsapp(self, partner, mensaje, cfg=None, destino=None):
|
||||||
"""Envía un WhatsApp real vía la API v1 del WACRM.
|
"""Envía un WhatsApp real vía la API v1 del WACRM.
|
||||||
|
|
||||||
Sin credenciales configuradas lanza un error claro (nunca envía a
|
`destino` permite mandar a un número explícito (envío de prueba de una
|
||||||
ciegas ni revienta el proceso de la cola).
|
campaña) sin depender del paciente. Sin credenciales configuradas lanza
|
||||||
|
un error claro (nunca envía a ciegas ni revienta el proceso de la cola).
|
||||||
"""
|
"""
|
||||||
url, key = self._wacrm_credenciales()
|
url, key = self._wacrm_credenciales()
|
||||||
if not url or not key:
|
if not url or not key:
|
||||||
raise UserError(_('No hay credenciales de WhatsApp (WACRM) configuradas '
|
raise UserError(_('No hay credenciales de WhatsApp (WACRM) configuradas '
|
||||||
'en esta instancia.'))
|
'en esta instancia.'))
|
||||||
|
if destino:
|
||||||
|
phone = str(destino).strip()
|
||||||
|
else:
|
||||||
phone = (partner.whatsapp or partner.phone or '').strip() if partner else ''
|
phone = (partner.whatsapp or partner.phone or '').strip() if partner else ''
|
||||||
if not phone:
|
if not phone:
|
||||||
raise UserError(_('El paciente no tiene teléfono ni WhatsApp registrado.'))
|
raise UserError(_('El paciente no tiene teléfono ni WhatsApp registrado.'))
|
||||||
@@ -547,12 +574,17 @@ class SkeenNotificacionService(models.AbstractModel):
|
|||||||
return bool(self.env['ir.mail_server'].sudo().search_count([]))
|
return bool(self.env['ir.mail_server'].sudo().search_count([]))
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def enviar_email(self, partner, asunto, mensaje, cfg=None):
|
def enviar_email(self, partner, asunto, mensaje, cfg=None, destino=None):
|
||||||
"""Envía un correo real con `mail.mail`. Sin SMTP, error claro."""
|
"""Envía un correo real con `mail.mail`. Sin SMTP, error claro.
|
||||||
if not partner:
|
|
||||||
raise UserError(_('Falta el paciente destinatario.'))
|
`destino` permite mandar a un correo explícito (envío de prueba de una
|
||||||
destino = (partner.email or '').strip()
|
campaña) sin depender del paciente.
|
||||||
if not destino:
|
"""
|
||||||
|
if destino:
|
||||||
|
email = str(destino).strip()
|
||||||
|
else:
|
||||||
|
email = (partner.email or '').strip() if partner else ''
|
||||||
|
if not email:
|
||||||
raise UserError(_('El paciente no tiene correo registrado.'))
|
raise UserError(_('El paciente no tiene correo registrado.'))
|
||||||
if not self._smtp_configurado():
|
if not self._smtp_configurado():
|
||||||
raise UserError(_('No hay servidor SMTP configurado en Odoo.'))
|
raise UserError(_('No hay servidor SMTP configurado en Odoo.'))
|
||||||
@@ -564,7 +596,7 @@ class SkeenNotificacionService(models.AbstractModel):
|
|||||||
'subject': asunto or 'Notificación',
|
'subject': asunto or 'Notificación',
|
||||||
'body_html': '<div style="white-space:pre-wrap">%s</div>'
|
'body_html': '<div style="white-space:pre-wrap">%s</div>'
|
||||||
% html.escape(mensaje or ''),
|
% html.escape(mensaje or ''),
|
||||||
'email_to': destino,
|
'email_to': email,
|
||||||
'email_from': remitente or None,
|
'email_from': remitente or None,
|
||||||
})
|
})
|
||||||
mail.send()
|
mail.send()
|
||||||
|
|||||||
@@ -101,6 +101,17 @@ PERMISSION_GROUPS = [
|
|||||||
('formularios.editar', 'Administrar plantillas de formularios'),
|
('formularios.editar', 'Administrar plantillas de formularios'),
|
||||||
('formularios.enviar', 'Enviar formularios a pacientes'),
|
('formularios.enviar', 'Enviar formularios a pacientes'),
|
||||||
]),
|
]),
|
||||||
|
('campanas', 'Campañas y segmentos', [
|
||||||
|
('campanas.ver', 'Ver campañas y segmentos'),
|
||||||
|
('campanas.gestionar', 'Crear y editar campañas y segmentos'),
|
||||||
|
('campanas.enviar', 'Lanzar campañas y enviar pruebas'),
|
||||||
|
('campanas.medir', 'Ver la medición de las campañas'),
|
||||||
|
]),
|
||||||
|
('encuestas', 'Encuestas de salida (NPS)', [
|
||||||
|
('encuestas.ver', 'Ver la configuración de encuestas'),
|
||||||
|
('encuestas.gestionar', 'Configurar y enviar las encuestas de salida'),
|
||||||
|
('encuestas.medir', 'Ver el reporte de satisfacción'),
|
||||||
|
]),
|
||||||
('membresias', 'Membresías y suscripciones', [
|
('membresias', 'Membresías y suscripciones', [
|
||||||
('membresias.ver', 'Ver membresías, planes y adeudos'),
|
('membresias.ver', 'Ver membresías, planes y adeudos'),
|
||||||
('membresias.crear', 'Dar de alta membresías'),
|
('membresias.crear', 'Dar de alta membresías'),
|
||||||
@@ -173,6 +184,8 @@ READ_PERM_KEYS = (
|
|||||||
'whatsapp.ver',
|
'whatsapp.ver',
|
||||||
'notificaciones.ver',
|
'notificaciones.ver',
|
||||||
'formularios.ver',
|
'formularios.ver',
|
||||||
|
'campanas.ver', 'campanas.medir',
|
||||||
|
'encuestas.ver', 'encuestas.medir',
|
||||||
'membresias.ver',
|
'membresias.ver',
|
||||||
'compras.ver',
|
'compras.ver',
|
||||||
'proveedores.ver',
|
'proveedores.ver',
|
||||||
@@ -274,6 +287,17 @@ DEFAULT_PERM_ROLES = {
|
|||||||
'formularios.crear': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
|
'formularios.crear': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
|
||||||
'formularios.editar': AG,
|
'formularios.editar': AG,
|
||||||
'formularios.enviar': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
|
'formularios.enviar': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
|
||||||
|
# Campañas por segmentos: ver y medir es de todos; gestionar y lanzar es de
|
||||||
|
# administración/gerencia y del rol de marketing (su dueño natural).
|
||||||
|
'campanas.ver': ALL,
|
||||||
|
'campanas.gestionar': ('admin', 'gerencia', 'marketing'),
|
||||||
|
'campanas.enviar': ('admin', 'gerencia', 'marketing'),
|
||||||
|
'campanas.medir': ALL,
|
||||||
|
# Encuestas de salida (NPS): ver y medir es de todos; configurar y enviar
|
||||||
|
# es de admin/gerencia.
|
||||||
|
'encuestas.ver': ALL,
|
||||||
|
'encuestas.gestionar': AG,
|
||||||
|
'encuestas.medir': ALL,
|
||||||
# Membresías y suscripciones. Ver es de todos; dar de alta y cobrar es de
|
# Membresías y suscripciones. Ver es de todos; dar de alta y cobrar es de
|
||||||
# recepción/gerencia/admin; editar planes y cancelar/congelar es de
|
# recepción/gerencia/admin; editar planes y cancelar/congelar es de
|
||||||
# gerencia/admin (contra el dinero recurrente de la clínica).
|
# gerencia/admin (contra el dinero recurrente de la clínica).
|
||||||
|
|||||||
@@ -187,6 +187,11 @@ DEFAULT_MENU_ROLES = {
|
|||||||
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||||
'/configuracion': ['admin', 'gerencia'],
|
'/configuracion': ['admin', 'gerencia'],
|
||||||
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||||
|
# Campañas y satisfacción: apagadas por defecto (campanas.activo /
|
||||||
|
# encuestas.activo). Se listan aquí para que canSee las reconozca cuando la
|
||||||
|
# instancia las encienda.
|
||||||
|
'/campanas': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||||
|
'/satisfaccion': ['admin', 'recepcion', 'gerencia', 'medico'],
|
||||||
'/wacrm/messages': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
'/wacrm/messages': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||||
'/wacrm/leads': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
'/wacrm/leads': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||||
# El chat interno se lista explícitamente para dejar fuera a marketing
|
# El chat interno se lista explícitamente para dejar fuera a marketing
|
||||||
@@ -197,7 +202,7 @@ DEFAULT_MENU_ROLES = {
|
|||||||
# Marketing NO hereda los menús de 'lectura': sólo estos (mínimo privilegio).
|
# Marketing NO hereda los menús de 'lectura': sólo estos (mínimo privilegio).
|
||||||
MARKETING_MENUS = (
|
MARKETING_MENUS = (
|
||||||
'/', '/agenda', '/pacientes', '/cumpleanos', '/visitas',
|
'/', '/agenda', '/pacientes', '/cumpleanos', '/visitas',
|
||||||
'/wacrm/messages', '/wacrm/leads',
|
'/wacrm/messages', '/wacrm/leads', '/campanas',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Roles nuevos que por default ven lo mismo que 'lectura'.
|
# Roles nuevos que por default ven lo mismo que 'lectura'.
|
||||||
|
|||||||
Reference in New Issue
Block a user