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