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:
2026-10-12 02:34:34 +00:00
parent 705b5a2842
commit a6d6df4ebd
37 changed files with 4619 additions and 44 deletions

View File

@@ -503,10 +503,12 @@ formularios.
requerir firma y/o foto, duración estimada y asociación opcional a sucursales
y servicios. Las plantillas se **versionan**: cada respuesta guarda la versión
con la que se llenó.
- **Tipos de campo** (12): texto, texto largo, número, fecha, selección,
multiselección, casilla, escala, firma, foto, archivo y consentimiento. Los
campos de foto/archivo pueden marcarse como antes / después / otro, lo que
arma la **galería antes/después** del expediente.
- **Tipos de campo** (13): texto, texto largo, número, fecha, selección,
multiselección, casilla, escala, **recomendación (NPS 0-10)**, firma, foto,
archivo y consentimiento. Los campos de foto/archivo pueden marcarse como
antes / después / otro, lo que arma la **galería antes/después** del
expediente. El campo de recomendación (NPS) es un tipo propio: se puede
analizar sin depender del texto (ver la sección de encuestas de salida).
- **Envío**: por WhatsApp, correo o enlace. El enlace es un token firmado (HMAC,
vigencia 180 días) y deja de servir al llenarse.
- **Llenado por el paciente**: página pública `/formulario/<token>` (también
@@ -515,6 +517,104 @@ formularios.
y fotos requeridas.
- **Historial**: respuestas por paciente con canal, fecha, firma y fotos.
## 14 bis. Campañas por segmentos — apagado por defecto
**Configuración → Campañas** (interruptor) y **Campañas** (menú, sección
"Comunicación"), con dos pestañas: **Campañas** y **Segmentos**. Con el bloque
apagado (`campanas.activo = false`, el default) no aparece ninguna pantalla ni
enlace, no corre ningún proceso automático y no se envía nada.
- **Segmentos** (`skeen.segmento`): un segmento guarda nombre, criterios y una
**previsualización** (cuántos pacientes y una muestra de 5) que se calcula
*antes* de usarlo. Criterios disponibles (todos opcionales):
- última visita hace más de N días, o sin visita en N meses (incluye a quien
nunca ha venido);
- cumpleaños del mes en curso o en los próximos N días;
- nivel de lealtad;
- gasto acumulado (suma de ventas no canceladas) mayor o igual a X;
- sucursal (con al menos una cita ahí);
- servicio que **sí** ha tomado y servicio que **nunca** ha tomado;
- con o sin membresía activa;
- con o sin paquetes con sesiones disponibles;
- con o sin saldo pendiente;
- con o sin consentimiento de marketing.
La evaluación corre en el servidor y su conteo coincide con lo que responde
la base de datos.
- **Campañas** (`skeen.campana`): nombre, segmento, canal (WhatsApp, correo o
ambos), mensaje con variables (`{nombre}`, `{servicio}`, `{sucursal}`,
`{enlace}`, `{doctor}`, `{fecha}`, `{hora}`, `{telefono}`), programación
(una sola vez en una fecha, mensual, semanal o **según el propio segmento**,
p. ej. cumpleaños) y estado (borrador, programada, en ejecución, pausada,
terminada, cancelada).
- **Envío de prueba**: manda el mensaje a un número o correo propio antes de
lanzar. Queda registrado en la bitácora marcado como prueba (no cuenta en la
medición).
- **Medición por campaña**: pacientes alcanzados, enviados, errores y
pendientes; **citas generadas como consecuencia** (atribuidas por paciente y
dentro de una **ventana configurable**, por defecto 7 días) y su valor en
dinero; y la tasa de conversión. Lo que no se puede medir con lo que hay
(entrega real y respuestas del paciente) se muestra como **no disponible** en
lugar de inventarse.
- **Cuidado de WhatsApp**: **límite de envíos por minuto** configurable,
**pausa automática** si se acumulan errores seguidos y **respeto al
consentimiento** (ver abajo).
- **Opciones del bloque** (defaults): `activo` (no), `envios_por_minuto` (20),
`pausar_tras_errores` (5), `ventana_atribucion_dias` (7),
`exigir_consentimiento` (sí) y `hora_envio` (`10:00`, para las recurrentes).
### 14 bis.1 Consentimiento de marketing por canal
En el paciente (`res.partner`) hay un interruptor **general** y uno por canal:
- `consentimiento_marketing` (general), `consentimiento_whatsapp` y
`consentimiento_email`, más la fecha y el origen del consentimiento.
Un paciente **sin consentimiento de marketing nunca recibe una campaña**, aunque
cumpla el criterio del segmento. Los **avisos transaccionales** (recordatorio de
cita, confirmación, encuesta de salida) **no dependen** de esto: son parte del
servicio que el paciente ya contrató. El consentimiento nace **apagado**.
### 14 bis.2 Permisos
- `campanas.ver`: ver campañas y segmentos (por defecto: todos los roles).
- `campanas.gestionar`: crear y editar campañas y segmentos (admin, gerencia y
marketing).
- `campanas.enviar`: lanzar campañas y enviar pruebas (admin, gerencia y
marketing).
- `campanas.medir`: ver la medición (por defecto: todos los roles).
## 14 ter. Encuestas de salida y satisfacción (NPS) — apagado por defecto
**Configuración → Encuestas** (interruptor) y **Satisfacción** (menú). Con el
bloque apagado (`encuestas.activo = false`, el default) no se envía ninguna
encuesta tras completar una cita y la pantalla de Satisfacción no aparece.
- **Envío automático**: cuando una cita pasa a completada, tras **N horas**
(default 2) se envía al paciente la encuesta configurada, por WhatsApp, correo
o ambos. Hay **recordatorio opcional** si no responde (default 48 h) y una
opción de **enviarla solo a la primera visita**.
- **Captura**: reutiliza la página pública de formularios `/formulario/<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
**Configuración → Tickets**: ancho (58/80 mm), fuente, logo y qué secciones se
@@ -600,6 +700,9 @@ El panel es una **aplicación web instalable** (PWA), sin tienda de aplicaciones
- [ ] Compras y proveedores (si controla abasto y margen).
- [ ] Comisiones (reglas, metas y liquidaciones).
- [ ] Formularios y consentimientos.
- [ ] Campañas por segmentos (si hará marketing): activar y registrar el
consentimiento de los pacientes.
- [ ] Encuestas de salida: crear la plantilla con la pregunta NPS y activar.
- [ ] Reservas en línea (activar y difundir el enlace).
- [ ] Portal del cliente (si se ofrece cuenta): activar y difundir `/mi-cuenta`.
- [ ] Notificaciones en modo prueba → revisar textos → encender.
@@ -628,6 +731,8 @@ Todas las fases están **entregadas**.
| 3.5 | Licenciamiento por plan, API keys gestionables y webhooks | Entregado |
| 3.6 | i18n español/inglés | Entregado |
| 3.7 | Cuenta del cliente (portal de autoservicio) | Entregado |
| 3.8 | Campañas por segmentos con medición y consentimiento de marketing | Entregado |
| 3.9 | Encuestas de salida y satisfacción (NPS) | Entregado |
| Operación | Backups automáticos verificados y panel/CLI multi-instancia | Entregado |
Los pendientes y limitaciones conocidos (los que aún no están cerrados) viven en

View File

@@ -13,7 +13,7 @@ import { RecursosProvider } from './lib/recursos';
import { QuickLinksProvider } from './lib/quicklinks';
import { PatientDrawerProvider } from './components/PatientDrawer';
import ChatNotifications from './lib/chat-notifications';
import { useClinicSettings } from './lib/clinic-settings';
import { useClinicSettings, useClinicSettingsCargado } from './lib/clinic-settings';
import Dashboard from './pages/Dashboard';
import Agenda from './pages/Agenda';
import Pacientes from './pages/Pacientes';
@@ -32,6 +32,8 @@ import Cortes from './pages/Cortes';
import Reportes from './pages/Reportes';
import Configuracion from './pages/Configuracion';
import Cumpleanos from './pages/Cumpleanos';
import Campanas from './pages/Campanas';
import Satisfaccion from './pages/Satisfaccion';
import Inventario from './pages/Inventario';
import Compras from './pages/Compras';
import Liquidaciones from './pages/Liquidaciones';
@@ -146,6 +148,24 @@ const LiquidacionesRoute: FC = () => {
return <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 { user, canSee } = useAuth();
const { t } = useI18n();
@@ -214,6 +234,9 @@ const App: FC = () => {
<Route path="/configuracion" element={<RequireAuth href="/configuracion"><Configuracion /></RequireAuth>} />
<Route path="/usuarios" element={<Navigate to="/configuracion?tab=usuarios" replace />} />
<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>} />
{/* Compras solo cuando la clínica la activa (compras.activo). */}
<Route path="/compras" element={<RequireAuth href="/compras"><ComprasRoute /></RequireAuth>} />

View 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;

View 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;

View File

@@ -38,6 +38,8 @@ import {
Download,
Repeat,
Truck,
Megaphone,
Smile,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
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_COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
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 }) => {
const location = useLocation();
@@ -126,7 +130,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
const { term } = useGlosario();
const { t } = useI18n();
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(
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: navLabel(traducirEtiqueta(it.label, term)) })),
[term, navLabel]
@@ -148,6 +152,16 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
? { ...s, items: [...s.items, HOME_COMISIONES_ITEM] }
: 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.
if (!licenciaModuloIncluido(licenciaCfg, 'whatsapp')) {
base = base
@@ -163,7 +177,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
label: navLabel(s.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 [expanded, setExpanded] = useState<string[]>(['Clínica']);
const chatUnread = useChatUnread();

View File

@@ -40,6 +40,8 @@ import {
Download,
Repeat,
Truck,
Megaphone,
Smile,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
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' };
// La sección Liquidaciones solo existe con `comisiones.activo=true`.
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 }) => {
const location = useLocation();
@@ -139,7 +144,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const { term } = useGlosario();
const { t } = useI18n();
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(() => {
let base = menuGroups;
if (membresiasCfg.activo) {
@@ -157,6 +162,16 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
? { ...g, items: [...g.items, COMISIONES_ITEM] }
: 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.
if (!licenciaModuloIncluido(licenciaCfg, 'whatsapp')) {
base = base
@@ -169,7 +184,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
label: navLabel(g.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 chatUnread = useChatUnread();
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas

View File

@@ -241,6 +241,33 @@ const CampoFormulario: FC<Props> = ({
</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':
return (
<FirmaCanvas value={firma ?? null} onChange={(v) => onFirma?.(v)} disabled={disabled} />

View File

@@ -12,6 +12,7 @@ export const CAMPO_TIPOS: { tipo: FormularioCampoTipo; label: string; descripcio
{ tipo: 'multi_seleccion', label: 'Varias opciones', descripcion: 'Elegir varias' },
{ tipo: 'checkbox', label: 'Casilla', descripcion: 'Sí / no' },
{ 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: 'foto', label: 'Foto', descripcion: 'Cámara o galería' },
{ 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';
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':
if (vacio) { if (campo.obligatorio) errores[campo.clave] = 'Falta este dato'; break; }
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 (typeof valor === 'boolean') return valor ? 'Sí' : 'No';
if (campo.tipo === 'escala') return `${valor} / 10`;
if (campo.tipo === 'nps') return `${valor} / 10 (NPS)`;
return String(valor);
}

View File

@@ -66,6 +66,10 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/configuracion': ['admin', 'gerencia'],
'/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/leads': ['admin', 'recepcion', 'gerencia', 'marketing'],
// El chat se lista explícitamente para dejar fuera a marketing (antes, al no

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LEALTAD_DEFAULT, PROPINAS_DEFAULT, 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).
// 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 || {}) },
lealtad: { ...LEALTAD_DEFAULT, ...(settings?.lealtad || {}) },
propinas: { ...PROPINAS_DEFAULT, ...(settings?.propinas || {}) },
campanas: { ...CAMPANAS_DEFAULT, ...(settings?.campanas || {}) },
encuestas: { ...ENCUESTAS_DEFAULT, ...(settings?.encuestas || {}) },
licencia: {
...LICENCIA_DEFAULT,
...(settings?.licencia || {}),
@@ -37,6 +39,19 @@ let actual: ClinicSettings = normalizar(null);
let peticion: Promise<ClinicSettings> | null = null;
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) => {
actual = 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
// defaults para no ensuciar la consola con un 401.
if (typeof window !== 'undefined' && !window.localStorage.getItem('skeen_token')) {
marcarCargado();
return Promise.resolve(actual);
}
if (peticion) return peticion;
@@ -56,7 +72,7 @@ export const cargarClinicSettings = (): Promise<ClinicSettings> => {
return actual;
})
.catch(() => actual)
.finally(() => { peticion = null; });
.finally(() => { peticion = null; marcarCargado(); });
return peticion;
};
@@ -80,3 +96,20 @@ export const useClinicSettings = (): ClinicSettings => {
}, []);
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;
};

View File

@@ -327,6 +327,8 @@ export const es: Dict = {
'config.tabs.comisiones': 'Comisiones',
'config.tabs.lealtad': 'Lealtad',
'config.tabs.propinas': 'Propinas',
'config.tabs.campanas': 'Campañas',
'config.tabs.encuestas': 'Encuestas',
'config.tabs.terminologia': 'Terminología',
'config.tabs.usuarios': 'Usuarios',
'config.tabs.roles': 'Roles',
@@ -848,6 +850,125 @@ export const es: Dict = {
'portal.common.currency': 'Moneda',
'portal.common.status': 'Estado',
'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.lealtad': 'Loyalty',
'config.tabs.propinas': 'Tips',
'config.tabs.campanas': 'Campaigns',
'config.tabs.encuestas': 'Surveys',
'config.tabs.terminologia': 'Terminology',
'config.tabs.usuarios': 'Users',
'config.tabs.roles': 'Roles',
@@ -1651,6 +1774,125 @@ export const en: Dict = {
'portal.common.currency': 'Currency',
'portal.common.status': 'Status',
'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 };
@@ -1725,6 +1967,8 @@ export const MENU_LABEL_KEYS: Record<string, string> = { // grupos
'Leads': 'nav.leads',
'Membresías': 'nav.memberships',
'Liquidaciones': 'nav.liquidations',
'Campañas': 'nav.campanas',
'Satisfacción': 'nav.satisfaccion',
};
// Etiquetas del tema "clásico" -> clave i18n (reutiliza nav.* cuando coincide).

View File

@@ -40,6 +40,8 @@ export const MENU_ITEMS: MenuItem[] = [
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },
{ label: 'Exportar', href: '/reportes?tab=exportar' },
{ label: 'Cumpleañeros', href: '/cumpleanos' },
{ label: 'Campañas', href: '/campanas' },
{ label: 'Satisfacción', href: '/satisfaccion' },
{ label: 'Mis ventas', href: '/mis-ventas' },
{ label: 'Chat', href: '/chat' },
{ label: 'Configuración', href: '/configuracion' },

View File

@@ -19,6 +19,8 @@ export const PERM_KEYS: string[] = [
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar',
'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.cancelar',
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
@@ -53,6 +55,8 @@ const READ_PERM_KEYS = new Set([
'whatsapp.ver',
'notificaciones.ver',
'formularios.ver',
'campanas.ver', 'campanas.medir',
'encuestas.ver', 'encuestas.medir',
'membresias.ver',
'compras.ver',
'proveedores.ver',
@@ -84,6 +88,9 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG,
'formularios.ver': ALL, 'formularios.crear': FORM_WRITE, 'formularios.editar': AG,
'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.cobrar': ['admin', 'recepcion', 'gerencia'], 'membresias.editar': AG,
'membresias.cancelar': AG,

View 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;

View File

@@ -24,6 +24,8 @@ import ComprasPanel from '../components/ComprasPanel';
import ComisionesPanel from '../components/ComisionesPanel';
import LealtadPanel from '../components/LealtadPanel';
import PropinasPanel from '../components/PropinasPanel';
import CampanasPanel from '../components/CampanasPanel';
import EncuestasPanel from '../components/EncuestasPanel';
import LicenciaPanel from '../components/LicenciaPanel';
import ApiKeysPanel from '../components/ApiKeysPanel';
import WebhooksPanel from '../components/WebhooksPanel';
@@ -49,6 +51,8 @@ const TABS = [
{ key: 'comisiones', label: 'Comisiones' },
{ key: 'lealtad', label: 'Lealtad' },
{ key: 'propinas', label: 'Propinas' },
{ key: 'campanas', label: 'Campañas' },
{ key: 'encuestas', label: 'Encuestas' },
{ key: 'terminologia', label: 'Terminología' },
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'roles', label: 'Roles' },
@@ -62,7 +66,7 @@ const TABS = [
] as const;
// Secciones que ve gerencia (admin del sistema ve todas).
const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'lealtad', 'propinas', '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 = [
{ key: 'diagnosticos', label: 'Diagnósticos' },
@@ -94,6 +98,8 @@ const Configuracion: FC = () => {
comisiones: 'comisiones.editar',
lealtad: 'lealtad.configurar',
propinas: 'propinas.configurar',
campanas: 'campanas.gestionar',
encuestas: 'encuestas.gestionar',
recursos: 'recursos.editar',
licencia: 'config.editar',
api: 'api.claves',
@@ -458,6 +464,18 @@ const Configuracion: FC = () => {
<PropinasPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</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' ? (
<Card>
<Card.Body>

View File

@@ -138,6 +138,9 @@ const FormularioPublico: FC = () => {
<p className="mt-2 text-sm text-theme-muted">
{enviado ? t('public.form.registered') : t('public.form.alreadyFilled')}
</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>
</div>
</Centro>

View 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;

View File

@@ -1097,6 +1097,8 @@ export interface ClinicSettings {
propinas: PropinasSettings;
licencia: LicenciaSettings;
portal_cliente: PortalClienteSettings;
campanas: CampanasSettings;
encuestas: EncuestasSettings;
}
// ---- Licencia por plan (bloque `licencia` de clinic-settings) ----
@@ -1700,6 +1702,48 @@ export const PROPINAS_DEFAULT: PropinasSettings = {
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 {
employee_id: number | null;
empleado: string;
@@ -2456,7 +2500,7 @@ export interface Cobro {
// ---- Formularios digitales y consentimientos ----
export type FormularioCampoTipo =
| '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 FormularioEstado = 'borrador' | 'enviado' | 'llenado' | 'cancelado';
export type FormularioMomento = 'antes' | 'despues' | 'otro';
@@ -2548,6 +2592,7 @@ export interface FormularioPublicoConfig {
cita: { fecha: string | null; hora: string | null; servicio: string | null } | null;
estado: FormularioEstado;
ya_llenado: boolean;
agradecimiento?: string;
plantilla: {
nombre: string;
tipo: FormularioPlantillaTipo;
@@ -2561,8 +2606,176 @@ export interface FormularioPublicoConfig {
};
}
export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
nombre: BRAND_DEFAULT.nombre,
// ---- Campañas y segmentos ----
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: '',
email: '',
direccion: '',
@@ -2586,6 +2799,8 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
propinas: PROPINAS_DEFAULT,
licencia: LICENCIA_DEFAULT,
portal_cliente: PORTAL_CLIENTE_DEFAULT,
campanas: CAMPANAS_DEFAULT,
encuestas: ENCUESTAS_DEFAULT,
};
export interface FrontendUser {
@@ -3297,6 +3512,127 @@ export const odooApi = {
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 ----
async getPatientLealtad(patientId: number): Promise<LealtadPaciente> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/lealtad`);

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View 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',
}

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import campanas
from . import encuestas

