UX: notificaciones de chat con toast y sonido (WebAudio) + skeletons de carga en Agenda y Chat
This commit is contained in:
@@ -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>} />
|
||||
|
||||
141
frontend/src/lib/chat-notifications.tsx
Normal file
141
frontend/src/lib/chat-notifications.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user