fix: chat — group members endpoint (all roles), mention first-name only, restore direct-chat button

This commit is contained in:
2026-04-14 17:44:33 +03:00
parent 8353eb7c7f
commit 18e1eb86af
3 changed files with 37 additions and 10 deletions

View File

@@ -521,6 +521,24 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
// ── POST notify ───────────────────────────────────────────────────────────
// ── GET /chat/members — list hotel members (accessible to all roles) ─────
fastify.get<SlugParam>(
'/api/hotels/:slug/chat/members',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
`SELECT id, name, role FROM users WHERE hotel_id = $1 AND active = true ORDER BY name`,
[hotelId],
)
return rows
},
)
fastify.post<SlugParam & { Body: { text: string; system_name?: string } }>(
'/api/hotels/:slug/chat/notify',
{ onRequest: [fastify.authenticate] },

View File

@@ -163,6 +163,7 @@ export function ChatWidget() {
const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people')
const [loadingSearch, setLoadingSearch] = useState(false)
const [allUsers, setAllUsers] = useState<User[]>([])
const [chatMembers, setChatMembers] = useState<{ id: string; name: string; role: string }[]>([])
// Settings + pins + muted
const [settings, setSettings] = useState<ChatSettings>(loadSettings)
@@ -309,13 +310,19 @@ export function ChatWidget() {
// Clear typing names when leaving room
useEffect(() => { if (view !== 'messages') setTypingNames([]) }, [view])
// Load users list (for search view + @mentions in general chat + create-group)
// Load full users (manager+ only) for search view
useEffect(() => {
if (!slug || view !== 'search') return
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
useEffect(() => {
if (!slug) return
if (view === 'search' || view === 'create-group' || (view === 'messages' && activeRoom?.type === 'general')) {
if (allUsers.length === 0) api.users.list(slug).then(setAllUsers).catch(() => {/**/})
if (view === 'create-group' || (view === 'messages' && activeRoom?.type === 'general')) {
if (chatMembers.length === 0) api.chat.listMembers(slug).then(setChatMembers).catch(() => {/**/})
}
}, [view, slug, activeRoom?.type, allUsers.length])
}, [view, slug, activeRoom?.type, chatMembers.length])
// Search effect
useEffect(() => {
@@ -370,13 +377,13 @@ export function ChatWidget() {
typingTimerRef.current = setTimeout(() => sendTyping(false), 4000)
}
const insertMention = (mentionUser: User) => {
const insertMention = (mentionUser: { id: string; name: string; role: string }) => {
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)
const newText = text.slice(0, start) + `@${mentionUser.name.split(' ')[0]} ` + text.slice(cursor)
setText(newText)
setMentionQuery(null)
setTimeout(() => textareaRef.current?.focus(), 0)
@@ -630,7 +637,7 @@ export function ChatWidget() {
</div>
{view === 'rooms' && (
<div className="flex items-center gap-0.5">
<button onClick={() => setView('search')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" title="Поиск"><Search size={15} /></button>
<button onClick={() => setView('search')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" title="Поиск и новый чат"><PenSquare size={15} /></button>
<button onClick={() => { setGroupName(''); setGroupMemberIds([]); setView('create-group') }} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" title="Новая группа"><UserPlus size={15} /></button>
<button onClick={() => setView('settings')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" title="Настройки"><Settings size={15} /></button>
</div>
@@ -767,10 +774,10 @@ export function ChatWidget() {
)}
</div>
<div className="flex-1 overflow-y-auto">
{allUsers.length === 0 ? (
{chatMembers.length === 0 ? (
<div className="flex items-center justify-center py-8"><Loader2 size={18} className="animate-spin text-slate-400" /></div>
) : (
allUsers.filter(u => u.id !== user?.id).map(u => {
chatMembers.filter(u => u.id !== user?.id).map(u => {
const selected = groupMemberIds.includes(u.id)
const roleLabels: Record<string, string> = {
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
@@ -903,7 +910,7 @@ export function ChatWidget() {
{/* @mention dropdown */}
{mentionQuery !== null && (() => {
const q = mentionQuery.toLowerCase()
const candidates = allUsers
const candidates = chatMembers
.filter(u => u.id !== user?.id && u.name.toLowerCase().includes(q))
.slice(0, 6)
if (candidates.length === 0) return null

View File

@@ -356,6 +356,8 @@ export const api = {
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/read`),
openDirect: (slug: string, otherUserId: string) =>
req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/direct/${otherUserId}`),
listMembers: (slug: string) =>
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 }),
editMessage: (slug: string, roomId: string, msgId: string, text: string) =>