feat: chat — search, notifications room, settings
- Migration 071: nullable sender_id, is_system flag, notifications room type - Backend: notifications room auto-created per hotel, search messages endpoint, notify endpoint (manager/admin posts system messages), read-only enforcement - API: ChatSearchResult type, chat.search(), chat.notify(), updated ChatMessage type - Frontend ChatWidget: search view (people + messages tabs), settings panel (notifications visibility toggle, sound toggle), notifications room (bell icon, read-only banner, amber styling for system messages), new-chat button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
15
backend/migrations/071_chat_notifications.sql
Normal file
15
backend/migrations/071_chat_notifications.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Make sender_id nullable to support system messages
|
||||
ALTER TABLE chat_messages ALTER COLUMN sender_id DROP NOT NULL;
|
||||
|
||||
-- Add system message fields
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS is_system BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS system_name TEXT;
|
||||
|
||||
-- Expand room type to include notifications
|
||||
ALTER TABLE chat_rooms DROP CONSTRAINT IF EXISTS chat_rooms_type_check;
|
||||
ALTER TABLE chat_rooms ADD CONSTRAINT chat_rooms_type_check
|
||||
CHECK (type IN ('general', 'direct', 'notifications'));
|
||||
|
||||
-- One notifications room per hotel
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_chat_rooms_notifications_hotel
|
||||
ON chat_rooms(hotel_id) WHERE type = 'notifications';
|
||||
@@ -30,7 +30,24 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
return existing[0]?.id as string
|
||||
}
|
||||
|
||||
// GET /api/hotels/:slug/chat/rooms — list rooms (general + directs for current user)
|
||||
// Ensure notifications room exists for hotel
|
||||
const ensureNotificationsRoom = async (hotelId: string) => {
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO chat_rooms (hotel_id, type, name)
|
||||
VALUES ($1, 'notifications', 'Уведомления')
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id`,
|
||||
[hotelId],
|
||||
)
|
||||
if (rows[0]) return rows[0].id as string
|
||||
const { rows: existing } = await db.query(
|
||||
`SELECT id FROM chat_rooms WHERE hotel_id = $1 AND type = 'notifications'`,
|
||||
[hotelId],
|
||||
)
|
||||
return existing[0]?.id as string
|
||||
}
|
||||
|
||||
// GET /api/hotels/:slug/chat/rooms — list rooms (general + notifications + directs for current user)
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/chat/rooms',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
@@ -41,10 +58,10 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const generalRoomId = await ensureGeneralRoom(hotelId)
|
||||
await ensureGeneralRoom(hotelId)
|
||||
await ensureNotificationsRoom(hotelId)
|
||||
const userId = request.user.sub
|
||||
|
||||
// Get all rooms this user belongs to (general + directs)
|
||||
const { rows } = await db.query(
|
||||
`SELECT r.id, r.type, r.name,
|
||||
(SELECT COUNT(*) FROM chat_messages m
|
||||
@@ -53,23 +70,22 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
(SELECT rs.last_read FROM chat_read_status rs WHERE rs.room_id = r.id AND rs.user_id = $2),
|
||||
'1970-01-01'
|
||||
)
|
||||
AND m.sender_id != $2
|
||||
AND (m.sender_id != $2 OR m.sender_id IS NULL)
|
||||
) AS unread_count,
|
||||
(SELECT m.text FROM chat_messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_message,
|
||||
(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 u.name FROM chat_messages m 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 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,
|
||||
-- for direct rooms: get the other user's name
|
||||
(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 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
|
||||
FROM chat_rooms r
|
||||
WHERE r.hotel_id = $1
|
||||
AND (r.type = 'general' OR EXISTS (
|
||||
AND (r.type IN ('general', 'notifications') OR EXISTS (
|
||||
SELECT 1 FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id = $2
|
||||
))
|
||||
ORDER BY last_message_at DESC NULLS LAST, r.type = 'general' DESC`,
|
||||
[hotelId, userId],
|
||||
)
|
||||
void generalRoomId
|
||||
return rows
|
||||
},
|
||||
)
|
||||
@@ -88,9 +104,11 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const limit = Math.min(Number(request.query.limit ?? 50), 100)
|
||||
const { rows } = await db.query(
|
||||
`SELECT m.id, m.room_id, m.sender_id, m.text, m.created_at,
|
||||
u.name AS sender_name, u.role AS sender_role
|
||||
m.is_system, m.system_name,
|
||||
COALESCE(m.system_name, u.name) AS sender_name,
|
||||
COALESCE(u.role, 'system') AS sender_role
|
||||
FROM chat_messages m
|
||||
JOIN users u ON u.id = m.sender_id
|
||||
LEFT JOIN users u ON u.id = m.sender_id
|
||||
WHERE m.room_id = $1 AND m.hotel_id = $2
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $3`,
|
||||
@@ -118,6 +136,11 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
// Disallow posting to notifications room
|
||||
const { rows: roomRows } = await db.query('SELECT type FROM chat_rooms WHERE id = $1', [roomId])
|
||||
if (roomRows[0]?.type === 'notifications')
|
||||
return reply.code(403).send({ error: 'Cannot post to notifications room' })
|
||||
|
||||
const { text } = request.body
|
||||
if (!text?.trim()) return reply.code(400).send({ error: 'Text required' })
|
||||
|
||||
@@ -128,9 +151,8 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
[roomId, hotelId, request.user.sub, text.trim()],
|
||||
)
|
||||
const msg = rows[0]
|
||||
// Get sender name
|
||||
const { rows: uRows } = await db.query('SELECT name, role FROM users WHERE id = $1', [request.user.sub])
|
||||
const result = { ...msg, sender_name: uRows[0]?.name, sender_role: uRows[0]?.role }
|
||||
const result = { ...msg, sender_name: uRows[0]?.name, sender_role: uRows[0]?.role, is_system: false, system_name: null }
|
||||
return reply.code(201).send(result)
|
||||
},
|
||||
)
|
||||
@@ -147,7 +169,6 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const userId = request.user.sub
|
||||
// Check if direct room already exists between these two users
|
||||
const { rows: existing } = await db.query(
|
||||
`SELECT r.id FROM chat_rooms r
|
||||
JOIN chat_room_members m1 ON m1.room_id = r.id AND m1.user_id = $1
|
||||
@@ -158,7 +179,6 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
)
|
||||
if (existing[0]) return { room_id: existing[0].id }
|
||||
|
||||
// Create new direct room
|
||||
const { rows: [room] } = await db.query(
|
||||
`INSERT INTO chat_rooms (hotel_id, type) VALUES ($1, 'direct') RETURNING id`,
|
||||
[hotelId],
|
||||
@@ -185,6 +205,71 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
// GET /api/hotels/:slug/chat/search?q= — search messages across accessible rooms
|
||||
fastify.get<SlugParam & { Querystring: { q?: string } }>(
|
||||
'/api/hotels/:slug/chat/search',
|
||||
{ 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 q = (request.query.q ?? '').trim()
|
||||
if (!q) return []
|
||||
|
||||
const userId = request.user.sub
|
||||
const { rows } = await db.query(
|
||||
`SELECT m.id, m.room_id, m.text, m.created_at,
|
||||
COALESCE(m.system_name, u.name) AS sender_name,
|
||||
r.type AS room_type, r.name AS room_name,
|
||||
(SELECT u2.name FROM chat_room_members crm2 JOIN users u2 ON u2.id = crm2.user_id
|
||||
WHERE crm2.room_id = r.id AND crm2.user_id != $2 LIMIT 1) AS other_user_name
|
||||
FROM chat_messages m
|
||||
JOIN chat_rooms r ON r.id = m.room_id
|
||||
LEFT JOIN users u ON u.id = m.sender_id
|
||||
WHERE m.hotel_id = $1
|
||||
AND m.text ILIKE $3
|
||||
AND (r.type IN ('general', 'notifications') OR EXISTS (
|
||||
SELECT 1 FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id = $2
|
||||
))
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT 30`,
|
||||
[hotelId, userId, `%${q}%`],
|
||||
)
|
||||
return rows
|
||||
},
|
||||
)
|
||||
|
||||
// POST /api/hotels/:slug/chat/notify — post a system notification (manager/admin only)
|
||||
fastify.post<SlugParam & { Body: { text: string; system_name?: string } }>(
|
||||
'/api/hotels/:slug/chat/notify',
|
||||
{ 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' })
|
||||
if (!['super_admin', 'hotel_admin', 'manager'].includes(request.user.role))
|
||||
return reply.code(403).send({ error: 'Insufficient permissions' })
|
||||
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { text, system_name = 'Система' } = request.body
|
||||
if (!text?.trim()) return reply.code(400).send({ error: 'Text required' })
|
||||
|
||||
const notifRoomId = await ensureNotificationsRoom(hotelId)
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO chat_messages (room_id, hotel_id, sender_id, text, is_system, system_name)
|
||||
VALUES ($1, $2, NULL, $3, true, $4)
|
||||
RETURNING id, room_id, text, created_at, is_system, system_name`,
|
||||
[notifRoomId, hotelId, text.trim(), system_name],
|
||||
)
|
||||
return reply.code(201).send({ ...rows[0], sender_name: system_name, sender_role: 'system' })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default chatRoutes
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { MessageSquare, X, ChevronLeft, Send, Users, Loader2 } from 'lucide-react'
|
||||
import { api, type ChatRoom, type ChatMessage } from '../../lib/api'
|
||||
import {
|
||||
MessageSquare, X, ChevronLeft, Send, Users, Loader2,
|
||||
Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX,
|
||||
} from 'lucide-react'
|
||||
import { api, type ChatRoom, type ChatMessage, type ChatSearchResult } from '../../lib/api'
|
||||
import type { User } from '../../types'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
// Color for avatar based on name
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function avatarColor(name: string) {
|
||||
const colors = ['#4F46E5','#059669','#2563EB','#7C3AED','#DC2626','#D97706','#DB2777','#0891B2']
|
||||
let h = 0
|
||||
@@ -17,7 +22,11 @@ function initials(name: string) {
|
||||
}
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const today = now.toDateString() === d.toDateString()
|
||||
if (today) return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function Avatar({ name, size = 28 }: { name: string; size?: number }) {
|
||||
@@ -29,11 +38,46 @@ function Avatar({ name, size = 28 }: { name: string; size?: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SystemAvatar({ size = 28 }: { size?: number }) {
|
||||
return (
|
||||
<div style={{ width: size, height: size, fontSize: size * 0.45 }}
|
||||
className="rounded-full flex items-center justify-center shrink-0 bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
|
||||
<Bell size={size * 0.5} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface ChatSettings {
|
||||
notifVisible: boolean
|
||||
soundEnabled: boolean
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = 'hotelsync-chat-settings'
|
||||
|
||||
function loadSettings(): ChatSettings {
|
||||
try {
|
||||
const s = localStorage.getItem(SETTINGS_KEY)
|
||||
if (s) return { notifVisible: true, soundEnabled: false, ...JSON.parse(s) as Partial<ChatSettings> }
|
||||
} catch { /* ignore */ }
|
||||
return { notifVisible: true, soundEnabled: false }
|
||||
}
|
||||
|
||||
function saveSettings(s: ChatSettings) {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(s))
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────
|
||||
|
||||
type View = 'rooms' | 'messages' | 'search' | 'settings'
|
||||
|
||||
export function ChatWidget() {
|
||||
const { user } = useAuth()
|
||||
const slug = user?.hotelSlug ?? ''
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState<'rooms' | 'messages'>('rooms')
|
||||
const [view, setView] = useState<View>('rooms')
|
||||
const [rooms, setRooms] = useState<ChatRoom[]>([])
|
||||
const [activeRoom, setActiveRoom] = useState<ChatRoom | null>(null)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
@@ -41,11 +85,33 @@ export function ChatWidget() {
|
||||
const [loadingRooms, setLoadingRooms] = useState(false)
|
||||
const [loadingMsgs, setLoadingMsgs] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchUsers, setSearchUsers] = useState<User[]>([])
|
||||
const [searchResults, setSearchResults] = useState<ChatSearchResult[]>([])
|
||||
const [searchTab, setSearchTab] = useState<'people' | 'messages'>('people')
|
||||
const [loadingSearch, setLoadingSearch] = useState(false)
|
||||
const [allUsers, setAllUsers] = useState<User[]>([])
|
||||
|
||||
// Settings
|
||||
const [settings, setSettings] = useState<ChatSettings>(loadSettings)
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0)
|
||||
|
||||
// ── Filtered rooms (exclude notifications if hidden, apply room filter) ─
|
||||
|
||||
const visibleRooms = rooms.filter(r => {
|
||||
if (r.type === 'notifications' && !settings.notifVisible) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// ── Data loaders ────────────────────────────────────────────────────────
|
||||
|
||||
const loadRooms = useCallback(async () => {
|
||||
if (!slug) return
|
||||
try {
|
||||
@@ -54,7 +120,7 @@ export function ChatWidget() {
|
||||
} catch { /* ignore */ }
|
||||
}, [slug])
|
||||
|
||||
// Poll for new messages every 5s when open
|
||||
// Poll rooms when open
|
||||
useEffect(() => {
|
||||
if (!open || !slug) return
|
||||
setLoadingRooms(true)
|
||||
@@ -63,20 +129,7 @@ export function ChatWidget() {
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||
}, [open, slug, loadRooms])
|
||||
|
||||
const openRoom = async (room: ChatRoom) => {
|
||||
setActiveRoom(room)
|
||||
setView('messages')
|
||||
setLoadingMsgs(true)
|
||||
try {
|
||||
const msgs = await api.chat.getMessages(slug, room.id)
|
||||
setMessages(msgs)
|
||||
await api.chat.markRead(slug, room.id)
|
||||
setRooms(prev => prev.map(r => r.id === room.id ? { ...r, unreadCount: 0 } : r))
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingMsgs(false) }
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
// Auto-scroll on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
@@ -93,6 +146,93 @@ export function ChatWidget() {
|
||||
return () => clearInterval(interval)
|
||||
}, [view, activeRoom, slug])
|
||||
|
||||
// Load all users once when search view opens
|
||||
useEffect(() => {
|
||||
if (view !== 'search' || !slug) return
|
||||
api.users.list(slug).then(setAllUsers).catch(() => { /* ignore */ })
|
||||
}, [view, slug])
|
||||
|
||||
// Search effect
|
||||
useEffect(() => {
|
||||
if (view !== 'search') return
|
||||
const q = searchQuery.trim()
|
||||
if (!q) {
|
||||
setSearchUsers([])
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
const ql = q.toLowerCase()
|
||||
// People: filter client-side from allUsers
|
||||
const matched = allUsers.filter(u =>
|
||||
u.id !== user?.id &&
|
||||
(u.name.toLowerCase().includes(ql) || u.email.toLowerCase().includes(ql)),
|
||||
)
|
||||
setSearchUsers(matched)
|
||||
|
||||
// Messages: debounce + API
|
||||
const timer = setTimeout(async () => {
|
||||
if (searchTab !== 'messages') return
|
||||
setLoadingSearch(true)
|
||||
try {
|
||||
const results = await api.chat.search(slug, q)
|
||||
setSearchResults(results)
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingSearch(false) }
|
||||
}, 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchQuery, view, allUsers, user?.id, slug, searchTab])
|
||||
|
||||
// Focus search input when search view opens
|
||||
useEffect(() => {
|
||||
if (view === 'search') {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [view])
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
const openRoom = async (room: ChatRoom) => {
|
||||
setActiveRoom(room)
|
||||
setView('messages')
|
||||
setLoadingMsgs(true)
|
||||
try {
|
||||
const msgs = await api.chat.getMessages(slug, room.id)
|
||||
setMessages(msgs)
|
||||
await api.chat.markRead(slug, room.id)
|
||||
setRooms(prev => prev.map(r => r.id === room.id ? { ...r, unreadCount: 0 } : r))
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingMsgs(false) }
|
||||
}
|
||||
|
||||
const openDirect = async (targetUser: User) => {
|
||||
try {
|
||||
const { roomId } = await api.chat.openDirect(slug, targetUser.id)
|
||||
await loadRooms()
|
||||
const room: ChatRoom = {
|
||||
id: roomId,
|
||||
type: 'direct',
|
||||
name: null,
|
||||
unreadCount: 0,
|
||||
lastMessage: null,
|
||||
lastMessageAt: null,
|
||||
lastSender: null,
|
||||
otherUserName: targetUser.name,
|
||||
otherUserId: targetUser.id,
|
||||
}
|
||||
await openRoom(room)
|
||||
setSearchQuery('')
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const openSearchResult = async (result: ChatSearchResult) => {
|
||||
const room = rooms.find(r => r.id === result.roomId)
|
||||
if (room) {
|
||||
setSearchQuery('')
|
||||
setView('rooms')
|
||||
await openRoom(room)
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!text.trim() || !activeRoom || sending) return
|
||||
const t = text.trim()
|
||||
@@ -115,8 +255,30 @@ export function ChatWidget() {
|
||||
}
|
||||
}
|
||||
|
||||
const updateSettings = (patch: Partial<ChatSettings>) => {
|
||||
const next = { ...settings, ...patch }
|
||||
setSettings(next)
|
||||
saveSettings(next)
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
if (view === 'messages') { setView('rooms'); setActiveRoom(null) }
|
||||
else if (view === 'search') { setView('rooms'); setSearchQuery('') }
|
||||
else if (view === 'settings') setView('rooms')
|
||||
}
|
||||
|
||||
// ── Room name helper ────────────────────────────────────────────────────
|
||||
|
||||
const roomDisplayName = (room: ChatRoom) => {
|
||||
if (room.type === 'general') return 'Общий чат'
|
||||
if (room.type === 'notifications') return 'Уведомления'
|
||||
return room.otherUserName ?? 'Чат'
|
||||
}
|
||||
|
||||
if (!slug) return null
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating button */}
|
||||
@@ -144,40 +306,58 @@ export function ChatWidget() {
|
||||
'w-80 bg-white dark:bg-slate-800 rounded-2xl shadow-2xl',
|
||||
'border border-slate-200 dark:border-slate-700',
|
||||
'flex flex-col overflow-hidden',
|
||||
'transition-all',
|
||||
)} style={{ height: 480 }}>
|
||||
)} style={{ height: 520 }}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-brand-600 text-white rounded-t-2xl shrink-0">
|
||||
{view === 'messages' && (
|
||||
<button onClick={() => { setView('rooms'); setActiveRoom(null) }} className="p-1 hover:bg-white/20 rounded-lg transition-colors">
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center gap-2 px-3 py-3 border-b border-slate-200 dark:border-slate-700 bg-brand-600 text-white rounded-t-2xl shrink-0">
|
||||
{view !== 'rooms' && (
|
||||
<button onClick={goBack} className="p-1 hover:bg-white/20 rounded-lg transition-colors shrink-0">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm truncate">
|
||||
{view === 'rooms' ? 'Чат сотрудников' : (activeRoom?.type === 'general' ? 'Общий чат' : activeRoom?.otherUserName ?? 'Чат')}
|
||||
{view === 'rooms' && 'Чат сотрудников'}
|
||||
{view === 'messages' && (activeRoom ? roomDisplayName(activeRoom) : 'Чат')}
|
||||
{view === 'search' && 'Поиск'}
|
||||
{view === 'settings' && 'Настройки чата'}
|
||||
</p>
|
||||
{view === 'rooms' && (
|
||||
<p className="text-xs text-white/70">{rooms.length} чатов</p>
|
||||
<p className="text-xs text-white/70">{visibleRooms.length} чатов</p>
|
||||
)}
|
||||
{view === 'messages' && activeRoom?.type === 'notifications' && (
|
||||
<p className="text-xs text-white/70">Только чтение</p>
|
||||
)}
|
||||
</div>
|
||||
<Users size={16} className="opacity-70" />
|
||||
{/* Header actions for rooms view */}
|
||||
{view === 'rooms' && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setView('search')} title="Поиск" className="p-1.5 hover:bg-white/20 rounded-lg transition-colors">
|
||||
<Search size={15} />
|
||||
</button>
|
||||
<button onClick={() => setView('search')} title="Новое сообщение" className="p-1.5 hover:bg-white/20 rounded-lg transition-colors">
|
||||
<PenSquare size={15} />
|
||||
</button>
|
||||
<button onClick={() => setView('settings')} title="Настройки" className="p-1.5 hover:bg-white/20 rounded-lg transition-colors">
|
||||
<Settings size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rooms list */}
|
||||
{/* ── Rooms list ── */}
|
||||
{view === 'rooms' && (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loadingRooms ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 size={20} className="animate-spin text-slate-400" />
|
||||
</div>
|
||||
) : rooms.length === 0 ? (
|
||||
) : visibleRooms.length === 0 ? (
|
||||
<div className="text-center py-10 text-sm text-slate-400 px-4">
|
||||
Нет чатов. Начните общение!
|
||||
Нет чатов. Нажмите <PenSquare size={13} className="inline" /> чтобы начать.
|
||||
</div>
|
||||
) : (
|
||||
rooms.map(room => (
|
||||
visibleRooms.map(room => (
|
||||
<button
|
||||
key={room.id}
|
||||
onClick={() => openRoom(room)}
|
||||
@@ -188,6 +368,10 @@ export function ChatWidget() {
|
||||
<div className="w-9 h-9 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<Users size={16} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
) : room.type === 'notifications' ? (
|
||||
<div className="w-9 h-9 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<Bell size={16} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
) : (
|
||||
<Avatar name={room.otherUserName ?? '?'} size={36} />
|
||||
)}
|
||||
@@ -200,17 +384,16 @@ export function ChatWidget() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className={cn('text-sm font-medium truncate', Number(room.unreadCount) > 0 ? 'text-slate-900 dark:text-slate-100' : 'text-slate-700 dark:text-slate-300')}>
|
||||
{room.type === 'general' ? 'Общий чат' : room.otherUserName}
|
||||
{roomDisplayName(room)}
|
||||
</p>
|
||||
{room.lastMessageAt && (
|
||||
<span className="text-[10px] text-slate-400 shrink-0 ml-1">
|
||||
{fmtTime(room.lastMessageAt)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 shrink-0 ml-1">{fmtTime(room.lastMessageAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
{room.lastMessage && (
|
||||
<p className={cn('text-xs truncate mt-0.5', Number(room.unreadCount) > 0 ? 'text-slate-600 dark:text-slate-300 font-medium' : 'text-slate-400 dark:text-slate-500')}>
|
||||
{room.lastSender && room.type === 'general' ? `${room.lastSender.split(' ')[0]}: ` : ''}{room.lastMessage}
|
||||
{room.lastSender && room.type === 'general' ? `${room.lastSender.split(' ')[0]}: ` : ''}
|
||||
{room.lastMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -220,7 +403,132 @@ export function ChatWidget() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages view */}
|
||||
{/* ── Search ── */}
|
||||
{view === 'search' && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Search input */}
|
||||
<div className="px-3 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-2 bg-slate-100 dark:bg-slate-700 rounded-xl px-3 py-2">
|
||||
<Search size={14} className="text-slate-400 shrink-0" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Люди или сообщения..."
|
||||
className="flex-1 bg-transparent text-sm text-slate-800 dark:text-slate-200 placeholder-slate-400 outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button onClick={() => setSearchQuery('')} className="text-slate-400 hover:text-slate-600">
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex px-3 gap-1 shrink-0">
|
||||
{(['people', 'messages'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setSearchTab(tab)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 text-xs font-medium rounded-lg transition-colors',
|
||||
searchTab === tab
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700',
|
||||
)}
|
||||
>
|
||||
{tab === 'people' ? 'Люди' : 'Сообщения'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-y-auto mt-2">
|
||||
{searchTab === 'people' && (
|
||||
<>
|
||||
{!searchQuery ? (
|
||||
// Show all users when no query
|
||||
allUsers.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-slate-400">Загрузка...</div>
|
||||
) : (
|
||||
allUsers.filter(u => u.id !== user?.id).map(u => (
|
||||
<UserRow key={u.id} user={u} onClick={() => openDirect(u)} />
|
||||
))
|
||||
)
|
||||
) : searchUsers.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-slate-400">Никого не найдено</div>
|
||||
) : (
|
||||
searchUsers.map(u => (
|
||||
<UserRow key={u.id} user={u} onClick={() => openDirect(u)} />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{searchTab === 'messages' && (
|
||||
<>
|
||||
{!searchQuery ? (
|
||||
<div className="text-center py-6 text-xs text-slate-400">Введите запрос для поиска</div>
|
||||
) : loadingSearch ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 size={18} className="animate-spin text-slate-400" />
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-slate-400">Ничего не найдено</div>
|
||||
) : (
|
||||
searchResults.map(r => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => openSearchResult(r)}
|
||||
className="w-full text-left px-4 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors border-b border-slate-100 dark:border-slate-700/50"
|
||||
>
|
||||
<p className="text-xs text-slate-500 mb-0.5">
|
||||
{r.roomType === 'general' ? 'Общий чат' : r.roomType === 'notifications' ? 'Уведомления' : r.otherUserName ?? 'Чат'}
|
||||
{' · '}{r.senderName.split(' ')[0]}
|
||||
</p>
|
||||
<p className="text-sm text-slate-800 dark:text-slate-200 truncate">{r.text}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">{fmtTime(r.createdAt)}</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Settings ── */}
|
||||
{view === 'settings' && (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-5">
|
||||
<SettingsSection title="Канал уведомлений">
|
||||
<ToggleRow
|
||||
icon={settings.notifVisible ? <Bell size={15} /> : <BellOff size={15} />}
|
||||
label="Показывать канал уведомлений"
|
||||
checked={settings.notifVisible}
|
||||
onChange={v => updateSettings({ notifVisible: v })}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Звук">
|
||||
<ToggleRow
|
||||
icon={settings.soundEnabled ? <Volume2 size={15} /> : <VolumeX size={15} />}
|
||||
label="Звуковое уведомление"
|
||||
checked={settings.soundEnabled}
|
||||
onChange={v => updateSettings({ soundEnabled: v })}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="О чате">
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
Чат сотрудников доступен всем пользователям отеля.
|
||||
Канал «Уведомления» содержит автоматические сообщения о событиях в системе.
|
||||
</p>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Messages ── */}
|
||||
{view === 'messages' && (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
|
||||
@@ -230,23 +538,32 @@ export function ChatWidget() {
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center py-10 text-sm text-slate-400">
|
||||
Нет сообщений. Напишите первым!
|
||||
{activeRoom?.type === 'notifications' ? 'Уведомлений пока нет' : 'Нет сообщений. Напишите первым!'}
|
||||
</div>
|
||||
) : (
|
||||
messages.map(msg => {
|
||||
const isOwn = msg.senderId === user?.id
|
||||
const isSystem = msg.isSystem
|
||||
const isOwn = !isSystem && msg.senderId === user?.id
|
||||
return (
|
||||
<div key={msg.id} className={cn('flex gap-2', isOwn && 'flex-row-reverse')}>
|
||||
{!isOwn && <Avatar name={msg.senderName} size={24} />}
|
||||
<div className={cn('max-w-[75%]', isOwn && 'items-end flex flex-col')}>
|
||||
{!isOwn && (
|
||||
isSystem
|
||||
? <SystemAvatar size={24} />
|
||||
: <Avatar name={msg.senderName} size={24} />
|
||||
)}
|
||||
<div className={cn('max-w-[80%]', isOwn && 'items-end flex flex-col')}>
|
||||
{!isOwn && (
|
||||
<p className="text-[10px] text-slate-400 mb-0.5 ml-1">{msg.senderName.split(' ')[0]}</p>
|
||||
<p className="text-[10px] text-slate-400 mb-0.5 ml-1">
|
||||
{isSystem ? msg.systemName ?? 'Система' : msg.senderName.split(' ')[0]}
|
||||
</p>
|
||||
)}
|
||||
<div className={cn(
|
||||
'px-3 py-2 rounded-2xl text-sm',
|
||||
isOwn
|
||||
? 'bg-brand-600 text-white rounded-tr-sm'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 rounded-tl-sm',
|
||||
isSystem
|
||||
? 'bg-amber-50 dark:bg-amber-900/20 text-amber-900 dark:text-amber-200 border border-amber-200 dark:border-amber-800 rounded-tl-sm'
|
||||
: isOwn
|
||||
? 'bg-brand-600 text-white rounded-tr-sm'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 rounded-tl-sm',
|
||||
)}>
|
||||
{msg.text}
|
||||
</div>
|
||||
@@ -259,32 +576,43 @@ export function ChatWidget() {
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-3 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Сообщение..."
|
||||
rows={1}
|
||||
className={cn(
|
||||
'flex-1 resize-none rounded-xl px-3 py-2 text-sm',
|
||||
'bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100',
|
||||
'placeholder-slate-400 outline-none',
|
||||
'max-h-24 overflow-y-auto',
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void sendMessage()}
|
||||
disabled={!text.trim() || sending}
|
||||
className="p-2.5 rounded-xl bg-brand-600 hover:bg-brand-700 disabled:opacity-40 text-white transition-colors shrink-0"
|
||||
>
|
||||
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
</button>
|
||||
{/* Input — hidden for notifications room */}
|
||||
{activeRoom?.type !== 'notifications' && (
|
||||
<div className="px-3 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Сообщение..."
|
||||
rows={1}
|
||||
className={cn(
|
||||
'flex-1 resize-none rounded-xl px-3 py-2 text-sm',
|
||||
'bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100',
|
||||
'placeholder-slate-400 outline-none',
|
||||
'max-h-24 overflow-y-auto',
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void sendMessage()}
|
||||
disabled={!text.trim() || sending}
|
||||
className="p-2.5 rounded-xl bg-brand-600 hover:bg-brand-700 disabled:opacity-40 text-white transition-colors shrink-0"
|
||||
>
|
||||
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-1.5">Enter — отправить, Shift+Enter — перенос</p>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-1.5">Enter — отправить, Shift+Enter — перенос</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notifications room banner */}
|
||||
{activeRoom?.type === 'notifications' && (
|
||||
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700 bg-amber-50 dark:bg-amber-900/10 shrink-0">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 text-center flex items-center justify-center gap-1.5">
|
||||
<Bell size={12} /> Канал только для чтения — автоматические уведомления
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -292,3 +620,64 @@ export function ChatWidget() {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────
|
||||
|
||||
function UserRow({ user, onClick }: { user: User; onClick: () => void }) {
|
||||
const roleLabels: Record<string, string> = {
|
||||
hotel_admin: 'Администратор',
|
||||
manager: 'Менеджер',
|
||||
housekeeper: 'Горничная',
|
||||
receptionist: 'Ресепшн',
|
||||
accountant: 'Бухгалтер',
|
||||
security: 'Охрана',
|
||||
technician: 'Техник',
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors border-b border-slate-100 dark:border-slate-700/50 text-left"
|
||||
>
|
||||
<Avatar name={user.name} size={32} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{user.name}</p>
|
||||
<p className="text-xs text-slate-400 truncate">{roleLabels[user.role] ?? user.role}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">{title}</p>
|
||||
<div className="space-y-1">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleRow({ icon, label, checked, onChange }: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<span className="text-slate-500 shrink-0">{icon}</span>
|
||||
<span className="flex-1 text-sm text-slate-700 dark:text-slate-300">{label}</span>
|
||||
<button
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'relative w-10 h-5 rounded-full transition-colors shrink-0',
|
||||
checked ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600',
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform',
|
||||
checked ? 'translate-x-5' : 'translate-x-0.5',
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -340,7 +340,11 @@ export const api = {
|
||||
markRead: (slug: string, roomId: string) =>
|
||||
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/read`),
|
||||
openDirect: (slug: string, otherUserId: string) =>
|
||||
req<{ room_id: string }>('POST', `/api/hotels/${slug}/chat/direct/${otherUserId}`),
|
||||
req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/direct/${otherUserId}`),
|
||||
search: (slug: string, q: string) =>
|
||||
req<ChatSearchResult[]>('GET', `/api/hotels/${slug}/chat/search?q=${encodeURIComponent(q)}`),
|
||||
notify: (slug: string, text: string, systemName?: string) =>
|
||||
req<ChatMessage>('POST', `/api/hotels/${slug}/chat/notify`, { text, system_name: systemName }),
|
||||
},
|
||||
|
||||
// ── Workstations ──────────────────────────────────────────────────────────
|
||||
@@ -1302,7 +1306,7 @@ export interface LoyaltyTransaction {
|
||||
|
||||
export interface ChatRoom {
|
||||
id: string
|
||||
type: 'general' | 'direct'
|
||||
type: 'general' | 'direct' | 'notifications'
|
||||
name: string | null
|
||||
unreadCount: number
|
||||
lastMessage: string | null
|
||||
@@ -1315,11 +1319,24 @@ export interface ChatRoom {
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
roomId: string
|
||||
senderId: string
|
||||
senderId: string | null
|
||||
senderName: string
|
||||
senderRole: string
|
||||
text: string
|
||||
createdAt: string
|
||||
isSystem: boolean
|
||||
systemName: string | null
|
||||
}
|
||||
|
||||
export interface ChatSearchResult {
|
||||
id: string
|
||||
roomId: string
|
||||
text: string
|
||||
createdAt: string
|
||||
senderName: string
|
||||
roomType: 'general' | 'direct' | 'notifications'
|
||||
roomName: string | null
|
||||
otherUserName: string | null
|
||||
}
|
||||
|
||||
export interface WorkstationDevice {
|
||||
|
||||
Reference in New Issue
Block a user