View 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 '',
}

View 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)

View 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>

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from . import consentimiento
from . import segmento
from . import campana
from . import encuesta

View 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,
}

View 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

View 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()

View 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))

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_segmento Acceso a Segmentos SKEEN model_skeen_segmento base.group_user 1 1 1 1
3 access_skeen_campana Acceso a Campañas SKEEN model_skeen_campana base.group_user 1 1 1 1

View File

@@ -1535,6 +1535,201 @@ def get_propinas(env):
"""Bloque de propinas vigente (copia). Apagado por defecto."""
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).
#
@@ -1966,6 +2161,8 @@ def get_settings(env, aplicar_licencia=True):
'comisiones': dict(COMISIONES_DEFAULT),
'lealtad': normalizar_lealtad(None),
'propinas': dict(PROPINAS_DEFAULT),
'campanas': dict(CAMPANAS_DEFAULT),
'encuestas': dict(ENCUESTAS_DEFAULT),
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
'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['lealtad'] = normalizar_lealtad(guardado.get('lealtad'))
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['portal_cliente'] = normalizar_portal_cliente(
guardado.get('portal_cliente'))
@@ -2059,6 +2258,16 @@ def set_settings(env, vals):
bloque = validar_propinas(vals['propinas'])
if bloque is not None:
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:
bloque = validar_licencia(vals['licencia'])
if bloque is not None:

