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