fix: chat — group members endpoint (all roles), mention first-name only, restore direct-chat button
This commit is contained in:
@@ -521,6 +521,24 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// ── POST notify ───────────────────────────────────────────────────────────
|
// ── 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 } }>(
|
fastify.post<SlugParam & { Body: { text: string; system_name?: string } }>(
|
||||||
'/api/hotels/:slug/chat/notify',
|
'/api/hotels/:slug/chat/notify',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export function ChatWidget() {
|
|||||||
const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people')
|
const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people')
|
||||||
const [loadingSearch, setLoadingSearch] = useState(false)
|
const [loadingSearch, setLoadingSearch] = useState(false)
|
||||||
const [allUsers, setAllUsers] = useState<User[]>([])
|
const [allUsers, setAllUsers] = useState<User[]>([])
|
||||||
|
const [chatMembers, setChatMembers] = useState<{ id: string; name: string; role: string }[]>([])
|
||||||
|
|
||||||
// Settings + pins + muted
|
// Settings + pins + muted
|
||||||
const [settings, setSettings] = useState<ChatSettings>(loadSettings)
|
const [settings, setSettings] = useState<ChatSettings>(loadSettings)
|
||||||
@@ -309,13 +310,19 @@ export function ChatWidget() {
|
|||||||
// Clear typing names when leaving room
|
// Clear typing names when leaving room
|
||||||
useEffect(() => { if (view !== 'messages') setTypingNames([]) }, [view])
|
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(() => {
|
useEffect(() => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
if (view === 'search' || view === 'create-group' || (view === 'messages' && activeRoom?.type === 'general')) {
|
if (view === 'create-group' || (view === 'messages' && activeRoom?.type === 'general')) {
|
||||||
if (allUsers.length === 0) api.users.list(slug).then(setAllUsers).catch(() => {/**/})
|
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
|
// Search effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -370,13 +377,13 @@ export function ChatWidget() {
|
|||||||
typingTimerRef.current = setTimeout(() => sendTyping(false), 4000)
|
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 cursor = textareaRef.current?.selectionStart ?? text.length
|
||||||
const before = text.slice(0, cursor)
|
const before = text.slice(0, cursor)
|
||||||
const match = before.match(/@([^\s@]*)$/)
|
const match = before.match(/@([^\s@]*)$/)
|
||||||
if (!match) return
|
if (!match) return
|
||||||
const start = cursor - match[0].length
|
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)
|
setText(newText)
|
||||||
setMentionQuery(null)
|
setMentionQuery(null)
|
||||||
setTimeout(() => textareaRef.current?.focus(), 0)
|
setTimeout(() => textareaRef.current?.focus(), 0)
|
||||||
@@ -630,7 +637,7 @@ export function ChatWidget() {
|
|||||||
</div>
|
</div>
|
||||||
{view === 'rooms' && (
|
{view === 'rooms' && (
|
||||||
<div className="flex items-center gap-0.5">
|
<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={() => { 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>
|
<button onClick={() => setView('settings')} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" title="Настройки"><Settings size={15} /></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -767,10 +774,10 @@ export function ChatWidget() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<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>
|
<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 selected = groupMemberIds.includes(u.id)
|
||||||
const roleLabels: Record<string, string> = {
|
const roleLabels: Record<string, string> = {
|
||||||
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
|
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
|
||||||
@@ -903,7 +910,7 @@ export function ChatWidget() {
|
|||||||
{/* @mention dropdown */}
|
{/* @mention dropdown */}
|
||||||
{mentionQuery !== null && (() => {
|
{mentionQuery !== null && (() => {
|
||||||
const q = mentionQuery.toLowerCase()
|
const q = mentionQuery.toLowerCase()
|
||||||
const candidates = allUsers
|
const candidates = chatMembers
|
||||||
.filter(u => u.id !== user?.id && u.name.toLowerCase().includes(q))
|
.filter(u => u.id !== user?.id && u.name.toLowerCase().includes(q))
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
if (candidates.length === 0) return null
|
if (candidates.length === 0) return null
|
||||||
|
|||||||
@@ -356,6 +356,8 @@ export const api = {
|
|||||||
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/read`),
|
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/read`),
|
||||||
openDirect: (slug: string, otherUserId: string) =>
|
openDirect: (slug: string, otherUserId: string) =>
|
||||||
req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/direct/${otherUserId}`),
|
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[]) =>
|
createGroup: (slug: string, name: string, memberIds: string[]) =>
|
||||||
req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/group`, { name, memberIds }),
|
req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/group`, { name, memberIds }),
|
||||||
editMessage: (slug: string, roomId: string, msgId: string, text: string) =>
|
editMessage: (slug: string, roomId: string, msgId: string, text: string) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user