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:
parent
31bafcb039
commit
aa32439e8b
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user