/** * RecipientPicker - компонент для выбора получателей заявления в стиле email-клиента * Использует Token/Chip input с autocomplete для удобного выбора получателей * * Особенности: * - Autocomplete с поиском по имени/email * - Chip-based UI (как в Gmail) * - Разделение To/CC (как в почтовых клиентах) * - Выбор отделов через dropdown * - Опция "Всем сотрудникам отделов" */ export class RecipientPicker { constructor(container, options = {}) { this.container = container; this.options = { apiUsersUrl: '/api/v1/employees/', apiDepartmentsUrl: '/api/v1/departments/', placeholder: 'Начните вводить имя или выберите из списка...', showCC: true, showDepartments: true, onChange: null, ...options }; this.state = { departments: [], recipients: [], ccUsers: [], sendToAllDepartment: false, showCC: false, showDepartments: false, // Новый флаг для показа строки отделов loading: false, searchQuery: '', searchQueryCC: '', showDropdown: false, showDropdownCC: false }; this.users = []; this.departments = []; this.init(); } async init() { await this.loadData(); this.render(); this.attachEvents(); } async loadData() { this.state.loading = true; try { // Загружаем пользователей с пагинацией (все страницы) this.users = await this.loadAllUsers(); // Загружаем отделы const deptsRes = await fetch(this.options.apiDepartmentsUrl); if (deptsRes.ok) { const deptsData = await deptsRes.json(); this.departments = deptsData.results || deptsData; } } catch (error) { console.error('RecipientPicker: Failed to load data', error); } finally { this.state.loading = false; } } /** * Загрузка всех пользователей с автоматической обработкой пагинации */ async loadAllUsers() { const allUsers = []; let nextUrl = this.options.apiUsersUrl + '?page_size=100&active=true'; while (nextUrl) { try { const response = await fetch(nextUrl); if (!response.ok) break; const data = await response.json(); const users = data.results || data; if (Array.isArray(users)) { allUsers.push(...users); } // Проверяем наличие следующей страницы nextUrl = data.next || null; } catch (error) { console.error('RecipientPicker: Error loading users page:', error); break; } } console.log(`RecipientPicker: Loaded ${allUsers.length} users`); return allUsers; } render() { this.container.innerHTML = `
${this.state.showDepartments ? `
${this.renderDepartmentChips()}
${this.state.departments.length > 0 ? `
` : ''}
` : ''}
${this.renderUserChips(this.state.recipients)}
${!this.state.showCC && this.options.showCC ? ` ` : ''} ${!this.state.showDepartments && this.options.showDepartments ? ` ` : ''}
${this.state.showCC && this.options.showCC ? `
${this.renderUserChips(this.state.ccUsers, true)}
` : ''} ${this.state.sendToAllDepartment && this.state.departments.length > 0 ? `
Заявление будет отправлено всем сотрудникам выбранных отделов ${this.state.recipients.length > 0 ? ' + указанным получателям' : ''}.
` : ''}
`; } renderDepartmentChips() { if (this.state.departments.length === 0) { return 'Не выбрано'; } return this.state.departments.map(id => { const dept = this.departments.find(d => d.id === id); if (!dept) return ''; return ` ${dept.name} `; }).join(''); } renderUserChips(userIds, isCC = false) { return userIds.map(id => { const user = this.users.find(u => u.id === id); if (!user) return ''; const displayName = `${user.last_name} ${user.first_name}`; return ` ${displayName} `; }).join(''); } renderUserDropdown(excludeIds, type) { const query = type === 'cc' ? this.state.searchQueryCC : this.state.searchQuery; const allExcludeIds = [...this.state.recipients, ...this.state.ccUsers]; let filteredUsers = this.users.filter(u => !allExcludeIds.includes(u.id)); // Фильтрация по запросу if (query) { const lowerQuery = query.toLowerCase(); filteredUsers = filteredUsers.filter(u => { const fullName = `${u.last_name} ${u.first_name} ${u.patronymic || ''}`.toLowerCase(); const email = (u.email || '').toLowerCase(); return fullName.includes(lowerQuery) || email.includes(lowerQuery); }); } if (filteredUsers.length === 0) { return ''; } // Ограничиваем до 10 результатов return filteredUsers.slice(0, 10).map(user => { const displayName = `${user.last_name} ${user.first_name}`; const subtitle = user.position || user.email || ''; // Аватар или инициалы let avatarHtml; if (user.avatar) { avatarHtml = `${displayName}`; } else { avatarHtml = `
${this.getInitials(user)}
`; } return ` `; }).join(''); } getInitials(user) { const first = (user.first_name || '').charAt(0).toUpperCase(); const last = (user.last_name || '').charAt(0).toUpperCase(); return first + last || '?'; } attachEvents() { // Показать CC const showCCBtn = this.container.querySelector('#showCCBtn'); if (showCCBtn) { showCCBtn.addEventListener('click', () => { this.state.showCC = true; this.render(); this.attachEvents(); // Фокус на CC input setTimeout(() => { this.container.querySelector('#ccInput')?.focus(); }, 100); }); } // Показать Отделы const showDepartmentsBtn = this.container.querySelector('#showDepartmentsBtn'); if (showDepartmentsBtn) { showDepartmentsBtn.addEventListener('click', () => { this.state.showDepartments = true; this.render(); this.attachEvents(); }); } // Флаг "всем сотрудникам" const sendToAllCheckbox = this.container.querySelector('#sendToAllDept'); if (sendToAllCheckbox) { sendToAllCheckbox.addEventListener('change', (e) => { this.state.sendToAllDepartment = e.target.checked; this.render(); this.attachEvents(); this.triggerChange(); }); } // Toggle отделов через dropdown this.container.querySelectorAll('[data-action="toggle-dept"]').forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); const deptId = parseInt(e.currentTarget.dataset.deptId); if (this.state.departments.includes(deptId)) { this.state.departments = this.state.departments.filter(id => id !== deptId); } else { this.state.departments.push(deptId); } this.render(); this.attachEvents(); this.triggerChange(); }); }); // Удаление отдела через chip this.container.querySelectorAll('[data-action="remove-dept"]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const deptId = parseInt(e.currentTarget.dataset.deptId); this.state.departments = this.state.departments.filter(id => id !== deptId); this.render(); this.attachEvents(); this.triggerChange(); }); }); // Input для получателей с autocomplete const recipientInput = this.container.querySelector('#recipientInput'); if (recipientInput) { recipientInput.addEventListener('input', (e) => { this.state.searchQuery = e.target.value; const dropdown = this.container.querySelector('#recipientDropdown'); if (this.state.searchQuery.length > 0) { dropdown.innerHTML = this.renderUserDropdown(this.state.recipients, 'recipient'); dropdown.style.display = 'block'; this.state.showDropdown = true; } else { dropdown.style.display = 'none'; this.state.showDropdown = false; } this.attachDropdownEvents(); }); // Показать dropdown при фокусе recipientInput.addEventListener('focus', () => { const dropdown = this.container.querySelector('#recipientDropdown'); dropdown.innerHTML = this.renderUserDropdown(this.state.recipients, 'recipient'); dropdown.style.display = 'block'; this.state.showDropdown = true; this.attachDropdownEvents(); }); // Скрыть dropdown при потере фокуса (с задержкой для клика) recipientInput.addEventListener('blur', () => { setTimeout(() => { const dropdown = this.container.querySelector('#recipientDropdown'); if (dropdown) { dropdown.style.display = 'none'; this.state.showDropdown = false; } }, 200); }); } // Input для CC с autocomplete const ccInput = this.container.querySelector('#ccInput'); if (ccInput) { ccInput.addEventListener('input', (e) => { this.state.searchQueryCC = e.target.value; const dropdown = this.container.querySelector('#ccDropdown'); if (this.state.searchQueryCC.length > 0) { dropdown.innerHTML = this.renderUserDropdown(this.state.ccUsers, 'cc'); dropdown.style.display = 'block'; this.state.showDropdownCC = true; } else { dropdown.style.display = 'none'; this.state.showDropdownCC = false; } this.attachDropdownEvents(); }); ccInput.addEventListener('focus', () => { const dropdown = this.container.querySelector('#ccDropdown'); dropdown.innerHTML = this.renderUserDropdown(this.state.ccUsers, 'cc'); dropdown.style.display = 'block'; this.state.showDropdownCC = true; this.attachDropdownEvents(); }); ccInput.addEventListener('blur', () => { setTimeout(() => { const dropdown = this.container.querySelector('#ccDropdown'); if (dropdown) { dropdown.style.display = 'none'; this.state.showDropdownCC = false; } }, 200); }); } // Удаление получателей через chip this.container.querySelectorAll('[data-action="remove-recipient"]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const userId = parseInt(e.currentTarget.dataset.userId); this.state.recipients = this.state.recipients.filter(id => id !== userId); this.render(); this.attachEvents(); this.triggerChange(); }); }); // Удаление CC через chip this.container.querySelectorAll('[data-action="remove-cc"]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const userId = parseInt(e.currentTarget.dataset.userId); this.state.ccUsers = this.state.ccUsers.filter(id => id !== userId); this.render(); this.attachEvents(); this.triggerChange(); }); }); } attachDropdownEvents() { // Добавление получателя из dropdown this.container.querySelectorAll('[data-action="add-recipient"]').forEach(item => { item.addEventListener('click', () => { const userId = parseInt(item.dataset.userId); if (!this.state.recipients.includes(userId)) { this.state.recipients.push(userId); this.state.searchQuery = ''; const input = this.container.querySelector('#recipientInput'); if (input) input.value = ''; this.render(); this.attachEvents(); this.triggerChange(); // Возвращаем фокус setTimeout(() => { this.container.querySelector('#recipientInput')?.focus(); }, 100); } }); }); // Добавление CC из dropdown this.container.querySelectorAll('[data-action="add-cc"]').forEach(item => { item.addEventListener('click', () => { const userId = parseInt(item.dataset.userId); if (!this.state.ccUsers.includes(userId)) { this.state.ccUsers.push(userId); this.state.searchQueryCC = ''; const input = this.container.querySelector('#ccInput'); if (input) input.value = ''; this.render(); this.attachEvents(); this.triggerChange(); // Возвращаем фокус setTimeout(() => { this.container.querySelector('#ccInput')?.focus(); }, 100); } }); }); } triggerChange() { // Очищаем ошибку при любом изменении this.clearError(); if (this.options.onChange) { this.options.onChange(this.getValues()); } } getValues() { return { department_ids: this.state.departments, recipient_ids: this.state.recipients, cc_user_ids: this.state.ccUsers, sent_to_all_department: this.state.sendToAllDepartment }; } /** * Проверяет, заполнены ли получатели * @returns {boolean} true если есть получатели или отделы */ validate() { const hasDepartments = this.state.departments.length > 0; const hasRecipients = this.state.recipients.length > 0; const isValid = hasDepartments || hasRecipients; if (!isValid) { this.setError('Укажите хотя бы одного получателя или отдел'); } else { this.clearError(); } return isValid; } /** * Устанавливает сообщение об ошибке * @param {string} message - Текст ошибки */ setError(message) { this.clearError(); const errorDiv = document.createElement('div'); errorDiv.className = 'invalid-feedback d-block'; errorDiv.textContent = message; errorDiv.id = 'recipientPickerError'; this.container.appendChild(errorDiv); this.container.classList.add('is-invalid'); } /** * Убирает сообщение об ошибке */ clearError() { const errorDiv = this.container.querySelector('#recipientPickerError'); if (errorDiv) { errorDiv.remove(); } this.container.classList.remove('is-invalid'); } setValues(data) { if (data.department_ids) { this.state.departments = Array.isArray(data.department_ids) ? data.department_ids : [data.department_ids]; // Если есть отделы - показываем строку отделов if (this.state.departments.length > 0) { this.state.showDepartments = true; } } if (data.recipient_ids) { this.state.recipients = Array.isArray(data.recipient_ids) ? data.recipient_ids : [data.recipient_ids]; } if (data.cc_user_ids) { this.state.ccUsers = Array.isArray(data.cc_user_ids) ? data.cc_user_ids : [data.cc_user_ids]; // Если есть CC - показываем строку CC if (this.state.ccUsers.length > 0) { this.state.showCC = true; } } if (data.sent_to_all_department !== undefined) { this.state.sendToAllDepartment = data.sent_to_all_department; } this.render(); this.attachEvents(); } reset() { this.state.departments = []; this.state.recipients = []; this.state.ccUsers = []; this.state.sendToAllDepartment = false; this.state.showCC = false; this.state.showDepartments = false; this.render(); this.attachEvents(); } destroy() { this.container.innerHTML = ''; } }