Corp/backend/static/js/components/recipientPicker.js
Игорь Надеин 2543ce54c4 fix: improve recipient picker - load all users with avatars
RecipientPicker (заявления):
- Added pagination support to load ALL users (not just first page)
- Added avatar images from media URLs instead of base64
- Falls back to initials when no avatar available
- Loads users in batches of 100 per page

RecipientPickerHandler (чаты):
- Added pagination support for full user list
- Keeps existing avatar display logic
- Loads 100 users per page when not searching
- Limits to 20 results when searching

CSS improvements:
- Added styles for avatar images (.user-avatar-img)
- Added placeholder styles for initials
- Proper object-fit and border-radius for avatars
2025-12-08 12:09:32 +03:00

635 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = `
<div class="recipient-picker-email">
<!-- Отделы (показываются по кнопке) -->
${this.state.showDepartments ? `
<div class="recipient-row">
<label class="recipient-label">
<i class="bi-building"></i>
<span>Отделы:</span>
</label>
<div class="recipient-field">
<div class="recipient-chips" id="departmentChips">
${this.renderDepartmentChips()}
</div>
<div class="dropdown d-inline-block">
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle"
data-bs-toggle="dropdown">
Выбрать отдел
</button>
<div class="dropdown-menu" id="departmentDropdown">
${this.departments.map(dept => `
<div class="dropdown-item" data-action="toggle-dept" data-dept-id="${dept.id}">
<input type="checkbox" class="form-check-input me-2"
${this.state.departments.includes(dept.id) ? 'checked' : ''}>
${dept.name}
</div>
`).join('')}
</div>
</div>
<!-- Флаг: всем в отделе (только если выбран хотя бы 1 отдел) -->
${this.state.departments.length > 0 ? `
<div class="form-check form-check-inline ms-3">
<input class="form-check-input" type="checkbox" id="sendToAllDept"
${this.state.sendToAllDepartment ? 'checked' : ''}>
<label class="form-check-label text-muted small" for="sendToAllDept">
<i class="bi-people-fill"></i> Всем в отделе${this.state.departments.length > 1 ? 'ах' : ''}
</label>
</div>
` : ''}
</div>
</div>
` : ''}
<!-- Основные получатели (To:) -->
<div class="recipient-row">
<label class="recipient-label">
<i class="bi-person-check"></i>
<span>Кому:</span>
</label>
<div class="recipient-field">
<div class="recipient-input-wrapper" id="recipientsWrapper">
${this.renderUserChips(this.state.recipients)}
<input type="text"
class="recipient-input"
id="recipientInput"
placeholder="${this.options.placeholder}"
autocomplete="off">
<div class="recipient-dropdown" id="recipientDropdown" style="display: none;">
${this.renderUserDropdown(this.state.recipients, 'recipient')}
</div>
</div>
<div class="d-flex gap-2 ms-2">
${!this.state.showCC && this.options.showCC ? `
<button type="button" class="btn btn-sm btn-link text-decoration-none p-0"
id="showCCBtn">
+ Копия
</button>
` : ''}
${!this.state.showDepartments && this.options.showDepartments ? `
<button type="button" class="btn btn-sm btn-link text-decoration-none p-0"
id="showDepartmentsBtn">
+ Отделы
</button>
` : ''}
</div>
</div>
</div>
<!-- Копия (CC:) -->
${this.state.showCC && this.options.showCC ? `
<div class="recipient-row">
<label class="recipient-label">
<i class="bi-person-plus"></i>
<span>Копия:</span>
</label>
<div class="recipient-field">
<div class="recipient-input-wrapper" id="ccWrapper">
${this.renderUserChips(this.state.ccUsers, true)}
<input type="text"
class="recipient-input"
id="ccInput"
placeholder="Добавить в копию..."
autocomplete="off">
<div class="recipient-dropdown" id="ccDropdown" style="display: none;">
${this.renderUserDropdown(this.state.ccUsers, 'cc')}
</div>
</div>
</div>
</div>
` : ''}
<!-- Подсказка -->
${this.state.sendToAllDepartment && this.state.departments.length > 0 ? `
<div class="alert alert-info alert-sm">
<i class="bi-info-circle"></i>
<small>
Заявление будет отправлено всем сотрудникам выбранных отделов
${this.state.recipients.length > 0 ? ' + указанным получателям' : ''}.
</small>
</div>
` : ''}
</div>
`;
}
renderDepartmentChips() {
if (this.state.departments.length === 0) {
return '<span class="text-muted small">Не выбрано</span>';
}
return this.state.departments.map(id => {
const dept = this.departments.find(d => d.id === id);
if (!dept) return '';
return `
<span class="recipient-chip">
${dept.name}
<button type="button" class="chip-remove" data-action="remove-dept" data-dept-id="${id}">
<i class="bi-x"></i>
</button>
</span>
`;
}).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 `
<span class="recipient-chip ${isCC ? 'chip-cc' : ''}">
${displayName}
<button type="button" class="chip-remove"
data-action="remove-${isCC ? 'cc' : 'recipient'}"
data-user-id="${id}">
<i class="bi-x"></i>
</button>
</span>
`;
}).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 '<div class="dropdown-item-empty">Никого не найдено</div>';
}
// Ограничиваем до 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 = `<img src="${user.avatar}" alt="${displayName}" class="user-avatar-img">`;
} else {
avatarHtml = `<div class="user-avatar-placeholder">${this.getInitials(user)}</div>`;
}
return `
<div class="dropdown-item-user" data-action="add-${type}" data-user-id="${user.id}">
<div class="user-avatar-small">${avatarHtml}</div>
<div class="user-info">
<div class="user-name">${displayName}</div>
${subtitle ? `<div class="user-subtitle">${subtitle}</div>` : ''}
</div>
</div>
`;
}).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 = '';
}
}