diff --git a/backend/src/db.ts b/backend/src/db.ts index 0a49cea..8bedd01 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -86,10 +86,11 @@ export async function initDB() { CREATE INDEX IF NOT EXISTS idx_chat_members_user_id ON chat_members(user_id); `); - // Migrations: add avatar columns if not exist + // Migrations await pool.query(` ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT DEFAULT NULL; ALTER TABLE chats ADD COLUMN IF NOT EXISTS avatar TEXT DEFAULT NULL; + ALTER TABLE users ADD COLUMN IF NOT EXISTS position TEXT DEFAULT NULL; `); // Seed admin if no users diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 2b3ddd8..cdf72bd 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -16,19 +16,20 @@ export default async function adminRoutes(app: FastifyInstance) { // List users app.get('/users', async () => { const { rows } = await pool.query( - `SELECT id, username, display_name, avatar_color, is_admin, is_active, last_seen, created_at + `SELECT id, username, display_name, avatar_color, is_admin, is_active, last_seen, created_at, phone, position FROM users ORDER BY created_at DESC` ); return rows.map(u => ({ id: u.id, username: u.username, displayName: u.display_name, avatarColor: u.avatar_color, isAdmin: u.is_admin, isActive: u.is_active, lastSeen: u.last_seen, createdAt: u.created_at, + phone: u.phone || '', position: u.position || '', })); }); // Create user app.post('/users', async (req, reply) => { - const { username, displayName, password, isAdmin } = req.body as any; + const { username, displayName, password, isAdmin, phone, position } = req.body as any; const { id: createdBy } = req.user as { id: string }; if (!username || !displayName || !password) { @@ -40,9 +41,9 @@ export default async function adminRoutes(app: FastifyInstance) { try { const { rows: [user] } = await pool.query( - `INSERT INTO users (username, display_name, password_hash, avatar_color, is_admin, created_by) - VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, username, display_name, avatar_color, is_admin`, - [username, displayName, hash, color, !!isAdmin, createdBy] + `INSERT INTO users (username, display_name, password_hash, avatar_color, is_admin, phone, position, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id, username, display_name, avatar_color, is_admin`, + [username, displayName, hash, color, !!isAdmin, phone || '', position || null, createdBy] ); return reply.status(201).send({ id: user.id, username: user.username, displayName: user.display_name, @@ -57,7 +58,7 @@ export default async function adminRoutes(app: FastifyInstance) { // Update user app.put('/users/:id', async (req, reply) => { const { id } = req.params as { id: string }; - const { displayName, isAdmin, isActive, password } = req.body as any; + const { displayName, isAdmin, isActive, password, phone, position } = req.body as any; if (password) { const hash = await bcrypt.hash(password, 10); @@ -68,9 +69,11 @@ export default async function adminRoutes(app: FastifyInstance) { `UPDATE users SET display_name = COALESCE($1, display_name), is_admin = COALESCE($2, is_admin), - is_active = COALESCE($3, is_active) - WHERE id = $4`, - [displayName, isAdmin, isActive, id] + is_active = COALESCE($3, is_active), + phone = COALESCE($4, phone), + position = COALESCE($5, position) + WHERE id = $6`, + [displayName, isAdmin, isActive, phone, position, id] ); return { ok: true }; @@ -85,6 +88,29 @@ export default async function adminRoutes(app: FastifyInstance) { return { ok: true }; }); + // All chats (admin view) + app.get('/chats', async () => { + const { rows } = await pool.query(` + SELECT c.id, c.type, c.title, c.avatar_color, c.avatar, c.is_public, c.created_at, + (SELECT COUNT(*) FROM chat_members WHERE chat_id = c.id) AS member_count, + (SELECT COUNT(*) FROM messages WHERE chat_id = c.id AND is_deleted = FALSE) AS message_count + FROM chats c ORDER BY c.created_at DESC + `); + return rows.map(r => ({ + id: r.id, type: r.type, title: r.title, + avatarColor: r.avatar_color, avatar: r.avatar || null, isPublic: r.is_public, + memberCount: parseInt(r.member_count), messageCount: parseInt(r.message_count), + createdAt: r.created_at, + })); + }); + + // Delete chat (admin) + app.delete('/chats/:id', async (req) => { + const { id } = req.params as { id: string }; + await pool.query('DELETE FROM chats WHERE id = $1', [id]); + return { ok: true }; + }); + // Stats app.get('/stats', async () => { const [users, chats, messages] = await Promise.all([ diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 49e2179..a8675cb 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -9,16 +9,19 @@ function userDto(u: any) { return { id: u.id, username: u.username, displayName: u.display_name, avatarColor: u.avatar_color, avatar: u.avatar || null, - bio: u.bio, phone: u.phone, isAdmin: u.is_admin, + bio: u.bio, phone: u.phone, position: u.position || null, isAdmin: u.is_admin, }; } export default async function authRoutes(app: FastifyInstance) { app.post('/login', async (req, reply) => { - const { username, password } = req.body as { username: string; password: string }; + const { username, phone, password } = req.body as { username?: string; phone?: string; password: string }; + const login = phone || username || ''; const { rows: [user] } = await pool.query( - 'SELECT * FROM users WHERE username = $1 AND is_active = TRUE', [username] + `SELECT * FROM users WHERE is_active = TRUE AND ( + (phone != '' AND phone = $1) OR username = $1 + )`, [login] ); if (!user) return reply.status(401).send({ error: 'Неверный логин или пароль' }); @@ -35,14 +38,14 @@ export default async function authRoutes(app: FastifyInstance) { app.get('/me', { preHandler: [app.authenticate] }, async (req) => { const { id } = req.user as { id: string }; const { rows: [user] } = await pool.query( - 'SELECT id, username, display_name, avatar_color, avatar, bio, phone, is_admin, last_seen FROM users WHERE id = $1', [id] + 'SELECT id, username, display_name, avatar_color, avatar, bio, phone, position, is_admin, last_seen FROM users WHERE id = $1', [id] ); return { ...userDto(user), lastSeen: user.last_seen }; }); app.put('/me', { preHandler: [app.authenticate] }, async (req, reply) => { const { id } = req.user as { id: string }; - const { displayName, bio, phone, password, newPassword } = req.body as any; + const { displayName, bio, phone, position, password, newPassword } = req.body as any; if (newPassword) { const { rows: [user] } = await pool.query('SELECT password_hash FROM users WHERE id = $1', [id]); @@ -53,12 +56,12 @@ export default async function authRoutes(app: FastifyInstance) { } await pool.query( - 'UPDATE users SET display_name = COALESCE($1, display_name), bio = COALESCE($2, bio), phone = COALESCE($3, phone) WHERE id = $4', - [displayName, bio, phone, id] + 'UPDATE users SET display_name = COALESCE($1, display_name), bio = COALESCE($2, bio), phone = COALESCE($3, phone), position = COALESCE($4, position) WHERE id = $5', + [displayName, bio, phone, position, id] ); const { rows: [user] } = await pool.query( - 'SELECT id, username, display_name, avatar_color, avatar, bio, phone, is_admin FROM users WHERE id = $1', [id] + 'SELECT id, username, display_name, avatar_color, avatar, bio, phone, position, is_admin FROM users WHERE id = $1', [id] ); return userDto(user); }); diff --git a/backend/src/routes/chats.ts b/backend/src/routes/chats.ts index dffe6f8..90d876d 100644 --- a/backend/src/routes/chats.ts +++ b/backend/src/routes/chats.ts @@ -80,7 +80,7 @@ export default async function chatRoutes(app: FastifyInstance) { if (!chat) return reply.status(404).send({ error: 'Not found' }); const { rows: members } = await pool.query(` - SELECT u.id, u.username, u.display_name, u.avatar_color, u.avatar, u.last_seen, cm.role, cm.can_send_messages + SELECT u.id, u.username, u.display_name, u.avatar_color, u.avatar, u.position, u.last_seen, cm.role, cm.can_send_messages FROM chat_members cm JOIN users u ON u.id = cm.user_id WHERE cm.chat_id = $1 @@ -104,6 +104,7 @@ export default async function chatRoutes(app: FastifyInstance) { members: members.map(m => ({ id: m.id, username: m.username, displayName: m.display_name, avatarColor: m.avatar_color, avatar: m.avatar || null, + position: m.position || null, role: m.role, online: onlineSet.has(m.id), lastSeen: m.last_seen, canSendMessages: m.can_send_messages, })), diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index edfd21a..585a747 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -11,9 +11,9 @@ export default async function userRoutes(app: FastifyInstance) { const { id: userId } = req.user as { id: string }; const { rows } = await pool.query( - `SELECT id, username, display_name, avatar_color, last_seen FROM users + `SELECT id, username, display_name, avatar_color, avatar, position, last_seen FROM users WHERE is_active = TRUE AND id != $1 - AND (username ILIKE $2 OR display_name ILIKE $2) + AND (username ILIKE $2 OR display_name ILIKE $2 OR phone ILIKE $2) ORDER BY display_name LIMIT 20`, [userId, `%${q}%`] ); @@ -21,7 +21,8 @@ export default async function userRoutes(app: FastifyInstance) { const online = connections; return rows.map(u => ({ id: u.id, username: u.username, displayName: u.display_name, - avatarColor: u.avatar_color, online: online.has(u.id), lastSeen: u.last_seen, + avatarColor: u.avatar_color, avatar: u.avatar || null, + position: u.position || null, online: online.has(u.id), lastSeen: u.last_seen, })); }); @@ -29,14 +30,15 @@ export default async function userRoutes(app: FastifyInstance) { app.get('/', async (req) => { const { id: userId } = req.user as { id: string }; const { rows } = await pool.query( - `SELECT id, username, display_name, avatar_color, last_seen FROM users + `SELECT id, username, display_name, avatar_color, avatar, position, last_seen FROM users WHERE is_active = TRUE AND id != $1 ORDER BY display_name`, [userId] ); const online = connections; return rows.map(u => ({ id: u.id, username: u.username, displayName: u.display_name, - avatarColor: u.avatar_color, online: online.has(u.id), lastSeen: u.last_seen, + avatarColor: u.avatar_color, avatar: u.avatar || null, + position: u.position || null, online: online.has(u.id), lastSeen: u.last_seen, })); }); @@ -44,12 +46,13 @@ export default async function userRoutes(app: FastifyInstance) { app.get('/:id', async (req, reply) => { const { id } = req.params as { id: string }; const { rows: [u] } = await pool.query( - 'SELECT id, username, display_name, avatar_color, bio, last_seen FROM users WHERE id = $1 AND is_active = TRUE', [id] + 'SELECT id, username, display_name, avatar_color, avatar, bio, position, last_seen FROM users WHERE id = $1 AND is_active = TRUE', [id] ); if (!u) return reply.status(404).send({ error: 'Not found' }); return { id: u.id, username: u.username, displayName: u.display_name, - avatarColor: u.avatar_color, bio: u.bio, online: connections.has(u.id), lastSeen: u.last_seen, + avatarColor: u.avatar_color, avatar: u.avatar || null, + position: u.position || null, bio: u.bio, online: connections.has(u.id), lastSeen: u.last_seen, }; }); } diff --git a/frontend/src/components/ChatInfoPanel.tsx b/frontend/src/components/ChatInfoPanel.tsx index 07cd620..cf2a7ca 100644 --- a/frontend/src/components/ChatInfoPanel.tsx +++ b/frontend/src/components/ChatInfoPanel.tsx @@ -204,12 +204,15 @@ export default function ChatInfoPanel({ chat, onClose, onRefresh }: Props) {
-
- {m.displayName} +
+ {m.displayName} {roleIcon(m.role)} {!m.canSendMessages && }
-
@{m.username}
+
+ @{m.username} + {m.position && {m.position}} +
{isOwnerOrAdmin && m.id !== me?.id && m.role !== 'owner' && (
diff --git a/frontend/src/components/ProfileModal.tsx b/frontend/src/components/ProfileModal.tsx index d249e57..c002f38 100644 --- a/frontend/src/components/ProfileModal.tsx +++ b/frontend/src/components/ProfileModal.tsx @@ -1,6 +1,8 @@ import { useState, useRef } from 'react'; -import { X, Camera, Trash2, Save, Lock } from 'lucide-react'; +import { X, Camera, Trash2, Save, Lock, LogOut } from 'lucide-react'; import { useStore } from '../store'; +import { useNavigate } from 'react-router-dom'; +import { wsClient } from '../api/ws'; import api from '../api/client'; import Avatar from './Avatar'; @@ -9,10 +11,12 @@ interface Props { } export default function ProfileModal({ onClose }: Props) { - const { user, setUser } = useStore(); + const { user, setUser, logout } = useStore(); + const navigate = useNavigate(); const [displayName, setDisplayName] = useState(user?.displayName || ''); const [bio, setBio] = useState(user?.bio || ''); const [phone, setPhone] = useState(user?.phone || ''); + const [position, setPosition] = useState(user?.position || ''); const [password, setPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [saving, setSaving] = useState(false); @@ -26,7 +30,7 @@ export default function ProfileModal({ onClose }: Props) { setError(''); setSuccess(''); try { - const body: any = { displayName, bio, phone }; + const body: any = { displayName, bio, phone, position: position || null }; if (newPassword) { if (!password) { setError('Введите текущий пароль'); setSaving(false); return; } body.password = password; @@ -162,6 +166,15 @@ export default function ProfileModal({ onClose }: Props) { placeholder="+7..." />
+
+ + setPosition(e.target.value)} + className="w-full px-3 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-200" + placeholder="Менеджер, Директор..." + /> +
{/* Password */} @@ -190,7 +203,7 @@ export default function ProfileModal({ onClose }: Props) { {success &&

{success}

}
-
+
+
diff --git a/frontend/src/components/admin/AdminPanel.tsx b/frontend/src/components/admin/AdminPanel.tsx index b903c31..8d077da 100644 --- a/frontend/src/components/admin/AdminPanel.tsx +++ b/frontend/src/components/admin/AdminPanel.tsx @@ -8,21 +8,32 @@ interface Props { onClose: () => void; } interface UserRow { id: string; username: string; displayName: string; avatarColor: string; isAdmin: boolean; isActive: boolean; lastSeen: string; createdAt: string; + phone: string; position: string; +} + +interface ChatRow { + id: string; type: string; title: string; avatarColor: string; avatar: string | null; + memberCount: number; messageCount: number; createdAt: string; } export default function AdminPanel({ onClose }: Props) { - const [tab, setTab] = useState<'users' | 'stats'>('users'); + const [tab, setTab] = useState<'users' | 'chats' | 'stats'>('users'); const [users, setUsers] = useState([]); + const [chats, setChats] = useState([]); const [stats, setStats] = useState(null); const [showCreate, setShowCreate] = useState(false); const [editUser, setEditUser] = useState(null); - useEffect(() => { loadUsers(); loadStats(); }, []); + useEffect(() => { loadUsers(); loadStats(); loadChats(); }, []); async function loadUsers() { const { data } = await api.get('/api/admin/users'); setUsers(data); } + async function loadChats() { + const { data } = await api.get('/api/admin/chats'); + setChats(data); + } async function loadStats() { const { data } = await api.get('/api/admin/stats'); setStats(data); @@ -34,6 +45,12 @@ export default function AdminPanel({ onClose }: Props) { loadUsers(); } + async function deleteChat(id: string) { + if (!confirm('Удалить чат?')) return; + await api.delete(`/api/admin/chats/${id}`); + loadChats(); + } + async function toggleActive(u: UserRow) { await api.put(`/api/admin/users/${u.id}`, { isActive: !u.isActive }); loadUsers(); @@ -51,7 +68,7 @@ export default function AdminPanel({ onClose }: Props) { {/* Tabs */}
- {[['users','Пользователи'],['stats','Статистика']].map(([k,v]) => ( + {[['users','Пользователи'],['chats','Чаты'],['stats','Статистика']].map(([k,v]) => ( +
+ ))} + {chats.length === 0 &&

Нет чатов

} + + )} + {tab === 'stats' && stats && (
{[ @@ -137,6 +180,8 @@ function UserForm({ user, onSave, onCancel }: { user: UserRow | null; onSave: () const [form, setForm] = useState({ username: user?.username || '', displayName: user?.displayName || '', + phone: user?.phone || '', + position: user?.position || '', password: '', isAdmin: user?.isAdmin || false, }); @@ -151,6 +196,8 @@ function UserForm({ user, onSave, onCancel }: { user: UserRow | null; onSave: () if (user) { await api.put(`/api/admin/users/${user.id}`, { displayName: form.displayName, + phone: form.phone || undefined, + position: form.position || undefined, isAdmin: form.isAdmin, password: form.password || undefined, }); @@ -174,6 +221,10 @@ function UserForm({ user, onSave, onCancel }: { user: UserRow | null; onSave: () )} setForm({...form, displayName: e.target.value})} placeholder="Имя" required className="px-3 py-2 border border-gray-200 rounded-lg text-sm" /> + setForm({...form, phone: e.target.value})} + type="tel" placeholder="Телефон +7..." className="px-3 py-2 border border-gray-200 rounded-lg text-sm" /> + setForm({...form, position: e.target.value})} + placeholder="Должность" className="px-3 py-2 border border-gray-200 rounded-lg text-sm" /> setForm({...form, password: e.target.value})} type="password" placeholder={user ? 'Новый пароль (необязательно)' : 'Пароль'} required={!user} className="px-3 py-2 border border-gray-200 rounded-lg text-sm" /> diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 55b739a..e15bfe8 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -9,7 +9,7 @@ interface Props { export default function LoginPage({ onLogin }: Props) { const navigate = useNavigate(); - const [username, setUsername] = useState(''); + const [phone, setPhone] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); @@ -19,7 +19,7 @@ export default function LoginPage({ onLogin }: Props) { setError(''); setLoading(true); try { - const { data } = await api.post('/api/auth/login', { username, password }); + const { data } = await api.post('/api/auth/login', { phone, password }); onLogin(data.token, data.user); navigate('/'); } catch (err: any) { @@ -43,14 +43,14 @@ export default function LoginPage({ onLogin }: Props) {
- + setUsername(e.target.value)} + type="tel" + value={phone} + onChange={e => setPhone(e.target.value)} className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition" - placeholder="Введите логин" - autoComplete="username" + placeholder="+7..." + autoComplete="tel" required />
diff --git a/frontend/src/pages/MainLayout.tsx b/frontend/src/pages/MainLayout.tsx index 1c5fc7d..832990e 100644 --- a/frontend/src/pages/MainLayout.tsx +++ b/frontend/src/pages/MainLayout.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Search, Edit, LogOut, Settings, Shield, Wifi, WifiOff } from 'lucide-react'; +import { Search, Edit, Settings, Shield, Wifi, WifiOff, MessageCircle } from 'lucide-react'; import { useStore } from '../store'; import { useNavigate } from 'react-router-dom'; import { wsClient } from '../api/ws'; @@ -16,9 +16,10 @@ import { usePushNotifications } from '../hooks/usePushNotifications'; import { Chat } from '../types'; export default function MainLayout() { - const { user, chats, activeChat, setActiveChat, connected, logout, onlineUsers, updateChat } = useStore(); + const { user, chats, activeChat, setActiveChat, connected, onlineUsers, updateChat, setChats } = useStore(); const navigate = useNavigate(); const [search, setSearch] = useState(''); + const [searchUsers, setSearchUsers] = useState([]); const [showNew, setShowNew] = useState(false); const [showAdmin, setShowAdmin] = useState(false); const [showInfo, setShowInfo] = useState(false); @@ -68,10 +69,32 @@ export default function MainLayout() { setShowInfo(false); } - function handleLogout() { - wsClient.disconnect(); - logout(); - navigate('/login'); + // Global search: fetch users when query changes + useEffect(() => { + if (search.trim().length < 1) { setSearchUsers([]); return; } + const timer = setTimeout(async () => { + try { + const { data } = await api.get('/api/users'); + const q = search.toLowerCase(); + setSearchUsers(data.filter((u: any) => + (u.displayName || '').toLowerCase().includes(q) || + (u.username || '').toLowerCase().includes(q) || + (u.phone || '').includes(q) + )); + } catch {} + }, 200); + return () => clearTimeout(timer); + }, [search]); + + async function openOrCreatePrivate(userId: string) { + try { + const { data } = await api.post(`/api/chats/private/${userId}`); + const { data: allChats } = await api.get('/api/chats'); + setChats(allChats); + const chat = allChats.find((c: any) => c.id === data.id); + if (chat) openChat(chat); + setSearch(''); + } catch {} } const filtered = chats.filter(c => @@ -119,10 +142,6 @@ export default function MainLayout() { className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600" title="Новый чат"> -
@@ -139,20 +158,44 @@ export default function MainLayout() { {/* Chat List */}
- {filtered.length === 0 && ( -
- {search ? 'Ничего не найдено' : 'Нет чатов. Создайте новый!'} -
- )} {filtered.map(chat => ( openChat(chat)} + onClick={() => { openChat(chat); setSearch(''); }} /> ))} + + {/* Global user search results */} + {search && searchUsers.length > 0 && ( + <> + {filtered.length > 0 &&
Пользователи
} + {searchUsers.map(u => ( + + ))} + + )} + + {search && filtered.length === 0 && searchUsers.length === 0 && ( +
Ничего не найдено
+ )} + {!search && filtered.length === 0 && ( +
Нет чатов. Создайте новый!
+ )}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d7c0ecd..ce43bd8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -6,6 +6,7 @@ export interface User { avatar?: string | null; bio?: string; phone?: string; + position?: string | null; isAdmin: boolean; lastSeen?: string; online?: boolean; @@ -17,6 +18,7 @@ export interface ChatMember { displayName: string; avatarColor: string; avatar?: string | null; + position?: string | null; role: 'owner' | 'admin' | 'member'; online: boolean; lastSeen: string;