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:
236
frontend/src/components/NotificacionesEnviadasPanel.tsx
Normal file
236
frontend/src/components/NotificacionesEnviadasPanel.tsx
Normal file
@@ -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<Notificacion[]>([]);
|
||||
const [resumen, setResumen] = useState<Record<string, number>>({});
|
||||
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<number | null>(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 (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-heading text-xl text-theme-heading">Notificaciones enviadas</h3>
|
||||
<p className="text-sm text-theme-muted">
|
||||
Historial de avisos de {term('appointment_plural').toLowerCase()}. Cada intento queda registrado.
|
||||
</p>
|
||||
</div>
|
||||
{puedeEnviar && (
|
||||
<Button variant="outline" loading={procesando} onClick={procesar}>
|
||||
<Play size={15} className="mr-1.5" /> Procesar cola ahora
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resumen por estado */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(resumen).map(([estado, n]) => (
|
||||
<button key={estado} type="button"
|
||||
onClick={() => setFiltro('estado', filtros.estado === estado ? '' : estado)}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-medium transition ${
|
||||
filtros.estado === estado ? 'border-theme-accent bg-theme-accent text-theme-inverse' : 'border-theme-border text-theme-muted hover:bg-theme-bg'
|
||||
}`}>
|
||||
{estado}: {n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Select label="Estado" value={filtros.estado}
|
||||
onChange={(e) => setFiltro('estado', e.target.value)} options={ESTADOS} />
|
||||
<Select label="Tipo" value={filtros.tipo}
|
||||
onChange={(e) => setFiltro('tipo', e.target.value)} options={TIPOS} />
|
||||
<Input label="Desde" type="date" value={filtros.start}
|
||||
onChange={(e) => setFiltro('start', e.target.value)} />
|
||||
<Input label="Hasta" type="date" value={filtros.end}
|
||||
onChange={(e) => setFiltro('end', e.target.value)} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
{loading ? (
|
||||
<Skeleton className="h-12" count={4} />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState title="Sin notificaciones" subtitle="No hay avisos que coincidan con los filtros." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-theme-bg">
|
||||
<tr>
|
||||
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Estado</th>
|
||||
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Tipo</th>
|
||||
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Canal</th>
|
||||
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Paciente</th>
|
||||
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Mensaje</th>
|
||||
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Fecha</th>
|
||||
{puedeEnviar && <th className="p-3 text-left text-xs font-medium uppercase text-theme-muted"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{rows.map((n) => (
|
||||
<tr key={n.id} className="align-top hover:bg-theme-bg">
|
||||
<td className="p-3">
|
||||
<Badge variant={badgeEstado(n.estado)}>{n.estado_label}</Badge>
|
||||
{n.intentos > 0 && (
|
||||
<span className="mt-1 block text-[11px] text-theme-muted">{n.intentos} intento(s)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-heading">{n.tipo_label}</td>
|
||||
<td className="p-3 text-sm text-theme-muted">{n.canal_label}</td>
|
||||
<td className="p-3 text-sm">
|
||||
<span className="text-theme-heading">{n.patient}</span>
|
||||
{n.cita_ref && <span className="block text-[11px] text-theme-muted">{n.cita_ref}</span>}
|
||||
</td>
|
||||
<td className="max-w-md p-3 text-sm text-theme-muted">
|
||||
<span className="line-clamp-2 whitespace-pre-wrap">{n.mensaje}</span>
|
||||
{n.error && <span className="mt-1 block text-[11px] text-rose-600">{n.error}</span>}
|
||||
</td>
|
||||
<td className="whitespace-nowrap p-3 text-xs text-theme-muted">
|
||||
{n.enviada_at || n.programada_para || '-'}
|
||||
</td>
|
||||
{puedeEnviar && (
|
||||
<td className="p-3">
|
||||
{['error', 'pendiente'].includes(n.estado) && (
|
||||
<Button size="sm" variant="ghost" loading={reintentando === n.id}
|
||||
onClick={() => reintentar(n.id)}>
|
||||
<RotateCcw size={14} className="mr-1" /> Reintentar
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPaginas > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<p className="text-xs text-theme-muted">{total} notificaciones</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
Anterior
|
||||
</Button>
|
||||
<span className="text-xs text-theme-muted">Página {page} de {totalPaginas}</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPaginas} onClick={() => setPage((p) => p + 1)}>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button size="sm" variant="ghost" onClick={cargar}>
|
||||
<RefreshCw size={14} className="mr-1" /> Actualizar
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificacionesEnviadasPanel;
|
||||
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;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT } from '../services/odoo';
|
||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_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.
|
||||
@@ -9,6 +9,7 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
|
||||
...CLINIC_SETTINGS_DEFAULT,
|
||||
...(settings || {}),
|
||||
impuestos: { ...IMPUESTOS_DEFAULT, ...(settings?.impuestos || {}) },
|
||||
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(settings?.notificaciones || {}) },
|
||||
});
|
||||
|
||||
let actual: ClinicSettings = normalizar(null);
|
||||
|
||||
@@ -15,6 +15,7 @@ export const PERM_KEYS: string[] = [
|
||||
'servicios.ver', 'servicios.editar',
|
||||
'reportes.ver', 'reportes.exportar', 'caja.ver', 'caja.cerrar',
|
||||
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
|
||||
'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar',
|
||||
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
|
||||
];
|
||||
|
||||
@@ -35,6 +36,7 @@ const READ_PERM_KEYS = new Set([
|
||||
'reportes.ver', 'reportes.exportar', 'caja.ver',
|
||||
'ventas.reimprimir',
|
||||
'whatsapp.ver',
|
||||
'notificaciones.ver',
|
||||
]);
|
||||
|
||||
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||
@@ -53,6 +55,7 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||
'servicios.ver': ALL, 'servicios.editar': AD,
|
||||
'reportes.ver': ALL, 'reportes.exportar': ALL, 'caja.ver': ALL, 'caja.cerrar': MONEY,
|
||||
'whatsapp.ver': W, 'whatsapp.responder': W, 'whatsapp.asignar': W,
|
||||
'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG,
|
||||
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet, ChevronDown, CalendarOff, Users, Play, Check, Globe } from 'lucide-react';
|
||||
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet, ChevronDown, CalendarOff, Users, Play, Check, Globe, Bell } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import AgendaPrint from '../components/AgendaPrint';
|
||||
@@ -26,9 +26,10 @@ import {
|
||||
badgeForAppointmentState,
|
||||
} from '../components/ui';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData, type AgendaPerm, type GlossaryKey, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||
import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData, type AgendaPerm, type GlossaryKey, type Notificacion, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useGlosario } from '../lib/glosario';
|
||||
import { useClinicSettings } from '../lib/clinic-settings';
|
||||
import { useSucursales } from '../lib/sucursales';
|
||||
import { todayLocal } from '../lib/utils';
|
||||
import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh';
|
||||
@@ -36,6 +37,17 @@ import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh';
|
||||
// Duración por defecto cuando ni la cita ni el servicio la definen
|
||||
const DEFAULT_DURATION = 30;
|
||||
|
||||
// Color del badge según el estado de una notificación.
|
||||
const notifBadge = (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;
|
||||
}
|
||||
};
|
||||
|
||||
// Opciones de duración de una cita (el servicio propone la suya)
|
||||
const DURATION_OPTIONS = [
|
||||
{ value: '15', label: '15 min' },
|
||||
@@ -145,9 +157,10 @@ const QUARTER_TIME_OPTIONS = (() => {
|
||||
const Agenda: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { user, canAgenda } = useAuth();
|
||||
const { user, canAgenda, can } = useAuth();
|
||||
const { term } = useGlosario();
|
||||
const { sucursales, defaultCode } = useSucursales();
|
||||
const clinicSettings = useClinicSettings();
|
||||
const { openPatient } = usePatientDrawer();
|
||||
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor).
|
||||
// Cosmetología ve la agenda completa (es personal clínico, sin la privacidad
|
||||
@@ -1218,12 +1231,41 @@ const Agenda: FC = () => {
|
||||
const [saldoPaciente, setSaldoPaciente] = useState<PatientWallet | null>(null);
|
||||
const [recargaAptOpen, setRecargaAptOpen] = useState(false);
|
||||
const [reciboApt, setReciboApt] = useState<ReceiptData | null>(null);
|
||||
// Notificaciones ya registradas para la cita abierta (recordatorio/confirmación)
|
||||
const notifActivo = !!clinicSettings.notificaciones?.activo;
|
||||
const puedeEnviarNotif = can('notificaciones.enviar');
|
||||
const [notifCita, setNotifCita] = useState<Notificacion[]>([]);
|
||||
const [enviandoNotif, setEnviandoNotif] = useState(false);
|
||||
|
||||
const cargarNotifCita = (citaId: number) => {
|
||||
if (!notifActivo) { setNotifCita([]); return; }
|
||||
odooApi.getNotificaciones({ cita_id: citaId, limit: 50 })
|
||||
.then((res) => setNotifCita(res.notificaciones || []))
|
||||
.catch(() => setNotifCita([]));
|
||||
};
|
||||
|
||||
const enviarRecordatorioAhora = async () => {
|
||||
if (!detailApt) return;
|
||||
setEnviandoNotif(true);
|
||||
try {
|
||||
const res = await odooApi.enviarNotificacion({ cita_id: detailApt.id });
|
||||
toast.success(res.message || 'Recordatorio enviado');
|
||||
cargarNotifCita(detailApt.id);
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo enviar el recordatorio');
|
||||
} finally {
|
||||
setEnviandoNotif(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openAptDetail = (apt: Appointment) => {
|
||||
setDetailApt(apt);
|
||||
setSaldoPaciente(null);
|
||||
setRecargaAptOpen(false);
|
||||
setStateHistory([]);
|
||||
setNotifCita([]);
|
||||
cargarNotifCita(apt.id);
|
||||
if (apt.patient_id) {
|
||||
odooApi.getPatientWallet(apt.patient_id)
|
||||
.then((w) => { if (w.status === 'success') setSaldoPaciente(w); })
|
||||
@@ -3116,6 +3158,36 @@ const Agenda: FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{notifActivo && (
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0 text-sm">
|
||||
<p className="font-medium text-theme-heading">Recordatorios automáticos</p>
|
||||
{notifCita.length === 0 ? (
|
||||
<p className="text-xs text-theme-muted">Sin notificaciones registradas para esta cita.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-theme-muted">
|
||||
{notifCita.length} notificación(es) · última: {notifCita[0]?.estado_label}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{notifCita.slice(0, 4).map((n) => (
|
||||
<Badge key={n.id} variant={notifBadge(n.estado)}>
|
||||
{n.tipo_label}: {n.estado_label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{puedeEnviarNotif && !['cancelled', 'no_show', 'done'].includes(detailApt.state) && (
|
||||
<Button size="sm" variant="outline" loading={enviandoNotif} onClick={enviarRecordatorioAhora}>
|
||||
<Bell size={16} className="mr-1.5" /> Enviar recordatorio ahora
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 pt-2 border-t border-theme-border">
|
||||
{puedeEditar && (
|
||||
<Button size="sm" variant="outline" onClick={() => setEditMode(true)}>
|
||||
|
||||
@@ -14,14 +14,18 @@ import SucursalesPanel from '../components/SucursalesPanel';
|
||||
import CategoriasPanel from '../components/CategoriasPanel';
|
||||
import TerminologiaPanel from '../components/TerminologiaPanel';
|
||||
import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
|
||||
import NotificacionesPanel from '../components/NotificacionesPanel';
|
||||
import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel';
|
||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT } from '../services/odoo';
|
||||
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { publicarClinicSettings } from '../lib/clinic-settings';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'clinica', label: 'Clínica' },
|
||||
{ key: 'reservas', label: 'Reservas en línea' },
|
||||
{ key: 'notificaciones', label: 'Notificaciones' },
|
||||
{ key: 'notif_enviadas', label: 'Notificaciones enviadas' },
|
||||
{ key: 'sucursales', label: 'Sucursales' },
|
||||
{ key: 'categorias', label: 'Categorías' },
|
||||
{ key: 'terminologia', label: 'Terminología' },
|
||||
@@ -34,7 +38,7 @@ const TABS = [
|
||||
] as const;
|
||||
|
||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
||||
const TABS_GERENCIA = ['reservas', 'sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
||||
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
||||
|
||||
const SUBCATALOGOS = [
|
||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||
@@ -55,6 +59,8 @@ const Configuracion: FC = () => {
|
||||
usuarios: 'config.usuarios',
|
||||
roles: 'config.roles',
|
||||
auditoria: 'config.auditoria',
|
||||
notificaciones: 'notificaciones.editar',
|
||||
notif_enviadas: 'notificaciones.ver',
|
||||
};
|
||||
const tabsVisibles = useMemo(
|
||||
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
|
||||
@@ -109,6 +115,7 @@ const Configuracion: FC = () => {
|
||||
...next,
|
||||
impuestos: { ...IMPUESTOS_DEFAULT, ...(next.impuestos || {}) },
|
||||
reservas_online: { ...RESERVAS_ONLINE_DEFAULT, ...(next.reservas_online || {}) },
|
||||
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(next.notificaciones || {}) },
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -381,6 +388,18 @@ const Configuracion: FC = () => {
|
||||
<ReservasOnlinePanel settings={settings} onSaved={setSettings} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'notificaciones' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<NotificacionesPanel settings={settings} onSaved={setSettings} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : tab === 'notif_enviadas' ? (
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<NotificacionesEnviadasPanel />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
<Card>
|
||||
|
||||
@@ -1013,6 +1013,7 @@ export interface ClinicSettings {
|
||||
nota_ticket: string;
|
||||
impuestos: ImpuestosSettings;
|
||||
reservas_online: ReservasOnlineSettings;
|
||||
notificaciones: NotificacionesSettings;
|
||||
}
|
||||
|
||||
// ---- Reservas en línea (portal público de auto-agendado) ----
|
||||
@@ -1046,6 +1047,115 @@ export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
|
||||
telefono_contacto: '',
|
||||
};
|
||||
|
||||
// ---- Notificaciones y recordatorios de citas ----
|
||||
export type NotificacionCanal = 'whatsapp' | 'email';
|
||||
export type NotificacionCanalConfig = NotificacionCanal | 'ambos';
|
||||
export type NotificacionTipo = 'recordatorio' | 'confirmacion' | 'cumpleanos' | 'post_visita' | 'recuperacion' | 'manual';
|
||||
export type NotificacionEstado = 'pendiente' | 'enviada' | 'simulada' | 'error' | 'cancelada';
|
||||
|
||||
export interface NotificacionesSettings {
|
||||
/** Enciende recordatorios y notificaciones. Apagado = no se envía ni se encola nada. */
|
||||
activo: boolean;
|
||||
/** Modo prueba: registra "simulada" sin llamar a ningún proveedor externo. */
|
||||
modo_prueba: boolean;
|
||||
canal: NotificacionCanalConfig;
|
||||
/** Ventanas (horas antes de la cita) en que se avisa. */
|
||||
recordatorio_horas_antes: number[];
|
||||
confirmacion_al_crear: boolean;
|
||||
cumpleanos: boolean;
|
||||
hora_envio_cumpleanos: string;
|
||||
/** Horas tras la visita para el mensaje de seguimiento (0 = apagado). */
|
||||
post_visita_horas: number;
|
||||
recuperacion_no_show: boolean;
|
||||
plantilla_recordatorio: string;
|
||||
plantilla_confirmacion: string;
|
||||
plantilla_cumpleanos: string;
|
||||
plantilla_post_visita: string;
|
||||
whatsapp_numero: string;
|
||||
email_remitente: string;
|
||||
}
|
||||
|
||||
export const NOTIFICACIONES_DEFAULT: NotificacionesSettings = {
|
||||
activo: false,
|
||||
modo_prueba: true,
|
||||
canal: 'whatsapp',
|
||||
recordatorio_horas_antes: [24, 2],
|
||||
confirmacion_al_crear: false,
|
||||
cumpleanos: false,
|
||||
hora_envio_cumpleanos: '09:00',
|
||||
post_visita_horas: 0,
|
||||
recuperacion_no_show: false,
|
||||
plantilla_recordatorio: 'Hola {nombre}, te recordamos tu {cita} de {servicio} el {fecha} a las {hora} con {doctor} en {sucursal}. Responde CONFIRMAR o CANCELAR.',
|
||||
plantilla_confirmacion: 'Hola {nombre}, tu {cita} de {servicio} quedó registrada para el {fecha} a las {hora} con {doctor} en {sucursal}. Te esperamos.',
|
||||
plantilla_cumpleanos: '¡Feliz cumpleaños, {nombre}! Todo el equipo de {sucursal} te desea un gran día. Te esperamos pronto.',
|
||||
plantilla_post_visita: 'Hola {nombre}, gracias por tu visita de {servicio} el {fecha}. Si tienes dudas o quieres agendar de nuevo, llámanos al {telefono}.',
|
||||
whatsapp_numero: '',
|
||||
email_remitente: '',
|
||||
};
|
||||
|
||||
/** Variables permitidas en las plantillas (espejo del backend). */
|
||||
export const NOTIF_VARIABLES = ['nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor', 'sucursal', 'telefono'] as const;
|
||||
|
||||
/** Ventanas sugeridas para los chips de recordatorio. */
|
||||
export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
|
||||
{ horas: 1, label: '1 h' },
|
||||
{ horas: 2, label: '2 h' },
|
||||
{ horas: 24, label: '24 h' },
|
||||
{ horas: 48, label: '48 h' },
|
||||
{ horas: 168, label: '1 semana' },
|
||||
];
|
||||
|
||||
export interface Notificacion {
|
||||
id: number;
|
||||
partner_id: number;
|
||||
patient: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
cita_id: number | null;
|
||||
cita_ref: string;
|
||||
tipo: NotificacionTipo;
|
||||
tipo_label: string;
|
||||
canal: NotificacionCanal;
|
||||
canal_label: string;
|
||||
estado: NotificacionEstado;
|
||||
estado_label: string;
|
||||
asunto: string;
|
||||
mensaje: string;
|
||||
programada_para: string | null;
|
||||
enviada_at: string | null;
|
||||
error: string;
|
||||
intentos: number;
|
||||
}
|
||||
|
||||
export interface NotificacionesResponse {
|
||||
status: string;
|
||||
notificaciones: Notificacion[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
resumen: Record<string, number>;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ProcesarNotificacionesResult {
|
||||
status: string;
|
||||
message: string;
|
||||
encoladas: number;
|
||||
revisadas: number;
|
||||
enviadas: number;
|
||||
simuladas: number;
|
||||
fallidas: number;
|
||||
canceladas: number;
|
||||
}
|
||||
|
||||
export interface ProbarNotificacionResult {
|
||||
status: string;
|
||||
ok: boolean;
|
||||
simulado: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Contrato del portal público (endpoints /public/booking/*)
|
||||
export interface BookingConfig {
|
||||
activo: boolean;
|
||||
@@ -1129,6 +1239,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
||||
nota_ticket: '',
|
||||
impuestos: IMPUESTOS_DEFAULT,
|
||||
reservas_online: RESERVAS_ONLINE_DEFAULT,
|
||||
notificaciones: NOTIFICACIONES_DEFAULT,
|
||||
};
|
||||
|
||||
export interface FrontendUser {
|
||||
@@ -1815,6 +1926,32 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---- Notificaciones y recordatorios de citas ----
|
||||
async getNotificaciones(params?: Record<string, string | number>): Promise<NotificacionesResponse> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/notificaciones`, { params });
|
||||
return data;
|
||||
},
|
||||
|
||||
async procesarNotificaciones(limit?: number): Promise<ProcesarNotificacionesResult> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/notificaciones/procesar`, { limit });
|
||||
return data;
|
||||
},
|
||||
|
||||
async enviarNotificacion(payload: { cita_id: number; tipo?: string; canal?: NotificacionCanal }): Promise<{ status: string; notificaciones: Notificacion[]; simulado: boolean; message: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/notificaciones/enviar`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async reintentarNotificacion(id: number): Promise<{ status: string; estado: string; notificacion: Notificacion; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/notificaciones/${id}/reintentar`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async probarNotificacion(payload: { canal: NotificacionCanal; destino?: string }): Promise<ProbarNotificacionResult> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/notificaciones/probar`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateRolePermissions(matrix: Record<string, FrontendRole[]>): Promise<{ status: string; matrix: Record<string, FrontendRole[]>; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`, { matrix });
|
||||
return data;
|
||||
|
||||
Reference in New Issue
Block a user