From e6caf10a984fb5ce0a72da54cc8ccde344dcc1bf Mon Sep 17 00:00:00 2001 From: HotelSync Date: Wed, 15 Apr 2026 11:32:52 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20chat=20=E2=80=94=20group=20settings=20(?= =?UTF-8?q?rename,=20photo,=20add/remove=20members),=20fix=20mention=20und?= =?UTF-8?q?erline=20style?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/migrations/077_chat_room_avatar.sql | 2 + backend/src/routes/chat.ts | 16 +- src/components/chat/ChatWidget.tsx | 225 ++++++++++++++++++-- src/lib/api.ts | 3 + 4 files changed, 229 insertions(+), 17 deletions(-) create mode 100644 backend/migrations/077_chat_room_avatar.sql diff --git a/backend/migrations/077_chat_room_avatar.sql b/backend/migrations/077_chat_room_avatar.sql new file mode 100644 index 0000000..dd598e9 --- /dev/null +++ b/backend/migrations/077_chat_room_avatar.sql @@ -0,0 +1,2 @@ +-- Group room avatar +ALTER TABLE chat_rooms ADD COLUMN IF NOT EXISTS avatar_url TEXT; diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 4200711..349ae76 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -79,7 +79,7 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { const userId = request.user.sub const { rows } = await db.query( - `SELECT r.id, r.type, r.name, + `SELECT r.id, r.type, r.name, r.avatar_url, (SELECT COUNT(*) FROM chat_messages m WHERE m.room_id = r.id AND m.deleted_at IS NULL AND m.created_at > COALESCE( @@ -367,7 +367,7 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { // ── PATCH group room (rename / add/remove members) ──────────────────────── - fastify.patch( + fastify.patch( '/api/hotels/:slug/chat/rooms/:roomId/group', { onRequest: [fastify.authenticate] }, async (request, reply) => { @@ -375,10 +375,20 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' }) - const { name, addMemberIds = [], removeMemberIds = [] } = request.body + const b = request.body as Record + const name = b.name as string | undefined + const avatarUrl = (b.avatar_url ?? b.avatarUrl) as string | undefined + const addMemberIds: string[] = Array.isArray(b.add_member_ids) ? b.add_member_ids as string[] + : Array.isArray(b.addMemberIds) ? b.addMemberIds as string[] : [] + const removeMemberIds: string[] = Array.isArray(b.remove_member_ids) ? b.remove_member_ids as string[] + : Array.isArray(b.removeMemberIds) ? b.removeMemberIds as string[] : [] + if (name) { await db.query('UPDATE chat_rooms SET name = $1 WHERE id = $2', [name.trim(), roomId]) } + if (avatarUrl !== undefined) { + await db.query('UPDATE chat_rooms SET avatar_url = $1 WHERE id = $2', [avatarUrl || null, roomId]) + } if (addMemberIds.length > 0) { const vals = addMemberIds.map((_, i) => `($1, $${i + 2})`).join(', ') await db.query( diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx index 1cb4791..7469d74 100644 --- a/src/components/chat/ChatWidget.tsx +++ b/src/components/chat/ChatWidget.tsx @@ -105,17 +105,14 @@ interface ToastNotif { id: string; roomId: string; roomName: string; senderName: string; text: string } -function renderWithMentions(text: string, myName?: string) { +function renderWithMentions(text: string, myName?: string, isOwn = false) { const parts = text.split(/(@\S+)/g) if (parts.length === 1) return <>{text} - const myFirst = myName?.split(' ')[0]?.toLowerCase() return <> {parts.map((part, i) => { if (!part.startsWith('@')) return {part} - const mentioned = part.slice(1).toLowerCase() - const isMe = myFirst && mentioned.startsWith(myFirst) return ( - + {part} ) @@ -125,7 +122,7 @@ function renderWithMentions(text: string, myName?: string) { // ── Main ─────────────────────────────────────────────────────────────────── -type View = 'rooms' | 'messages' | 'search' | 'settings' | 'create-group' +type View = 'rooms' | 'messages' | 'search' | 'settings' | 'create-group' | 'group-info' export function ChatWidget() { const { user } = useAuth() @@ -186,6 +183,11 @@ export function ChatWidget() { const [groupMemberIds, setGroupMemberIds] = useState([]) const [creatingGroup, setCreatingGroup] = useState(false) + // Group info edit + const [editGroupName, setEditGroupName] = useState('') + const [savingGroup, setSavingGroup] = useState(false) + const groupAvatarInputRef = useRef(null) + const widgetRef = useRef(null) const textareaRef = useRef(null) const messagesContainerRef = useRef(null) @@ -316,10 +318,10 @@ export function ChatWidget() { if (allUsers.length === 0) api.users.list(slug).then(setAllUsers).catch(() => {/**/}) }, [view, slug, allUsers.length]) - // Load chat members (all roles) for @mentions and create-group + // Load chat members (all roles) for @mentions, create-group, group-info useEffect(() => { if (!slug) return - if (view === 'create-group' || (view === 'messages' && activeRoom?.type === 'general')) { + if (view === 'create-group' || view === 'group-info' || (view === 'messages' && activeRoom?.type === 'general')) { if (chatMembers.length === 0) api.chat.listMembers(slug).then(setChatMembers).catch(() => {/**/}) } }, [view, slug, activeRoom?.type, chatMembers.length]) @@ -426,7 +428,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, otherUserRole: null, otherUserId: targetUser.id, otherUserLastRead: null, memberCount: 0, memberNames: 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, memberCount: 0, memberNames: null, avatarUrl: null }) setSearchQuery('') } catch { /**/ } } @@ -525,6 +527,35 @@ export function ChatWidget() { finally { setLoadingOlder(false) } } + const openGroupInfo = () => { + if (!activeRoom) return + setEditGroupName(activeRoom.name ?? '') + setView('group-info') + } + + const handleGroupAvatarUpload = async (file: File) => { + if (!activeRoom) return + try { + const { url } = await api.chat.uploadImage(file) + await api.chat.updateGroup(slug, activeRoom.id, { avatarUrl: url }) + setActiveRoom(prev => prev ? { ...prev, avatarUrl: url } : prev) + setRooms(prev => prev.map(r => r.id === activeRoom.id ? { ...r, avatarUrl: url } : r)) + } catch { /**/ } + } + + const handleSaveGroupInfo = async (patch: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[] }) => { + if (!activeRoom || savingGroup) return + setSavingGroup(true) + try { + await api.chat.updateGroup(slug, activeRoom.id, patch) + const data = await api.chat.listRooms(slug) + setRooms(data) + const updated = data.find(r => r.id === activeRoom.id) + if (updated) setActiveRoom(updated) + } catch { /**/ } + finally { setSavingGroup(false) } + } + const handleCreateGroup = async () => { if (!groupName.trim() || groupMemberIds.length === 0 || creatingGroup) return setCreatingGroup(true) @@ -550,6 +581,7 @@ export function ChatWidget() { else if (view === 'search') { setView('rooms'); setSearchQuery('') } else if (view === 'settings') setView('rooms') else if (view === 'create-group') { setView('rooms'); setGroupName(''); setGroupMemberIds([]) } + else if (view === 'group-info') { setView('messages') } } const roomDisplayName = (room: ChatRoom) => @@ -605,6 +637,7 @@ export function ChatWidget() { {view === 'search' && 'Поиск'} {view === 'settings' && 'Настройки чата'} {view === 'create-group' && 'Новая группа'} + {view === 'group-info' && 'Настройки группы'}

{/* Online dot in messages header */} {view === 'messages' && activeRoom && isOtherOnline(activeRoom) && ( @@ -643,10 +676,17 @@ export function ChatWidget() { )} {view === 'messages' && activeRoom?.type !== 'notifications' && ( - +
+ + {activeRoom?.type === 'group' && ( + + )} +
)} @@ -814,6 +854,22 @@ export function ChatWidget() { )} + {/* ── Group Info ── */} + {view === 'group-info' && activeRoom && ( + groupAvatarInputRef.current?.click()} + onSave={handleSaveGroupInfo} + /> + )} + { const f = e.target.files?.[0]; if (f) void handleGroupAvatarUpload(f); e.target.value = '' }} /> + {/* ── Messages ── */} {view === 'messages' && ( <> @@ -1155,7 +1211,7 @@ function MessageBubble({ msg, isOwn, isEditing, editText, isRead, currentUserNam )} {msg.text && (

- {renderWithMentions(msg.text, currentUserName)} + {renderWithMentions(msg.text, currentUserName, isOwn)}

)} @@ -1300,6 +1356,147 @@ function ToggleRow({ icon, label, checked, onChange }: { ) } +// ── GroupInfoView ────────────────────────────────────────────────────────── + +function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEditName, onAvatarClick, onSave }: { + room: ChatRoom + allMembers: { id: string; name: string; role: string }[] + currentUserId: string + saving: boolean + editName: string + onEditName: (v: string) => void + onAvatarClick: () => void + onSave: (patch: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[] }) => void +}) { + const roleLabels: Record = { + hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная', + receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник', + } + const [addingMembers, setAddingMembers] = useState(false) + const [selectedAdd, setSelectedAdd] = useState([]) + + // Current member ids from allMembers that are in this room (from memberNames we don't have IDs, so use a different approach) + // We track removes locally until saved + const [removedIds, setRemovedIds] = useState([]) + + const currentMemberIds = allMembers + .filter(u => room.memberNames?.some(n => n === u.name) || u.id === currentUserId) + .map(u => u.id) + .filter(id => !removedIds.includes(id)) + + const removableMemberIds = allMembers.filter(u => + (room.memberNames?.some(n => n === u.name) || u.id === currentUserId) && u.id !== currentUserId + ).map(u => u.id) + + const notMembers = allMembers.filter(u => + u.id !== currentUserId && + !currentMemberIds.includes(u.id) && + !removedIds.includes(u.id) + ) + + const handleRemove = (userId: string) => { + setRemovedIds(p => [...p, userId]) + onSave({ removeMemberIds: [userId] }) + } + + const handleAddMembers = () => { + if (selectedAdd.length === 0) { setAddingMembers(false); return } + onSave({ addMemberIds: selectedAdd }) + setSelectedAdd([]) + setAddingMembers(false) + } + + return ( +
+ {/* Avatar + name */} +
+ +
+ onEditName(e.target.value)} + className="flex-1 text-center text-sm font-semibold bg-transparent border-b border-slate-300 dark:border-slate-600 focus:border-brand-500 outline-none py-0.5 text-slate-800 dark:text-slate-100" + placeholder="Название группы" /> + +
+
+ + {/* Members list */} +
+
+

+ Участники · {currentMemberIds.length} +

+ +
+ + {/* Add members panel */} + {addingMembers && notMembers.length > 0 && ( +
+ {notMembers.map(u => ( + + ))} + {selectedAdd.length > 0 && ( + + )} +
+ )} + + {/* Current members */} + {allMembers.filter(u => currentMemberIds.includes(u.id)).map(u => ( +
+ +
+

+ {u.name} {u.id === currentUserId && (вы)} +

+

{roleLabels[u.role] ?? u.role}

+
+ {u.id !== currentUserId && ( + + )} +
+ ))} +
+
+ ) +} + function RoomSkeleton({ icon, bg, label }: { icon: React.ReactNode; bg: string; label: string }) { return (
diff --git a/src/lib/api.ts b/src/lib/api.ts index 1a81ecd..333d153 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -360,6 +360,8 @@ export const api = { req<{ id: string; name: string; role: string }[]>('GET', `/api/hotels/${slug}/chat/members`), createGroup: (slug: string, name: string, memberIds: string[]) => req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/group`, { name, memberIds }), + updateGroup: (slug: string, roomId: string, patch: { name?: string; avatarUrl?: string; addMemberIds?: string[]; removeMemberIds?: string[] }) => + req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/group`, patch), editMessage: (slug: string, roomId: string, msgId: string, text: string) => req('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/messages/${msgId}`, { text }), deleteMessage: (slug: string, roomId: string, msgId: string) => @@ -1351,6 +1353,7 @@ export interface ChatRoom { otherUserLastRead: string | null memberCount: number memberNames: string[] | null + avatarUrl: string | null } export interface ChatReaction {