View File

@@ -163,6 +163,12 @@ def limpiar_respuestas(campos, respuestas):
out[clave] = int(valor)
except (TypeError, ValueError):
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'):
out[clave] = bool(valor)
else:
@@ -209,6 +215,18 @@ def validar_respuestas(plantilla, campos, respuestas, fotos_claves,
except (TypeError, ValueError):
errores.append('%s debe ser un número del 1 al 10' % etiqueta)
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 valor in (None, ''):
if obligatorio:

View File

@@ -15,7 +15,9 @@ from odoo import fields, http
from odoo.http import request
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 .comun import (
@@ -86,6 +88,17 @@ def _primer_nombre(partner):
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):
# ------------------------------------------------------------------
@@ -118,6 +131,7 @@ class SkeenPublicFormularioController(http.Controller):
} if cita else None,
'estado': respuesta.estado,
'ya_llenado': respuesta.estado == 'llenado',
'agradecimiento': _agradecimiento(request.env, plantilla),
'plantilla': {
'nombre': plantilla.name,
'tipo': plantilla.tipo,

View File

@@ -17,7 +17,7 @@ from odoo import api, fields, models
TIPOS_CAMPO = (
'texto', 'texto_largo', 'numero', 'fecha', 'seleccion',
'multi_seleccion', 'checkbox', 'escala', 'firma', 'foto',
'multi_seleccion', 'checkbox', 'escala', 'nps', 'firma', 'foto',
'archivo', 'consentimiento',
)
TIPOS_PLANTILLA = (

View File

@@ -48,6 +48,8 @@ TIPOS = [
('cobro_membresia', 'Cobro de membresía'),
('acceso_codigo', 'Código de acceso al portal'),
('acceso_enlace', 'Enlace de acceso al portal'),
('campana', 'Campaña'),
('encuesta', 'Encuesta'),
('manual', 'Manual'),
]
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
@@ -98,7 +100,7 @@ class SkeenNotificacion(models.Model):
_description = 'Notificación de SKEEN'
_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')
cita_id = fields.Many2one('skeen.cita', string='Cita', index=True,
ondelete='cascade')
@@ -108,6 +110,12 @@ class SkeenNotificacion(models.Model):
default='pendiente', index=True)
asunto = fields.Char(string='Asunto')
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,
default=fields.Datetime.now, index=True)
enviada_at = fields.Datetime(string='Enviada el')
@@ -241,51 +249,64 @@ class SkeenNotificacionService(models.AbstractModel):
@api.model
def encolar(self, cita=None, tipo='recordatorio', cuando=None, canal=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).
Con `activo=False` no hace nada. No duplica: si ya existe la misma
`clave_unica` (misma cita + tipo + ventana + canal) se salta. Devuelve el
recordset de lo creado. `contexto_extra` alimenta variables adicionales
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()
cfg = self._cfg()
if not cfg['activo']:
if not cfg['activo'] and not forzar:
return Notif
partner = partner or (cita.partner_id if cita else None)
if not partner:
if not partner and not destino:
return Notif
plantilla = cfg.get(PLANTILLA_POR_TIPO.get(tipo, 'plantilla_recordatorio')) or ''
if not plantilla.strip():
return Notif
mensaje = self.render(plantilla, cita=cita, partner=partner, cfg=cfg,
extra=contexto_extra)
if mensaje is None:
plantilla = cfg.get(PLANTILLA_POR_TIPO.get(tipo, 'plantilla_recordatorio')) or ''
if not plantilla.strip():
return Notif
mensaje = self.render(plantilla, cita=cita, partner=partner, cfg=cfg,
extra=contexto_extra)
if asunto is None:
asunto = self._asunto(tipo)
canales = [canal] if canal else self._canales(cfg)
creadas = Notif
for ch in canales:
if not self._destino_disponible(partner, ch):
if not destino and not self._destino_disponible(partner, ch):
continue
clave = clave_unica or self._clave(tipo, cita, partner, ch, ventana)
if clave_unica and len(canales) > 1:
clave = '%s:%s' % (clave_unica, ch)
if Notif.search([('clave_unica', '=', clave)], limit=1):
continue
creadas |= Notif.create({
'partner_id': partner.id,
vals = {
'partner_id': partner.id if partner else False,
'cita_id': cita.id if cita is not None else False,
'tipo': tipo,
'canal': ch,
'estado': 'pendiente',
'asunto': asunto,
'mensaje': mensaje,
'destino': destino or False,
'programada_para': cuando or fields.Datetime.now(),
'clave_unica': clave,
})
}
if vals_extra:
vals.update(vals_extra)
creadas |= Notif.create(vals)
return creadas
@api.model
@@ -476,11 +497,13 @@ class SkeenNotificacionService(models.AbstractModel):
return 'simulada'
try:
destino = notif.destino or None
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:
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:
intentos = notif.intentos + 1
vals = {'intentos': intentos, 'error': str(exc)[:500]}
@@ -509,17 +532,21 @@ class SkeenNotificacionService(models.AbstractModel):
return (url or '').rstrip('/'), (key or '')
@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.
Sin credenciales configuradas lanza un error claro (nunca envía a
ciegas ni revienta el proceso de la cola).
`destino` permite mandar a un número explícito (envío de prueba de una
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()
if not url or not key:
raise UserError(_('No hay credenciales de WhatsApp (WACRM) configuradas '
'en esta instancia.'))
phone = (partner.whatsapp or partner.phone or '').strip() if partner else ''
if destino:
phone = str(destino).strip()
else:
phone = (partner.whatsapp or partner.phone or '').strip() if partner else ''
if not phone:
raise UserError(_('El paciente no tiene teléfono ni WhatsApp registrado.'))
digits = re.sub(r'\D', '', phone)
@@ -547,12 +574,17 @@ class SkeenNotificacionService(models.AbstractModel):
return bool(self.env['ir.mail_server'].sudo().search_count([]))
@api.model
def enviar_email(self, partner, asunto, mensaje, cfg=None):
"""Envía un correo real con `mail.mail`. Sin SMTP, error claro."""
if not partner:
raise UserError(_('Falta el paciente destinatario.'))
destino = (partner.email or '').strip()
if not destino:
def enviar_email(self, partner, asunto, mensaje, cfg=None, destino=None):
"""Envía un correo real con `mail.mail`. Sin SMTP, error claro.
`destino` permite mandar a un correo explícito (envío de prueba de una
campaña) sin depender del paciente.
"""
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.'))
if not self._smtp_configurado():
raise UserError(_('No hay servidor SMTP configurado en Odoo.'))
@@ -564,7 +596,7 @@ class SkeenNotificacionService(models.AbstractModel):
'subject': asunto or 'Notificación',
'body_html': '<div style="white-space:pre-wrap">%s</div>'
% html.escape(mensaje or ''),
'email_to': destino,
'email_to': email,
'email_from': remitente or None,
})
mail.send()

