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, 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; // Las fechas llegan en UTC ("YYYY-MM-DD HH:MM:SS"); se muestran en hora local const fmtTime = (d: string | null): string => { if (!d) return ''; const dt = new Date(d.replace(' ', 'T') + 'Z'); return dt.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }); }; const fmtDay = (d: string | null): string => { if (!d) return ''; const dt = new Date(d.replace(' ', 'T') + 'Z'); const today = new Date(); const sameDay = dt.toDateString() === today.toDateString(); return sameDay ? fmtTime(d) : dt.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' }); }; const initials = (name: string) => (name || '?').split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase(); const Chat: FC = () => { const [users, setUsers] = useState([]); const [loadingUsers, setLoadingUsers] = useState(true); const [activeId, setActiveId] = useState(null); const [messages, setMessages] = useState([]); const [loadingThread, setLoadingThread] = useState(false); const [draft, setDraft] = useState(''); const [sending, setSending] = useState(false); const [search, setSearch] = useState(''); const lastIdRef = useRef(0); const bottomRef = useRef(null); const [searchParams] = useSearchParams(); const activeUser = users.find((u) => u.id === activeId) || null; const fetchUsers = useCallback(async () => { try { const res = await odooApi.getChatUsers(); if (res.status === 'success') setUsers(res.users); } catch { // silencioso en polling } finally { setLoadingUsers(false); } }, []); const fetchThread = useCallback(async (otherId: number, incremental: boolean) => { try { const res = await odooApi.getChatMessages(otherId, incremental ? lastIdRef.current : undefined); if (res.status !== 'success') return; if (incremental) { if (res.messages.length === 0) return; setMessages((prev) => { const known = new Set(prev.map((m) => m.id)); return [...prev, ...res.messages.filter((m) => !known.has(m.id))]; }); } else { setMessages(res.messages); } lastIdRef.current = Math.max(...res.messages.map((m) => m.id), lastIdRef.current); setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50); } catch { // silencioso en polling } }, []); const openConversation = useCallback(async (id: number) => { setActiveId(id); setMessages([]); lastIdRef.current = 0; setLoadingThread(true); await fetchThread(id, false); setLoadingThread(false); // El servidor ya marcó leídos: refrescar conteos de la lista fetchUsers(); }, [fetchThread, fetchUsers]); // Carga inicial + polling de lista y hilo abierto useEffect(() => { fetchUsers(); const id = setInterval(() => { fetchUsers(); if (activeId) fetchThread(activeId, true); }, POLL_MS); 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(); if (!body || !activeId || sending) return; try { setSending(true); const res = await odooApi.sendChatMessage(activeId, body); if (res.status === 'success') { setMessages((prev) => [...prev, res.message]); lastIdRef.current = Math.max(lastIdRef.current, res.message.id); setDraft(''); setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50); fetchUsers(); } } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'No se pudo enviar el mensaje'); } finally { setSending(false); } }; const onDraftKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }; const filteredUsers = users.filter((u) => !search || u.name.toLowerCase().includes(search.toLowerCase())); return (
{/* Columna izquierda: conversaciones */}
setSearch(e.target.value)} className="w-full rounded-xl border border-theme-border-strong bg-theme-surface pl-9 pr-3 py-2 text-sm text-theme-heading focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent transition" />
{loadingUsers ? (
{Array.from({ length: 6 }).map((_, i) => (
))}
) : filteredUsers.length === 0 ? (

No hay usuarios para mostrar.

) : (
    {filteredUsers.map((u) => (
  • ))}
)}
{/* Columna derecha: hilo */}
{!activeUser ? (
) : ( <>
{initials(activeUser.name)}

{activeUser.name}

{ROLE_LABELS[activeUser.role] || activeUser.role}

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

Aún no hay mensajes. Escribe el primero.

) : ( messages.map((m) => (

{m.body}

{fmtTime(m.date)}

)) )}