diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index d1d7fcf..118b57d 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -95,6 +95,7 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { (SELECT m.created_at FROM chat_messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_message_at, (SELECT COALESCE(m.system_name, u.name) FROM chat_messages m LEFT JOIN users u ON u.id = m.sender_id WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_sender, (SELECT u.name FROM chat_room_members crm JOIN users u ON u.id = crm.user_id WHERE crm.room_id = r.id AND crm.user_id != $2 LIMIT 1) AS other_user_name, + (SELECT u.role FROM chat_room_members crm JOIN users u ON u.id = crm.user_id WHERE crm.room_id = r.id AND crm.user_id != $2 LIMIT 1) AS other_user_role, (SELECT crm.user_id FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id != $2 LIMIT 1) AS other_user_id, (SELECT rs.last_read FROM chat_read_status rs WHERE rs.room_id = r.id AND rs.user_id != $2 LIMIT 1) AS other_user_last_read FROM chat_rooms r diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx index 9fa3d4f..f7d6c06 100644 --- a/src/components/chat/ChatWidget.tsx +++ b/src/components/chat/ChatWidget.tsx @@ -159,7 +159,11 @@ export function ChatWidget() { const [roomSearchOpen, setRoomSearchOpen] = useState(false) const roomSearchInputRef = useRef(null) + // @mentions + const [mentionQuery, setMentionQuery] = useState(null) + const widgetRef = useRef(null) + const textareaRef = useRef(null) const messagesContainerRef = useRef(null) const messagesEndRef = useRef(null) const searchInputRef = useRef(null) @@ -278,11 +282,13 @@ export function ChatWidget() { // Clear typing names when leaving room useEffect(() => { if (view !== 'messages') setTypingNames([]) }, [view]) - // Search users + // Load users list (for search view + @mentions in general chat) useEffect(() => { - if (view !== 'search' || !slug) return - api.users.list(slug).then(setAllUsers).catch(() => {/**/}) - }, [view, slug]) + if (!slug) return + if (view === 'search' || (view === 'messages' && activeRoom?.type === 'general')) { + if (allUsers.length === 0) api.users.list(slug).then(setAllUsers).catch(() => {/**/}) + } + }, [view, slug, activeRoom?.type, allUsers.length]) // Search effect useEffect(() => { @@ -335,12 +341,30 @@ export function ChatWidget() { const handleTextChange = (val: string) => { setText(val) + // Detect @mention: find last @ before cursor position + const cursor = textareaRef.current?.selectionStart ?? val.length + const before = val.slice(0, cursor) + const match = before.match(/@([^\s@]*)$/) + setMentionQuery(match ? match[1] : null) + if (!val.trim()) { sendTyping(false); return } sendTyping(true) if (typingTimerRef.current) clearTimeout(typingTimerRef.current) typingTimerRef.current = setTimeout(() => sendTyping(false), 4000) } + const insertMention = (mentionUser: User) => { + const cursor = textareaRef.current?.selectionStart ?? text.length + const before = text.slice(0, cursor) + const match = before.match(/@([^\s@]*)$/) + if (!match) return + const start = cursor - match[0].length + const newText = text.slice(0, start) + `@${mentionUser.name} ` + text.slice(cursor) + setText(newText) + setMentionQuery(null) + setTimeout(() => textareaRef.current?.focus(), 0) + } + // ── Actions ─────────────────────────────────────────────────────────────── const openRoomById = async (roomId: string) => { @@ -378,7 +402,7 @@ export function ChatWidget() { const { roomId } = await api.chat.openDirect(slug, targetUser.id) const data = await api.chat.listRooms(slug) setRooms(data) - await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserId: targetUser.id, otherUserLastRead: null }) + await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserRole: null, otherUserId: targetUser.id, otherUserLastRead: null }) setSearchQuery('') } catch { /**/ } } @@ -399,7 +423,7 @@ export function ChatWidget() { const sendMessage = async () => { if ((!text.trim() && !attachment) || !activeRoom || sending) return const t = text.trim(), file = attachment - setText(''); setAttachment(null); setAttachPreview(null); setSending(true) + setText(''); setAttachment(null); setAttachPreview(null); setSending(true); setMentionQuery(null) sendTyping(false) try { let url: string | undefined @@ -542,9 +566,20 @@ export function ChatWidget() { )} {view === 'rooms' && visibleRooms.length > 0 &&

{visibleRooms.length} чатов

} - {view === 'messages' && activeRoom?.type === 'direct' && isOtherOnline(activeRoom) && ( -

в сети

- )} + {view === 'messages' && activeRoom?.type === 'direct' && (() => { + const roleLabels: Record = { + hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная', + receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник', + } + const roleLabel = activeRoom.otherUserRole ? roleLabels[activeRoom.otherUserRole] : null + return ( +

+ {isOtherOnline(activeRoom) + ? в сети{roleLabel ? ` · ${roleLabel}` : ''} + : roleLabel ?? ''} +

+ ) + })()} {view === 'messages' && activeRoom?.type === 'notifications' && (

Только чтение

)} @@ -763,13 +798,32 @@ export function ChatWidget() { )} + {/* @mention dropdown */} + {mentionQuery !== null && (() => { + const q = mentionQuery.toLowerCase() + const candidates = allUsers + .filter(u => u.id !== user?.id && u.name.toLowerCase().includes(q)) + .slice(0, 6) + if (candidates.length === 0) return null + return ( +
+ {candidates.map(u => ( + + ))} +
+ ) + })()}
-