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:
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user