View File

@@ -101,6 +101,17 @@ PERMISSION_GROUPS = [
('formularios.editar', 'Administrar plantillas de formularios'),
('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.ver', 'Ver membresías, planes y adeudos'),
('membresias.crear', 'Dar de alta membresías'),
@@ -173,6 +184,8 @@ READ_PERM_KEYS = (
'whatsapp.ver',
'notificaciones.ver',
'formularios.ver',
'campanas.ver', 'campanas.medir',
'encuestas.ver', 'encuestas.medir',
'membresias.ver',
'compras.ver',
'proveedores.ver',
@@ -274,6 +287,17 @@ DEFAULT_PERM_ROLES = {
'formularios.crear': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
'formularios.editar': AG,
'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
# recepción/gerencia/admin; editar planes y cancelar/congelar es de
# gerencia/admin (contra el dinero recurrente de la clínica).

View File

@@ -187,6 +187,11 @@ DEFAULT_MENU_ROLES = {
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/configuracion': ['admin', 'gerencia'],
'/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/leads': ['admin', 'recepcion', 'gerencia', '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_MENUS = (
'/', '/agenda', '/pacientes', '/cumpleanos', '/visitas',
'/wacrm/messages', '/wacrm/leads',
'/wacrm/messages', '/wacrm/leads', '/campanas',
)
# Roles nuevos que por default ven lo mismo que 'lectura'.