From dabd8c6bd7e99be80cecdd58bc7c0bb3807e7ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Sun, 11 Oct 2026 09:05:19 +0000 Subject: [PATCH] fase 2.4: recordatorios y notificaciones automaticas (modelo+cola+bitacora, cron cada 5 min, recordatorios por ventana, confirmacion, cumpleanos, post-visita, recuperacion de no-show, modo prueba, plantillas con glosario, historial y reintentos); apagado por defecto --- .../NotificacionesEnviadasPanel.tsx | 236 +++++++ .../src/components/NotificacionesPanel.tsx | 281 ++++++++ frontend/src/lib/clinic-settings.ts | 3 +- frontend/src/lib/permissions.ts | 3 + frontend/src/pages/Agenda.tsx | 78 ++- frontend/src/pages/Configuracion.tsx | 23 +- frontend/src/services/odoo.ts | 137 ++++ odoo-addons/skeen_citas/clinic_settings.py | 223 +++++++ odoo-addons/skeen_whatsapp/__manifest__.py | 1 + .../skeen_whatsapp/controllers/frontend.py | 189 ++++++ .../controllers/public_booking.py | 23 +- .../data/notificaciones_cron.xml | 21 + odoo-addons/skeen_whatsapp/models/__init__.py | 1 + .../skeen_whatsapp/models/notificacion.py | 613 ++++++++++++++++++ odoo-addons/skeen_whatsapp/permissions.py | 11 + .../security/ir.model.access.csv | 1 + 16 files changed, 1832 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/NotificacionesEnviadasPanel.tsx create mode 100644 frontend/src/components/NotificacionesPanel.tsx create mode 100644 odoo-addons/skeen_whatsapp/data/notificaciones_cron.xml create mode 100644 odoo-addons/skeen_whatsapp/models/notificacion.py diff --git a/frontend/src/components/NotificacionesEnviadasPanel.tsx b/frontend/src/components/NotificacionesEnviadasPanel.tsx new file mode 100644 index 0000000..d1fb107 --- /dev/null +++ b/frontend/src/components/NotificacionesEnviadasPanel.tsx @@ -0,0 +1,236 @@ +import type { FC } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { Play, RefreshCw, RotateCcw } from 'lucide-react'; +import { Badge, Button, Card, EmptyState, Input, Select, toast } from './ui'; +import { Skeleton } from './ui/Skeleton'; +import { odooApi, type Notificacion, type NotificacionEstado } from '../services/odoo'; +import { useAuth } from '../lib/auth'; +import { useGlosario } from '../lib/glosario'; + +// Historial de notificaciones enviadas (y simuladas/fallidas). Nada se manda en +// silencio: cada intento queda aquí con su estado, canal y mensaje. + +const ESTADOS: { value: '' | NotificacionEstado; label: string }[] = [ + { value: '', label: 'Todos los estados' }, + { value: 'pendiente', label: 'Pendiente' }, + { value: 'enviada', label: 'Enviada' }, + { value: 'simulada', label: 'Simulada' }, + { value: 'error', label: 'Error' }, + { value: 'cancelada', label: 'Cancelada' }, +]; + +const TIPOS: { value: string; label: string }[] = [ + { value: '', label: 'Todos los tipos' }, + { value: 'recordatorio', label: 'Recordatorio' }, + { value: 'confirmacion', label: 'Confirmación' }, + { value: 'cumpleanos', label: 'Cumpleaños' }, + { value: 'post_visita', label: 'Post-visita' }, + { value: 'recuperacion', label: 'Recuperación' }, + { value: 'manual', label: 'Manual' }, +]; + +const badgeEstado = (estado: string) => { + switch (estado) { + case 'enviada': return 'success' as const; + case 'simulada': return 'info' as const; + case 'pendiente': return 'pending' as const; + case 'error': return 'danger' as const; + default: return 'done' as const; + } +}; + +const NotificacionesEnviadasPanel: FC = () => { + const { can } = useAuth(); + const { term } = useGlosario(); + const puedeEnviar = can('notificaciones.enviar'); + const [rows, setRows] = useState([]); + const [resumen, setResumen] = useState>({}); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize] = useState(25); + const [loading, setLoading] = useState(true); + const [procesando, setProcesando] = useState(false); + const [reintentando, setReintentando] = useState(null); + const [filtros, setFiltros] = useState({ estado: '', tipo: '', start: '', end: '' }); + + const cargar = useCallback(async () => { + setLoading(true); + try { + const res = await odooApi.getNotificaciones({ + page, page_size: pageSize, + ...(filtros.estado ? { estado: filtros.estado } : {}), + ...(filtros.tipo ? { tipo: filtros.tipo } : {}), + ...(filtros.start ? { start: filtros.start } : {}), + ...(filtros.end ? { end: filtros.end } : {}), + }); + setRows(res.notificaciones || []); + setResumen(res.resumen || {}); + setTotal(res.total || 0); + } catch { + toast.error('No se pudo cargar el historial de notificaciones'); + } finally { + setLoading(false); + } + }, [page, pageSize, filtros]); + + useEffect(() => { cargar(); }, [cargar]); + + const setFiltro = (k: keyof typeof filtros, v: string) => { + setPage(1); + setFiltros((prev) => ({ ...prev, [k]: v })); + }; + + const procesar = async () => { + setProcesando(true); + try { + const res = await odooApi.procesarNotificaciones(200); + toast.success(`Encoladas ${res.encoladas} · enviadas ${res.enviadas} · simuladas ${res.simuladas} · fallidas ${res.fallidas}`); + await cargar(); + } catch { + toast.error('No se pudo procesar la cola'); + } finally { + setProcesando(false); + } + }; + + const reintentar = async (id: number) => { + setReintentando(id); + try { + const res = await odooApi.reintentarNotificacion(id); + toast.success(`Reintento: ${res.notificacion?.estado_label || res.estado}`); + await cargar(); + } catch { + toast.error('No se pudo reintentar'); + } finally { + setReintentando(null); + } + }; + + const totalPaginas = Math.max(1, Math.ceil(total / pageSize)); + + return ( +
+
+
+

