refactor(requests): Улучшен UX выбора получателей в стиле email-клиента

Переработан RecipientPicker компонент:
✨ Chip-based UI (как в Gmail/Outlook)
- Пользователи отображаются как chips с крестиком для удаления
- Autocomplete с живым поиском по имени/email
- Dropdown с аватарами и должностями пользователей

� Интуитивный интерфейс:
- Структура To:/CC:/Отделы: (как в почтовых клиентах)
- Получатели всегда видны (убраны collapse)
- Кнопка '+ Копия' для показа поля CC
- Inline флаг 'Всем сотрудникам'

� Улучшенная верстка:
- Модальные окна расширены до modal-lg
- Получатели перенесены в начало (как тема в email)
- Добавлены иконки для визуальной группировки
- Добавлены emoji в типы заявлений для удобства

� CSS обновлен:
- Email-style компактный дизайн
- Фокус-состояния для inputs
- Hover-эффекты для dropdown items
- Адаптивность для мобильных устройств
- Поддержка темной темы

� UX улучшения:
- Поиск с автодополнением вместо select
- Визуальные аватары пользователей (инициалы)
- Показ должности и email в dropdown
- Автофокус после добавления пользователя
- Ограничение результатов поиска до 10
This commit is contained in:
admin i 2025-12-02 18:09:19 +03:00
parent 31bafcb039
commit aa32439e8b
3 changed files with 632 additions and 235 deletions

View File

