From 087cff7901dd089c3d1dcab66ecf551c48268ae2 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: Mon, 29 Dec 2025 11:02:39 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=20approver=20=D0=B8=D0=B7=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=D1=83=D0=B2?= =?UTF-8?q?=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=BE?= =?UTF-8?q?=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Approver (тот кто принял решение) не получает уведомление о своем действии - Исключены employee.id и approver_id из recipients при approve/reject - Упрощен signal create_request_notifications (убран лишний запрос в БД) - Теперь передается актуальный request_obj с установленным approver_id --- backend/api/v1/requests_app/permissions.py | 18 +- backend/api/v1/requests_app/views.py | 128 +- backend/requests_app/enums.py | 1 + backend/requests_app/notification_signals.py | 293 ++-- backend/requests_app/views.py | 26 +- backend/static/css/components/page-header.css | 26 +- .../static/css/components/request-list.css | 28 +- .../js/components/requestCrudHandler.js | 292 ++-- .../js/components/requestListHandler.js | 430 +++--- .../js/notifications/notification-manager.js | 1174 +++++++++-------- .../requests_app/request_process.html | 6 +- docs/reports/MOBILE_OPTIMIZATION_REPORT.md | 26 +- 12 files changed, 1285 insertions(+), 1163 deletions(-) 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 = '
Потеряна связь с интернетом
'; + console.log("Network lost"); + listElement.innerHTML = + '
Потеряна связь с интернетом
'; }); /** @@ -67,105 +75,108 @@ export function initRequestListHandler(options) { */ async function loadRequests(append = false) { if (loading || (append && !hasMore)) { - console.log('loadRequests: skipped', { loading, append, hasMore }); + console.log("loadRequests: skipped", { loading, append, hasMore }); return []; } // Проверка сети на мобильных if (!isOnline) { - console.warn('loadRequests: offline'); - listElement.innerHTML = '
Нет интернет-соединения. Проверьте подключение.
'; + console.warn("loadRequests: offline"); + listElement.innerHTML = + '
Нет интернет-соединения. Проверьте подключение.
'; return []; } - + loading = true; showLoadingSpinner(); - + try { let url; if (append && nextUrl) { url = nextUrl; - console.log('loadRequests: using nextUrl', url); + console.log("loadRequests: using nextUrl", url); } else if (append && !nextUrl) { - console.warn('loadRequests: append=true but no nextUrl, stopping'); + console.warn("loadRequests: append=true but no nextUrl, stopping"); loading = false; hideLoadingSpinner(); return []; } else { // Формируем URL с параметрами фильтров const params = new URLSearchParams(); - if (currentView === 'mine') { - params.append('view', 'mine'); - } else if (currentView === 'addressed') { - params.append('addressed_to_me', 'true'); + if (currentView === "mine") { + params.append("view", "mine"); + } else if (currentView === "addressed") { + params.append("addressed_to_me", "true"); } if (currentType) { - params.append('type', currentType); + params.append("type", currentType); } if (currentStatus) { - params.append('status', currentStatus); + params.append("status", currentStatus); } - + url = `${apiListUrl}?${params}`; - console.log('loadRequests: initial load', url); + console.log("loadRequests: initial load", url); } - + const controller = new AbortController(); // На мобильных устройствах увеличиваем таймаут до 15 секунд - const timeoutMs = /iPhone|iPad|Android|Mobile/.test(navigator.userAgent) ? 15000 : 10000; + const timeoutMs = /iPhone|iPad|Android|Mobile/.test(navigator.userAgent) + ? 15000 + : 10000; const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - - const response = await fetch(url, { + + const response = await fetch(url, { headers, - signal: controller.signal + signal: controller.signal, }); - + clearTimeout(timeoutId); - + if (!response.ok) { - throw new Error('HTTP ' + response.status); + throw new Error("HTTP " + response.status); } const data = await response.json(); const newRequests = data.results || []; - - console.log('loadRequests: received', { + + console.log("loadRequests: received", { newCount: newRequests.length, totalCount: data.count, hasNext: !!data.next, - currentRequests: allRequests.length + currentRequests: allRequests.length, }); - + // Сохраняем метаданные totalCount = data.count || newRequests.length; nextUrl = data.next || null; hasMore = !!nextUrl; - + if (append) { allRequests = [...allRequests, ...newRequests]; - console.log('loadRequests: appended, total now:', allRequests.length); + console.log("loadRequests: appended, total now:", allRequests.length); } else { allRequests = newRequests; - console.log('loadRequests: replaced, total now:', allRequests.length); + console.log("loadRequests: replaced, total now:", allRequests.length); } - + loading = false; hideLoadingSpinner(); - + return newRequests; } catch (error) { - console.error('Ошибка загрузки заявлений:', error); + console.error("Ошибка загрузки заявлений:", error); loading = false; hideLoadingSpinner(); - + // Более информативное сообщение об ошибке - let errorMsg = 'Не удалось загрузить заявления'; - if (error.name === 'AbortError') { - errorMsg = 'Время ожидания истекло. Проверьте интернет-соединение.'; + let errorMsg = "Не удалось загрузить заявления"; + if (error.name === "AbortError") { + errorMsg = "Время ожидания истекло. Проверьте интернет-соединение."; } else if (!navigator.onLine) { - errorMsg = 'Нет интернет-соединения.'; + errorMsg = "Нет интернет-соединения."; } - + listElement.innerHTML = `
${errorMsg}
@@ -175,16 +186,16 @@ export function initRequestListHandler(options) { return []; } } - + /** * Показать спиннер загрузки */ function showLoadingSpinner() { - const existing = listElement.querySelector('.loading-spinner'); + const existing = listElement.querySelector(".loading-spinner"); if (existing) return; - - const spinner = document.createElement('div'); - spinner.className = 'loading-spinner text-center py-4'; + + const spinner = document.createElement("div"); + spinner.className = "loading-spinner text-center py-4"; spinner.innerHTML = `
Загрузка... @@ -192,56 +203,56 @@ export function initRequestListHandler(options) { `; listElement.appendChild(spinner); } - + /** * Скрыть спиннер загрузки */ function hideLoadingSpinner() { - const spinner = listElement.querySelector('.loading-spinner'); + const spinner = listElement.querySelector(".loading-spinner"); if (spinner) { spinner.remove(); } // Удаляем начальный спиннер из шаблона - const initialSpinner = document.getElementById('reqListLoading'); + const initialSpinner = document.getElementById("reqListLoading"); if (initialSpinner) { initialSpinner.remove(); } } - + /** * Загрузка следующей порции заявлений */ async function loadMore() { - console.log('loadMore: triggered', { + console.log("loadMore: triggered", { hasMore, loading, nextUrl, - currentCount: allRequests.length + currentCount: allRequests.length, }); - + try { const newRequests = await loadRequests(true); // append = true renderRequests(newRequests, true); // append = true } catch (error) { - console.error('Failed to load more requests:', error); + console.error("Failed to load more requests:", error); } } - + /** * Настройка Intersection Observer для бесконечной прокрутки */ function setupInfiniteScroll() { if (!observerTarget) return; - + // Создаем observer с адаптивными параметрами для мобильных const isMobile = /iPhone|iPad|Android|Mobile/.test(navigator.userAgent); - const rootMargin = isMobile ? '200px' : '100px'; // На мобильных загружаем раньше - + const rootMargin = isMobile ? "200px" : "100px"; // На мобильных загружаем раньше + observer = new IntersectionObserver( (entries) => { - entries.forEach(entry => { + entries.forEach((entry) => { if (entry.isIntersecting && hasMore && !loading) { - console.log('Intersection observer: triggering loadMore'); + console.log("Intersection observer: triggering loadMore"); loadMore(); } }); @@ -249,14 +260,14 @@ export function initRequestListHandler(options) { { root: null, rootMargin, - threshold: 0.01 + threshold: 0.01, } ); - + // Наблюдаем за observer target observer.observe(observerTarget); } - + /** * Фильтрует по поисковому запросу * @param {Array} items - Список заявлений @@ -264,13 +275,17 @@ export function initRequestListHandler(options) { */ function filterBySearch(items) { if (!searchQuery.trim()) return items; - + const query = searchQuery.toLowerCase().trim(); - return items.filter(req => { - const title = (req.display_title || req.title || '').toLowerCase(); - const comment = (req.comment || '').toLowerCase(); - const employeeName = (req.employee?.full_name || '').toLowerCase(); - return title.includes(query) || comment.includes(query) || employeeName.includes(query); + return items.filter((req) => { + const title = (req.display_title || req.title || "").toLowerCase(); + const comment = (req.comment || "").toLowerCase(); + const employeeName = (req.employee?.full_name || "").toLowerCase(); + return ( + title.includes(query) || + comment.includes(query) || + employeeName.includes(query) + ); }); } @@ -282,46 +297,48 @@ export function initRequestListHandler(options) { function renderRequests(requests, append = false) { if (!requests || requests.length === 0) { if (!append) { - listElement.innerHTML = '
Нет доступных заявлений.
'; + listElement.innerHTML = + '
Нет доступных заявлений.
'; } return; } let items = filterBySearch(requests); - + if (items.length === 0 && !append) { - listElement.innerHTML = '
Нет заявлений, соответствующих фильтрам.
'; + listElement.innerHTML = + '
Нет заявлений, соответствующих фильтрам.
'; return; } - console.log('renderRequests: rendering', { + console.log("renderRequests: rendering", { count: items.length, append, - totalInMemory: allRequests.length + totalInMemory: allRequests.length, }); - const itemsHtml = items.map(req => renderRequestRow(req)).join(''); - + const itemsHtml = items.map((req) => renderRequestRow(req)).join(""); + if (append) { // Удаляем observer target перед добавлением if (observerTarget && observerTarget.parentNode) { observerTarget.remove(); } // Добавляем новые карточки - listElement.insertAdjacentHTML('beforeend', itemsHtml); - console.log('renderRequests: appended to DOM'); + listElement.insertAdjacentHTML("beforeend", itemsHtml); + console.log("renderRequests: appended to DOM"); // Возвращаем observer target обратно if (observerTarget) { listElement.appendChild(observerTarget); } } else { listElement.innerHTML = itemsHtml; - console.log('renderRequests: replaced DOM'); + console.log("renderRequests: replaced DOM"); // Создаем observer target при первой загрузке if (hasMore && !observerTarget) { - observerTarget = document.createElement('div'); - observerTarget.className = 'load-more-trigger'; - observerTarget.style.height = '1px'; + observerTarget = document.createElement("div"); + observerTarget.className = "load-more-trigger"; + observerTarget.style.height = "1px"; listElement.appendChild(observerTarget); } } @@ -333,42 +350,57 @@ export function initRequestListHandler(options) { * @returns {string} HTML */ function renderRequestRow(req) { - const type = (req.type || '').toLowerCase(); - const status = (req.status || '').toLowerCase(); - + const type = (req.type || "").toLowerCase(); + const status = (req.status || "").toLowerCase(); + // Иконка типа - let typeIcon = 'bi-clipboard-check text-secondary'; - if (type === 'vacation') typeIcon = 'bi-calendar-check text-primary'; - else if (type === 'sick_leave') typeIcon = 'bi-capsule text-danger'; - else if (type === 'day_off') typeIcon = 'bi-umbrella-fill text-info'; - else if (type === 'transfer') typeIcon = 'bi-box-arrow-right text-info'; - else if (type === 'dismissal') typeIcon = 'bi-door-open text-warning'; - + let typeIcon = "bi-clipboard-check text-secondary"; + if (type === "vacation") typeIcon = "bi-calendar-check text-primary"; + else if (type === "sick_leave") typeIcon = "bi-capsule text-danger"; + else if (type === "day_off") typeIcon = "bi-umbrella-fill text-info"; + else if (type === "transfer") typeIcon = "bi-box-arrow-right text-info"; + else if (type === "dismissal") typeIcon = "bi-door-open text-warning"; + // Текст типа - const typeText = { - 'vacation': 'Отпуск', - 'sick_leave': 'Больничный', - 'day_off': 'Отгул', - 'transfer': 'Перевод', - 'dismissal': 'Увольнение', - 'other': 'Другое' - }[type] || req.type; - + const typeText = + { + vacation: "Отпуск", + sick_leave: "Больничный", + day_off: "Отгул", + transfer: "Перевод", + dismissal: "Увольнение", + other: "Другое", + }[type] || req.type; + // Класс и текст статуса - let statusBadgeClass = 'text-bg-light'; + let statusBadgeClass = "text-bg-light"; let statusText = req.status; - if (status === 'draft') { statusBadgeClass = 'text-bg-info'; statusText = 'Черновик'; } - else if (status === 'pending') { statusBadgeClass = 'text-bg-warning'; statusText = 'На рассмотр.'; } - else if (status === 'approved') { statusBadgeClass = 'text-bg-success'; statusText = 'Одобрено'; } - else if (status === 'rejected') { statusBadgeClass = 'text-bg-danger'; statusText = 'Отклонено'; } - else if (status === 'cancelled') { statusBadgeClass = 'text-bg-secondary'; statusText = 'Отменено'; } - + if (status === "draft") { + statusBadgeClass = "text-bg-info"; + statusText = "Черновик"; + } else if (status === "pending") { + statusBadgeClass = "text-bg-warning"; + statusText = "На рассмотр."; + } else if (status === "approved") { + statusBadgeClass = "text-bg-success"; + statusText = "Одобрено"; + } else if (status === "rejected") { + statusBadgeClass = "text-bg-danger"; + statusText = "Отклонено"; + } else if (status === "cancelled") { + statusBadgeClass = "text-bg-secondary"; + statusText = "Отменено"; + } + // URL для сотрудника const employeeUrl = `/employees/${req.employee?.id}/`; - const employeeName = req.employee?.id === userId ? 'Вы' : (req.employee?.full_name || req.employee?.display_name || 'Сотрудник'); - + const employeeName = + req.employee?.id === userId + ? "Вы" + : req.employee?.full_name || req.employee?.display_name || "Сотрудник"; + // Кнопки действий - let actionButtons = ''; + let actionButtons = ""; if (req.employee?.id !== userId && canProcess) { actionButtons = ` @@ -402,7 +434,7 @@ export function initRequestListHandler(options) { `; } - + return `
@@ -412,47 +444,73 @@ export function initRequestListHandler(options) {
- +
- ${escapeHtml(req.title || 'Без заголовка')} - ${type ? `${typeText}` : ''} - ${req.comment ? `
${escapeHtml(req.comment.substring(0, 100))}
` : ''} + ${escapeHtml(req.title || "Без заголовка")} + ${ + type + ? `${typeText}` + : "" + } + ${ + req.comment + ? `
${escapeHtml( + req.comment.substring(0, 100) + )}
` + : "" + }
- +
- ${req.employee ? `${employeeName}` : ''} + ${ + req.employee + ? `${employeeName}` + : "" + }
- +
- ${req.date_from || req.date_to ? ` - ${req.date_from ? formatDate(req.date_from) : '—'}
- ${req.date_to ? formatDate(req.date_to) : '—'} - ` : '—'} + ${ + req.date_from || req.date_to + ? ` + ${req.date_from ? formatDate(req.date_from) : "—"}
+ ${req.date_to ? formatDate(req.date_to) : "—"} + ` + : '—' + }
- +
- ${req.status ? `${statusText}` : ''} + ${ + req.status + ? `${statusText}` + : "" + }
- +
- ${req.created_at ? formatDate(req.created_at) : '—'} + ${ + req.created_at + ? formatDate(req.created_at) + : '—' + }
- +
@@ -470,17 +528,25 @@ export function initRequestListHandler(options) { aria-controls="rcoll-${req.id}" aria-expanded="false"> - ${req.comments_count || 0} + ${ + req.comments_count || 0 + } Скрыть - ${req.attachment_url ? ` + ${ + req.attachment_url + ? ` Вложение - ` : ''} + ` + : "" + } -
+
Комментарии @@ -516,13 +582,13 @@ export function initRequestListHandler(options) { * @returns {string} CSRF токен */ function getCsrfToken() { - const name = 'csrftoken'; - let cookieValue = ''; - if (document.cookie && document.cookie !== '') { - const cookies = document.cookie.split(';'); + const name = "csrftoken"; + let cookieValue = ""; + if (document.cookie && document.cookie !== "") { + const cookies = document.cookie.split(";"); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); - if (cookie.substring(0, name.length + 1) === (name + '=')) { + if (cookie.substring(0, name.length + 1) === name + "=") { cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); break; } @@ -538,12 +604,12 @@ export function initRequestListHandler(options) { */ function getStatusClass(status) { const classes = { - 'pending': 'warning', - 'approved': 'success', - 'rejected': 'danger', - 'cancelled': 'secondary' + pending: "warning", + approved: "success", + rejected: "danger", + cancelled: "secondary", }; - return classes[status] || 'secondary'; + return classes[status] || "secondary"; } /** @@ -553,10 +619,10 @@ export function initRequestListHandler(options) { */ function getStatusText(status) { const texts = { - 'pending': 'На рассмотрении', - 'approved': 'Одобрено', - 'rejected': 'Отклонено', - 'cancelled': 'Отменено' + pending: "На рассмотрении", + approved: "Одобрено", + rejected: "Отклонено", + cancelled: "Отменено", }; return texts[status] || status; } @@ -567,12 +633,12 @@ export function initRequestListHandler(options) { * @returns {string} Отформатированная дата */ function formatDate(dateStr) { - if (!dateStr) return ''; + if (!dateStr) return ""; const date = new Date(dateStr); - return date.toLocaleDateString('ru-RU', { - day: '2-digit', - month: '2-digit', - year: 'numeric' + return date.toLocaleDateString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", }); } @@ -582,8 +648,8 @@ export function initRequestListHandler(options) { * @returns {string} Экранированная строка */ function escapeHtml(str) { - if (!str) return ''; - const div = document.createElement('div'); + if (!str) return ""; + const div = document.createElement("div"); div.textContent = str; return div.innerHTML; } @@ -620,7 +686,7 @@ export function initRequestListHandler(options) { renderRequests(reqs, false); setupInfiniteScroll(); } - + /** * Обработчик поиска (работает только с уже загруженными заявлениями) */ @@ -628,21 +694,21 @@ export function initRequestListHandler(options) { // Применяем фильтрацию ко всем загруженным заявлениям renderRequests(allRequests, false); } - + /** * Настройка обработчика поиска */ function setupSearch() { if (!searchInput) return; - + let searchTimeout; - searchInput.addEventListener('input', (e) => { + searchInput.addEventListener("input", (e) => { clearTimeout(searchTimeout); searchQuery = e.target.value; - + // Debounce: ждем 300ms после последнего ввода searchTimeout = setTimeout(() => { - console.log('Search query:', searchQuery); + console.log("Search query:", searchQuery); handleSearch(); }, 300); }); @@ -672,11 +738,11 @@ export function initRequestListHandler(options) { setView, setType, setStatus, - destroy + destroy, }; } // Экспорт для совместимости -if (typeof window !== 'undefined') { +if (typeof window !== "undefined") { window.initRequestListHandler = initRequestListHandler; } diff --git a/backend/static/js/notifications/notification-manager.js b/backend/static/js/notifications/notification-manager.js index eb938b53..55d5919d 100644 --- a/backend/static/js/notifications/notification-manager.js +++ b/backend/static/js/notifications/notification-manager.js @@ -2,618 +2,668 @@ * Менеджер уведомлений - подключение к WebSocket и управление */ -import { getNotifications, invalidateNotifications } from '../api/notificationsApi.js'; +import { + getNotifications, + invalidateNotifications, +} from "../api/notificationsApi.js"; class NotificationManager { - constructor() { - this.ws = null; + constructor() { + this.ws = null; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 5; + this.reconnectDelay = 1000; + this.isConnected = false; + this.notificationPermission = "default"; + this.soundEnabled = this.loadSoundPreference(); + + this.init(); + } + + /** + * Загрузить настройку звука из localStorage + */ + loadSoundPreference() { + const saved = localStorage.getItem("notificationSoundEnabled"); + return saved === null ? true : saved === "true"; + } + + /** + * Сохранить настройку звука + */ + saveSoundPreference(enabled) { + this.soundEnabled = enabled; + localStorage.setItem("notificationSoundEnabled", enabled.toString()); + } + + /** + * Воспроизвести звук уведомления + * Приоритет: 1) MP3 файл, 2) Web Audio API (fallback) + */ + playNotificationSound() { + if (!this.soundEnabled) { + console.log("[Notifications] Sound disabled"); + return; + } + + try { + // Попытка воспроизвести MP3 файл + const audio = new Audio("/static/sounds/notification.mp3"); + audio.volume = 0.3; + console.log("[Notifications] Playing MP3 sound"); + audio + .play() + .then(() => { + console.log("[Notifications] MP3 sound played successfully"); + }) + .catch((err) => { + console.warn( + "[Notifications] MP3 playback failed, using fallback:", + err + ); + // Fallback на Web Audio API если MP3 недоступен + this.playFallbackSound(); + }); + } catch (error) { + console.warn( + "[Notifications] MP3 loading failed, using fallback:", + error + ); + // Fallback на Web Audio API + this.playFallbackSound(); + } + } + + /** + * Резервный метод: синтетический звук через Web Audio API + */ + playFallbackSound() { + console.log("[Notifications] Playing fallback Web Audio API sound"); + try { + const audioContext = new (window.AudioContext || + window.webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.value = 800; // Частота звука + oscillator.type = "sine"; + + gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); + gainNode.gain.exponentialRampToValueAtTime( + 0.01, + audioContext.currentTime + 0.3 + ); + + oscillator.start(audioContext.currentTime); + oscillator.stop(audioContext.currentTime + 0.3); + console.log("[Notifications] Fallback sound completed"); + } catch (error) { + console.warn("[Notifications] Sound playback failed:", error); + } + } + + init() { + this.requestNotificationPermission(); + this.connectWebSocket(); + this.loadInitialNotifications(); + this.setupDropdown(); + } + + /** + * Запросить разрешение на показ браузерных уведомлений + */ + async requestNotificationPermission() { + if (!("Notification" in window)) { + console.warn("[Notifications] Browser does not support notifications"); + return; + } + + if (Notification.permission === "granted") { + this.notificationPermission = "granted"; + console.log("[Notifications] Permission already granted"); + return; + } + + if (Notification.permission !== "denied") { + try { + const permission = await Notification.requestPermission(); + this.notificationPermission = permission; + console.log("[Notifications] Permission:", permission); + } catch (error) { + console.error("[Notifications] Error requesting permission:", error); + } + } + } + + connectWebSocket() { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + // Используем универсальный WebSocket endpoint (realtime architecture) + const wsUrl = `${protocol}//${window.location.host}/ws/`; + + console.log("[Notifications] Connecting to:", wsUrl); + + try { + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log("[Notifications] WebSocket connected"); + this.isConnected = true; this.reconnectAttempts = 0; - this.maxReconnectAttempts = 5; - this.reconnectDelay = 1000; + this.updateConnectionStatus(true); + }; + + this.ws.onmessage = (event) => { + this.handleMessage(event); + }; + + this.ws.onclose = () => { + console.log("[Notifications] WebSocket disconnected"); this.isConnected = false; - this.notificationPermission = 'default'; - this.soundEnabled = this.loadSoundPreference(); - - this.init(); + this.updateConnectionStatus(false); + this.attemptReconnect(); + }; + + this.ws.onerror = (error) => { + console.error("[Notifications] WebSocket error:", error); + }; + } catch (error) { + console.error("[Notifications] Failed to create WebSocket:", error); + this.attemptReconnect(); } - - /** - * Загрузить настройку звука из localStorage - */ - loadSoundPreference() { - const saved = localStorage.getItem('notificationSoundEnabled'); - return saved === null ? true : saved === 'true'; + } + + handleMessage(event) { + try { + const data = JSON.parse(event.data); + + // Игнорируем ping сообщения для keepalive (не логируем) + if (data.type === "ping") { + return; + } + + console.log("[Notifications] Received:", data); + + switch (data.type) { + case "unread_count": + this.updateBadge(data.count); + break; + + case "notification": // UserConsumer использует "notification" + this.showNewNotification(data.notification); + // Инвалидируем кеш при получении нового уведомления + invalidateNotifications(); + break; + + case "new_notification": // Backwards compatibility + this.showNewNotification(data.notification); + this.updateBadge(data.notification.unread_count); + // Инвалидируем кеш при получении нового уведомления + invalidateNotifications(); + break; + + case "count_update": + this.updateBadge(data.count); + // Инвалидируем кеш при изменении счетчика + invalidateNotifications(); + break; + + default: + console.warn("[Notifications] Unknown message type:", data.type); + } + } catch (error) { + console.error("[Notifications] Error parsing message:", error); } - - /** - * Сохранить настройку звука - */ - saveSoundPreference(enabled) { - this.soundEnabled = enabled; - localStorage.setItem('notificationSoundEnabled', enabled.toString()); - } - - /** - * Воспроизвести звук уведомления - * Приоритет: 1) MP3 файл, 2) Web Audio API (fallback) - */ - playNotificationSound() { - if (!this.soundEnabled) { - console.log('[Notifications] Sound disabled'); - return; - } - - try { - // Попытка воспроизвести MP3 файл - const audio = new Audio('/static/sounds/notification.mp3'); - audio.volume = 0.3; - console.log('[Notifications] Playing MP3 sound'); - audio.play().then(() => { - console.log('[Notifications] MP3 sound played successfully'); - }).catch((err) => { - console.warn('[Notifications] MP3 playback failed, using fallback:', err); - // Fallback на Web Audio API если MP3 недоступен - this.playFallbackSound(); - }); - } catch (error) { - console.warn('[Notifications] MP3 loading failed, using fallback:', error); - // Fallback на Web Audio API - this.playFallbackSound(); - } - } - - /** - * Резервный метод: синтетический звук через Web Audio API - */ - playFallbackSound() { - console.log('[Notifications] Playing fallback Web Audio API sound'); - try { - const audioContext = new (window.AudioContext || window.webkitAudioContext)(); - const oscillator = audioContext.createOscillator(); - const gainNode = audioContext.createGain(); - - oscillator.connect(gainNode); - gainNode.connect(audioContext.destination); - - oscillator.frequency.value = 800; // Частота звука - oscillator.type = 'sine'; - - gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); - gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3); - - oscillator.start(audioContext.currentTime); - oscillator.stop(audioContext.currentTime + 0.3); - console.log('[Notifications] Fallback sound completed'); - } catch (error) { - console.warn('[Notifications] Sound playback failed:', error); - } - } - - init() { - this.requestNotificationPermission(); + } + + attemptReconnect() { + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + const delay = this.reconnectDelay * this.reconnectAttempts; + + console.log( + `[Notifications] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})` + ); + + setTimeout(() => { this.connectWebSocket(); - this.loadInitialNotifications(); - this.setupDropdown(); + }, delay); + } else { + console.error("[Notifications] Max reconnect attempts reached"); } - - /** - * Запросить разрешение на показ браузерных уведомлений - */ - async requestNotificationPermission() { - if (!('Notification' in window)) { - console.warn('[Notifications] Browser does not support notifications'); - return; - } - - if (Notification.permission === 'granted') { - this.notificationPermission = 'granted'; - console.log('[Notifications] Permission already granted'); - return; - } - - if (Notification.permission !== 'denied') { - try { - const permission = await Notification.requestPermission(); - this.notificationPermission = permission; - console.log('[Notifications] Permission:', permission); - } catch (error) { - console.error('[Notifications] Error requesting permission:', error); - } - } + } + + updateConnectionStatus(connected) { + // Визуальная индикация статуса подключения (опционально) + const badge = document.getElementById("notificationBadge"); + if (badge) { + if (!connected) { + badge.style.backgroundColor = "#dc3545"; // красный при отключении + } else { + badge.style.backgroundColor = ""; // вернуть стандартный цвет + } } - - connectWebSocket() { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - // Используем универсальный WebSocket endpoint (realtime architecture) - const wsUrl = `${protocol}//${window.location.host}/ws/`; - - console.log('[Notifications] Connecting to:', wsUrl); - - try { - this.ws = new WebSocket(wsUrl); - - this.ws.onopen = () => { - console.log('[Notifications] WebSocket connected'); - this.isConnected = true; - this.reconnectAttempts = 0; - this.updateConnectionStatus(true); - }; - - this.ws.onmessage = (event) => { - this.handleMessage(event); - }; - - this.ws.onclose = () => { - console.log('[Notifications] WebSocket disconnected'); - this.isConnected = false; - this.updateConnectionStatus(false); - this.attemptReconnect(); - }; - - this.ws.onerror = (error) => { - console.error('[Notifications] WebSocket error:', error); - }; - } catch (error) { - console.error('[Notifications] Failed to create WebSocket:', error); - this.attemptReconnect(); - } + } + + updateBadge(count) { + const badge = document.getElementById("notificationBadge"); + if (badge) { + if (count > 0) { + badge.textContent = count > 99 ? "99+" : count; + badge.classList.remove("d-none"); + } else { + badge.classList.add("d-none"); + } } - - handleMessage(event) { - try { - const data = JSON.parse(event.data); - - // Игнорируем ping сообщения для keepalive (не логируем) - if (data.type === 'ping') { - return; - } - - console.log('[Notifications] Received:', data); - - switch (data.type) { - case 'unread_count': - this.updateBadge(data.count); - break; - - case 'notification': // UserConsumer использует "notification" - this.showNewNotification(data.notification); - // Инвалидируем кеш при получении нового уведомления - invalidateNotifications(); - break; - - case 'new_notification': // Backwards compatibility - this.showNewNotification(data.notification); - this.updateBadge(data.notification.unread_count); - // Инвалидируем кеш при получении нового уведомления - invalidateNotifications(); - break; - - case 'count_update': - this.updateBadge(data.count); - // Инвалидируем кеш при изменении счетчика - invalidateNotifications(); - break; - - default: - console.warn('[Notifications] Unknown message type:', data.type); - } - } catch (error) { - console.error('[Notifications] Error parsing message:', error); - } - } - - attemptReconnect() { - if (this.reconnectAttempts < this.maxReconnectAttempts) { - this.reconnectAttempts++; - const delay = this.reconnectDelay * this.reconnectAttempts; - - console.log(`[Notifications] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); - - setTimeout(() => { - this.connectWebSocket(); - }, delay); - } else { - console.error('[Notifications] Max reconnect attempts reached'); - } - } - - updateConnectionStatus(connected) { - // Визуальная индикация статуса подключения (опционально) - const badge = document.getElementById('notificationBadge'); - if (badge) { - if (!connected) { - badge.style.backgroundColor = '#dc3545'; // красный при отключении - } else { - badge.style.backgroundColor = ''; // вернуть стандартный цвет - } - } - } - - updateBadge(count) { - const badge = document.getElementById('notificationBadge'); - if (badge) { - if (count > 0) { - badge.textContent = count > 99 ? '99+' : count; - badge.classList.remove('d-none'); - } else { - badge.classList.add('d-none'); - } - } - } - - async loadInitialNotifications() { - try { - // Используем кешированный API вместо прямого fetch - const data = await getNotifications({ - page: 1, - page_size: 5, - unread_only: true - }); - - console.log('[Notifications] Loaded data:', data); - this.renderNotifications(data.notifications || []); - this.updateBadge(data.total || 0); - } catch (error) { - console.error('[Notifications] Error loading notifications:', error); - // Показываем заглушку при ошибке - const list = document.getElementById('notificationList'); - if (list) { - list.innerHTML = ` + } + + async loadInitialNotifications() { + try { + // Используем кешированный API вместо прямого fetch + const data = await getNotifications({ + page: 1, + page_size: 5, + unread_only: true, + }); + + console.log("[Notifications] Loaded data:", data); + this.renderNotifications(data.notifications || []); + this.updateBadge(data.total || 0); + } catch (error) { + console.error("[Notifications] Error loading notifications:", error); + // Показываем заглушку при ошибке + const list = document.getElementById("notificationList"); + if (list) { + list.innerHTML = `
Ошибка загрузки уведомлений
`; - } - } + } } - - renderNotifications(notifications) { - const listElement = document.getElementById('notificationList'); - if (!listElement) return; - - if (notifications.length === 0) { - listElement.innerHTML = ` + } + + renderNotifications(notifications) { + const listElement = document.getElementById("notificationList"); + if (!listElement) return; + + if (notifications.length === 0) { + listElement.innerHTML = `
Нет новых уведомлений
`; - return; - } - - listElement.innerHTML = notifications.map(n => this.renderNotificationItem(n)).join(''); - - // Добавить обработчики событий - listElement.querySelectorAll('[data-notification-id]').forEach(item => { - item.addEventListener('click', (e) => { - e.preventDefault(); - const notificationId = item.dataset.notificationId; - const actionUrl = item.dataset.actionUrl; - - this.markAsRead(notificationId).then(() => { - this.closeDropdown(); // Закрыть dropdown - if (actionUrl) { - window.location.href = actionUrl; - } - }); - }); - }); + return; } - - renderNotificationItem(notification) { - const iconColorClass = `text-${notification.color}`; - const timeAgo = this.getTimeAgo(notification.created_at); - - return ` -
this.renderNotificationItem(n)) + .join(""); + + // Добавить обработчики событий + listElement.querySelectorAll("[data-notification-id]").forEach((item) => { + item.addEventListener("click", (e) => { + e.preventDefault(); + const notificationId = item.dataset.notificationId; + const actionUrl = item.dataset.actionUrl; + + this.markAsRead(notificationId).then(() => { + this.closeDropdown(); // Закрыть dropdown + if (actionUrl) { + window.location.href = actionUrl; + } + }); + }); + }); + } + + renderNotificationItem(notification) { + const iconColorClass = `text-${notification.color}`; + const timeAgo = this.getTimeAgo(notification.created_at); + + return ` +
+ data-action-url="${notification.action_url || ""}">
-
${this.escapeHtml(notification.title)}
-
${this.escapeHtml(notification.message)}
+
${this.escapeHtml( + notification.title + )}
+
${this.escapeHtml( + notification.message + )}
${timeAgo}
- ${!notification.is_read ? '
' : ''} + ${ + !notification.is_read + ? '
' + : "" + }
`; + } + + showNewNotification(notification) { + // Показать нативное браузерное уведомление + this.showBrowserNotification(notification); + + // Обновить список в dropdown + this.loadInitialNotifications(); + + // Воспроизвести звук (опционально) + this.playNotificationSound(); + } + + /** + * Показать нативное браузерное уведомление + */ + showBrowserNotification(notification) { + if (!("Notification" in window)) { + console.warn("[Notifications] Browser notifications not supported"); + // Fallback to toast + this.showToast(notification); + return; } - - showNewNotification(notification) { - // Показать нативное браузерное уведомление - this.showBrowserNotification(notification); - - // Обновить список в dropdown - this.loadInitialNotifications(); - - // Воспроизвести звук (опционально) - this.playNotificationSound(); + + if (Notification.permission !== "granted") { + console.warn("[Notifications] Permission not granted"); + // Fallback to toast + this.showToast(notification); + return; } - - /** - * Показать нативное браузерное уведомление - */ - showBrowserNotification(notification) { - if (!('Notification' in window)) { - console.warn('[Notifications] Browser notifications not supported'); - // Fallback to toast - this.showToast(notification); - return; - } - - if (Notification.permission !== 'granted') { - console.warn('[Notifications] Permission not granted'); - // Fallback to toast - this.showToast(notification); - return; - } - - // Определить иконку по категории - const iconMap = { - 'communications': '💬', - 'documents': '📄', - 'requests': '📋', - 'calendar': '📅', - 'department': '👥', - 'profile': '👤', - 'feed': '📰', - 'system': '⚙️' - }; - - const options = { - body: notification.message, - icon: '/static/img/logo.png', // Можно заменить на специальную иконку - badge: '/static/img/favicon-32.png', - tag: `notification-${notification.id}`, - requireInteraction: notification.priority === 'urgent', - silent: false, - data: { - notificationId: notification.id, - actionUrl: notification.action_url - } - }; - - try { - const browserNotification = new Notification( - `${iconMap[notification.category] || '🔔'} ${notification.title}`, - options - ); - - // Обработать клик по уведомлению - browserNotification.onclick = (event) => { - event.preventDefault(); - window.focus(); - - // Отметить как прочитанное - this.markAsRead(notification.id); - - // Перейти по ссылке ТОЛЬКО если это не текущая страница - // (избегаем ненужной перезагрузки при клике на уведомление о решении заявления) - if (notification.action_url) { - const currentPath = window.location.pathname; - const notificationPath = new URL(notification.action_url, window.location.origin).pathname; - - // Если это уже текущая страница, просто закрываем уведомление - if (currentPath !== notificationPath) { - window.location.href = notification.action_url; - } - } - - browserNotification.close(); - }; - - // Автозакрытие через 10 секунд (если не urgent) - if (notification.priority !== 'urgent') { - setTimeout(() => { - browserNotification.close(); - }, 10000); - } - - console.log('[Notifications] Browser notification shown:', notification.title); - } catch (error) { - console.error('[Notifications] Error showing browser notification:', error); - // Fallback to toast - this.showToast(notification); + + // Определить иконку по категории + const iconMap = { + communications: "💬", + documents: "📄", + requests: "📋", + calendar: "📅", + department: "👥", + profile: "👤", + feed: "📰", + system: "⚙️", + }; + + const options = { + body: notification.message, + icon: "/static/img/logo.png", // Можно заменить на специальную иконку + badge: "/static/img/favicon-32.png", + tag: `notification-${notification.id}`, + requireInteraction: notification.priority === "urgent", + silent: false, + data: { + notificationId: notification.id, + actionUrl: notification.action_url, + }, + }; + + try { + const browserNotification = new Notification( + `${iconMap[notification.category] || "🔔"} ${notification.title}`, + options + ); + + // Обработать клик по уведомлению + browserNotification.onclick = (event) => { + event.preventDefault(); + window.focus(); + + // Отметить как прочитанное + this.markAsRead(notification.id); + + // Перейти по ссылке ТОЛЬКО если это не текущая страница + // (избегаем ненужной перезагрузки при клике на уведомление о решении заявления) + if (notification.action_url) { + const currentPath = window.location.pathname; + const notificationPath = new URL( + notification.action_url, + window.location.origin + ).pathname; + + // Если это уже текущая страница, просто закрываем уведомление + if (currentPath !== notificationPath) { + window.location.href = notification.action_url; + } } + + browserNotification.close(); + }; + + // Автозакрытие через 10 секунд (если не urgent) + if (notification.priority !== "urgent") { + setTimeout(() => { + browserNotification.close(); + }, 10000); + } + + console.log( + "[Notifications] Browser notification shown:", + notification.title + ); + } catch (error) { + console.error( + "[Notifications] Error showing browser notification:", + error + ); + // Fallback to toast + this.showToast(notification); } - - showToast(notification) { - // Создать toast элемент - const toastHtml = ` - - +
@@ -260,13 +260,13 @@ // Получаем обновленное содержимое const html = await response.text(); - + // Заменяем контент страницы const parser = new DOMParser(); const newDoc = parser.parseFromString(html, 'text/html'); const newContent = newDoc.querySelector('[role="main"]') || newDoc.body; const currentContent = document.querySelector('[role="main"]') || document.body; - + currentContent.innerHTML = newContent.innerHTML; // Показываем успех diff --git a/docs/reports/MOBILE_OPTIMIZATION_REPORT.md b/docs/reports/MOBILE_OPTIMIZATION_REPORT.md index 5018d49e..9446d6b0 100644 --- a/docs/reports/MOBILE_OPTIMIZATION_REPORT.md +++ b/docs/reports/MOBILE_OPTIMIZATION_REPORT.md @@ -1,7 +1,7 @@ # Отчет: Мобильная оптимизация системы заявлений -**Дата:** 29 декабря 2025 г. -**Версия:** 1.0 +**Дата:** 29 декабря 2025 г. +**Версия:** 1.0 **Статус:** ✅ Готово к тестированию и merge --- @@ -21,7 +21,7 @@ ### 1. CSS Оптимизация (`request-list.css`) -**Было:** Grid template с 7 колонками, не переделывающийся на мобильных +**Было:** Grid template с 7 колонками, не переделывающийся на мобильных **Стало:** Flexbox с `flex-direction: column` на мобильных ```css @@ -137,9 +137,9 @@ const rootMargin = isMobile ? '200px' : '100px'; // На мобильных з ### Тестирование -✅ Enum включает все 6 типов -✅ Миграция применена успешно -✅ Все формы отображают новый тип +✅ Enum включает все 6 типов +✅ Миграция применена успешно +✅ Все формы отображают новый тип ✅ Фильтрация работает корректно --- @@ -156,7 +156,7 @@ const rootMargin = isMobile ? '200px' : '100px'; // На мобильных з ✓ backend/requests_app/migrations/0009_alter_request_type.py (NEW) ``` -**Git Commit:** `c857ab9` +**Git Commit:** `c857ab9` **Сообщение:** "📱 Мобильная оптимизация и новый тип заявления 'Отгул'" --- @@ -186,12 +186,12 @@ const rootMargin = isMobile ? '200px' : '100px'; // На мобильных з ## Статус Готовности к Merge -✅ **CSS:** Полностью переработан для мобильных -✅ **JavaScript:** Обработка ошибок и таймауты добавлены -✅ **Django check:** Нет ошибок -✅ **Миграции:** Применены -✅ **Git:** Коммит готов -✅ **Документация:** Обновлена +✅ **CSS:** Полностью переработан для мобильных +✅ **JavaScript:** Обработка ошибок и таймауты добавлены +✅ **Django check:** Нет ошибок +✅ **Миграции:** Применены +✅ **Git:** Коммит готов +✅ **Документация:** Обновлена ### Готово к: - 🔄 Pull Request Review