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:
parent
5b44848a35
commit
ba422c7669
67
backend/static/css/components/recipientPicker.css
Normal file
67
backend/static/css/components/recipientPicker.css
Normal 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;
|
||||
}
|
||||
}
|
||||
320
backend/static/js/components/recipientPicker.js
Normal file
320
backend/static/js/components/recipientPicker.js
Normal 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 = '';
|
||||
}
|
||||
}
|
||||
@ -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 кодом
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user