Files
rikkei_simple_care/management/src/components/ChatWidget.tsx
PhuocNTB 8803cd622a
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m25s
tam 2
2026-07-13 09:04:09 +07:00

283 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api';
import { type StaffChatOpenDetail } from '../chatEvents';
import { useAuth } from '../auth/AuthContext';
import { onStaffChatMessage, onStudentViolation, playChatSound, kindLabel } from '../hooks/useStaffChatSocket';
export function ChatWidget() {
const { staff } = useAuth();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [students, setStudents] = useState<ChatStudent[]>([]);
const [conversations, setConversations] = useState<ChatConversation[]>([]);
const [active, setActive] = useState<ChatStudent | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const [toast, setToast] = useState<{ title: string; body: string } | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const activeRef = useRef<ChatStudent | null>(null);
const openRef = useRef(false);
useEffect(() => { activeRef.current = active; }, [active]);
useEffect(() => { openRef.current = open; }, [open]);
const scrollBottom = () => {
requestAnimationFrame(() => {
if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight;
});
};
const totalUnread = conversations.reduce((n, c) => n + (c.unread || 0), 0);
const loadConversations = useCallback(async () => {
const res = await apiChat.listConversations();
setConversations(res.data);
return res.data;
}, []);
const loadMessages = useCallback(async (studentRkId: number) => {
const res = await apiChat.listMessages(studentRkId);
setMessages(res.data);
scrollBottom();
await loadConversations();
}, [loadConversations]);
const searchStudents = useCallback(async (q: string) => {
const res = await apiChat.searchStudents(q);
setStudents(res.data);
}, []);
const showToast = (title: string, body: string) => {
setToast({ title, body });
setTimeout(() => setToast(null), 4500);
};
const handleIncoming = useCallback((msg: ChatMessage) => {
const studentId = Number(msg.studentRkId);
const current = activeRef.current;
void loadConversations();
if (msg.senderRole === 'student') {
playChatSound();
const preview = msg.body?.slice(0, 80) || 'Tin nhắn mới';
if (!openRef.current || !current || Number(current.studentRkId) !== studentId) {
showToast('Sinh viên nhắn tin', preview);
}
}
if (current && Number(current.studentRkId) === studentId) {
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg];
});
scrollBottom();
}
}, [loadConversations]);
useEffect(() => {
const off = onStaffChatMessage(handleIncoming);
return () => { off(); };
}, [handleIncoming]);
useEffect(() => {
return onStudentViolation((v) => {
const name = v.studentName || v.studentCode || `SV #${v.studentId}`;
showToast(`${kindLabel(v.kind)}`, `${name}: ${v.reason || 'Vi phạm giám sát'}`);
});
}, []);
useEffect(() => {
const onOpen = (e: Event) => {
const detail = (e as CustomEvent<StaffChatOpenDetail>).detail;
if (!detail?.studentRkId) return;
setOpen(true);
setActive({
studentRkId: detail.studentRkId,
fullName: detail.fullName,
studentCode: detail.studentCode,
email: detail.email || '',
online: false,
});
setPickerOpen(false);
setQuery('');
loadMessages(detail.studentRkId).catch(console.error);
};
window.addEventListener('staff-chat:open', onOpen);
return () => window.removeEventListener('staff-chat:open', onOpen);
}, [loadMessages]);
useEffect(() => {
if (!staff?.id) return;
loadConversations().catch(console.error);
const t = setInterval(() => loadConversations().catch(console.error), 5000);
return () => clearInterval(t);
}, [staff?.id, loadConversations]);
const pickStudent = async (s: ChatStudent) => {
setActive(s);
setPickerOpen(false);
setQuery('');
await loadMessages(s.studentRkId);
};
const pickFromConversation = async (c: ChatConversation) => {
await pickStudent({
studentRkId: c.studentRkId,
fullName: c.fullName,
studentCode: c.studentCode,
email: '',
online: false,
});
};
useEffect(() => {
if (!open || !staff?.id) return;
loadConversations().catch(console.error);
searchStudents('').catch(console.error);
}, [open, staff?.id, loadConversations, searchStudents]);
const send = async () => {
if (!active || !draft.trim()) return;
setSending(true);
try {
const res = await apiChat.sendMessage(active.studentRkId, draft.trim());
setMessages((prev) => [...prev, res.data]);
setDraft('');
scrollBottom();
await loadConversations();
} finally {
setSending(false);
}
};
if (!staff) return null;
const dock = (
<div className="chat-dock">
{toast && (
<div className={`chat-toast ${toast.title.startsWith('⚠') ? 'chat-toast-alert' : ''}`} role="status">
<strong>{toast.title}</strong>
<span>{toast.body}</span>
</div>
)}
{!open ? (
<button type="button" className="chat-fab" onClick={() => setOpen(true)} title="Tin nhắn">
💬
{totalUnread > 0 && (
<span className="chat-fab-badge">{totalUnread > 9 ? '9+' : totalUnread}</span>
)}
</button>
) : (
<div className="chat-messenger">
<header className="chat-messenger-head">
<strong>Tin nhắn</strong>
<div className="chat-head-actions">
<button type="button" className="chat-icon-btn" onClick={() => setPickerOpen((v) => !v)} title="Tin mới">
</button>
<button type="button" className="chat-icon-btn" onClick={() => { setOpen(false); setActive(null); setPickerOpen(false); }} title="Đóng">
×
</button>
</div>
</header>
<div className="chat-messenger-body">
<aside className="chat-sidebar">
<div className="chat-sidebar-search">
<input
placeholder="Tìm sinh viên..."
value={query}
onChange={(e) => {
setQuery(e.target.value);
searchStudents(e.target.value).catch(console.error);
}}
onFocus={() => setPickerOpen(true)}
/>
</div>
{pickerOpen && (
<ul className="chat-picker-list">
{students.map((s) => (
<li key={s.studentRkId}>
<button type="button" onClick={() => pickStudent(s)}>
<span className="chat-conv-name">{s.fullName}</span>
<span className="chat-conv-meta">{s.studentCode}{s.online ? ' · online' : ''}</span>
</button>
</li>
))}
{students.length === 0 && <li className="chat-empty-hint">Không tìm thấy sinh viên</li>}
</ul>
)}
<ul className="chat-conv-list">
{conversations.map((c) => (
<li key={c.studentRkId}>
<button
type="button"
className={active?.studentRkId === c.studentRkId ? 'active' : ''}
onClick={() => pickFromConversation(c)}
>
<div className="chat-conv-row">
<span className="chat-conv-name">{c.fullName}</span>
{c.unread > 0 && <span className="chat-conv-unread">{c.unread}</span>}
</div>
<span className="chat-conv-preview">{c.lastMessage || '—'}</span>
<span className="chat-conv-meta">{c.studentCode}</span>
</button>
</li>
))}
{conversations.length === 0 && !pickerOpen && (
<li className="chat-empty-hint">Chưa hội thoại bấm đ nhắn sinh viên</li>
)}
</ul>
</aside>
<section className="chat-thread">
{!active ? (
<div className="chat-thread-empty">
<p>Chọn hội thoại bên trái hoặc tìm sinh viên đ bắt đu chat</p>
</div>
) : (
<>
<div className="chat-thread-head">
<div>
<strong>{active.fullName}</strong>
<div className="chat-sub">{active.studentCode}</div>
</div>
</div>
<div className="chat-messages" ref={listRef}>
{messages.map((m) => (
<div key={m.id} className={`chat-bubble chat-bubble--${m.senderRole}`}>
<div className="chat-bubble-body">{m.body}</div>
<div className="chat-bubble-time">
{new Date(m.createdAt).toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
</div>
</div>
))}
{messages.length === 0 && <div className="chat-empty-hint">Chưa tin nhắn</div>}
</div>
<div className="chat-compose">
<input
placeholder="Nhập tin nhắn..."
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
/>
<button type="button" className="btn btn-primary btn-sm" disabled={sending || !draft.trim()} onClick={send}>
Gửi
</button>
</div>
</>
)}
</section>
</div>
</div>
)}
</div>
);
return createPortal(dock, document.body);
}