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
This commit is contained in:
281
frontend/src/components/NotificacionesPanel.tsx
Normal file
281
frontend/src/components/NotificacionesPanel.tsx
Normal file
@@ -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<Props> = ({ settings, onSaved }) => {
|
||||
const { term } = useGlosario();
|
||||
const [form, setForm] = useState<NotificacionesSettings>(
|
||||
settings.notificaciones || NOTIFICACIONES_DEFAULT);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [probando, setProbando] = useState(false);
|
||||
const [pruebaCanal, setPruebaCanal] = useState<NotificacionCanal>('whatsapp');
|
||||
const [pruebaDestino, setPruebaDestino] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setForm(settings.notificaciones || NOTIFICACIONES_DEFAULT);
|
||||
}, [settings.notificaciones]);
|
||||
|
||||
const set = <K extends keyof NotificacionesSettings>(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<string, string>)[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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<BellRing size={20} className="mt-0.5 shrink-0 text-theme-accent" />
|
||||
<div>
|
||||
<h3 className="font-heading text-xl text-theme-heading">Recordatorios y notificaciones</h3>
|
||||
<p className="text-sm text-theme-muted">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toggle label="Activar notificaciones" checked={form.activo}
|
||||
onChange={(v) => set('activo', v)}
|
||||
ayuda="Mientras esté apagado no se envía ni se registra ninguna notificación." />
|
||||
|
||||
<div className="rounded-2xl border border-amber-300 bg-amber-50 p-4">
|
||||
<Toggle label="Modo prueba (recomendado al empezar)" checked={form.modo_prueba}
|
||||
onChange={(v) => 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 && (
|
||||
<p className="mt-2 text-xs font-medium text-amber-700">
|
||||
Atención: los pacientes recibirán mensajes reales.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Select label="Canal" value={form.canal}
|
||||
onChange={(e) => set('canal', e.target.value as NotificacionesSettings['canal'])}
|
||||
options={[
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
{ value: 'email', label: 'Correo' },
|
||||
{ value: 'ambos', label: 'Ambos' },
|
||||
]} />
|
||||
<Input label="Número de WhatsApp de la clínica" value={form.whatsapp_numero}
|
||||
onChange={(e) => set('whatsapp_numero', e.target.value)}
|
||||
placeholder="+52 661 000 0000 (opcional)" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Input label="Correo remitente" value={form.email_remitente}
|
||||
onChange={(e) => set('email_remitente', e.target.value)}
|
||||
placeholder="citas@clinica.mx (opcional)" />
|
||||
</div>
|
||||
|
||||
{/* Ventanas de recordatorio */}
|
||||
<div className="rounded-2xl border border-theme-border p-4">
|
||||
<p className="mb-1 text-sm font-medium text-theme-heading">
|
||||
Avisar antes de la {term('appointment_singular').toLowerCase()}
|
||||
</p>
|
||||
<p className="mb-3 text-xs text-theme-muted">
|
||||
Puedes elegir varias ventanas (por ejemplo, 24 h y 2 h). Se envía una sola vez por ventana.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{NOTIF_VENTANAS.map((v) => {
|
||||
const activa = (form.recordatorio_horas_antes || []).includes(v.horas);
|
||||
return (
|
||||
<button key={v.horas} type="button" onClick={() => toggleVentana(v.horas)}
|
||||
className={`rounded-full px-3 py-1.5 text-sm font-medium transition ${
|
||||
activa ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}>
|
||||
{v.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(form.recordatorio_horas_antes || []).length === 0 && (
|
||||
<p className="mt-2 text-xs text-amber-700">
|
||||
Sin ventanas seleccionadas no se enviarán recordatorios.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Toggle label="Confirmar al reservar en línea" checked={form.confirmacion_al_crear}
|
||||
onChange={(v) => set('confirmacion_al_crear', v)}
|
||||
ayuda="Avisa al paciente cuando agenda solo, desde el portal de reservas en línea." />
|
||||
<Toggle label="Felicitar cumpleaños" checked={form.cumpleanos}
|
||||
onChange={(v) => set('cumpleanos', v)} />
|
||||
<Toggle label="Recuperar inasistencias (no-show)" checked={form.recuperacion_no_show}
|
||||
onChange={(v) => set('recuperacion_no_show', v)}
|
||||
ayuda="Mensaje a quien no se presentó, para reagendar." />
|
||||
<Input label="Hora de cumpleaños" type="time" value={form.hora_envio_cumpleanos}
|
||||
onChange={(e) => set('hora_envio_cumpleanos', e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs">
|
||||
<Input label="Seguimiento post-visita (horas después)" type="number" min={0} max={168}
|
||||
value={String(form.post_visita_horas)}
|
||||
onChange={(e) => set('post_visita_horas', Math.max(0, Number(e.target.value) || 0))} />
|
||||
<p className="mt-1 text-xs text-theme-muted">0 = apagado.</p>
|
||||
</div>
|
||||
|
||||
{/* Plantillas con variables y vista previa */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-theme-heading">Plantillas</p>
|
||||
<p className="text-xs text-theme-muted">
|
||||
Variables disponibles:{' '}
|
||||
{NOTIF_VARIABLES.map((v) => (
|
||||
<code key={v} className="mr-1 rounded bg-theme-bg px-1 py-0.5 text-[11px]">{`{${v}}`}</code>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
{TIPOS_PLANTILLA.map(({ key, label }) => (
|
||||
<div key={key} className="rounded-2xl border border-theme-border p-4">
|
||||
<TextArea label={label} rows={3}
|
||||
value={String(form[key] || '')}
|
||||
onChange={(e) => set(key, e.target.value as NotificacionesSettings[typeof key])} />
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{NOTIF_VARIABLES.map((v) => (
|
||||
<button key={v} type="button"
|
||||
onClick={() => set(key, `${String(form[key] || '')}{${v}}` as NotificacionesSettings[typeof key])}
|
||||
className="rounded-full bg-theme-accent-bg px-2 py-0.5 text-[11px] font-medium text-theme-heading hover:bg-theme-bg">
|
||||
{`{${v}}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl bg-theme-bg p-3">
|
||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-wide text-theme-muted">Vista previa</p>
|
||||
<p className="whitespace-pre-wrap text-sm text-theme-heading">
|
||||
{previsualizar(String(form[key] || ''))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Prueba de conexión */}
|
||||
<div className="rounded-2xl border border-theme-border p-4">
|
||||
<p className="mb-1 flex items-center text-sm font-medium text-theme-heading">
|
||||
<PlugZap size={16} className="mr-2" /> Probar conexión
|
||||
</p>
|
||||
<p className="mb-3 text-xs text-theme-muted">
|
||||
En modo prueba solo se valida que el canal esté configurado; no se envía a nadie real.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<Select label="Canal" value={pruebaCanal}
|
||||
onChange={(e) => setPruebaCanal(e.target.value as NotificacionCanal)}
|
||||
options={[{ value: 'whatsapp', label: 'WhatsApp' }, { value: 'email', label: 'Correo' }]} />
|
||||
<div className="flex-1">
|
||||
<Input label="Destino (opcional)"
|
||||
value={pruebaDestino} onChange={(e) => setPruebaDestino(e.target.value)}
|
||||
placeholder={pruebaCanal === 'email' ? 'correo@ejemplo.com' : '+52 ...'} />
|
||||
</div>
|
||||
<Button variant="outline" loading={probando} onClick={probar}>
|
||||
<FlaskConical size={15} className="mr-1.5" /> Probar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button loading={saving} onClick={guardar}>
|
||||
<Save size={16} className="mr-2" /> Guardar notificaciones
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: ReactNode }> = ({
|
||||
label, checked, onChange, ayuda,
|
||||
}) => (
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-2xl border border-theme-border px-4 py-3">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 accent-theme-accent" />
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-theme-heading">{label}</span>
|
||||
{ayuda && <span className="mt-0.5 block text-xs text-theme-muted">{ayuda}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
export default NotificacionesPanel;
|
||||
Reference in New Issue
Block a user