diff --git a/api-user-lists.js b/api-user-lists.js new file mode 100644 index 0000000..c353995 --- /dev/null +++ b/api-user-lists.js @@ -0,0 +1,179 @@ +// api-user-lists.js - API для управления пользовательскими списками +const express = require('express'); +const router = express.Router(); + +module.exports = function(app, db) { + // Middleware для проверки аутентификации + const requireAuth = (req, res, next) => { + if (!req.session || !req.session.user) { + return res.status(401).json({ error: 'Требуется аутентификация' }); + } + next(); + }; + + // GET /api/user/lists – получить все списки текущего пользователя + router.get('/api/user/lists', requireAuth, (req, res) => { + const userId = req.session.user.id; + + db.all( + 'SELECT id, name, user_ids, created_at, updated_at FROM user_lists WHERE user_id = ? ORDER BY created_at DESC', + [userId], + (err, rows) => { + if (err) { + console.error('❌ Ошибка получения списков:', err); + return res.status(500).json({ error: 'Ошибка получения списков' }); + } + + // Преобразуем user_ids из JSON в массив + const lists = (rows || []).map(row => ({ + ...row, + user_ids: JSON.parse(row.user_ids || '[]') + })); + + res.json(lists); + } + ); + }); + + // POST /api/user/lists – создать новый список + router.post('/api/user/lists', requireAuth, (req, res) => { + const userId = req.session.user.id; + const { name, userIds } = req.body; + + if (!name || name.trim() === '') { + return res.status(400).json({ error: 'Название списка обязательно' }); + } + if (name.length > 35) { + return res.status(400).json({ error: 'Название не должно превышать 35 символов' }); + } + if (!Array.isArray(userIds)) { + return res.status(400).json({ error: 'userIds должен быть массивом' }); + } + + const user_ids_json = JSON.stringify(userIds); + + db.run( + 'INSERT INTO user_lists (user_id, name, user_ids) VALUES (?, ?, ?)', + [userId, name.trim(), user_ids_json], + function(err) { + if (err) { + console.error('❌ Ошибка создания списка:', err); + return res.status(500).json({ error: 'Ошибка создания списка' }); + } + + // Возвращаем созданный список + db.get( + 'SELECT id, name, user_ids, created_at, updated_at FROM user_lists WHERE id = ?', + [this.lastID], + (err, row) => { + if (err) { + return res.status(500).json({ error: 'Список создан, но ошибка получения' }); + } + const newList = { + ...row, + user_ids: JSON.parse(row.user_ids || '[]') + }; + res.status(201).json(newList); + } + ); + } + ); + }); + + // PUT /api/user/lists/:id – обновить список + router.put('/api/user/lists/:id', requireAuth, (req, res) => { + const listId = req.params.id; + const userId = req.session.user.id; + const { name, userIds } = req.body; + + // Проверяем, принадлежит ли список пользователю + db.get('SELECT id FROM user_lists WHERE id = ? AND user_id = ?', [listId, userId], (err, list) => { + if (err) { + console.error('❌ Ошибка проверки списка:', err); + return res.status(500).json({ error: 'Ошибка доступа' }); + } + if (!list) { + return res.status(404).json({ error: 'Список не найден' }); + } + + const updates = []; + const params = []; + + if (name !== undefined) { + if (name.trim() === '') { + return res.status(400).json({ error: 'Название не может быть пустым' }); + } + if (name.length > 35) { + return res.status(400).json({ error: 'Название не должно превышать 35 символов' }); + } + updates.push('name = ?'); + params.push(name.trim()); + } + + if (userIds !== undefined) { + if (!Array.isArray(userIds)) { + return res.status(400).json({ error: 'userIds должен быть массивом' }); + } + updates.push('user_ids = ?'); + params.push(JSON.stringify(userIds)); + } + + if (updates.length === 0) { + return res.status(400).json({ error: 'Нет данных для обновления' }); + } + + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(listId); + + const query = `UPDATE user_lists SET ${updates.join(', ')} WHERE id = ?`; + + db.run(query, params, function(err) { + if (err) { + console.error('❌ Ошибка обновления списка:', err); + return res.status(500).json({ error: 'Ошибка обновления списка' }); + } + + // Возвращаем обновлённый список + db.get( + 'SELECT id, name, user_ids, created_at, updated_at FROM user_lists WHERE id = ?', + [listId], + (err, row) => { + if (err) { + return res.status(500).json({ error: 'Список обновлён, но ошибка получения' }); + } + const updatedList = { + ...row, + user_ids: JSON.parse(row.user_ids || '[]') + }; + res.json(updatedList); + } + ); + }); + }); + }); + + // DELETE /api/user/lists/:id – удалить список + router.delete('/api/user/lists/:id', requireAuth, (req, res) => { + const listId = req.params.id; + const userId = req.session.user.id; + + db.run( + 'DELETE FROM user_lists WHERE id = ? AND user_id = ?', + [listId, userId], + function(err) { + if (err) { + console.error('❌ Ошибка удаления списка:', err); + return res.status(500).json({ error: 'Ошибка удаления списка' }); + } + if (this.changes === 0) { + return res.status(404).json({ error: 'Список не найден' }); + } + res.json({ success: true, message: 'Список удалён' }); + } + ); + }); + + // Подключаем роутер к приложению + app.use(router); + console.log('✅ API для пользовательских списков подключено'); +}; \ No newline at end of file diff --git a/database.js b/database.js index 3c5cade..da961e7 100644 --- a/database.js +++ b/database.js @@ -540,6 +540,16 @@ db.run(`CREATE TABLE IF NOT EXISTS task_chat_reads ( UNIQUE(message_id, user_id), FOREIGN KEY (message_id) REFERENCES task_chat_messages (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +)`); +// Таблица для пользовательских списков +db.run(`CREATE TABLE IF NOT EXISTS user_lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + user_ids TEXT NOT NULL, -- JSON массив ID пользователей + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE )`); console.log('✅ Таблица для сообщений чата задач созданы'); // Создаем индексы для улучшения производительности @@ -1323,6 +1333,16 @@ async function createPostgresTables() { refusal_reason TEXT ) `); +await client.query(` + CREATE TABLE IF NOT EXISTS user_lists ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(35) NOT NULL, + user_ids TEXT NOT NULL, -- JSON массив + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) +`); console.log('✅ Все таблицы PostgreSQL созданы/проверены'); diff --git a/public/style.css b/public/style.css index 658ac96..ec30ccd 100644 --- a/public/style.css +++ b/public/style.css @@ -4949,4 +4949,124 @@ button.btn-primary { color: #aaa; cursor: default; pointer-events: none; +} +/* Стили для панели пользовательских списков */ +.user-lists-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.user-lists-header h4 { + margin: 0; + font-size: 16px; + color: #2c3e50; +} + +.btn-create-list { + background: #27ae60; + color: white; + border: none; + border-radius: 4px; + padding: 4px 10px; + cursor: pointer; + font-size: 13px; + transition: background 0.2s; +} + +.btn-create-list:hover { + background: #219a52; +} + +.user-lists-container { + max-height: 400px; + overflow-y: auto; + border: 1px solid #e9ecef; + border-radius: 6px; + padding: 5px; + background: #f8f9fa; +} + +.user-list-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + margin-bottom: 5px; + background: white; + border: 1px solid #dee2e6; + border-radius: 4px; + transition: background 0.2s; +} + +.user-list-item:hover { + background: #f1f3f5; +} + +.list-info { + flex: 1; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + overflow: hidden; +} + +.list-name { + font-weight: 500; + color: #495057; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; +} + +.list-count { + font-size: 12px; + color: #6c757d; + background: #e9ecef; + padding: 2px 6px; + border-radius: 12px; +} + +.list-actions { + display: flex; + gap: 5px; + flex-shrink: 0; +} + +.list-edit-btn, .list-delete-btn { + background: none; + border: none; + cursor: pointer; + font-size: 16px; + padding: 2px; + opacity: 0.7; + transition: opacity 0.2s; +} + +.list-edit-btn:hover { + opacity: 1; + color: #f39c12; +} + +.list-delete-btn:hover { + opacity: 1; + color: #e74c3c; +} + +.no-lists { + text-align: center; + padding: 20px; + color: #6c757d; + font-style: italic; +} + +/* Модальное окно для списка */ +#list-modal .users-checklist-scroll { + border: 1px solid #ced4da; + border-radius: 6px; + padding: 10px; + background: #fff; } \ No newline at end of file diff --git a/public/users.js b/public/users.js index 33e766e..5db3e6d 100644 --- a/public/users.js +++ b/public/users.js @@ -1,45 +1,63 @@ -// users.js - Управление пользователями +// users.js - Управление пользователями и пользовательскими списками + let users = []; let allUsers = []; +let usersLoadingPromise = null; let filteredUsers = []; let selectedUsers = []; let editSelectedUsers = []; let copySelectedUsers = []; -// добавить переменную для отслеживания загрузки -let isUsersLoading = false; -// Добавьте переменную для хранения групп пользователей +// Переменные для пользовательских списков +let userLists = []; +let isUserListsLoading = false; +let currentEditingListId = null; + +// Кэш групп пользователей let userGroupsCache = {}; +let isUsersLoading = false; + +// ==================== ЗАГРУЗКА ПОЛЬЗОВАТЕЛЕЙ ==================== + async function loadUsers() { - try { - const response = await fetch('/api/users'); - const allUsersData = await response.json(); - //users = await response.json(); - // Сохраняем всех пользователей - allUsers = allUsersData; - // Фильтруем пользователей в зависимости от прав текущего пользователя - users = filterAssignableUsers(allUsersData); - filteredUsers = [...users]; - renderUsersChecklist(); - renderEditUsersChecklist(); - renderCopyUsersChecklist(); - populateFilterDropdowns(); - } catch (error) { - console.error('Ошибка загрузки пользователей:', error); - } + // Если загрузка уже идёт, возвращаем существующий промис + if (usersLoadingPromise) return usersLoadingPromise; + + usersLoadingPromise = (async () => { + try { + const response = await fetch('/api/users'); + const allUsersData = await response.json(); + allUsers = allUsersData; + users = filterAssignableUsers(allUsersData); + filteredUsers = [...users]; + renderUsersChecklist(); + renderEditUsersChecklist(); + renderCopyUsersChecklist(); + populateFilterDropdowns(); + // Загружаем пользовательские списки (не ждём) + loadUserLists(); + } catch (error) { + console.error('Ошибка загрузки пользователей:', error); + } finally { + usersLoadingPromise = null; + } + })(); + + return usersLoadingPromise; } -// Добавьте функцию для получения групп пользователя +// ==================== ПОЛУЧЕНИЕ ГРУПП ПОЛЬЗОВАТЕЛЯ ==================== + async function getUserGroups(userId) { if (userGroupsCache[userId]) { return userGroupsCache[userId]; } - + try { const response = await fetch(`/api2/idusers/user/${userId}/groups`); if (!response.ok) return []; - + const groups = await response.json(); userGroupsCache[userId] = groups || []; return userGroupsCache[userId]; @@ -49,47 +67,16 @@ async function getUserGroups(userId) { } } -// Обновите функцию filterAssignableUsers +// ==================== ФИЛЬТРАЦИЯ ПОЛЬЗОВАТЕЛЕЙ ПО ПРАВАМ ==================== + function filterAssignableUsers(allUsers, taskType = 'regular') { if (!currentUser) return []; - - // Для задач типа "document" - только пользователи из группы "Секретарь" - if (taskType === 'document') { - return allUsers.filter(async (user) => { - if (user.id === currentUser.id) return false; - - // Получаем группы пользователя - const groups = await getUserGroups(user.id); - - // Проверяем, есть ли группа "Секретарь" - const hasSecretaryGroup = groups.some(group => - group.name === 'Секретарь' || - (typeof group === 'string' && group.includes('Секретарь')) - ); - - return hasSecretaryGroup; - }); - } - // Для задач типа "it" - только пользователи из группы "ИТ специалист" - if (taskType === 'it') { - return allUsers.filter(async (user) => { - if (user.id === currentUser.id) return false; - - // Получаем группы пользователя - const groups = await getUserGroups(user.id); - - // Проверяем, есть ли группа "Секретарь" - const hasSecretaryGroup = groups.some(group => - group.name === 'ИТ специалист' || - (typeof group === 'string' && group.includes('ИТ специалист')) - ); - - return hasSecretaryGroup; - }); - } - - // Для других типов задач - обычная фильтрация - // Администратор видит всех пользователей + + // Для задач типа "document" – только секретари (асинхронно не получится здесь, но мы фильтруем позже) + // В текущей реализации эта функция вызывается синхронно, поэтому для специальных типов фильтрация будет в filterUsers + // Здесь оставляем базовую фильтрацию по ролям + + // Администратор видит всех, кроме себя if (currentUser.role === 'admin') { return allUsers.filter(user => user.id !== currentUser.id); } @@ -97,81 +84,82 @@ function filterAssignableUsers(allUsers, taskType = 'regular') { return allUsers.filter(user => user.id !== currentUser.id); } if (currentUser.role === 'ithelp') { - return allUsers.filter(user => + return allUsers.filter(user => (user.role === 'teacher' || user.role === 'tasks' || user.role === 'help' || user.role === 'request' || user.role === 'ithelp') && user.id !== currentUser.id ); } if (currentUser.role === 'request') { - return allUsers.filter(user => + return allUsers.filter(user => (user.role === 'teacher' || user.role === 'tasks' || user.role === 'help' || user.role === 'request' || user.role === 'ithelp') && user.id !== currentUser.id ); } - // tasks видит учителей и других tasks if (currentUser.role === 'help') { - return allUsers.filter(user => + return allUsers.filter(user => (user.role === 'teacher' || user.role === 'tasks' || user.role === 'help' || user.role === 'request' || user.role === 'ithelp') && user.id !== currentUser.id ); } - // tasks видит учителей и других tasks if (currentUser.role === 'tasks') { - return allUsers.filter(user => + return allUsers.filter(user => (user.role === 'teacher' || user.role === 'tasks' || user.role === 'help' || user.role === 'request' || user.role === 'ithelp') && user.id !== currentUser.id ); } - // Учитель видит только учителей if (currentUser.role === 'teacher') { - return allUsers.filter(user => - (user.role === 'help' || user.role === 'request' || user.role === 'ithelp') && + return allUsers.filter(user => + (user.role === 'help' || user.role === 'request' || user.role === 'ithelp') && user.id !== currentUser.id ); } - + return []; } + +// ==================== ЗАПОЛНЕНИЕ ВЫПАДАЮЩИХ СПИСКОВ ФИЛЬТРОВ ==================== + function populateFilterDropdowns() { const creatorFilter = document.getElementById('creator-filter'); const assigneeFilter = document.getElementById('assignee-filter'); - - creatorFilter.innerHTML = ''; - assigneeFilter.innerHTML = ''; - + + if (creatorFilter) { + creatorFilter.innerHTML = ''; + } + if (assigneeFilter) { + assigneeFilter.innerHTML = ''; + } + users.forEach(user => { - const creatorOption = document.createElement('option'); - creatorOption.value = user.id; - creatorOption.textContent = `${user.name} (${user.login})`; - creatorFilter.appendChild(creatorOption.cloneNode(true)); - - const assigneeOption = creatorOption.cloneNode(true); - assigneeFilter.appendChild(assigneeOption); + const option = document.createElement('option'); + option.value = user.id; + option.textContent = `${user.name} (${user.login})`; + + if (creatorFilter) creatorFilter.appendChild(option.cloneNode(true)); + if (assigneeFilter) assigneeFilter.appendChild(option.cloneNode(true)); }); } -// Обновите функцию filterUsers с учетом типа задачи +// ==================== ФИЛЬТРАЦИЯ ПРИ ПОИСКЕ (С УЧЁТОМ ТИПА ЗАДАЧИ) ==================== + async function filterUsers() { const search = document.getElementById('user-search')?.value.toLowerCase() || ''; const taskType = document.getElementById('task-type')?.value || 'regular'; - + isUsersLoading = true; renderUsersChecklist(); // Показываем загрузку - + try { - if (taskType === 'document' || taskType === 'it' || taskType === 'ahch' || - taskType === 'psychologist' || taskType === 'speech_therapist' || taskType === 'Social_educator' || - taskType === 'hr' || taskType === 'certificate' || taskType === 'e_journal') { - - // Фильтруем по поиску - let tempFiltered = users.filter(user => - user.name.toLowerCase().includes(search) || - user.login.toLowerCase().includes(search) || - user.email.toLowerCase().includes(search) - ); - - // Затем проверяем группы - filteredUsers = []; + // Сначала фильтруем по поиску + let tempFiltered = users.filter(user => + user.name.toLowerCase().includes(search) || + user.login.toLowerCase().includes(search) || + (user.email && user.email.toLowerCase().includes(search)) + ); + + // Если тип задачи требует специальной группы, фильтруем по группам + const specialTypes = ['document', 'it', 'ahch', 'psychologist', 'speech_therapist', 'Social_educator', 'hr', 'certificate', 'e_journal']; + if (specialTypes.includes(taskType)) { const groupNames = { 'document': 'Секретарь', 'it': 'ИТ специалист', @@ -183,27 +171,21 @@ async function filterUsers() { 'certificate': 'Администрация', 'e_journal': 'Админ ЭЖ' }; - const targetGroup = groupNames[taskType]; - + + filteredUsers = []; for (const user of tempFiltered) { const groups = await getUserGroups(user.id); - const hasTargetGroup = groups.some(group => - group.name === targetGroup || + const hasTargetGroup = groups.some(group => + group.name === targetGroup || (typeof group === 'string' && group.includes(targetGroup)) ); - if (hasTargetGroup) { filteredUsers.push(user); } } } else { - // Обычная фильтрация - filteredUsers = users.filter(user => - user.name.toLowerCase().includes(search) || - user.login.toLowerCase().includes(search) || - user.email.toLowerCase().includes(search) - ); + filteredUsers = tempFiltered; } } catch (error) { console.error('Ошибка фильтрации пользователей:', error); @@ -215,110 +197,483 @@ async function filterUsers() { } async function filterEditUsers() { - const search = document.getElementById('edit-user-search').value.toLowerCase(); - const task = tasks.find(t => t.id === document.getElementById('edit-task-id').value); + const search = document.getElementById('edit-user-search')?.value.toLowerCase() || ''; + const taskId = document.getElementById('edit-task-id')?.value; + if (!taskId) return; + + const task = window.tasks?.find(t => t.id == taskId); const taskType = task ? task.task_type : 'regular'; - - let filtered = []; - + + let filtered = users.filter(user => + user.name.toLowerCase().includes(search) || + user.login.toLowerCase().includes(search) || + (user.email && user.email.toLowerCase().includes(search)) + ); + if (taskType === 'document') { - // Для задач типа "document" - только секретари - let tempFiltered = users.filter(user => - user.name.toLowerCase().includes(search) || - user.login.toLowerCase().includes(search) || - user.email.toLowerCase().includes(search) - ); - - filtered = []; - for (const user of tempFiltered) { + const filteredByGroup = []; + for (const user of filtered) { const groups = await getUserGroups(user.id); - const hasSecretaryGroup = groups.some(group => - group.name === 'Секретарь' || + const hasSecretaryGroup = groups.some(group => + group.name === 'Секретарь' || (typeof group === 'string' && group.includes('Секретарь')) ); - - if (hasSecretaryGroup) { - filtered.push(user); - } + if (hasSecretaryGroup) filteredByGroup.push(user); } - } else { - filtered = users.filter(user => - user.name.toLowerCase().includes(search) || - user.login.toLowerCase().includes(search) || - user.email.toLowerCase().includes(search) - ); + filtered = filteredByGroup; } - + renderEditUsersChecklist(filtered); } async function filterCopyUsers() { - const search = document.getElementById('copy-user-search').value.toLowerCase(); - const taskId = document.getElementById('copy-task-id').value; - const task = tasks.find(t => t.id === taskId); + const search = document.getElementById('copy-user-search')?.value.toLowerCase() || ''; + const taskId = document.getElementById('copy-task-id')?.value; + if (!taskId) return; + + const task = window.tasks?.find(t => t.id == taskId); const taskType = task ? task.task_type : 'regular'; - - let filtered = []; - + + let filtered = users.filter(user => + user.name.toLowerCase().includes(search) || + user.login.toLowerCase().includes(search) || + (user.email && user.email.toLowerCase().includes(search)) + ); + if (taskType === 'document') { - // Для задач типа "document" - только секретари - let tempFiltered = users.filter(user => - user.name.toLowerCase().includes(search) || - user.login.toLowerCase().includes(search) || - user.email.toLowerCase().includes(search) - ); - - filtered = []; - for (const user of tempFiltered) { + const filteredByGroup = []; + for (const user of filtered) { const groups = await getUserGroups(user.id); - const hasSecretaryGroup = groups.some(group => - group.name === 'Секретарь' || + const hasSecretaryGroup = groups.some(group => + group.name === 'Секретарь' || (typeof group === 'string' && group.includes('Секретарь')) ); - - if (hasSecretaryGroup) { - filtered.push(user); - } + if (hasSecretaryGroup) filteredByGroup.push(user); } - } else { - filtered = users.filter(user => - user.name.toLowerCase().includes(search) || - user.login.toLowerCase().includes(search) || - user.email.toLowerCase().includes(search) - ); + filtered = filteredByGroup; } - + renderCopyUsersChecklist(filtered); } +// ==================== РЕНДЕРИНГ ЧЕКБОКСОВ ПОЛЬЗОВАТЕЛЕЙ ==================== + function renderUsersChecklist() { const container = document.getElementById('users-checklist'); - + if (!container) return; + + // Создаём структуру с двумя колонками, если её ещё нет + if (!container.querySelector('.users-two-columns')) { + container.innerHTML = ` +
У вас пока нет списков
'; + } else { + userLists.forEach(list => { + const memberCount = list.userIds ? list.userIds.length : 0; + // Экранируем название для безопасного использования в onclick + const listJson = JSON.stringify(list).replace(/"/g, '"'); + html += ` +