diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1fafcb8..c0b6c35 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => { + } /> } /> diff --git a/frontend/src/lib/chat-notifications.tsx b/frontend/src/lib/chat-notifications.tsx new file mode 100644 index 0000000..f248778 --- /dev/null +++ b/frontend/src/lib/chat-notifications.tsx @@ -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>({}); + 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 = {}; + 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) => ( + + ), { 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; diff --git a/frontend/src/pages/Agenda.tsx b/frontend/src/pages/Agenda.tsx index f449223..f0b6c8d 100644 --- a/frontend/src/pages/Agenda.tsx +++ b/frontend/src/pages/Agenda.tsx @@ -1179,7 +1179,29 @@ const Agenda: FC = () => { {error &&

{error}

} {loading ? ( - + viewMode === 'lista' ? ( + + ) : ( + /* Skeleton con forma de grid (columnas de médicos × filas) */ +
+
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+
+ {Array.from({ length: 6 }).map((_, r) => ( +
+
+ {Array.from({ length: 5 }).map((_, c) => ( +
+ ))} +
+ ))} +
+
+ ) ) : viewMode === 'doctores' ? ( gridColumns.length === 0 ? ( { const [search, setSearch] = useState(''); const lastIdRef = useRef(0); const bottomRef = useRef(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=), 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 = () => {
{loadingUsers ? ( -

Cargando...

+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
) : filteredUsers.length === 0 ? (

No hay usuarios para mostrar.

) : ( @@ -220,7 +248,13 @@ const Chat: FC = () => {
{loadingThread ? ( -

Cargando mensajes...

+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ ))} +
) : messages.length === 0 ? (

Aún no hay mensajes. Escribe el primero.