feat(requests): Этап 5 - реализован frontend для получателей заявлений

Создан компонент RecipientPicker:
- Множественный выбор отделов (ManyToMany)
- Выбор основных получателей (recipients)
- Выбор пользователей в копии (CC users)
- Флаг "Отправить всем сотрудникам отделов"
- Динамический рендеринг выбранных пользователей
- Валидация: нельзя указать recipients с флагом sent_to_all_department

Интеграция:
- Добавлены секции получателей в модальные окна создания и редактирования
- Обновлен requestCrudHandler.js для передачи данных получателей в API
- Добавлены стили recipientPicker.css
- Подключен CSS в request_list_full.html

Функциональность:
- Загрузка списка пользователей и отделов из API
- Добавление/удаление получателей через UI
- Автоматическая очистка форм после создания
- Поддержка редактирования существующих получателей
This commit is contained in:
admin i 2025-12-02 17:49:54 +03:00
parent fd6b020d26
commit deec86f3f9
4 changed files with 520 additions and 1 deletions

View File

@ -0,0 +1,67 @@
/* Recipient Picker Component Styles */
.recipient-picker {
padding: 0;
}
.recipient-picker .form-label {
font-weight: 500;
margin-bottom: 0.5rem;
}
.recipient-picker .form-label i {
color: var(--bs-primary);
margin-right: 0.25rem;
}
.recipient-list {
min-height: 2rem;
padding: 0.5rem;
background-color: var(--bs-light);
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
}
.recipient-list .badge {
font-size: 0.875rem;
padding: 0.375rem 0.5rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.recipient-list .btn-close-sm {
width: 0.75em;
height: 0.75em;
padding: 0;
background-size: 0.75em;
opacity: 0.7;
}
.recipient-list .btn-close-sm:hover {
opacity: 1;
}
.recipient-picker select[multiple] {
min-height: 80px;
}
.recipient-picker .form-check {
padding-left: 1.75rem;
}
.recipient-picker .form-check-input {
margin-top: 0.15rem;
}
.recipient-picker .alert {
margin-top: 1rem;
padding: 0.75rem 1rem;
}
/* Адаптивность */
@media (max-width: 576px) {
.recipient-list .badge {
font-size: 0.8rem;
padding: 0.3rem 0.4rem;
}
}

View File

@ -0,0 +1,320 @@
/**
* RecipientPicker - компонент для выбора получателей заявления
* Поддерживает выбор:
* - Нескольких отделов
* - Основных получателей (recipients)
* - Пользователей в копии (CC)
* - Флаг "Отправить всем сотрудникам отделов"
*/
export class RecipientPicker {
constructor(container, options = {}) {
this.container = container;
this.options = {
apiUsersUrl: '/api/v1/employees/',
apiDepartmentsUrl: '/api/v1/departments/',
onChange: null,
...options
};
this.state = {
departments: [],
recipients: [],
ccUsers: [],
sendToAllDepartment: false,
loading: false
};
this.users = [];
this.departments = [];
this.init();
}
async init() {
await this.loadData();
this.render();
this.attachEvents();
}
async loadData() {
this.state.loading = true;
try {
const [usersRes, deptsRes] = await Promise.all([
fetch(this.options.apiUsersUrl),
fetch(this.options.apiDepartmentsUrl)
]);
if (usersRes.ok) {
const usersData = await usersRes.json();
this.users = usersData.results || usersData;
}
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;
}
}
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> Отправить всем сотрудникам выбранных отделов
</label>
</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>
</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>
</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')}
</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>
Указаны и конкретные получатели, и флаг "всем сотрудникам".
Уведомления получат все сотрудники отделов + дополнительные получатели.
</div>
` : ''}
</div>
`;
}
renderSelectedUsers(userIds, type) {
if (!userIds || userIds.length === 0) {
return '<div class="text-muted small">Не выбрано</div>';
}
return userIds.map(id => {
const user = this.users.find(u => u.id === id);
if (!user) return '';
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>
</div>
`;
}).join('');
}
getAvailableUsers(type) {
// Исключаем уже выбранных пользователей
const excludeIds = [
...this.state.recipients,
...this.state.ccUsers
];
return this.users.filter(u => !excludeIds.includes(u.id));
}
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();
});
}
// Флаг "всем сотрудникам"
const sendToAllCheckbox = this.container.querySelector('#sendToAllDept');
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();
}
});
}
// Добавление в копию
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();
}
});
}
// Удаление получателя
this.container.querySelectorAll('[data-action="remove-recipient"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const userId = parseInt(e.target.dataset.userId);
this.state.recipients = this.state.recipients.filter(id => id !== userId);
this.render();
this.attachEvents();
this.triggerChange();
});
});
// Удаление CC
this.container.querySelectorAll('[data-action="remove-cc"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const userId = parseInt(e.target.dataset.userId);
this.state.ccUsers = this.state.ccUsers.filter(id => id !== userId);
this.render();
this.attachEvents();
this.triggerChange();
});
});
}
triggerChange() {
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
};
}
setValues(data) {
if (data.department_ids) {
this.state.departments = Array.isArray(data.department_ids)
? data.department_ids
: [data.department_ids];
}
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];
}
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.render();
this.attachEvents();
}
destroy() {
this.container.innerHTML = '';
}
}

View File

@ -3,6 +3,8 @@
* @description Обработчик CRUD операций для заявлений через API
*/
import { RecipientPicker } from './recipientPicker.js';
/**
* Инициализирует обработчики CRUD операций для заявлений
* @param {Object} options - Опции инициализации
@ -32,6 +34,26 @@ export function initRequestCrudHandler(options) {
return { destroy: () => {} };
}
// Инициализация RecipientPicker для создания
let createRecipientPicker = null;
const createPickerContainer = document.getElementById('createRecipientPicker');
if (createPickerContainer) {
createRecipientPicker = new RecipientPicker(createPickerContainer, {
apiUsersUrl: '/api/v1/employees/',
apiDepartmentsUrl: '/api/v1/departments/'
});
}
// Инициализация RecipientPicker для редактирования
let editRecipientPicker = null;
const editPickerContainer = document.getElementById('editRecipientPicker');
if (editPickerContainer) {
editRecipientPicker = new RecipientPicker(editPickerContainer, {
apiUsersUrl: '/api/v1/employees/',
apiDepartmentsUrl: '/api/v1/departments/'
});
}
/**
* Обработчик создания заявления
*/
@ -41,6 +63,37 @@ export function initRequestCrudHandler(options) {
const saveAs = e.submitter?.value || 'submit'; // 'draft' or 'submit'
const formData = new FormData(createForm);
// Добавляем данные получателей из RecipientPicker
if (createRecipientPicker) {
const recipients = createRecipientPicker.getValues();
// Добавляем department_ids
if (recipients.department_ids && recipients.department_ids.length > 0) {
recipients.department_ids.forEach(id => {
formData.append('department_ids', id);
});
}
// Добавляем recipient_ids
if (recipients.recipient_ids && recipients.recipient_ids.length > 0) {
recipients.recipient_ids.forEach(id => {
formData.append('recipient_ids', id);
});
}
// Добавляем cc_user_ids
if (recipients.cc_user_ids && recipients.cc_user_ids.length > 0) {
recipients.cc_user_ids.forEach(id => {
formData.append('cc_user_ids', id);
});
}
// Добавляем sent_to_all_department
if (recipients.sent_to_all_department) {
formData.set('sent_to_all_department', 'true');
}
}
// Удаляем пустые поля для черновика
if (saveAs === 'draft') {
for (const [key, value] of Array.from(formData.entries())) {
@ -68,6 +121,13 @@ export function initRequestCrudHandler(options) {
// Показываем сообщение и перезагружаем
const message = saveAs === 'draft' ? 'Черновик сохранён.' : 'Заявление отправлено на рассмотрение.';
alert(message); // TODO: заменить на toast-уведомление
// Очищаем форму и picker
createForm.reset();
if (createRecipientPicker) {
createRecipientPicker.reset();
}
window.location.reload();
} catch (error) {
alert('Не удалось создать заявление: ' + error.message);
@ -84,6 +144,37 @@ export function initRequestCrudHandler(options) {
const saveAs = e.submitter?.value || 'submit';
const formData = new FormData(editForm);
// Добавляем данные получателей из RecipientPicker
if (editRecipientPicker) {
const recipients = editRecipientPicker.getValues();
// Добавляем department_ids
if (recipients.department_ids && recipients.department_ids.length > 0) {
recipients.department_ids.forEach(id => {
formData.append('department_ids', id);
});
}
// Добавляем recipient_ids
if (recipients.recipient_ids && recipients.recipient_ids.length > 0) {
recipients.recipient_ids.forEach(id => {
formData.append('recipient_ids', id);
});
}
// Добавляем cc_user_ids
if (recipients.cc_user_ids && recipients.cc_user_ids.length > 0) {
recipients.cc_user_ids.forEach(id => {
formData.append('cc_user_ids', id);
});
}
// Добавляем sent_to_all_department
if (recipients.sent_to_all_department) {
formData.set('sent_to_all_department', 'true');
}
}
// Удаляем пустые поля
for (const [key, value] of Array.from(formData.entries())) {
if (!value || value === '') {
@ -288,11 +379,23 @@ export function initRequestCrudHandler(options) {
rejectForm?.removeEventListener('submit', handleReject);
cancelForm?.removeEventListener('submit', handleCancel);
commentForm?.removeEventListener('submit', handleComment);
// Очистка RecipientPicker
if (createRecipientPicker) {
createRecipientPicker.destroy();
}
if (editRecipientPicker) {
editRecipientPicker.destroy();
}
}
console.log('Request CRUD handler initialized');
return { destroy };
return {
destroy,
createRecipientPicker,
editRecipientPicker
};
}
// Экспорт для совместимости с неModular кодом

View File

@ -6,6 +6,7 @@
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/document-list.css' %}">
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
<link rel="stylesheet" href="{% static 'css/components/recipientPicker.css' %}">
{% endblock %}
{% block content %}
@ -264,6 +265,20 @@
<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>
</div>
<div class="modal-footer">
@ -345,6 +360,20 @@
<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">