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:
2026-10-11 09:05:19 +00:00
parent 9257586943
commit dabd8c6bd7
16 changed files with 1832 additions and 12 deletions

View File

@@ -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)}>

View File

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