diff --git a/backend/static/css/components/recipientPicker.css b/backend/static/css/components/recipientPicker.css
new file mode 100644
index 00000000..92c67c4a
--- /dev/null
+++ b/backend/static/css/components/recipientPicker.css
@@ -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;
+ }
+}
diff --git a/backend/static/js/components/recipientPicker.js b/backend/static/js/components/recipientPicker.js
new file mode 100644
index 00000000..26447ef9
--- /dev/null
+++ b/backend/static/js/components/recipientPicker.js
@@ -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 = `
+
+
+
+
+
+
+ Заявление будет видно сотрудникам отделов с правами обработки
+
+
+
+
+
+
+
+
+
+
+ Все активные сотрудники выбранных отделов получат уведомление и доступ к заявлению
+
+
+
+
+
+
+
+ ${this.renderSelectedUsers(this.state.recipients, 'recipient')}
+
+
+
+ Получат уведомление и смогут работать с заявлением
+
+
+
+
+
+
+
+ ${this.renderSelectedUsers(this.state.ccUsers, 'cc')}
+
+
+
+ Получат уведомление, но не смогут изменять статус заявления
+
+
+
+ ${this.state.sendToAllDepartment && this.state.recipients.length > 0 ? `
+
+
+ Указаны и конкретные получатели, и флаг "всем сотрудникам".
+ Уведомления получат все сотрудники отделов + дополнительные получатели.
+
+ ` : ''}
+
+ `;
+ }
+
+ renderSelectedUsers(userIds, type) {
+ if (!userIds || userIds.length === 0) {
+ return 'Не выбрано
';
+ }
+
+ return userIds.map(id => {
+ const user = this.users.find(u => u.id === id);
+ if (!user) return '';
+
+ return `
+
+ ${user.last_name} ${user.first_name}
+
+
+ `;
+ }).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 = '';
+ }
+}
diff --git a/backend/static/js/components/requestCrudHandler.js b/backend/static/js/components/requestCrudHandler.js
index be673452..1452ceb1 100644
--- a/backend/static/js/components/requestCrudHandler.js
+++ b/backend/static/js/components/requestCrudHandler.js
@@ -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 кодом
diff --git a/backend/templates/requests_app/request_list_full.html b/backend/templates/requests_app/request_list_full.html
index fe324b45..ef0832cc 100644
--- a/backend/templates/requests_app/request_list_full.html
+++ b/backend/templates/requests_app/request_list_full.html
@@ -6,6 +6,7 @@
{% block extra_css %}
+
{% endblock %}
{% block content %}
@@ -264,6 +265,20 @@
Допустимые форматы и размер контролируются на сервере.
+
+
+
+
+
Получатели
+
+
+
+
+
+
+
+
+
Получатели
+
+
+
+