UX: notificaciones de chat con toast y sonido (WebAudio) + skeletons de carga en Agenda y Chat

This commit is contained in:
2026-09-01 22:51:01 +00:00
parent f0d7ac4c01
commit cfacb23f45
4 changed files with 203 additions and 4 deletions

View File

@@ -4,6 +4,7 @@ import { ToastProvider } from './components/ui';
import { AuthProvider, useAuth } from './lib/auth';
import { QuickLinksProvider } from './lib/quicklinks';
import { PatientDrawerProvider } from './components/PatientDrawer';
import ChatNotifications from './lib/chat-notifications';
import Dashboard from './pages/Dashboard';
import Agenda from './pages/Agenda';
import Pacientes from './pages/Pacientes';
@@ -63,6 +64,7 @@ const App: FC = () => {
<Router>
<ToastProvider />
<PatientDrawerProvider>
<ChatNotifications />
<Routes>
<Route path="/login" element={<LoginRoute />} />
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />

View File

@@ -0,0 +1,141 @@
import type { FC } from 'react';
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { MessageCircle } from 'lucide-react';
import { toast } from './toast';
import { odooApi } from '../services/odoo';
import { useAuth } from './auth';
// Notificaciones del chat interno sin recargar:
// polling ligero sobre getChatUsers (mismo mecanismo que useChatUnread);
// al detectar mensajes nuevos de un remitente que NO está en pantalla,
// muestra un toast discreto (clic → abre /chat con esa conversación) y un
// beep sutil generado con WebAudio (sin archivos de audio).
// ---- Conversación activa (la registra Chat.tsx) ----
let activeChatUserId: number | null = null;
export const setActiveChatUser = (id: number | null) => {
activeChatUserId = id;
};
// ---- Beep sutil con WebAudio ----
// El AudioContext solo se crea/reanuda tras la primera interacción del
// usuario (política de autoplay de los navegadores).
let audioCtx: AudioContext | null = null;
let audioArmed = false;
const armAudio = () => {
if (audioArmed) return;
audioArmed = true;
const init = () => {
try {
if (!audioCtx) {
const Ctor = window.AudioContext
|| (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return;
audioCtx = new Ctor();
}
if (audioCtx.state === 'suspended') audioCtx.resume().catch(() => {});
} catch {
// audio no disponible: se omite el sonido
}
};
window.addEventListener('pointerdown', init);
window.addEventListener('keydown', init);
};
const playChatBeep = () => {
try {
if (!audioCtx || audioCtx.state !== 'running') return;
const t0 = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(880, t0);
// Volumen bajo con caída rápida: beep corto y discreto
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.exponentialRampToValueAtTime(0.06, t0 + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18);
osc.connect(gain).connect(audioCtx.destination);
osc.start(t0);
osc.stop(t0 + 0.2);
} catch {
// silencioso
}
};
const POLL_MS = 15000;
/** Polling global de mensajes nuevos: toast + beep por remitente. Montar una sola vez. */
export const useChatNotifications = (intervalMs = POLL_MS) => {
const { user } = useAuth();
const navigate = useNavigate();
const prevUnread = useRef<Record<number, number>>({});
const baselineDone = useRef(false);
useEffect(() => {
if (!user) return;
armAudio();
let alive = true;
const fetchUsers = () => {
odooApi.getChatUsers()
.then((res) => {
if (!alive || res.status !== 'success') return;
const prev = prevUnread.current;
const next: Record<number, number> = {};
for (const u of res.users) next[u.id] = u.unread;
// Primera lectura: solo establece la línea base (no notificar lo viejo)
if (!baselineDone.current) {
baselineDone.current = true;
prevUnread.current = next;
return;
}
for (const u of res.users) {
const before = prev[u.id] ?? 0;
if (u.unread <= before) continue;
// No notificar si esa conversación está abierta en pantalla
if (activeChatUserId === u.id) continue;
// Un solo toast por remitente por ráfaga (el polling ya agrupa;
// se notifica solo cuando el conteo sube)
const preview = (u.last_message || '').slice(0, 90);
playChatBeep();
toast.custom((t) => (
<button
type="button"
onClick={() => {
toast.dismiss(t);
navigate(`/chat?with=${u.id}`);
}}
className="flex items-center gap-3 w-full max-w-sm rounded-xl border border-theme-border bg-theme-surface px-4 py-3 text-left shadow-soft"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-theme-heading">
<MessageCircle size={16} />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-theme-heading">{u.name}</span>
<span className="block truncate text-xs text-theme-muted">
{preview || 'Nuevo mensaje'}
</span>
</span>
</button>
), { duration: 6000 });
}
prevUnread.current = next;
})
.catch(() => {});
};
fetchUsers();
const id = setInterval(fetchUsers, intervalMs);
return () => { alive = false; clearInterval(id); };
}, [user, intervalMs, navigate]);
};
/** Componente mudo para montar el polling global dentro del Router. */
export const ChatNotifications: FC = () => {
useChatNotifications();
return null;
};
export default ChatNotifications;

View File

@@ -1179,7 +1179,29 @@ const Agenda: FC = () => {
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
viewMode === 'lista' ? (
<Skeleton count={6} className="h-12 w-full" />
) : (
/* Skeleton con forma de grid (columnas de médicos × filas) */
<div className="rounded-2xl border border-theme-border p-3 animate-pulse">
<div className="flex gap-2 mb-2">
<div className="h-9 w-20 shrink-0 rounded-xl bg-theme-border-strong" />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-9 flex-1 rounded-xl bg-theme-border-strong" />
))}
</div>
<div className="space-y-1.5">
{Array.from({ length: 6 }).map((_, r) => (
<div key={r} className="flex gap-2">
<div className="h-10 w-20 shrink-0 rounded-xl bg-theme-border-strong" />
{Array.from({ length: 5 }).map((_, c) => (
<div key={c} className="h-10 flex-1 rounded-xl bg-theme-border-strong" />
))}
</div>
))}
</div>
</div>
)
) : viewMode === 'doctores' ? (
gridColumns.length === 0 ? (
<EmptyState

View File

@@ -1,9 +1,11 @@
import type { FC, FormEvent, KeyboardEvent } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Send, Search, ArrowLeft } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, EmptyState, toast } from '../components/ui';
import { Card, Button, EmptyState, Skeleton, toast } from '../components/ui';
import { ROLE_LABELS } from '../lib/auth';import { odooApi, type ChatUser, type ChatMessage } from '../services/odoo';
import { setActiveChatUser } from '../lib/chat-notifications';
const POLL_MS = 5000;
@@ -38,6 +40,7 @@ const Chat: FC = () => {
const [search, setSearch] = useState('');
const lastIdRef = useRef(0);
const bottomRef = useRef<HTMLDivElement>(null);
const [searchParams] = useSearchParams();
const activeUser = users.find((u) => u.id === activeId) || null;
@@ -93,6 +96,21 @@ const Chat: FC = () => {
return () => clearInterval(id);
}, [fetchUsers, fetchThread, activeId]);
// Registrar la conversación visible: las notificaciones globales no suenan
// ni muestran toast para el remitente que ya está en pantalla
useEffect(() => {
setActiveChatUser(activeId);
return () => setActiveChatUser(null);
}, [activeId]);
// Abrir conversación directa por query (?with=<userId>), ej. desde el toast
useEffect(() => {
const withId = parseInt(searchParams.get('with') || '', 10);
if (!withId || withId === activeId) return;
if (!users.some((u) => u.id === withId)) return;
openConversation(withId);
}, [searchParams, users, activeId, openConversation]);
const send = async (e?: FormEvent) => {
e?.preventDefault();
const body = draft.trim();
@@ -146,7 +164,17 @@ const Chat: FC = () => {
</div>
<div className="flex-1 overflow-y-auto">
{loadingUsers ? (
<p className="p-4 text-sm text-theme-muted">Cargando...</p>
<div className="p-3 space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-2/3" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
) : filteredUsers.length === 0 ? (
<p className="p-4 text-sm text-theme-muted">No hay usuarios para mostrar.</p>
) : (
@@ -220,7 +248,13 @@ const Chat: FC = () => {
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-2 bg-theme-bg">
{loadingThread ? (
<p className="text-sm text-theme-muted text-center py-6">Cargando mensajes...</p>
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className={`flex ${i % 2 ? 'justify-end' : 'justify-start'}`}>
<Skeleton className={`h-9 ${i % 2 ? 'w-1/2' : 'w-2/3'} rounded-2xl`} />
</div>
))}
</div>
) : messages.length === 0 ? (
<p className="text-sm text-theme-muted text-center py-6">
Aún no hay mensajes. Escribe el primero.