@ -1,67 +1,269 @@
/* Recipient Picker Component Styles */
.recipient-picker {
padding: 0;
/* Recipient Picker - Email-style Component */
.recipient-picker-email {
font-size: 0.9375rem;
}
.recipient-picker .form-label {
/* Строка получателей (To:/CC:/Отделы:) */
.recipient-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--bs-border-color);
}
.recipient-row:last-child {
border-bottom: none;
}
/* Метка (To:/CC:/Отделы:) */
.recipient-label {
min-width: 80px;
padding-top: 0.5rem;
color: var(--bs-secondary);
font-weight: 500;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.recipient-picker .form-label i {
color: var(--bs-primary);
margin-right: 0.25rem;
.recipient-label i {
font-size: 1.1em;
}
.recipient-list {
min-height: 2rem;
padding: 0.5rem;
background-color: var(--bs-light);
/* Поле ввода */
.recipient-field {
flex: 1;
display: flex;
align-items: flex-start;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Wrapper для chips + input */
.recipient-input-wrapper {
flex: 1;
min-width: 300px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
position: relative;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.recipient-list .badge {
font-size: 0.875rem;
padding: 0.375rem 0.5rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
.recipient-input-wrapper:focus-within {
border-color: var(--bs-primary);
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15);
}
.recipient-list .btn-close-sm {
width: 0.75em;
height: 0.75em;
padding: 0;
background-size: 0.75em;
/* Input внутри wrapper */
.recipient-input {
flex: 1;
min-width: 200px;
border: none;
outline: none;
padding: 0.25rem;
font-size: inherit;
background: transparent;
}
.recipient-input::placeholder {
color: var(--bs-secondary-color);
opacity: 0.7;
}
.recipient-list .btn-close-sm:hover {
/* Chips (выбранные пользователи/отделы) */
.recipient-chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
background: var(--bs-primary);
color: white;
border-radius: 1rem;
font-size: 0.875rem;
white-space: nowrap;
transition: background-color 0.15s;
}
.recipient-chip:hover {
background: var(--bs-primary-dark, #0056b3);
}
.recipient-chip.chip-cc {
background: var(--bs-secondary);
}
.recipient-chip.chip-cc:hover {
background: var(--bs-secondary-dark, #5a6268);
}
.chip-remove {
background: none;
border: none;
padding: 0;
color: white;
cursor: pointer;
display: flex;
align-items: center;
opacity: 0.8;
transition: opacity 0.15s;
}
.chip-remove:hover {
opacity: 1;
}
.recipient-picker select[multiple] {
min-height: 80px;
.chip-remove i {
font-size: 1.1em;
line-height: 1;
}
.recipient-picker .form-check {
padding-left: 1.75rem;
/* Chips для отделов */
.recipient-chips {
display: inline-flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
}
.recipient-picker .form-check-input {
margin-top: 0.15rem;
/* Dropdown с autocomplete */
.recipient-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: white;
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
max-height: 300px;
overflow-y: auto;
z-index: 1050;
}
.recipient-picker .alert {
margin-top: 1rem;
.dropdown-item-user {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1rem;
cursor: pointer;
transition: background-color 0.15s;
}
.dropdown-item-user:hover {
background-color: var(--bs-light);
}
.dropdown-item-empty {
padding: 0.75rem 1rem;
color: var(--bs-secondary);
text-align: center;
font-size: 0.875rem;
}
/* Аватар пользователя */
.user-avatar-small {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bs-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.75rem;
flex-shrink: 0;
}
/* Информация о пользователе */
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-weight: 500;
color: var(--bs-body-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-subtitle {
font-size: 0.8125rem;
color: var(--bs-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Подсказки */
.alert-sm {
padding: 0.5rem 0.75rem;
margin-top: 0.75rem;
font-size: 0.875rem;
}
/* Адаптивность */
@media (max-width: 576px) {
.recipient-list .badge {
font-size: 0.8rem;
padding: 0.3rem 0.4rem;
@media (max-width: 768px) {
.recipient-row {
flex-direction: column;
gap: 0.5rem;
}
.recipient-label {
min-width: auto;
padding-top: 0;
}
.recipient-input-wrapper {
min-width: 100%;
}
.recipient-input {
min-width: 150px;
}
}
@media (max-width: 576px) {
.recipient-chip {
font-size: 0.8125rem;
padding: 0.2rem 0.4rem;
}
.user-avatar-small {
width: 28px;
height: 28px;
font-size: 0.7rem;
}
.dropdown-item-user {
padding: 0.5rem 0.75rem;
}
}
/* Темная тема (если нужна) */
@media (prefers-color-scheme: dark) {
.recipient-input-wrapper {
background: var(--bs-dark);
border-color: var(--bs-gray-700);
}
.recipient-dropdown {
background: var(--bs-dark);
border-color: var(--bs-gray-700);
}
.dropdown-item-user:hover {
background-color: var(--bs-gray-800);
}
}

View File

@ -1,10 +1,13 @@
/**
* RecipientPicker - компонент для выбора получателей заявления
* Поддерживает выбор:
* - Нескольких отделов
* - Основных получателей (recipients)
* - Пользователей в копии (CC)
* - Флаг "Отправить всем сотрудникам отделов"
* RecipientPicker - компонент для выбора получателей заявления в стиле email-клиента
* Использует Token/Chip input с autocomplete для удобного выбора получателей
*
* Особенности:
* - Autocomplete с поиском по имени/email
* - Chip-based UI (как в Gmail)
* - Разделение To/CC (как в почтовых клиентах)
* - Выбор отделов через dropdown
* - Опция "Всем сотрудникам отделов"
*/
export class RecipientPicker {
@ -13,6 +16,9 @@ export class RecipientPicker {
this.options = {
apiUsersUrl: '/api/v1/employees/',
apiDepartmentsUrl: '/api/v1/departments/',
placeholder: 'Начните вводить имя или выберите из списка...',
showCC: true,
showDepartments: true,
onChange: null,
...options
};
@ -22,7 +28,12 @@ export class RecipientPicker {
recipients: [],
ccUsers: [],
sendToAllDepartment: false,
loading: false
showCC: false,
loading: false,
searchQuery: '',
searchQueryCC: '',
showDropdown: false,
showDropdownCC: false
};
this.users = [];
@ -63,133 +74,206 @@ export class RecipientPicker {
render() {
this.container.innerHTML = `
<div class="recipient-picker">
<!-- Отделы -->
<div class="mb-3">
<label class="form-label">
<i class="bi-building"></i> Отделы
<span class="text-muted small">(можно выбрать несколько)</span>
</label>
<select class="form-select" id="recipientDepts" multiple size="3">
${this.departments.map(dept => `
<option value="${dept.id}" ${this.state.departments.includes(dept.id) ? 'selected' : ''}>
${dept.name}
</option>
`).join('')}
</select>
<div class="form-text">
Заявление будет видно сотрудникам отделов с правами обработки
</div>
</div>
<!-- Флаг: всем сотрудникам -->
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="sendToAllDept"
${this.state.sendToAllDepartment ? 'checked' : ''}>
<label class="form-check-label" for="sendToAllDept">
<i class="bi-people-fill"></i> Отправить всем сотрудникам выбранных отделов
<div class="recipient-picker-email">
<!-- Отделы (компактный выбор) -->
${this.options.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>
<!-- Флаг: всем сотрудникам -->
<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> Всем сотрудникам
</label>
</div>
</div>
</div>
<div class="form-text ms-4">
Все активные сотрудники выбранных отделов получат уведомление и доступ к заявлению
</div>
</div>
` : ''}
<!-- Основные получатели -->
<div class="mb-3" id="recipientsBlock">
<label class="form-label">
<i class="bi-person-check"></i> Основные получатели
<span class="text-muted small">(опционально)</span>
<!-- Основные получатели (To:) -->
<div class="recipient-row">
<label class="recipient-label">
<i class="bi-person-check"></i>
<span>Кому:</span>
</label>
<div class="recipient-list mb-2" id="recipientsList">
${this.renderSelectedUsers(this.state.recipients, 'recipient')}
</div>
<select class="form-select" id="recipientsSelect">
<option value="">Добавить получателя...</option>
${this.getAvailableUsers('recipients').map(user => `
<option value="${user.id}">
${user.last_name} ${user.first_name}${user.patronymic ? ' ' + user.patronymic : ''}
${user.position ? ` - ${user.position}` : ''}
</option>
`).join('')}
</select>
<div class="form-text">
Получат уведомление и смогут работать с заявлением
<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>
${!this.state.showCC && this.options.showCC ? `
<button type="button" class="btn btn-sm btn-link text-decoration-none p-0 ms-2"
id="showCCBtn">
+ Копия
</button>
` : ''}
</div>
</div>
<!-- Пользователи в копии -->
<div class="mb-3">
<label class="form-label">
<i class="bi-person-plus"></i> В копии (CC)
<span class="text-muted small">(опционально)</span>
</label>
<div class="recipient-list mb-2" id="ccList">
${this.renderSelectedUsers(this.state.ccUsers, 'cc')}
<!-- Копия (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>
<select class="form-select" id="ccSelect">
<option value="">Добавить в копию...</option>
${this.getAvailableUsers('cc').map(user => `
<option value="${user.id}">
${user.last_name} ${user.first_name}${user.patronymic ? ' ' + user.patronymic : ''}
${user.position ? ` - ${user.position}` : ''}
</option>
`).join('')}
</select>
<div class="form-text">
Получат уведомление, но не смогут изменять статус заявления
</div>
</div>
` : ''}
${this.state.sendToAllDepartment && this.state.recipients.length > 0 ? `
<div class="alert alert-warning small">
<i class="bi-exclamation-triangle"></i>
Указаны и конкретные получатели, и флаг "всем сотрудникам".
Уведомления получат все сотрудники отделов + дополнительные получатели.
<!-- Подсказка -->
${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>
`;
}
renderSelectedUsers(userIds, type) {
if (!userIds || userIds.length === 0) {
return '<div class="text-muted small">Не выбрано</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 `
<div class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle me-1 mb-1">
${user.last_name} ${user.first_name}
<button type="button" class="btn-close btn-close-sm ms-1"
data-action="remove-${type}" data-user-id="${id}"
aria-label="Удалить" style="font-size: 0.7em;"></button>
<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 || '';
return `
<div class="dropdown-item-user" data-action="add-${type}" data-user-id="${user.id}">
<div class="user-avatar-small">${this.getInitials(user)}</div>
<div class="user-info">
<div class="user-name">${displayName}</div>
${subtitle ? `<div class="user-subtitle">${subtitle}</div>` : ''}
</div>
</div>
`;
}).join('');
}
getAvailableUsers(type) {
// Исключаем уже выбранных пользователей
const excludeIds = [
...this.state.recipients,
...this.state.ccUsers
];
return this.users.filter(u => !excludeIds.includes(u.id));
getInitials(user) {
const first = (user.first_name || '').charAt(0).toUpperCase();
const last = (user.last_name || '').charAt(0).toUpperCase();
return first + last || '?';
}
attachEvents() {
// Выбор отделов
const deptsSelect = this.container.querySelector('#recipientDepts');
if (deptsSelect) {
deptsSelect.addEventListener('change', (e) => {
this.state.departments = Array.from(e.target.selectedOptions).map(o => parseInt(o.value));
this.triggerChange();
// Показать 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);
});
}
@ -198,55 +282,125 @@ export class RecipientPicker {
if (sendToAllCheckbox) {
sendToAllCheckbox.addEventListener('change', (e) => {
this.state.sendToAllDepartment = e.target.checked;
// Если включен флаг, отключаем возможность выбора конкретных получателей
const recipientsBlock = this.container.querySelector('#recipientsBlock');
if (recipientsBlock) {
if (e.target.checked) {
recipientsBlock.style.opacity = '0.6';
} else {
recipientsBlock.style.opacity = '1';
}
}
this.render();
this.attachEvents();
this.triggerChange();
});
}
// Добавление основного получателя
const recipientsSelect = this.container.querySelector('#recipientsSelect');
if (recipientsSelect) {
recipientsSelect.addEventListener('change', (e) => {
const userId = parseInt(e.target.value);
if (userId && !this.state.recipients.includes(userId)) {
this.state.recipients.push(userId);
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);
});
}
// Добавление в копию
const ccSelect = this.container.querySelector('#ccSelect');
if (ccSelect) {
ccSelect.addEventListener('change', (e) => {
const userId = parseInt(e.target.value);
if (userId && !this.state.ccUsers.includes(userId)) {
this.state.ccUsers.push(userId);
this.render();
this.attachEvents();
this.triggerChange();
// 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) => {
const userId = parseInt(e.target.dataset.userId);
e.stopPropagation();
const userId = parseInt(e.currentTarget.dataset.userId);
this.state.recipients = this.state.recipients.filter(id => id !== userId);
this.render();
this.attachEvents();
@ -254,10 +408,11 @@ export class RecipientPicker {
});
});
// Удаление CC
// Удаление CC через chip
this.container.querySelectorAll('[data-action="remove-cc"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const userId = parseInt(e.target.dataset.userId);
e.stopPropagation();
const userId = parseInt(e.currentTarget.dataset.userId);
this.state.ccUsers = this.state.ccUsers.filter(id => id !== userId);
this.render();
this.attachEvents();
@ -266,6 +421,54 @@ export class RecipientPicker {
});
}
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() {
if (this.options.onChange) {
this.options.onChange(this.getValues());

View File

@ -216,68 +216,65 @@
<!-- Create -->
<div class="modal fade" id="reqCreateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-dialog modal-dialog-centered modal-lg">
<form class="modal-content" id="reqCreateForm" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">Создать заявление</h5>
<h5 class="modal-title">
<i class="bi-plus-circle me-2"></i>Новое заявление
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<!-- Получатели (как в email - в начале) -->
<div class="mb-4 pb-3 border-bottom">
<div id="createRecipientPicker"></div>
</div>
<div class="mb-3">
<label class="form-label">Тип</label>
<label class="form-label">Тип заявления</label>
<select name="type" class="form-select" required>
<option value="" selected disabled>Выберите…</option>
<option value="vacation">Отпуск</option>
<option value="sick_leave">Больничный</option>
<option value="transfer">Перевод</option>
<option value="dismissal">Увольнение</option>
<option value="other">Другое</option>
<option value="" selected disabled>Выберите тип…</option>
<option value="vacation">📅 Отпуск</option>
<option value="sick_leave">🏥 Больничный</option>
<option value="transfer">↔️ Перевод</option>
<option value="dismissal">👋 Увольнение</option>
<option value="other">📋 Другое</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Заголовок</label>
<input type="text" name="title" class="form-control" maxlength="255">
<label class="form-label">Тема</label>
<input type="text" name="title" class="form-control"
placeholder="Краткое описание заявления..." maxlength="255">
</div>
<div class="row g-2">
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">С даты</label>
<label class="form-label">Дата начала</label>
<input type="date" name="date_from" class="form-control">
</div>
<div class="col">
<label class="form-label">По дату</label>
<label class="form-label">Дата окончания</label>
<input type="date" name="date_to" class="form-control">
</div>
</div>
<div class="mt-3">
<div class="mb-3">
<label class="form-label">Комментарий</label>
<div class="message-field message-field--solo">
<textarea name="comment" class="form-control message-input" rows="3"></textarea>
<textarea name="comment" class="form-control message-input" rows="3"
placeholder="Дополнительная информация..."></textarea>
</div>
</div>
<div class="mt-3">
<label class="form-label">Вложение</label>
<div class="mb-3">
<label class="form-label">
<i class="bi-paperclip"></i> Вложение
</label>
<input type="file" name="attachment" class="form-control">
<div class="form-text">Допустимые форматы и размер контролируются на сервере.</div>
</div>
<!-- Получатели -->
<div class="mt-4">
<div class="d-flex align-items-center gap-2 mb-3">
<h6 class="mb-0">Получатели</h6>
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="collapse" data-bs-target="#createRecipientsCollapse">
<i class="bi-people"></i> Настроить
</button>
</div>
<div class="collapse" id="createRecipientsCollapse">
<div id="createRecipientPicker"></div>
</div>
<div class="form-text">PDF, JPG, PNG до 10MB</div>
</div>
</div>
@ -303,12 +300,14 @@
<!-- Edit (PATCH) -->
<div class="modal fade" id="reqEditModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-dialog modal-dialog-centered modal-lg">
<form class="modal-content" id="reqEditForm" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="id" id="editId">
<div class="modal-header">
<h5 class="modal-title">Изменить заявление</h5>
<h5 class="modal-title">
<i class="bi-pencil me-2"></i>Редактировать заявление
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
@ -316,30 +315,35 @@
<!-- Значение по умолчанию — submit -->
<input type="hidden" name="_save_as" value="submit">
<!-- Получатели -->
<div class="mb-4 pb-3 border-bottom">
<div id="editRecipientPicker"></div>
</div>
<div class="mb-3">
<label class="form-label">Тип</label>
<label class="form-label">Тип заявления</label>
<select name="type" id="editType" class="form-select" required>
<option value="" disabled>Выберите…</option>
<option value="vacation">Отпуск</option>
<option value="sick_leave">Больничный</option>
<option value="transfer">Перевод</option>
<option value="dismissal">Увольнение</option>
<option value="other">Другое</option>
<option value="" disabled>Выберите тип…</option>
<option value="vacation">📅 Отпуск</option>
<option value="sick_leave">🏥 Больничный</option>
<option value="transfer">↔️ Перевод</option>
<option value="dismissal">👋 Увольнение</option>
<option value="other">📋 Другое</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Заголовок</label>
<label class="form-label">Тема</label>
<input type="text" name="title" id="editTitle" class="form-control" maxlength="255">
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">С даты</label>
<label class="form-label">Дата начала</label>
<input type="date" name="date_from" id="editDateFrom" class="form-control">
</div>
<div class="col">
<label class="form-label">По дату</label>
<label class="form-label">Дата окончания</label>
<input type="date" name="date_to" id="editDateTo" class="form-control">
</div>
</div>
@ -350,7 +354,9 @@
</div>
<div class="mb-3">
<label class="form-label">Новое вложение</label>
<label class="form-label">
<i class="bi-paperclip"></i> Новое вложение
</label>
<input type="file" name="attachment" class="form-control">
</div>
@ -360,20 +366,6 @@
<label class="form-check-label" for="editIsDraft">Сохранить как черновик</label>
<div class="form-text">Если отмечено, заявка будет переведена в статус черновика.</div>
</div>
<!-- Получатели -->
<div class="mt-4">
<div class="d-flex align-items-center gap-2 mb-3">
<h6 class="mb-0">Получатели</h6>
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="collapse" data-bs-target="#editRecipientsCollapse">
<i class="bi-people"></i> Настроить
</button>
</div>
<div class="collapse" id="editRecipientsCollapse">
<div id="editRecipientPicker"></div>
</div>
</div>
</div>
<div class="modal-footer">