From deec86f3f9b74264a1a0817c81f7cc9d5bfc6f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 2 Dec 2025 17:49:54 +0300 Subject: [PATCH] =?UTF-8?q?feat(requests):=20=D0=AD=D1=82=D0=B0=D0=BF=205?= =?UTF-8?q?=20-=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=20frontend=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=83=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Создан компонент RecipientPicker: - Множественный выбор отделов (ManyToMany) - Выбор основных получателей (recipients) - Выбор пользователей в копии (CC users) - Флаг "Отправить всем сотрудникам отделов" - Динамический рендеринг выбранных пользователей - Валидация: нельзя указать recipients с флагом sent_to_all_department Интеграция: - Добавлены секции получателей в модальные окна создания и редактирования - Обновлен requestCrudHandler.js для передачи данных получателей в API - Добавлены стили recipientPicker.css - Подключен CSS в request_list_full.html Функциональность: - Загрузка списка пользователей и отделов из API - Добавление/удаление получателей через UI - Автоматическая очистка форм после создания - Поддержка редактирования существующих получателей --- .../static/css/components/recipientPicker.css | 67 ++++ .../static/js/components/recipientPicker.js | 320 ++++++++++++++++++ .../js/components/requestCrudHandler.js | 105 +++++- .../requests_app/request_list_full.html | 29 ++ 4 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 backend/static/css/components/recipientPicker.css create mode 100644 backend/static/js/components/recipientPicker.js 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 @@
Допустимые форматы и размер контролируются на сервере.
+ + +
+
+
Получатели
+ +
+
+
+
+
+ + +
+
+
Получатели
+ +
+
+
+
+