308 lines
14 KiB
TypeScript
308 lines
14 KiB
TypeScript
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<ChatUser[]>([]);
|
|
const [loadingUsers, setLoadingUsers] = useState(true);
|
|
const [activeId, setActiveId] = useState<number | null>(null);
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
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<HTMLDivElement>(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=<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();
|
|
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<HTMLTextAreaElement>) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
send();
|
|
}
|
|
};
|
|
|
|
const filteredUsers = users.filter((u) =>
|
|
!search || u.name.toLowerCase().includes(search.toLowerCase()));
|
|
|
|
return (
|
|
<Layout title="Chat" subtitle="Mensajería interna del equipo">
|
|
<Card>
|
|
<Card.Body noPadding>
|
|
<div className="flex h-[calc(100vh-14rem)] min-h-[420px]">
|
|
{/* Columna izquierda: conversaciones */}
|
|
<div className={`w-full md:w-80 md:shrink-0 border-r border-theme-border flex flex-col ${activeId ? 'hidden md:flex' : 'flex'}`}>
|
|
<div className="p-3 border-b border-theme-border">
|
|
<div className="relative">
|
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar usuario..."
|
|
value={search}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto">
|
|
{loadingUsers ? (
|
|
<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>
|
|
) : (
|
|
<ul>
|
|
{filteredUsers.map((u) => (
|
|
<li key={u.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => openConversation(u.id)}
|
|
className={`w-full flex items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-theme-accent-bg ${activeId === u.id ? 'bg-theme-accent-bg' : ''}`}
|
|
>
|
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
|
|
{initials(u.name)}
|
|
</span>
|
|
<span className="flex-1 min-w-0">
|
|
<span className="flex items-center justify-between gap-2">
|
|
<span className="truncate text-sm font-medium text-theme-heading">{u.name}</span>
|
|
{u.last_date && (
|
|
<span className="shrink-0 text-[10px] text-theme-muted">{fmtDay(u.last_date)}</span>
|
|
)}
|
|
</span>
|
|
<span className="flex items-center justify-between gap-2">
|
|
<span className="truncate text-xs text-theme-muted">
|
|
{u.last_message
|
|
? `${u.last_mine ? 'Tú: ' : ''}${u.last_message}`
|
|
: ROLE_LABELS[u.role] || u.role}
|
|
</span>
|
|
{u.unread > 0 && (
|
|
<span className="shrink-0 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-theme-accent text-theme-inverse text-[10px] font-bold">
|
|
{u.unread}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Columna derecha: hilo */}
|
|
<div className={`flex-1 flex-col min-w-0 ${activeId ? 'flex' : 'hidden md:flex'}`}>
|
|
{!activeUser ? (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<EmptyState
|
|
title="Selecciona una conversación"
|
|
subtitle="Elige un usuario de la lista para iniciar o continuar un chat."
|
|
/>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-theme-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveId(null)}
|
|
className="md:hidden p-1.5 rounded-lg text-theme-muted hover:bg-theme-accent-bg"
|
|
aria-label="Volver a la lista"
|
|
>
|
|
<ArrowLeft size={18} />
|
|
</button>
|
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
|
|
{initials(activeUser.name)}
|
|
</span>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold text-theme-heading">{activeUser.name}</p>
|
|
<p className="text-xs text-theme-muted">{ROLE_LABELS[activeUser.role] || activeUser.role}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-2 bg-theme-bg">
|
|
{loadingThread ? (
|
|
<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.
|
|
</p>
|
|
) : (
|
|
messages.map((m) => (
|
|
<div key={m.id} className={`flex ${m.mine ? 'justify-end' : 'justify-start'}`}>
|
|
<div
|
|
className={`max-w-[75%] rounded-2xl px-3.5 py-2 text-sm shadow-card ${
|
|
m.mine
|
|
? 'bg-theme-accent text-theme-inverse rounded-br-md'
|
|
: 'bg-theme-surface text-theme-heading border border-theme-border rounded-bl-md'
|
|
}`}
|
|
>
|
|
<p className="whitespace-pre-wrap break-words">{m.body}</p>
|
|
<p className={`mt-1 text-[10px] text-right ${m.mine ? 'text-theme-inverse/70' : 'text-theme-muted'}`}>
|
|
{fmtTime(m.date)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
|
|
<form onSubmit={send} className="flex items-end gap-2 p-3 border-t border-theme-border">
|
|
<textarea
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onKeyDown={onDraftKeyDown}
|
|
placeholder={`Mensaje para ${activeUser.name}... (Enter envía)`}
|
|
rows={1}
|
|
maxLength={2000}
|
|
className="flex-1 resize-none rounded-xl border border-theme-border-strong bg-theme-surface px-3 py-2.5 text-sm text-theme-heading focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent transition"
|
|
/>
|
|
<Button type="submit" loading={sending} disabled={!draft.trim()} aria-label="Enviar mensaje">
|
|
<Send size={16} />
|
|
</Button>
|
|
</form>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card.Body>
|
|
</Card>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default Chat;
|