Notificaciones enviadas

+

+ Historial de avisos de {term('appointment_plural').toLowerCase()}. Cada intento queda registrado. +

+
+ {puedeEnviar && ( + + )} +
+ + {/* Resumen por estado */} +
+ {Object.entries(resumen).map(([estado, n]) => ( + + ))} +
+ +
+ setFiltro('tipo', e.target.value)} options={TIPOS} /> + setFiltro('start', e.target.value)} /> + setFiltro('end', e.target.value)} /> +
+ + + + {loading ? ( + + ) : rows.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + {puedeEnviar && } + + + + {rows.map((n) => ( + + + + + + + + {puedeEnviar && ( + + )} + + ))} + +
EstadoTipoCanalPacienteMensajeFecha
+ {n.estado_label} + {n.intentos > 0 && ( + {n.intentos} intento(s) + )} + {n.tipo_label}{n.canal_label} + {n.patient} + {n.cita_ref && {n.cita_ref}} + + {n.mensaje} + {n.error && {n.error}} + + {n.enviada_at || n.programada_para || '-'} + + {['error', 'pendiente'].includes(n.estado) && ( + + )} +
+
+ )} + + {totalPaginas > 1 && ( +
+

{total} notificaciones

+
+ + Página {page} de {totalPaginas} + +
+
+ )} + +
+ +
+
+
+
+ ); +}; + +export default NotificacionesEnviadasPanel; diff --git a/frontend/src/components/NotificacionesPanel.tsx b/frontend/src/components/NotificacionesPanel.tsx new file mode 100644 index 0000000..02b851b --- /dev/null +++ b/frontend/src/components/NotificacionesPanel.tsx @@ -0,0 +1,281 @@ +import type { FC, ReactNode } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { BellRing, FlaskConical, PlugZap, Save } from 'lucide-react'; +import { Button, Input, Select, TextArea, toast } from './ui'; +import { + odooApi, NOTIFICACIONES_DEFAULT, NOTIF_VARIABLES, NOTIF_VENTANAS, + type ClinicSettings, type NotificacionCanal, type NotificacionesSettings, +} from '../services/odoo'; +import { useGlosario } from '../lib/glosario'; + +// Editor del bloque `notificaciones` (recordatorios y avisos automáticos de +// citas). Todo apagado por defecto: mientras `activo` esté apagado nada se +// envía ni se encola. Con `modo_prueba` encendido (default) los mensajes se +// registran como simulados y no se llama a ningún proveedor externo. + +interface Props { + settings: ClinicSettings; + onSaved: (settings: ClinicSettings) => void; +} + +const TIPOS_PLANTILLA: { key: keyof NotificacionesSettings; label: string }[] = [ + { key: 'plantilla_recordatorio', label: 'Recordatorio' }, + { key: 'plantilla_confirmacion', label: 'Confirmación' }, + { key: 'plantilla_cumpleanos', label: 'Cumpleaños' }, + { key: 'plantilla_post_visita', label: 'Post-visita' }, +]; + +const NotificacionesPanel: FC = ({ settings, onSaved }) => { + const { term } = useGlosario(); + const [form, setForm] = useState( + settings.notificaciones || NOTIFICACIONES_DEFAULT); + const [saving, setSaving] = useState(false); + const [probando, setProbando] = useState(false); + const [pruebaCanal, setPruebaCanal] = useState('whatsapp'); + const [pruebaDestino, setPruebaDestino] = useState(''); + + useEffect(() => { + setForm(settings.notificaciones || NOTIFICACIONES_DEFAULT); + }, [settings.notificaciones]); + + const set = (k: K, v: NotificacionesSettings[K]) => + setForm((prev) => ({ ...prev, [k]: v })); + + // Ejemplo con datos ficticios para la vista previa (respeta el glosario). + const ejemplo = useMemo(() => ({ + nombre: 'Ana López', + cita: term('appointment_singular'), + servicio: 'Limpieza facial', + fecha: '15/03/2026', + hora: '10:30', + doctor: 'Dra. García', + sucursal: settings.nombre || 'Sucursal Centro', + telefono: settings.telefono || '+52 661 000 0000', + }), [term, settings.nombre, settings.telefono]); + + const previsualizar = (plantilla: string) => + plantilla.replace(/\{([a-zA-Z_]+)\}/g, (_m, key: string) => + (ejemplo as Record)[key] ?? `{${key}}`); + + const toggleVentana = (horas: number) => { + const actuales = form.recordatorio_horas_antes || []; + const siguientes = actuales.includes(horas) + ? actuales.filter((h) => h !== horas) + : [...actuales, horas]; + set('recordatorio_horas_antes', siguientes.sort((a, b) => a - b)); + }; + + const guardar = async () => { + setSaving(true); + try { + const res = await odooApi.putClinicSettings({ + notificaciones: form, + motivo: 'Notificaciones desde Configuración', + }); + if (res.status === 'success') { + onSaved(res.settings); + toast.success('Notificaciones guardadas'); + } + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'No se pudieron guardar las notificaciones'); + } finally { + setSaving(false); + } + }; + + const probar = async () => { + setProbando(true); + try { + const res = await odooApi.probarNotificacion({ canal: pruebaCanal, destino: pruebaDestino }); + if (res.ok) { + toast.success(res.message || 'Canal configurado'); + } else { + toast.error(res.message || 'El canal no está configurado'); + } + } catch { + toast.error('No se pudo probar el canal'); + } finally { + setProbando(false); + } + }; + + return ( +
+
+ +
+

