diff --git a/backend/migrations/038_loyalty_settings.sql b/backend/migrations/038_loyalty_settings.sql new file mode 100644 index 0000000..d433f18 --- /dev/null +++ b/backend/migrations/038_loyalty_settings.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS loyalty_settings ( + hotel_id UUID PRIMARY KEY REFERENCES hotels(id) ON DELETE CASCADE, + is_active BOOLEAN NOT NULL DEFAULT true, + points_per_ruble NUMERIC(10,4) NOT NULL DEFAULT 0.1, + point_value NUMERIC(10,4) NOT NULL DEFAULT 0.01, + expiry_months INT NOT NULL DEFAULT 12, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS loyalty_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + guest_id UUID NOT NULL REFERENCES guests(id) ON DELETE CASCADE, + amount INT NOT NULL, -- positive = accrual, negative = spend + reason VARCHAR(100) NOT NULL DEFAULT 'manual', + notes TEXT, + staff_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Ensure guests have loyalty columns (may already exist) +ALTER TABLE guests ADD COLUMN IF NOT EXISTS loyalty_tier VARCHAR(20) NOT NULL DEFAULT 'bronze'; +ALTER TABLE guests ADD COLUMN IF NOT EXISTS loyalty_points INT NOT NULL DEFAULT 0; diff --git a/backend/migrations/039_chat.sql b/backend/migrations/039_chat.sql new file mode 100644 index 0000000..0161017 --- /dev/null +++ b/backend/migrations/039_chat.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS chat_rooms ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + type VARCHAR(10) NOT NULL DEFAULT 'general' CHECK (type IN ('general', 'direct')), + name VARCHAR(100), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS chat_room_members ( + room_id UUID NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (room_id, user_id) +); + +CREATE TABLE IF NOT EXISTS chat_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + room_id UUID NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE, + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_chat_messages_room ON chat_messages(room_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS chat_read_status ( + room_id UUID NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + last_read TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (room_id, user_id) +); diff --git a/backend/src/app.ts b/backend/src/app.ts index 35f8dcd..96bf411 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -31,6 +31,8 @@ import uploadRoutes from './routes/upload' import housekeepingSettingsRoutes from './routes/housekeeping-settings' import notificationsRoutes from './routes/notifications' import scheduleRoutes from './routes/schedule' +import loyaltyRoutes from './routes/loyalty' +import chatRoutes from './routes/chat' import { startJobs } from './jobs' export async function buildApp() { @@ -107,6 +109,8 @@ export async function buildApp() { await fastify.register(housekeepingSettingsRoutes) await fastify.register(notificationsRoutes) await fastify.register(scheduleRoutes) + await fastify.register(loyaltyRoutes) + await fastify.register(chatRoutes) startJobs() diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts new file mode 100644 index 0000000..2fc0d3f --- /dev/null +++ b/backend/src/routes/chat.ts @@ -0,0 +1,190 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type RoomParam = { Params: { slug: string; roomId: string } } + +const chatRoutes: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string) => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id as string | undefined + } + + const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + + // Ensure general room exists for hotel + const ensureGeneralRoom = async (hotelId: string) => { + const { rows } = await db.query( + `INSERT INTO chat_rooms (hotel_id, type, name) + VALUES ($1, 'general', 'Общий чат') + 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 = 'general'`, + [hotelId], + ) + return existing[0]?.id as string + } + + // GET /api/hotels/:slug/chat/rooms — list rooms (general + directs for current user) + fastify.get( + '/api/hotels/:slug/chat/rooms', + { 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 generalRoomId = await ensureGeneralRoom(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 + WHERE m.room_id = r.id + AND m.created_at > COALESCE( + (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 + ) 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, + -- 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 ( + 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 + }, + ) + + // GET /api/hotels/:slug/chat/rooms/:roomId/messages + fastify.get( + '/api/hotels/:slug/chat/rooms/:roomId/messages', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, roomId } = 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 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 + FROM chat_messages m + 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`, + [roomId, hotelId, limit], + ) + // Update read status + await db.query( + `INSERT INTO chat_read_status (room_id, user_id, last_read) + VALUES ($1, $2, NOW()) + ON CONFLICT (room_id, user_id) DO UPDATE SET last_read = NOW()`, + [roomId, request.user.sub], + ) + return rows.reverse() // chronological order + }, + ) + + // POST /api/hotels/:slug/chat/rooms/:roomId/messages + fastify.post( + '/api/hotels/:slug/chat/rooms/:roomId/messages', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, roomId } = 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 { text } = request.body + if (!text?.trim()) return reply.code(400).send({ error: 'Text required' }) + + const { rows } = await db.query( + `INSERT INTO chat_messages (room_id, hotel_id, sender_id, text) + VALUES ($1, $2, $3, $4) + RETURNING id, room_id, sender_id, text, created_at`, + [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 } + return reply.code(201).send(result) + }, + ) + + // POST /api/hotels/:slug/chat/direct/:otherUserId — create/get direct room + fastify.post<{ Params: { slug: string; otherUserId: string } }>( + '/api/hotels/:slug/chat/direct/:otherUserId', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, otherUserId } = 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 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 + JOIN chat_room_members m2 ON m2.room_id = r.id AND m2.user_id = $2 + WHERE r.hotel_id = $3 AND r.type = 'direct' + LIMIT 1`, + [userId, otherUserId, hotelId], + ) + 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], + ) + await db.query( + `INSERT INTO chat_room_members (room_id, user_id) VALUES ($1, $2), ($1, $3)`, + [room.id, userId, otherUserId], + ) + return reply.code(201).send({ room_id: room.id }) + }, + ) + + // PATCH /api/hotels/:slug/chat/rooms/:roomId/read — mark as read + fastify.patch( + '/api/hotels/:slug/chat/rooms/:roomId/read', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { roomId } = request.params + await db.query( + `INSERT INTO chat_read_status (room_id, user_id, last_read) VALUES ($1, $2, NOW()) + ON CONFLICT (room_id, user_id) DO UPDATE SET last_read = NOW()`, + [roomId, request.user.sub], + ) + return { ok: true } + }, + ) +} + +export default chatRoutes diff --git a/backend/src/routes/loyalty.ts b/backend/src/routes/loyalty.ts new file mode 100644 index 0000000..ba392e1 --- /dev/null +++ b/backend/src/routes/loyalty.ts @@ -0,0 +1,271 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +type SlugParam = { Params: { slug: string } } +type SlugGuestParam = { Params: { slug: string; guestId: string } } + +interface LoyaltySettingsRow { + is_active: boolean + points_per_ruble: string + point_value: string + expiry_months: number +} + +interface LoyaltySettingsBody { + isActive?: boolean + pointsPerRuble?: number + pointValue?: number + expiryMonths?: number +} + +interface AddPointsBody { + amount: number + reason: string + notes?: string +} + +const DEFAULT_SETTINGS: LoyaltySettingsRow = { + is_active: true, + points_per_ruble: '0.1', + point_value: '0.01', + expiry_months: 12, +} + +function calcTier(points: number): string { + if (points >= 15000) return 'platinum' + if (points >= 5000) return 'gold' + if (points >= 1000) return 'silver' + return 'bronze' +} + +function formatSettings(row: LoyaltySettingsRow) { + return { + isActive: row.is_active, + pointsPerRuble: parseFloat(row.points_per_ruble), + pointValue: parseFloat(row.point_value), + expiryMonths: row.expiry_months, + } +} + +const loyaltyRoutes: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id ?? null + } + + const canAccess = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || userSlug === slug + + // GET /api/hotels/:slug/loyalty/settings + fastify.get( + '/api/hotels/:slug/loyalty/settings', + { 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 is_active, points_per_ruble, point_value, expiry_months FROM loyalty_settings WHERE hotel_id = $1', + [hotelId], + ) + return formatSettings(rows[0] ?? DEFAULT_SETTINGS) + }, + ) + + // PATCH /api/hotels/:slug/loyalty/settings + fastify.patch( + '/api/hotels/:slug/loyalty/settings', + { 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 { isActive, pointsPerRuble, pointValue, expiryMonths } = request.body + + // Fetch existing first + const { rows: existing } = await db.query( + 'SELECT is_active, points_per_ruble, point_value, expiry_months FROM loyalty_settings WHERE hotel_id = $1', + [hotelId], + ) + const cur = existing[0] ?? DEFAULT_SETTINGS + + const newIsActive = isActive !== undefined ? isActive : cur.is_active + const newPointsPerRuble = pointsPerRuble !== undefined ? pointsPerRuble : parseFloat(cur.points_per_ruble) + const newPointValue = pointValue !== undefined ? pointValue : parseFloat(cur.point_value) + const newExpiryMonths = expiryMonths !== undefined ? expiryMonths : cur.expiry_months + + await db.query( + `INSERT INTO loyalty_settings (hotel_id, is_active, points_per_ruble, point_value, expiry_months, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (hotel_id) DO UPDATE SET + is_active = EXCLUDED.is_active, + points_per_ruble = EXCLUDED.points_per_ruble, + point_value = EXCLUDED.point_value, + expiry_months = EXCLUDED.expiry_months, + updated_at = NOW()`, + [hotelId, newIsActive, newPointsPerRuble, newPointValue, newExpiryMonths], + ) + + const { rows } = await db.query( + 'SELECT is_active, points_per_ruble, point_value, expiry_months FROM loyalty_settings WHERE hotel_id = $1', + [hotelId], + ) + return formatSettings(rows[0]) + }, + ) + + // GET /api/hotels/:slug/loyalty/guests + fastify.get( + '/api/hotels/:slug/loyalty/guests', + { 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 + let query: string + let params: unknown[] + + if (q && q.trim()) { + query = ` + SELECT id, first_name, last_name, middle_name, email, phone, loyalty_points, loyalty_tier, total_spent + FROM guests + WHERE hotel_id = $1 + AND ( + lower(first_name || ' ' || last_name) LIKE lower($2) + OR lower(email) LIKE lower($2) + OR regexp_replace(phone, '[^0-9]', '', 'g') LIKE $3 + ) + ORDER BY loyalty_points DESC + LIMIT 100` + const likeQ = `%${q.trim()}%` + const digitsQ = `%${q.replace(/\D/g, '')}%` + params = [hotelId, likeQ, digitsQ] + } else { + query = ` + SELECT id, first_name, last_name, middle_name, email, phone, loyalty_points, loyalty_tier, total_spent + FROM guests + WHERE hotel_id = $1 + ORDER BY loyalty_points DESC + LIMIT 100` + params = [hotelId] + } + + const { rows } = await db.query(query, params) + return rows.map((g: Record) => ({ + id: g.id, + name: [g.last_name, g.first_name, g.middle_name].filter(Boolean).join(' '), + email: g.email ?? null, + phone: g.phone ?? null, + loyaltyPoints: Number(g.loyalty_points) || 0, + loyaltyTier: g.loyalty_tier ?? 'bronze', + totalSpent: parseFloat(String(g.total_spent)) || 0, + })) + }, + ) + + // POST /api/hotels/:slug/loyalty/guests/:guestId/points + fastify.post( + '/api/hotels/:slug/loyalty/guests/:guestId/points', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, guestId } = 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 { amount, reason, notes } = request.body + if (typeof amount !== 'number' || !reason) + return reply.code(400).send({ error: 'amount and reason are required' }) + + // Get current guest + const { rows: gRows } = await db.query( + 'SELECT id, first_name, last_name, middle_name, email, phone, loyalty_points, loyalty_tier, total_spent FROM guests WHERE id = $1 AND hotel_id = $2', + [guestId, hotelId], + ) + if (!gRows[0]) return reply.code(404).send({ error: 'Guest not found' }) + const guest = gRows[0] + + const newPoints = Math.max(0, (Number(guest.loyalty_points) || 0) + amount) + const newTier = calcTier(newPoints) + + await db.query( + 'UPDATE guests SET loyalty_points = $1, loyalty_tier = $2 WHERE id = $3', + [newPoints, newTier, guestId], + ) + + await db.query( + `INSERT INTO loyalty_transactions (hotel_id, guest_id, amount, reason, notes, staff_id) + VALUES ($1, $2, $3, $4, $5, $6)`, + [hotelId, guestId, amount, reason, notes ?? null, request.user.userId ?? null], + ) + + return { + id: guest.id, + name: [guest.last_name, guest.first_name, guest.middle_name].filter(Boolean).join(' '), + email: guest.email ?? null, + phone: guest.phone ?? null, + loyaltyPoints: newPoints, + loyaltyTier: newTier, + totalSpent: parseFloat(String(guest.total_spent)) || 0, + } + }, + ) + + // GET /api/hotels/:slug/loyalty/transactions + fastify.get( + '/api/hotels/:slug/loyalty/transactions', + { 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 limit = Math.min(parseInt(request.query.limit ?? '50') || 50, 200) + + const { rows } = await db.query( + `SELECT + lt.id, + lt.guest_id, + (g.last_name || ' ' || g.first_name) AS guest_name, + lt.amount, + lt.reason, + lt.notes, + u.name AS staff_name, + lt.created_at + FROM loyalty_transactions lt + JOIN guests g ON g.id = lt.guest_id + LEFT JOIN users u ON u.id = lt.staff_id + WHERE lt.hotel_id = $1 + ORDER BY lt.created_at DESC + LIMIT $2`, + [hotelId, limit], + ) + + return rows.map((r: Record) => ({ + id: r.id, + guestId: r.guest_id, + guestName: r.guest_name, + amount: Number(r.amount), + reason: r.reason, + notes: r.notes ?? null, + staffName: r.staff_name ?? null, + createdAt: r.created_at, + })) + }, + ) +} + +export default loyaltyRoutes diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx new file mode 100644 index 0000000..c150f70 --- /dev/null +++ b/src/components/chat/ChatWidget.tsx @@ -0,0 +1,294 @@ +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 { useAuth } from '../../contexts/AuthContext' +import { cn } from '../../lib/utils' + +// Color for avatar based on name +function avatarColor(name: string) { + const colors = ['#4F46E5','#059669','#2563EB','#7C3AED','#DC2626','#D97706','#DB2777','#0891B2'] + let h = 0 + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % colors.length + return colors[h] +} + +function initials(name: string) { + return name.split(' ').map(p => p[0]).join('').toUpperCase().slice(0, 2) +} + +function fmtTime(iso: string) { + return new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) +} + +function Avatar({ name, size = 28 }: { name: string; size?: number }) { + return ( +
+ {initials(name)} +
+ ) +} + +export function ChatWidget() { + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + const [open, setOpen] = useState(false) + const [view, setView] = useState<'rooms' | 'messages'>('rooms') + const [rooms, setRooms] = useState([]) + const [activeRoom, setActiveRoom] = useState(null) + const [messages, setMessages] = useState([]) + const [text, setText] = useState('') + const [loadingRooms, setLoadingRooms] = useState(false) + const [loadingMsgs, setLoadingMsgs] = useState(false) + const [sending, setSending] = useState(false) + const messagesEndRef = useRef(null) + const pollRef = useRef | null>(null) + + const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0) + + const loadRooms = useCallback(async () => { + if (!slug) return + try { + const data = await api.chat.listRooms(slug) + setRooms(data) + } catch { /* ignore */ } + }, [slug]) + + // Poll for new messages every 5s when open + useEffect(() => { + if (!open || !slug) return + setLoadingRooms(true) + loadRooms().finally(() => setLoadingRooms(false)) + pollRef.current = setInterval(loadRooms, 5000) + 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 + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + // Poll messages when in messages view + useEffect(() => { + if (view !== 'messages' || !activeRoom) return + const interval = setInterval(async () => { + try { + const msgs = await api.chat.getMessages(slug, activeRoom.id) + setMessages(msgs) + } catch { /* ignore */ } + }, 3000) + return () => clearInterval(interval) + }, [view, activeRoom, slug]) + + const sendMessage = async () => { + if (!text.trim() || !activeRoom || sending) return + const t = text.trim() + setText('') + setSending(true) + try { + const msg = await api.chat.sendMessage(slug, activeRoom.id, t) + setMessages(prev => [...prev, msg]) + } catch { + setText(t) + } finally { + setSending(false) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + void sendMessage() + } + } + + if (!slug) return null + + return ( + <> + {/* Floating button */} + + + {/* Chat panel */} + {open && ( +
+ + {/* Header */} +
+ {view === 'messages' && ( + + )} +
+

+ {view === 'rooms' ? 'Чат сотрудников' : (activeRoom?.type === 'general' ? 'Общий чат' : activeRoom?.otherUserName ?? 'Чат')} +

+ {view === 'rooms' && ( +

{rooms.length} чатов

+ )} +
+ +
+ + {/* Rooms list */} + {view === 'rooms' && ( +
+ {loadingRooms ? ( +
+ +
+ ) : rooms.length === 0 ? ( +
+ Нет чатов. Начните общение! +
+ ) : ( + rooms.map(room => ( + + )) + )} +
+ )} + + {/* Messages view */} + {view === 'messages' && ( + <> +
+ {loadingMsgs ? ( +
+ +
+ ) : messages.length === 0 ? ( +
+ Нет сообщений. Напишите первым! +
+ ) : ( + messages.map(msg => { + const isOwn = msg.senderId === user?.id + return ( +
+ {!isOwn && } +
+ {!isOwn && ( +

{msg.senderName.split(' ')[0]}

+ )} +
+ {msg.text} +
+

{fmtTime(msg.createdAt)}

+
+
+ ) + }) + )} +
+
+ + {/* Input */} +
+
+