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 {