/**
* 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.state.showCC && this.options.showCC ? `
` : ''}
${!this.state.showDepartments && this.options.showDepartments ? `
` : ''}
${this.state.showCC && this.options.showCC ? `
` : ''}
${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 = `
`;
} else {
avatarHtml = `${this.getInitials(user)}
`;
}
return `
${avatarHtml}
${displayName}
${subtitle ? `
${subtitle}
` : ''}
`;
}).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 = '';
}
}