diff --git a/backend/api/v1/requests_app/permissions.py b/backend/api/v1/requests_app/permissions.py index 87a044a3..a53a6d55 100644 --- a/backend/api/v1/requests_app/permissions.py +++ b/backend/api/v1/requests_app/permissions.py @@ -4,34 +4,34 @@ from __future__ import annotations from typing import Any, Optional from api.v1.permissions import AdminOrDeptAllowed -from rest_framework.permissions import BasePermission -from rest_framework.request import Request from employees.constants import DeptPerm from employees.models import Department +from rest_framework.permissions import BasePermission +from rest_framework.request import Request class IsRecipientOfRequest(BasePermission): """Проверяет, что пользователь является получателем заявки. - + Доступ разрешается только если пользователь указан в recipients данной заявки. Это базовая проверка которая должна комбинироваться с проверкой прав (AdminOrActionOrModelPerms или DeptCanProcess). - + Для staff/superuser проверка пропускается (они могут обрабатывать любые заявки). """ - + message = "Вы не являетесь получателем этой заявки." - + def has_permission(self, request: Request, view: Any) -> bool: return bool(getattr(request.user, "is_authenticated", False)) - + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: user = request.user - + # Staff/superuser могут обрабатывать любые заявки if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): return True - + # Проверяем, является ли пользователь получателем заявки # (только recipients, не cc_users - люди в копии не принимают решения) return obj.recipients.filter(id=user.id).exists() diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index 0348cd2e..be3e24d3 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -2,12 +2,12 @@ from __future__ import annotations from typing import Any, List, Optional # было: Any, Dict, List, Type -from django.db.models import Q, QuerySet, Count +from django.db.models import Count, Q, QuerySet from django.shortcuts import get_object_or_404 # раскомментируйте импорт -from employees.models import EmployeeDepartment, Department # добавлен Department +from employees.models import Department, EmployeeDepartment # добавлен Department +from requests_app.enums import RequestStatus from requests_app.models import Request as EmployeeRequest from requests_app.models import RequestComment -from requests_app.enums import RequestStatus # from django.shortcuts import get_object_or_404 # <- не используется from rest_framework import status, viewsets @@ -58,11 +58,11 @@ class RequestViewSet(viewsets.ModelViewSet): PermissionDenied: При нарушении правил доступа. """ - queryset = EmployeeRequest.objects.select_related( - "employee", "approver", "department" - ).annotate( - comments_count=Count('comments') - ).order_by('-created_at') + queryset = ( + EmployeeRequest.objects.select_related("employee", "approver", "department") + .annotate(comments_count=Count("comments")) + .order_by("-created_at") + ) parser_classes = (MultiPartParser, FormParser, JSONParser) # Используем глобальную пагинацию из settings (PageNumberPagination, PAGE_SIZE=20) @@ -136,7 +136,7 @@ class RequestViewSet(viewsets.ModelViewSet): want_mine = (params.get("view") == "mine") or ( mine_raw in {"1", "true", "yes", "on"} ) - + # Новый параметр для фильтрации "мне адресовано" addressed_to_me = params.get("addressed_to_me") == "true" @@ -146,38 +146,30 @@ class RequestViewSet(viewsets.ModelViewSet): elif addressed_to_me: # Заявки, где я получатель my_dept_ids = EmployeeDepartment.objects.filter( - employee=user, - is_active=True - ).values_list('department_id', flat=True) - + employee=user, is_active=True + ).values_list("department_id", flat=True) + scope = ( - Q(recipients=user) | - Q(cc_users=user) | - Q( - sent_to_all_department=True, - departments__in=my_dept_ids - ) + Q(recipients=user) + | Q(cc_users=user) + | Q(sent_to_all_department=True, departments__in=my_dept_ids) ) qs = qs.filter(scope).distinct() else: # Обычный пользователь scope = Q(employee_id=user.id) # Свои заявки - + # Заявки, где я получатель (основной или CC) scope |= Q(recipients=user) | Q(cc_users=user) - + # Заявки отделов с sent_to_all_department my_dept_ids = EmployeeDepartment.objects.filter( - employee=user, - is_active=True - ).values_list('department_id', flat=True) - + employee=user, is_active=True + ).values_list("department_id", flat=True) + if my_dept_ids: - scope |= Q( - sent_to_all_department=True, - departments__in=my_dept_ids - ) - + scope |= Q(sent_to_all_department=True, departments__in=my_dept_ids) + # Департаментные права (как было) view_dept_ids = ( EmployeeDepartment.objects.filter( @@ -197,18 +189,16 @@ class RequestViewSet(viewsets.ModelViewSet): .values_list("department_id", flat=True) .distinct() ) - head_dept_ids = Department.objects.filter( - head_id=user.id - ).values_list("id", flat=True) - - combined_ids = ( - set(view_dept_ids) | set(proc_dept_ids) | set(head_dept_ids) + head_dept_ids = Department.objects.filter(head_id=user.id).values_list( + "id", flat=True ) + combined_ids = set(view_dept_ids) | set(proc_dept_ids) | set(head_dept_ids) + if combined_ids: # Заявки этих отделов (новое поле departments) scope |= Q(departments__in=combined_ids) - + # Заявки сотрудников этих отделов dept_emp_ids = ( EmployeeDepartment.objects.filter( @@ -220,19 +210,17 @@ class RequestViewSet(viewsets.ModelViewSet): ) if dept_emp_ids: scope |= Q(employee_id__in=list(dept_emp_ids)) - + # Фильтр "только адресованные мне" if addressed_to_me: scope = ( - Q(recipients=user) | Q(cc_users=user) | - Q( - sent_to_all_department=True, - departments__in=my_dept_ids - ) + Q(recipients=user) + | Q(cc_users=user) + | Q(sent_to_all_department=True, departments__in=my_dept_ids) ) elif want_mine: scope = Q(employee_id=user.id) - + qs = qs.filter(scope).distinct() # Применяем фильтры type/status для всех пользователей @@ -271,7 +259,9 @@ class RequestViewSet(viewsets.ModelViewSet): }: lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field lookup_value = self.kwargs[lookup_url_kwarg] - base = EmployeeRequest.objects.select_related("employee", "approver", "department") + base = EmployeeRequest.objects.select_related( + "employee", "approver", "department" + ) obj = get_object_or_404(base, **{self.lookup_field: lookup_value}) self.check_object_permissions(self.request, obj) return obj @@ -289,7 +279,9 @@ class RequestViewSet(viewsets.ModelViewSet): extra = {"employee": user} if not is_power else {} save_as = (self.request.query_params.get("save_as") or "").strip().lower() if save_as == "draft": - extra["status"] = RequestStatus.DRAFT # ✅ обычному пользователю поле в payload всё равно бы «очистили» + extra["status"] = ( + RequestStatus.DRAFT + ) # ✅ обычному пользователю поле в payload всё равно бы «очистили» serializer.save(**extra) @@ -398,50 +390,34 @@ class RequestViewSet(viewsets.ModelViewSet): отвечаем read-сериализатором. """ import logging + logger = logging.getLogger(__name__) - + logger.info( f"[RequestViewSet.create] User: {request.user.id}, " f"Content-Type: {request.content_type}" ) - logger.info( - f"[RequestViewSet.create] POST keys: {list(request.POST.keys())}" - ) - logger.info( - f"[RequestViewSet.create] FILES keys: {list(request.FILES.keys())}" - ) - logger.info( - f"[RequestViewSet.create] Data keys: {list(request.data.keys())}" - ) - + logger.info(f"[RequestViewSet.create] POST keys: {list(request.POST.keys())}") + logger.info(f"[RequestViewSet.create] FILES keys: {list(request.FILES.keys())}") + logger.info(f"[RequestViewSet.create] Data keys: {list(request.data.keys())}") + # Логируем значения для отладки - for key in ['type', 'title', 'date_from', 'date_to', 'comment']: + for key in ["type", "title", "date_from", "date_to", "comment"]: value = request.data.get(key) logger.info( - f"[RequestViewSet.create] {key}: {value} " - f"(type: {type(value).__name__})" + f"[RequestViewSet.create] {key}: {value} (type: {type(value).__name__})" ) - + serializer = self.get_serializer(data=request.data) if not serializer.is_valid(): logger.error( - f"[RequestViewSet.create] Validation errors: " - f"{serializer.errors}" + f"[RequestViewSet.create] Validation errors: {serializer.errors}" ) - logger.error( - f"[RequestViewSet.create] Request data: {request.data}" - ) - + logger.error(f"[RequestViewSet.create] Request data: {request.data}") + serializer.is_valid(raise_exception=True) self.perform_create(serializer) instance = serializer.instance - read = RequestReadSerializer( - instance, - context=self.get_serializer_context() - ) + read = RequestReadSerializer(instance, context=self.get_serializer_context()) headers = self.get_success_headers(read.data) - return Response( - read.data, - status=status.HTTP_201_CREATED, - headers=headers - ) + return Response(read.data, status=status.HTTP_201_CREATED, headers=headers) diff --git a/backend/requests_app/enums.py b/backend/requests_app/enums.py index afd60865..650b2cef 100644 --- a/backend/requests_app/enums.py +++ b/backend/requests_app/enums.py @@ -1,4 +1,5 @@ from __future__ import annotations + from django.db.models import TextChoices diff --git a/backend/requests_app/notification_signals.py b/backend/requests_app/notification_signals.py index 1b61badf..958ea830 100644 --- a/backend/requests_app/notification_signals.py +++ b/backend/requests_app/notification_signals.py @@ -9,12 +9,12 @@ Signals для автоматической генерации уведомле - Изменение статуса заявки """ +from django.contrib.auth import get_user_model from django.db.models.signals import post_save, pre_save from django.dispatch import receiver -from django.contrib.auth import get_user_model +from notifications.services import NotificationService from .models import Request, RequestComment -from notifications.services import NotificationService Employee = get_user_model() @@ -23,29 +23,25 @@ Employee = get_user_model() def create_request_notifications(sender, instance, created, **kwargs): """ Создает уведомления при создании или изменении заявки. - + Обрабатывает: 1. Новая заявка - уведомление руководителю/ответственным 2. Изменение статуса - уведомление автору """ request_obj = instance - - if created and request_obj.status != 'draft': + + if created and request_obj.status != "draft": # Новая заявка - уведомляем ответственных notify_new_request(request_obj) elif not created: - # Проверяем изменение статуса - # Получаем предыдущее состояние из БД - try: - old_request = Request.objects.get(pk=request_obj.pk) - if hasattr(request_obj, '_old_status'): - old_status = request_obj._old_status - new_status = request_obj.status - - if old_status != new_status: - notify_status_change(request_obj, old_status, new_status) - except Request.DoesNotExist: - pass + # Проверяем изменение статуса через сохраненный атрибут _old_status + if hasattr(request_obj, "_old_status"): + old_status = request_obj._old_status + new_status = request_obj.status + + if old_status != new_status: + # Передаем текущий request_obj с актуальным approver_id + notify_status_change(request_obj, old_status, new_status) @receiver(pre_save, sender=Request) @@ -74,63 +70,68 @@ def create_comment_notification(sender, instance, created, **kwargs): """ if not created: return - + comment = instance request_obj = comment.request author = comment.author recipients_set = set() - + # Автор заявки if request_obj.employee.id != author.id: recipients_set.add(request_obj.employee) - + # Получатели recipients_set.update( request_obj.recipients.filter(is_active=True).exclude(id=author.id) ) - + # CC recipients_set.update( request_obj.cc_users.filter(is_active=True).exclude(id=author.id) ) - + # Согласующий if request_obj.approver and request_obj.approver.id != author.id: recipients_set.add(request_obj.approver) - + # Если sent_to_all_department - все сотрудники отделов if request_obj.sent_to_all_department: - dept_employees = Employee.objects.filter( - departments_links__department__in=request_obj.departments.all(), - departments_links__is_active=True, - is_active=True - ).exclude(id__in=[author.id, request_obj.employee.id]).distinct() - + dept_employees = ( + Employee.objects.filter( + departments_links__department__in=request_obj.departments.all(), + departments_links__is_active=True, + is_active=True, + ) + .exclude(id__in=[author.id, request_obj.employee.id]) + .distinct() + ) + recipients_set.update(dept_employees) - + # Отправляем уведомления for recipient in recipients_set: NotificationService.create_notification( recipient=recipient, - notification_type_code='request_comment', - title='Новый комментарий к заявке', + notification_type_code="request_comment", + title="Новый комментарий к заявке", message=( - f'{author.get_full_name() or author.username} ' - f'прокомментировал заявку: {comment.text[:100]}' + f"{author.get_full_name() or author.username} " + f"прокомментировал заявку: {comment.text[:100]}" ), content_object=request_obj, - action_url=f'/requests/{request_obj.id}/', + action_url=f"/requests/{request_obj.id}/", metadata={ - 'request_id': request_obj.id, - 'request_type': request_obj.type, - 'comment_id': comment.id, - 'author_id': author.id, - } + "request_id": request_obj.id, + "request_type": request_obj.type, + "comment_id": comment.id, + "author_id": author.id, + }, ) # ===== Вспомогательные функции ===== + def notify_new_request(request_obj): """ Отправляет уведомление о новой заявке: @@ -139,104 +140,109 @@ def notify_new_request(request_obj): - Согласующему (approver) - Руководителям отделов - Пользователям с правом can_process_requests - + При sent_to_all_department=True отправляет всем сотрудникам отделов """ recipients_set = set() - + # 1. Основные получатели for recipient in request_obj.recipients.filter(is_active=True): recipients_set.add(recipient) - + # 2. Копия (CC) for cc_user in request_obj.cc_users.filter(is_active=True): recipients_set.add(cc_user) - + # 3. Если sent_to_all_department - все сотрудники отделов if request_obj.sent_to_all_department: - dept_employees = Employee.objects.filter( - departments_links__department__in=request_obj.departments.all(), - departments_links__is_active=True, - is_active=True - ).exclude(id=request_obj.employee.id).distinct() - + dept_employees = ( + Employee.objects.filter( + departments_links__department__in=request_obj.departments.all(), + departments_links__is_active=True, + is_active=True, + ) + .exclude(id=request_obj.employee.id) + .distinct() + ) + recipients_set.update(dept_employees) - + # 4. Согласующий if request_obj.approver and request_obj.approver.id != request_obj.employee.id: recipients_set.add(request_obj.approver) - + # 5. Руководители отделов for department in request_obj.departments.all(): if department.head and department.head.id != request_obj.employee.id: recipients_set.add(department.head) - + # Также проверяем старое поле department для обратной совместимости if request_obj.department and request_obj.department.head: if request_obj.department.head.id != request_obj.employee.id: recipients_set.add(request_obj.department.head) - + # 6. Пользователи с правом обрабатывать заявки в этих отделах - dept_ids = list(request_obj.departments.values_list('id', flat=True)) + dept_ids = list(request_obj.departments.values_list("id", flat=True)) if request_obj.department_id and request_obj.department_id not in dept_ids: dept_ids.append(request_obj.department_id) - + if dept_ids: - dept_processors = Employee.objects.filter( - departments_links__department_id__in=dept_ids, - departments_links__is_active=True, - departments_links__role__scoped_permissions__code='can_process_requests', - is_active=True - ).exclude(id=request_obj.employee.id).distinct() - + dept_processors = ( + Employee.objects.filter( + departments_links__department_id__in=dept_ids, + departments_links__is_active=True, + departments_links__role__scoped_permissions__code="can_process_requests", + is_active=True, + ) + .exclude(id=request_obj.employee.id) + .distinct() + ) + recipients_set.update(dept_processors) - + # Определяем тип уведомления для каждого получателя - author_name = ( - request_obj.employee.get_full_name() or - request_obj.employee.username - ) - + author_name = request_obj.employee.get_full_name() or request_obj.employee.username + for recipient in recipients_set: # Определяем роль получателя is_primary = request_obj.recipients.filter(id=recipient.id).exists() is_cc = request_obj.cc_users.filter(id=recipient.id).exists() is_approver = request_obj.approver_id == recipient.id - + # Формируем заголовок и сообщение if is_approver: - title = f'Новая заявка на согласование от {author_name}' - notification_type = 'request_new' + title = f"Новая заявка на согласование от {author_name}" + notification_type = "request_new" elif is_primary: - title = f'Вам адресована заявка от {author_name}' - notification_type = 'request_new' + title = f"Вам адресована заявка от {author_name}" + notification_type = "request_new" elif is_cc: - title = f'Вы в копии заявки от {author_name}' - notification_type = 'request_new' + title = f"Вы в копии заявки от {author_name}" + notification_type = "request_new" else: - title = f'Новая заявка в отделе от {author_name}' - notification_type = 'request_new' - + title = f"Новая заявка в отделе от {author_name}" + notification_type = "request_new" + message = ( - f'Тип: {request_obj.get_type_display()}. ' - f'{request_obj.comment[:100] if request_obj.comment else ""}' + f"Тип: {request_obj.get_type_display()}. " + f"{request_obj.comment[:100] if request_obj.comment else ''}" ) - + NotificationService.create_notification( recipient=recipient, notification_type_code=notification_type, title=title, message=message, content_object=request_obj, - action_url=f'/requests/{request_obj.id}/', + action_url=f"/requests/{request_obj.id}/", metadata={ - 'request_id': request_obj.id, - 'request_type': request_obj.type, - 'employee_id': request_obj.employee.id, - 'is_primary_recipient': is_primary, - 'is_cc': is_cc, - 'is_approver': is_approver, - } + "request_id": request_obj.id, + "request_type": request_obj.type, + "employee_id": request_obj.employee.id, + "is_primary_recipient": is_primary, + "is_cc": is_cc, + "is_approver": is_approver, + }, ) @@ -246,84 +252,103 @@ def notify_status_change(request_obj, old_status, new_status): - Всех получателей (recipients) - Всех в копии (cc_users) - Сотрудников отделов (если sent_to_all_department) - - ВАЖНО: автор не получает уведомление о решении (approve/reject), - так как он видит результат на странице request_process.html + + ВАЖНО: + - Автор (employee) не получает уведомление о решении (approve/reject) + - Approver (тот кто принял решение) не получает уведомление о своем же решении """ recipients_to_notify = set() - + + # ID пользователей которых НЕ нужно уведомлять при approve/reject + exclude_ids = set() + if new_status in ("approved", "rejected"): + # Не уведомляем автора заявки (он видит результат на странице) + exclude_ids.add(request_obj.employee.id) + # Не уведомляем того кто принял решение (он сам нажал кнопку) + if request_obj.approver_id: + exclude_ids.add(request_obj.approver_id) + # 1. Автор - НЕ уведомляем при approved/rejected - # (автор видит результат прямо на странице) - if new_status not in ('approved', 'rejected'): + if new_status not in ("approved", "rejected"): recipients_to_notify.add(request_obj.employee) - - # 2. Основные получатели + + # 2. Основные получатели (исключая тех кто в exclude_ids) recipients_to_notify.update( - request_obj.recipients.filter(is_active=True) + request_obj.recipients.filter(is_active=True).exclude(id__in=exclude_ids) ) - - # 3. Копия + + # 3. Копия (исключая тех кто в exclude_ids) recipients_to_notify.update( - request_obj.cc_users.filter(is_active=True) + request_obj.cc_users.filter(is_active=True).exclude(id__in=exclude_ids) ) - - # 4. Если sent_to_all_department - все сотрудники отделов + + # 4. Если sent_to_all_department - все сотрудники отделов (исключая exclude_ids) if request_obj.sent_to_all_department: - dept_employees = Employee.objects.filter( - departments_links__department__in=request_obj.departments.all(), - departments_links__is_active=True, - is_active=True - ).exclude(id=request_obj.employee.id).distinct() - + # При approve/reject также исключаем approver + all_exclude_ids = {request_obj.employee.id} + all_exclude_ids.update(exclude_ids) + + dept_employees = ( + Employee.objects.filter( + departments_links__department__in=request_obj.departments.all(), + departments_links__is_active=True, + is_active=True, + ) + .exclude(id__in=all_exclude_ids) + .distinct() + ) + recipients_to_notify.update(dept_employees) - + # Формируем уведомления for recipient in recipients_to_notify: - if new_status == 'approved': - notification_type = 'request_approved' - title = 'Заявка одобрена' + if new_status == "approved": + notification_type = "request_approved" + title = "Заявка одобрена" approver_name = ( - request_obj.approver.get_full_name() - if request_obj.approver else 'Руководитель' + request_obj.approver.get_full_name() + if request_obj.approver + else "Руководитель" ) message = ( f'Заявка "{request_obj.get_type_display()}" ' - f'одобрена пользователем {approver_name}' + f"одобрена пользователем {approver_name}" ) - elif new_status == 'rejected': - notification_type = 'request_rejected' - title = 'Заявка отклонена' + elif new_status == "rejected": + notification_type = "request_rejected" + title = "Заявка отклонена" approver_name = ( - request_obj.approver.get_full_name() - if request_obj.approver else 'Руководитель' + request_obj.approver.get_full_name() + if request_obj.approver + else "Руководитель" ) message = ( f'Заявка "{request_obj.get_type_display()}" ' - f'отклонена пользователем {approver_name}' + f"отклонена пользователем {approver_name}" ) else: # Общее уведомление об изменении статуса - notification_type = 'request_status_changed' - title = 'Статус заявки изменен' + notification_type = "request_status_changed" + title = "Статус заявки изменен" message = ( f'Статус заявки "{request_obj.get_type_display()}" ' - f'изменен: {old_status} → {new_status}' + f"изменен: {old_status} → {new_status}" ) - + NotificationService.create_notification( recipient=recipient, notification_type_code=notification_type, title=title, message=message, content_object=request_obj, - action_url=f'/requests/{request_obj.id}/', + action_url=f"/requests/{request_obj.id}/", metadata={ - 'request_id': request_obj.id, - 'request_type': request_obj.type, - 'old_status': old_status, - 'new_status': new_status, - 'approver_id': ( + "request_id": request_obj.id, + "request_type": request_obj.type, + "old_status": old_status, + "new_status": new_status, + "approver_id": ( request_obj.approver.id if request_obj.approver else None ), - } + }, ) diff --git a/backend/requests_app/views.py b/backend/requests_app/views.py index 396471c3..90f7db02 100644 --- a/backend/requests_app/views.py +++ b/backend/requests_app/views.py @@ -146,39 +146,39 @@ def is_hr_or_head(user) -> bool: def can_process_requests(user) -> bool: """Проверяет, может ли пользователь обрабатывать заявки вообще. - + True если: HR/staff, руководитель отдела, или есть модельное право. """ if is_hr(user): return True if is_department_head(user): return True - if user.has_perm('requests_app.can_process_requests'): + if user.has_perm("requests_app.can_process_requests"): return True return False def get_allowed_request_qs(user): """Возвращает заявки, которые пользователь может обрабатывать. - + HR/staff — все заявки; Руководитель отдела — заявки сотрудников своих отделов; Получатель — заявки, где он указан как recipient. - + Финальная проверка в request_process дополнительно проверяет, что пользователь является получателем конкретной заявки. """ if is_hr(user): return BASE_REQUEST_QS - + from django.db.models import Q - + # Заявки от сотрудников отделов, где пользователь руководитель dept_head_qs = Q(employee__departments_links__department__head=user) - + # Заявки где пользователь указан как получатель recipient_qs = Q(recipients=user) - + return BASE_REQUEST_QS.filter(dept_head_qs | recipient_qs).distinct() @@ -349,9 +349,12 @@ def request_process(request, pk): with transaction.atomic(): req = get_object_or_404(allowed_qs.select_for_update(), pk=pk) req.refresh_from_db() - + # Проверка: не-staff должен быть получателем заявки - if not is_hr(request.user) and not req.recipients.filter(id=request.user.id).exists(): + if ( + not is_hr(request.user) + and not req.recipients.filter(id=request.user.id).exists() + ): raise PermissionDenied("Вы не являетесь получателем этой заявки.") post = request.POST.copy() @@ -489,6 +492,3 @@ def request_comment_delete(request, pk, comment_id): default_url = reverse(_detail_or_process_name(request.user), args=[pk]) return redirect(_safe_back_url(request, default_url)) - - - diff --git a/backend/static/css/components/page-header.css b/backend/static/css/components/page-header.css index bf375244..76777b22 100644 --- a/backend/static/css/components/page-header.css +++ b/backend/static/css/components/page-header.css @@ -1,6 +1,6 @@ /** * Page Header Component - * + * * Универсальная шапка для страниц списков с: * - Полупрозрачным фоном (sticky positioning) * - Иконкой и заголовком @@ -9,7 +9,7 @@ * - Кнопкой фильтров * - Кнопкой создания (вытянутая на десктопе, иконка на мобильных) * - Поиском iOS-стиль - * + * * Использование: * {% include "includes/components/page_header.html" with ... %} * @@ -22,7 +22,7 @@ z-index: 10; padding: var(--space-2) 0; /* 8px сверху/снизу */ margin-bottom: var(--space-3); /* 12px отступ снизу */ - + /* Полупрозрачный фон с градиентом */ background: linear-gradient( to bottom, @@ -32,9 +32,9 @@ color-mix(in srgb, var(--bs-body-bg) 40%, transparent) 70%, transparent 100% ); - + /* Убрана border-bottom - линия не нужна */ - + /* Анимация появления при скролле */ transition: background 0.2s ease, box-shadow 0.2s ease; } @@ -191,7 +191,7 @@ margin-top: var(--space-2); order: 10; } - + /* Кнопка фильтров переносится под поиск и прижимается к правому краю */ .page-filter-toggle { order: 11; @@ -199,7 +199,7 @@ margin-left: auto; margin-right: 0 !important; } - + /* Родительский контейнер должен переносить элементы */ .page-header .d-flex.align-items-center { flex-wrap: wrap; @@ -249,7 +249,7 @@ .page-title { font-size: 1.5rem; } - + .page-header { padding: var(--space-2) 0; } @@ -262,16 +262,16 @@ padding: var(--space-1) 0; top: var(--navbar-h, 60px); } - + .page-title { font-size: 1.125rem; font-weight: 700; } - + .page-count { font-size: 0.7rem; } - + .page-scope-switch { gap: 0.25rem; } @@ -374,7 +374,7 @@ ); border-bottom-color: color-mix(in srgb, var(--bs-border-color), transparent 60%); } - + .page-header.scrolled { background: linear-gradient( to bottom, @@ -385,7 +385,7 @@ transparent 100% ); } - + .page-filters { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); } diff --git a/backend/static/css/components/request-list.css b/backend/static/css/components/request-list.css index 58de7fd9..35218f2b 100644 --- a/backend/static/css/components/request-list.css +++ b/backend/static/css/components/request-list.css @@ -1,6 +1,6 @@ /** * request-list.css - * + * * Настоящий табличный вид списка заявлений. * Все поля идут в одну строку с фиксированной шириной и переносом текста. */ @@ -72,7 +72,7 @@ /* Табличная сетка */ #reqList .req-table-row { display: grid; - grid-template-columns: + grid-template-columns: 48px /* Иконка */ minmax(200px, 2fr) /* Заголовок (расширяемая) */ minmax(120px, 1fr) /* Автор */ @@ -185,7 +185,7 @@ @media (max-width: 992px) { #reqList .req-table-header .req-table-row, #reqList .req-table-row { - grid-template-columns: + grid-template-columns: 42px /* Иконка */ minmax(150px, 2fr) /* Заголовок */ minmax(100px, 1fr) /* Автор */ @@ -193,12 +193,12 @@ 100px /* Статус */ 70px; /* Создано */ } - + /* На планшетах скрываем колонку действий как отдельное поле */ #reqList .req-table-header .req-cell-actions { display: none; } - + /* Но сами кнопки остаются видимыми */ #reqList .req-cell-actions { grid-column: 6; @@ -245,7 +245,7 @@ width: 32px; height: 32px; } - + /* Основной контент - заголовок занимает полную ширину минус иконка */ #reqList .req-cell-title { flex: 1 1 calc(100% - 48px); @@ -269,7 +269,7 @@ font-size: 0.8rem; margin-top: 0.25rem; } - + /* Остальная информация - автор, даты, статус */ #reqList .req-cell-author, #reqList .req-cell-dates, @@ -300,12 +300,12 @@ padding: 0.25rem 0.5rem; white-space: nowrap; } - + /* Скрываем дату создания на мобильных */ #reqList .req-cell-created { display: none; } - + /* Кнопки действий занимают полную строку */ #reqList .req-cell-actions { display: flex !important; @@ -327,7 +327,7 @@ padding: 0.5rem 0.75rem; font-size: 0.85rem; } - + /* Футер с комментариями занимает полную ширину */ #reqList .req-footer { flex: 1 1 100%; @@ -343,7 +343,7 @@ padding: 0.4rem 0.6rem; font-size: 0.8rem; } - + /* Блок комментариев */ #reqList .comments-block { margin-left: 0; @@ -370,15 +370,15 @@ #reqList .req-table-row { padding: 0.625rem; } - + #reqList .req-cell-title .small { display: none; /* Скрываем превью комментария */ } - + #reqList .req-cell-dates br { display: none; /* Даты в одну строку */ } - + #reqList .req-cell-dates .req-cell-content::after { content: ' — '; } diff --git a/backend/static/js/components/requestCrudHandler.js b/backend/static/js/components/requestCrudHandler.js index c666a2b5..6359cfce 100644 --- a/backend/static/js/components/requestCrudHandler.js +++ b/backend/static/js/components/requestCrudHandler.js @@ -3,7 +3,7 @@ * @description Обработчик CRUD операций для заявлений через API */ -import { RecipientPicker } from './recipientPicker.js'; +import { RecipientPicker } from "./recipientPicker.js"; /** * Инициализирует обработчики CRUD операций для заявлений @@ -14,43 +14,41 @@ import { RecipientPicker } from './recipientPicker.js'; * @returns {Object} API с методом destroy */ export function initRequestCrudHandler(options) { - const { - apiListUrl, - apiDetailBase, - headers = {} - } = options; + const { apiListUrl, apiDetailBase, headers = {} } = options; // Формы - const createForm = document.getElementById('reqCreateForm'); - const editForm = document.getElementById('reqEditForm'); - const deleteForm = document.getElementById('reqDeleteForm'); - const approveForm = document.getElementById('reqApproveForm'); - const rejectForm = document.getElementById('reqRejectForm'); - const cancelForm = document.getElementById('reqCancelForm'); - const commentForm = document.getElementById('reqCommentForm'); + const createForm = document.getElementById("reqCreateForm"); + const editForm = document.getElementById("reqEditForm"); + const deleteForm = document.getElementById("reqDeleteForm"); + const approveForm = document.getElementById("reqApproveForm"); + const rejectForm = document.getElementById("reqRejectForm"); + const cancelForm = document.getElementById("reqCancelForm"); + const commentForm = document.getElementById("reqCommentForm"); if (!createForm) { - console.warn('initRequestCrudHandler: required forms not found'); + console.warn("initRequestCrudHandler: required forms not found"); return { destroy: () => {} }; } // Инициализация RecipientPicker для создания let createRecipientPicker = null; - const createPickerContainer = document.getElementById('createRecipientPicker'); + const createPickerContainer = document.getElementById( + "createRecipientPicker" + ); if (createPickerContainer) { createRecipientPicker = new RecipientPicker(createPickerContainer, { - apiUsersUrl: '/api/v1/employees/', - apiDepartmentsUrl: '/api/v1/departments/' + apiUsersUrl: "/api/v1/employees/", + apiDepartmentsUrl: "/api/v1/departments/", }); } // Инициализация RecipientPicker для редактирования let editRecipientPicker = null; - const editPickerContainer = document.getElementById('editRecipientPicker'); + const editPickerContainer = document.getElementById("editRecipientPicker"); if (editPickerContainer) { editRecipientPicker = new RecipientPicker(editPickerContainer, { - apiUsersUrl: '/api/v1/employees/', - apiDepartmentsUrl: '/api/v1/departments/' + apiUsersUrl: "/api/v1/employees/", + apiDepartmentsUrl: "/api/v1/departments/", }); } @@ -59,116 +57,122 @@ export function initRequestCrudHandler(options) { */ async function handleCreate(e) { e.preventDefault(); - - const saveAs = e.submitter?.value || 'submit'; // 'draft' or 'submit' + + const saveAs = e.submitter?.value || "submit"; // 'draft' or 'submit' const formData = new FormData(createForm); - + // Добавляем данные получателей из RecipientPicker if (createRecipientPicker) { // Валидация: для обычной отправки должны быть получатели или отделы - if (saveAs === 'submit') { + if (saveAs === "submit") { if (!createRecipientPicker.validate()) { return; } - + // Проверка: автор не может быть получателем const recipients = createRecipientPicker.getValues(); if (recipients.recipient_ids && window.currentUserId) { if (recipients.recipient_ids.includes(window.currentUserId)) { - alert('Вы не можете отправить заявление самому себе'); - createRecipientPicker.setError('Вы не можете быть получателем'); + alert("Вы не можете отправить заявление самому себе"); + createRecipientPicker.setError("Вы не можете быть получателем"); return; } } } - + 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); + 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); + 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); + 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'); + formData.set("sent_to_all_department", "true"); } } - + // Удаляем пустые поля для черновика - if (saveAs === 'draft') { + if (saveAs === "draft") { for (const [key, value] of Array.from(formData.entries())) { - if (!value || value === '') { + if (!value || value === "") { formData.delete(key); } } } try { - const url = saveAs === 'draft' ? `${apiListUrl}?save_as=draft` : apiListUrl; - + const url = + saveAs === "draft" ? `${apiListUrl}?save_as=draft` : apiListUrl; + // Логируем данные для отладки - console.log('[requestCrudHandler] Creating request with data:'); + console.log("[requestCrudHandler] Creating request with data:"); for (const [key, value] of formData.entries()) { console.log(` ${key}:`, value); } - + const response = await fetch(url, { - method: 'POST', + method: "POST", headers: headers, - body: formData + body: formData, }); if (!response.ok) { const error = await response.json(); - console.error('[requestCrudHandler] Validation error:', error); - + console.error("[requestCrudHandler] Validation error:", error); + // Показываем детали ошибки - let errorMessage = 'Не удалось создать заявление:\n'; + let errorMessage = "Не удалось создать заявление:\n"; if (error.detail) { errorMessage += error.detail; - } else if (typeof error === 'object') { + } else if (typeof error === "object") { // Форматируем ошибки валидации for (const [field, messages] of Object.entries(error)) { - const fieldMessages = Array.isArray(messages) ? messages.join(', ') : messages; + const fieldMessages = Array.isArray(messages) + ? messages.join(", ") + : messages; errorMessage += `\n${field}: ${fieldMessages}`; } } - + throw new Error(errorMessage); } await response.json(); - + // Показываем сообщение и перезагружаем - const message = saveAs === 'draft' ? 'Черновик сохранён.' : 'Заявление отправлено на рассмотрение.'; + const message = + saveAs === "draft" + ? "Черновик сохранён." + : "Заявление отправлено на рассмотрение."; alert(message); // TODO: заменить на toast-уведомление - + // Очищаем форму и picker createForm.reset(); if (createRecipientPicker) { createRecipientPicker.reset(); } - + window.location.reload(); } catch (error) { - alert('Не удалось создать заявление: ' + error.message); + alert("Не удалось создать заявление: " + error.message); } } @@ -177,88 +181,88 @@ export function initRequestCrudHandler(options) { */ async function handleEdit(e) { e.preventDefault(); - + const reqId = editForm.elements.id.value; - const saveAs = e.submitter?.value || 'submit'; + const saveAs = e.submitter?.value || "submit"; const formData = new FormData(editForm); - + // Добавляем данные получателей из RecipientPicker if (editRecipientPicker) { // Валидация: для обычной отправки должны быть получатели или отделы - if (saveAs === 'submit') { + if (saveAs === "submit") { if (!editRecipientPicker.validate()) { return; } - + // Проверка: автор не может быть получателем const recipients = editRecipientPicker.getValues(); if (recipients.recipient_ids && window.currentUserId) { if (recipients.recipient_ids.includes(window.currentUserId)) { - alert('Вы не можете отправить заявление самому себе'); - editRecipientPicker.setError('Вы не можете быть получателем'); + alert("Вы не можете отправить заявление самому себе"); + editRecipientPicker.setError("Вы не можете быть получателем"); return; } } } - + 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); + 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); + 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); + 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'); + formData.set("sent_to_all_department", "true"); } } - + // Удаляем пустые поля for (const [key, value] of Array.from(formData.entries())) { - if (!value || value === '') { + if (!value || value === "") { formData.delete(key); } } try { let url = `${apiDetailBase}${reqId}/`; - if (saveAs === 'draft' || saveAs === 'submit') { + if (saveAs === "draft" || saveAs === "submit") { url += `?save_as=${saveAs}`; } const response = await fetch(url, { - method: 'PATCH', + method: "PATCH", headers: headers, - body: formData + body: formData, }); if (!response.ok) { const error = await response.json(); - throw new Error(error.detail || 'HTTP ' + response.status); + throw new Error(error.detail || "HTTP " + response.status); } await response.json(); - alert('Заявление обновлено.'); + alert("Заявление обновлено."); window.location.reload(); } catch (error) { - alert('Не удалось обновить заявление: ' + error.message); + alert("Не удалось обновить заявление: " + error.message); } } @@ -267,28 +271,28 @@ export function initRequestCrudHandler(options) { */ async function handleDelete(e) { e.preventDefault(); - + const reqId = deleteForm.elements.id.value; - - if (!confirm('Вы уверены, что хотите удалить это заявление?')) { + + if (!confirm("Вы уверены, что хотите удалить это заявление?")) { return; } try { const response = await fetch(`${apiDetailBase}${reqId}/`, { - method: 'DELETE', - headers: headers + method: "DELETE", + headers: headers, }); if (!response.ok && response.status !== 204) { const error = await response.json(); - throw new Error(error.detail || 'HTTP ' + response.status); + throw new Error(error.detail || "HTTP " + response.status); } - alert('Заявление удалено.'); + alert("Заявление удалено."); window.location.reload(); } catch (error) { - alert('Не удалось удалить заявление: ' + error.message); + alert("Не удалось удалить заявление: " + error.message); } } @@ -297,29 +301,29 @@ export function initRequestCrudHandler(options) { */ async function handleApprove(e) { e.preventDefault(); - + const reqId = approveForm.elements.id.value; try { const response = await fetch(`${apiDetailBase}${reqId}/approve/`, { - method: 'POST', + method: "POST", headers: { ...headers, - 'Content-Type': 'application/json' + "Content-Type": "application/json", }, - body: JSON.stringify({}) + body: JSON.stringify({}), }); if (!response.ok) { const error = await response.json(); - throw new Error(error.detail || 'HTTP ' + response.status); + throw new Error(error.detail || "HTTP " + response.status); } await response.json(); - alert('Заявление одобрено.'); + alert("Заявление одобрено."); window.location.reload(); } catch (error) { - alert('Не удалось одобрить заявление: ' + error.message); + alert("Не удалось одобрить заявление: " + error.message); } } @@ -328,29 +332,29 @@ export function initRequestCrudHandler(options) { */ async function handleReject(e) { e.preventDefault(); - + const reqId = rejectForm.elements.id.value; try { const response = await fetch(`${apiDetailBase}${reqId}/reject/`, { - method: 'POST', + method: "POST", headers: { ...headers, - 'Content-Type': 'application/json' + "Content-Type": "application/json", }, - body: JSON.stringify({}) + body: JSON.stringify({}), }); if (!response.ok) { const error = await response.json(); - throw new Error(error.detail || 'HTTP ' + response.status); + throw new Error(error.detail || "HTTP " + response.status); } await response.json(); - alert('Заявление отклонено.'); + alert("Заявление отклонено."); window.location.reload(); } catch (error) { - alert('Не удалось отклонить заявление: ' + error.message); + alert("Не удалось отклонить заявление: " + error.message); } } @@ -359,33 +363,33 @@ export function initRequestCrudHandler(options) { */ async function handleCancel(e) { e.preventDefault(); - + const reqId = cancelForm.elements.id.value; - if (!confirm('Вы уверены, что хотите отменить это заявление?')) { + if (!confirm("Вы уверены, что хотите отменить это заявление?")) { return; } try { const response = await fetch(`${apiDetailBase}${reqId}/cancel/`, { - method: 'POST', + method: "POST", headers: { ...headers, - 'Content-Type': 'application/json' + "Content-Type": "application/json", }, - body: JSON.stringify({}) + body: JSON.stringify({}), }); if (!response.ok) { const error = await response.json(); - throw new Error(error.detail || 'HTTP ' + response.status); + throw new Error(error.detail || "HTTP " + response.status); } await response.json(); - alert('Заявление отменено.'); + alert("Заявление отменено."); window.location.reload(); } catch (error) { - alert('Не удалось отменить заявление: ' + error.message); + alert("Не удалось отменить заявление: " + error.message); } } @@ -394,59 +398,59 @@ export function initRequestCrudHandler(options) { */ async function handleComment(e) { e.preventDefault(); - + const reqId = commentForm.elements.id.value; - const text = (commentForm.elements.text.value || '').trim(); + const text = (commentForm.elements.text.value || "").trim(); if (!text) { - alert('Комментарий не может быть пустым.'); + alert("Комментарий не может быть пустым."); return; } try { const response = await fetch(`${apiDetailBase}${reqId}/comments/`, { - method: 'POST', + method: "POST", headers: { ...headers, - 'Content-Type': 'application/json' + "Content-Type": "application/json", }, - body: JSON.stringify({ text }) + body: JSON.stringify({ text }), }); if (!response.ok && response.status !== 201) { const error = await response.json(); - throw new Error(error.detail || 'HTTP ' + response.status); + throw new Error(error.detail || "HTTP " + response.status); } await response.json(); - alert('Комментарий добавлен.'); + alert("Комментарий добавлен."); window.location.reload(); } catch (error) { - alert('Не удалось добавить комментарий: ' + error.message); + alert("Не удалось добавить комментарий: " + error.message); } } // Установка обработчиков - createForm?.addEventListener('submit', handleCreate); - editForm?.addEventListener('submit', handleEdit); - deleteForm?.addEventListener('submit', handleDelete); - approveForm?.addEventListener('submit', handleApprove); - rejectForm?.addEventListener('submit', handleReject); - cancelForm?.addEventListener('submit', handleCancel); - commentForm?.addEventListener('submit', handleComment); + createForm?.addEventListener("submit", handleCreate); + editForm?.addEventListener("submit", handleEdit); + deleteForm?.addEventListener("submit", handleDelete); + approveForm?.addEventListener("submit", handleApprove); + rejectForm?.addEventListener("submit", handleReject); + cancelForm?.addEventListener("submit", handleCancel); + commentForm?.addEventListener("submit", handleComment); /** * Функция для удаления всех обработчиков */ function destroy() { - createForm?.removeEventListener('submit', handleCreate); - editForm?.removeEventListener('submit', handleEdit); - deleteForm?.removeEventListener('submit', handleDelete); - approveForm?.removeEventListener('submit', handleApprove); - rejectForm?.removeEventListener('submit', handleReject); - cancelForm?.removeEventListener('submit', handleCancel); - commentForm?.removeEventListener('submit', handleComment); - + createForm?.removeEventListener("submit", handleCreate); + editForm?.removeEventListener("submit", handleEdit); + deleteForm?.removeEventListener("submit", handleDelete); + approveForm?.removeEventListener("submit", handleApprove); + rejectForm?.removeEventListener("submit", handleReject); + cancelForm?.removeEventListener("submit", handleCancel); + commentForm?.removeEventListener("submit", handleComment); + // Очистка RecipientPicker if (createRecipientPicker) { createRecipientPicker.destroy(); @@ -456,16 +460,16 @@ export function initRequestCrudHandler(options) { } } - console.log('Request CRUD handler initialized'); + console.log("Request CRUD handler initialized"); - return { + return { destroy, createRecipientPicker, - editRecipientPicker + editRecipientPicker, }; } // Экспорт для совместимости с неModular кодом -if (typeof window !== 'undefined') { +if (typeof window !== "undefined") { window.initRequestCrudHandler = initRequestCrudHandler; } diff --git a/backend/static/js/components/requestListHandler.js b/backend/static/js/components/requestListHandler.js index 2821d497..c3d6e3db 100644 --- a/backend/static/js/components/requestListHandler.js +++ b/backend/static/js/components/requestListHandler.js @@ -19,45 +19,53 @@ export function initRequestListHandler(options) { detailUrlTemplate, userId, canProcess, - headers = {} + headers = {}, } = options; - const listElement = document.getElementById('reqListContent'); - const searchInput = document.getElementById('reqFilter'); - + const listElement = document.getElementById("reqListContent"); + const searchInput = document.getElementById("reqFilter"); + if (!listElement) { - console.warn('initRequestListHandler: #reqListContent not found'); + console.warn("initRequestListHandler: #reqListContent not found"); return { load: () => {}, destroy: () => {} }; } // Текущее состояние const urlParams = new URLSearchParams(window.location.search); - let currentView = urlParams.get('view') || ''; // 'mine' | 'addressed' | '' - let currentType = urlParams.get('type') || ''; - let currentStatus = urlParams.get('status') || ''; - let searchQuery = ''; // Поисковый запрос + let currentView = urlParams.get("view") || ""; // 'mine' | 'addressed' | '' + let currentType = urlParams.get("type") || ""; + let currentStatus = urlParams.get("status") || ""; + let searchQuery = ""; // Поисковый запрос let allRequests = []; // Все загруженные заявления let loading = false; let hasMore = true; let nextUrl = null; let totalCount = 0; - - console.log('requestListHandler: init with view =', currentView, 'type =', currentType, 'status =', currentStatus); - + + console.log( + "requestListHandler: init with view =", + currentView, + "type =", + currentType, + "status =", + currentStatus + ); + // Intersection Observer для бесконечной прокрутки let observerTarget = null; let observer = null; - + // Мобильная оптимизация: слушаем сетевые события let isOnline = navigator.onLine; - window.addEventListener('online', () => { + window.addEventListener("online", () => { isOnline = true; - console.log('Network restored'); + console.log("Network restored"); }); - window.addEventListener('offline', () => { + window.addEventListener("offline", () => { isOnline = false; - console.log('Network lost'); - listElement.innerHTML = '