Recordatorios y notificaciones

+

+ Avisos automáticos de {term('appointment_plural').toLowerCase()} por WhatsApp o correo: + recordatorios antes de la {term('appointment_singular').toLowerCase()}, confirmación al + reservar en línea, cumpleaños y recuperación de inasistencias. Apagado = nada cambia. +

+
+
+ + set('activo', v)} + ayuda="Mientras esté apagado no se envía ni se registra ninguna notificación." /> + +
+ set('modo_prueba', v)} + ayuda={form.modo_prueba + ? 'Modo prueba activo: los mensajes se registran como «simulados» y NO se envía nada real a ningún paciente.' + : 'Modo prueba apagado: los mensajes se intentarán enviar de verdad por el canal elegido.'} /> + {!form.modo_prueba && ( +

+ Atención: los pacientes recibirán mensajes reales. +

+ )} +
+ +
+ set('whatsapp_numero', e.target.value)} + placeholder="+52 661 000 0000 (opcional)" /> +
+ +
+ set('email_remitente', e.target.value)} + placeholder="citas@clinica.mx (opcional)" /> +
+ + {/* Ventanas de recordatorio */} +
+

+ Avisar antes de la {term('appointment_singular').toLowerCase()} +

+

+ Puedes elegir varias ventanas (por ejemplo, 24 h y 2 h). Se envía una sola vez por ventana. +

+
+ {NOTIF_VENTANAS.map((v) => { + const activa = (form.recordatorio_horas_antes || []).includes(v.horas); + return ( + + ); + })} +
+ {(form.recordatorio_horas_antes || []).length === 0 && ( +

+ Sin ventanas seleccionadas no se enviarán recordatorios. +

+ )} +
+ +
+ set('confirmacion_al_crear', v)} + ayuda="Avisa al paciente cuando agenda solo, desde el portal de reservas en línea." /> + set('cumpleanos', v)} /> + set('recuperacion_no_show', v)} + ayuda="Mensaje a quien no se presentó, para reagendar." /> + set('hora_envio_cumpleanos', e.target.value)} /> +
+ +
+ set('post_visita_horas', Math.max(0, Number(e.target.value) || 0))} /> +

0 = apagado.

+
+ + {/* Plantillas con variables y vista previa */} +
+
+

Plantillas

+

+ Variables disponibles:{' '} + {NOTIF_VARIABLES.map((v) => ( + {`{${v}}`} + ))} +

+
+ {TIPOS_PLANTILLA.map(({ key, label }) => ( +
+