fix: исключен approver из получателей уведомлений о решении

- Approver (тот кто принял решение) не получает уведомление о своем действии
- Исключены employee.id и approver_id из recipients при approve/reject
- Упрощен signal create_request_notifications (убран лишний запрос в БД)
- Теперь передается актуальный request_obj с установленным approver_id
This commit is contained in:
Игорь Надеин 2025-12-29 11:02:39 +03:00
parent aa6fa29e3a
commit c0b659f95d
12 changed files with 1285 additions and 1163 deletions

View File

@ -4,34 +4,34 @@ from __future__ import annotations
from typing import Any, Optional from typing import Any, Optional
from api.v1.permissions import AdminOrDeptAllowed 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.constants import DeptPerm
from employees.models import Department from employees.models import Department
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
class IsRecipientOfRequest(BasePermission): class IsRecipientOfRequest(BasePermission):
"""Проверяет, что пользователь является получателем заявки. """Проверяет, что пользователь является получателем заявки.
Доступ разрешается только если пользователь указан в recipients Доступ разрешается только если пользователь указан в recipients
данной заявки. Это базовая проверка которая должна комбинироваться данной заявки. Это базовая проверка которая должна комбинироваться
с проверкой прав (AdminOrActionOrModelPerms или DeptCanProcess). с проверкой прав (AdminOrActionOrModelPerms или DeptCanProcess).
Для staff/superuser проверка пропускается (они могут обрабатывать любые заявки). Для staff/superuser проверка пропускается (они могут обрабатывать любые заявки).
""" """
message = "Вы не являетесь получателем этой заявки." message = "Вы не являетесь получателем этой заявки."
def has_permission(self, request: Request, view: Any) -> bool: def has_permission(self, request: Request, view: Any) -> bool:
return bool(getattr(request.user, "is_authenticated", False)) return bool(getattr(request.user, "is_authenticated", False))
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
user = request.user user = request.user
# Staff/superuser могут обрабатывать любые заявки # Staff/superuser могут обрабатывать любые заявки
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True return True
# Проверяем, является ли пользователь получателем заявки # Проверяем, является ли пользователь получателем заявки
# (только recipients, не cc_users - люди в копии не принимают решения) # (только recipients, не cc_users - люди в копии не принимают решения)
return obj.recipients.filter(id=user.id).exists() return obj.recipients.filter(id=user.id).exists()

View File

@ -2,12 +2,12 @@ from __future__ import annotations
from typing import Any, List, Optional # было: Any, Dict, List, Type 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 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 Request as EmployeeRequest
from requests_app.models import RequestComment from requests_app.models import RequestComment
from requests_app.enums import RequestStatus
# from django.shortcuts import get_object_or_404 # <- не используется # from django.shortcuts import get_object_or_404 # <- не используется
from rest_framework import status, viewsets from rest_framework import status, viewsets
@ -58,11 +58,11 @@ class RequestViewSet(viewsets.ModelViewSet):
PermissionDenied: При нарушении правил доступа. PermissionDenied: При нарушении правил доступа.
""" """
queryset = EmployeeRequest.objects.select_related( queryset = (
"employee", "approver", "department" EmployeeRequest.objects.select_related("employee", "approver", "department")
).annotate( .annotate(comments_count=Count("comments"))
comments_count=Count('comments') .order_by("-created_at")
).order_by('-created_at') )
parser_classes = (MultiPartParser, FormParser, JSONParser) parser_classes = (MultiPartParser, FormParser, JSONParser)
# Используем глобальную пагинацию из settings (PageNumberPagination, PAGE_SIZE=20) # Используем глобальную пагинацию из settings (PageNumberPagination, PAGE_SIZE=20)
@ -136,7 +136,7 @@ class RequestViewSet(viewsets.ModelViewSet):
want_mine = (params.get("view") == "mine") or ( want_mine = (params.get("view") == "mine") or (
mine_raw in {"1", "true", "yes", "on"} mine_raw in {"1", "true", "yes", "on"}
) )
# Новый параметр для фильтрации "мне адресовано" # Новый параметр для фильтрации "мне адресовано"
addressed_to_me = params.get("addressed_to_me") == "true" addressed_to_me = params.get("addressed_to_me") == "true"
@ -146,38 +146,30 @@ class RequestViewSet(viewsets.ModelViewSet):
elif addressed_to_me: elif addressed_to_me:
# Заявки, где я получатель # Заявки, где я получатель
my_dept_ids = EmployeeDepartment.objects.filter( my_dept_ids = EmployeeDepartment.objects.filter(
employee=user, employee=user, is_active=True
is_active=True ).values_list("department_id", flat=True)
).values_list('department_id', flat=True)
scope = ( scope = (
Q(recipients=user) | Q(recipients=user)
Q(cc_users=user) | | Q(cc_users=user)
Q( | Q(sent_to_all_department=True, departments__in=my_dept_ids)
sent_to_all_department=True,
departments__in=my_dept_ids
)
) )
qs = qs.filter(scope).distinct() qs = qs.filter(scope).distinct()
else: else:
# Обычный пользователь # Обычный пользователь
scope = Q(employee_id=user.id) # Свои заявки scope = Q(employee_id=user.id) # Свои заявки
# Заявки, где я получатель (основной или CC) # Заявки, где я получатель (основной или CC)
scope |= Q(recipients=user) | Q(cc_users=user) scope |= Q(recipients=user) | Q(cc_users=user)
# Заявки отделов с sent_to_all_department # Заявки отделов с sent_to_all_department
my_dept_ids = EmployeeDepartment.objects.filter( my_dept_ids = EmployeeDepartment.objects.filter(
employee=user, employee=user, is_active=True
is_active=True ).values_list("department_id", flat=True)
).values_list('department_id', flat=True)
if my_dept_ids: if my_dept_ids:
scope |= Q( scope |= Q(sent_to_all_department=True, departments__in=my_dept_ids)
sent_to_all_department=True,
departments__in=my_dept_ids
)
# Департаментные права (как было) # Департаментные права (как было)
view_dept_ids = ( view_dept_ids = (
EmployeeDepartment.objects.filter( EmployeeDepartment.objects.filter(
@ -197,18 +189,16 @@ class RequestViewSet(viewsets.ModelViewSet):
.values_list("department_id", flat=True) .values_list("department_id", flat=True)
.distinct() .distinct()
) )
head_dept_ids = Department.objects.filter( head_dept_ids = Department.objects.filter(head_id=user.id).values_list(
head_id=user.id "id", flat=True
).values_list("id", flat=True)
combined_ids = (
set(view_dept_ids) | set(proc_dept_ids) | set(head_dept_ids)
) )
combined_ids = set(view_dept_ids) | set(proc_dept_ids) | set(head_dept_ids)
if combined_ids: if combined_ids:
# Заявки этих отделов (новое поле departments) # Заявки этих отделов (новое поле departments)
scope |= Q(departments__in=combined_ids) scope |= Q(departments__in=combined_ids)
# Заявки сотрудников этих отделов # Заявки сотрудников этих отделов
dept_emp_ids = ( dept_emp_ids = (
EmployeeDepartment.objects.filter( EmployeeDepartment.objects.filter(
@ -220,19 +210,17 @@ class RequestViewSet(viewsets.ModelViewSet):
) )
if dept_emp_ids: if dept_emp_ids:
scope |= Q(employee_id__in=list(dept_emp_ids)) scope |= Q(employee_id__in=list(dept_emp_ids))
# Фильтр "только адресованные мне" # Фильтр "только адресованные мне"
if addressed_to_me: if addressed_to_me:
scope = ( scope = (
Q(recipients=user) | Q(cc_users=user) | Q(recipients=user)
Q( | Q(cc_users=user)
sent_to_all_department=True, | Q(sent_to_all_department=True, departments__in=my_dept_ids)
departments__in=my_dept_ids
)
) )
elif want_mine: elif want_mine:
scope = Q(employee_id=user.id) scope = Q(employee_id=user.id)
qs = qs.filter(scope).distinct() qs = qs.filter(scope).distinct()
# Применяем фильтры type/status для всех пользователей # Применяем фильтры type/status для всех пользователей
@ -271,7 +259,9 @@ class RequestViewSet(viewsets.ModelViewSet):
}: }:
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
lookup_value = self.kwargs[lookup_url_kwarg] 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}) obj = get_object_or_404(base, **{self.lookup_field: lookup_value})
self.check_object_permissions(self.request, obj) self.check_object_permissions(self.request, obj)
return obj return obj
@ -289,7 +279,9 @@ class RequestViewSet(viewsets.ModelViewSet):
extra = {"employee": user} if not is_power else {} extra = {"employee": user} if not is_power else {}
save_as = (self.request.query_params.get("save_as") or "").strip().lower() save_as = (self.request.query_params.get("save_as") or "").strip().lower()
if save_as == "draft": if save_as == "draft":
extra["status"] = RequestStatus.DRAFT # ✅ обычному пользователю поле в payload всё равно бы «очистили» extra["status"] = (
RequestStatus.DRAFT
) # ✅ обычному пользователю поле в payload всё равно бы «очистили»
serializer.save(**extra) serializer.save(**extra)
@ -398,50 +390,34 @@ class RequestViewSet(viewsets.ModelViewSet):
отвечаем read-сериализатором. отвечаем read-сериализатором.
""" """
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info( logger.info(
f"[RequestViewSet.create] User: {request.user.id}, " f"[RequestViewSet.create] User: {request.user.id}, "
f"Content-Type: {request.content_type}" f"Content-Type: {request.content_type}"
) )
logger.info( logger.info(f"[RequestViewSet.create] POST keys: {list(request.POST.keys())}")
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] 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) value = request.data.get(key)
logger.info( logger.info(
f"[RequestViewSet.create] {key}: {value} " f"[RequestViewSet.create] {key}: {value} (type: {type(value).__name__})"
f"(type: {type(value).__name__})"
) )
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
if not serializer.is_valid(): if not serializer.is_valid():
logger.error( logger.error(
f"[RequestViewSet.create] Validation errors: " f"[RequestViewSet.create] Validation errors: {serializer.errors}"
f"{serializer.errors}"
) )
logger.error( logger.error(f"[RequestViewSet.create] Request data: {request.data}")
f"[RequestViewSet.create] Request data: {request.data}"
)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
self.perform_create(serializer) self.perform_create(serializer)
instance = serializer.instance instance = serializer.instance
read = RequestReadSerializer( read = RequestReadSerializer(instance, context=self.get_serializer_context())
instance,
context=self.get_serializer_context()
)
headers = self.get_success_headers(read.data) headers = self.get_success_headers(read.data)
return Response( return Response(read.data, status=status.HTTP_201_CREATED, headers=headers)
read.data,
status=status.HTTP_201_CREATED,
headers=headers
)

View File

@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from django.db.models import TextChoices from django.db.models import TextChoices

View File

@ -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.db.models.signals import post_save, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.contrib.auth import get_user_model from notifications.services import NotificationService
from .models import Request, RequestComment from .models import Request, RequestComment
from notifications.services import NotificationService
Employee = get_user_model() Employee = get_user_model()
@ -23,29 +23,25 @@ Employee = get_user_model()
def create_request_notifications(sender, instance, created, **kwargs): def create_request_notifications(sender, instance, created, **kwargs):
""" """
Создает уведомления при создании или изменении заявки. Создает уведомления при создании или изменении заявки.
Обрабатывает: Обрабатывает:
1. Новая заявка - уведомление руководителю/ответственным 1. Новая заявка - уведомление руководителю/ответственным
2. Изменение статуса - уведомление автору 2. Изменение статуса - уведомление автору
""" """
request_obj = instance request_obj = instance
if created and request_obj.status != 'draft': if created and request_obj.status != "draft":
# Новая заявка - уведомляем ответственных # Новая заявка - уведомляем ответственных
notify_new_request(request_obj) notify_new_request(request_obj)
elif not created: elif not created:
# Проверяем изменение статуса # Проверяем изменение статуса через сохраненный атрибут _old_status
# Получаем предыдущее состояние из БД if hasattr(request_obj, "_old_status"):
try: old_status = request_obj._old_status
old_request = Request.objects.get(pk=request_obj.pk) new_status = request_obj.status
if hasattr(request_obj, '_old_status'):
old_status = request_obj._old_status if old_status != new_status:
new_status = request_obj.status # Передаем текущий request_obj с актуальным approver_id
notify_status_change(request_obj, old_status, new_status)
if old_status != new_status:
notify_status_change(request_obj, old_status, new_status)
except Request.DoesNotExist:
pass
@receiver(pre_save, sender=Request) @receiver(pre_save, sender=Request)
@ -74,63 +70,68 @@ def create_comment_notification(sender, instance, created, **kwargs):
""" """
if not created: if not created:
return return
comment = instance comment = instance
request_obj = comment.request request_obj = comment.request
author = comment.author author = comment.author
recipients_set = set() recipients_set = set()
# Автор заявки # Автор заявки
if request_obj.employee.id != author.id: if request_obj.employee.id != author.id:
recipients_set.add(request_obj.employee) recipients_set.add(request_obj.employee)
# Получатели # Получатели
recipients_set.update( recipients_set.update(
request_obj.recipients.filter(is_active=True).exclude(id=author.id) request_obj.recipients.filter(is_active=True).exclude(id=author.id)
) )
# CC # CC
recipients_set.update( recipients_set.update(
request_obj.cc_users.filter(is_active=True).exclude(id=author.id) request_obj.cc_users.filter(is_active=True).exclude(id=author.id)
) )
# Согласующий # Согласующий
if request_obj.approver and request_obj.approver.id != author.id: if request_obj.approver and request_obj.approver.id != author.id:
recipients_set.add(request_obj.approver) recipients_set.add(request_obj.approver)
# Если sent_to_all_department - все сотрудники отделов # Если sent_to_all_department - все сотрудники отделов
if request_obj.sent_to_all_department: if request_obj.sent_to_all_department:
dept_employees = Employee.objects.filter( dept_employees = (
departments_links__department__in=request_obj.departments.all(), Employee.objects.filter(
departments_links__is_active=True, departments_links__department__in=request_obj.departments.all(),
is_active=True departments_links__is_active=True,
).exclude(id__in=[author.id, request_obj.employee.id]).distinct() is_active=True,
)
.exclude(id__in=[author.id, request_obj.employee.id])
.distinct()
)
recipients_set.update(dept_employees) recipients_set.update(dept_employees)
# Отправляем уведомления # Отправляем уведомления
for recipient in recipients_set: for recipient in recipients_set:
NotificationService.create_notification( NotificationService.create_notification(
recipient=recipient, recipient=recipient,
notification_type_code='request_comment', notification_type_code="request_comment",
title='Новый комментарий к заявке', title="Новый комментарий к заявке",
message=( message=(
f'{author.get_full_name() or author.username} ' f"{author.get_full_name() or author.username} "
f'прокомментировал заявку: {comment.text[:100]}' f"прокомментировал заявку: {comment.text[:100]}"
), ),
content_object=request_obj, content_object=request_obj,
action_url=f'/requests/{request_obj.id}/', action_url=f"/requests/{request_obj.id}/",
metadata={ metadata={
'request_id': request_obj.id, "request_id": request_obj.id,
'request_type': request_obj.type, "request_type": request_obj.type,
'comment_id': comment.id, "comment_id": comment.id,
'author_id': author.id, "author_id": author.id,
} },
) )
# ===== Вспомогательные функции ===== # ===== Вспомогательные функции =====
def notify_new_request(request_obj): def notify_new_request(request_obj):
""" """
Отправляет уведомление о новой заявке: Отправляет уведомление о новой заявке:
@ -139,104 +140,109 @@ def notify_new_request(request_obj):
- Согласующему (approver) - Согласующему (approver)
- Руководителям отделов - Руководителям отделов
- Пользователям с правом can_process_requests - Пользователям с правом can_process_requests
При sent_to_all_department=True отправляет всем сотрудникам отделов При sent_to_all_department=True отправляет всем сотрудникам отделов
""" """
recipients_set = set() recipients_set = set()
# 1. Основные получатели # 1. Основные получатели
for recipient in request_obj.recipients.filter(is_active=True): for recipient in request_obj.recipients.filter(is_active=True):
recipients_set.add(recipient) recipients_set.add(recipient)
# 2. Копия (CC) # 2. Копия (CC)
for cc_user in request_obj.cc_users.filter(is_active=True): for cc_user in request_obj.cc_users.filter(is_active=True):
recipients_set.add(cc_user) recipients_set.add(cc_user)
# 3. Если sent_to_all_department - все сотрудники отделов # 3. Если sent_to_all_department - все сотрудники отделов
if request_obj.sent_to_all_department: if request_obj.sent_to_all_department:
dept_employees = Employee.objects.filter( dept_employees = (
departments_links__department__in=request_obj.departments.all(), Employee.objects.filter(
departments_links__is_active=True, departments_links__department__in=request_obj.departments.all(),
is_active=True departments_links__is_active=True,
).exclude(id=request_obj.employee.id).distinct() is_active=True,
)
.exclude(id=request_obj.employee.id)
.distinct()
)
recipients_set.update(dept_employees) recipients_set.update(dept_employees)
# 4. Согласующий # 4. Согласующий
if request_obj.approver and request_obj.approver.id != request_obj.employee.id: if request_obj.approver and request_obj.approver.id != request_obj.employee.id:
recipients_set.add(request_obj.approver) recipients_set.add(request_obj.approver)
# 5. Руководители отделов # 5. Руководители отделов
for department in request_obj.departments.all(): for department in request_obj.departments.all():
if department.head and department.head.id != request_obj.employee.id: if department.head and department.head.id != request_obj.employee.id:
recipients_set.add(department.head) recipients_set.add(department.head)
# Также проверяем старое поле department для обратной совместимости # Также проверяем старое поле department для обратной совместимости
if request_obj.department and request_obj.department.head: if request_obj.department and request_obj.department.head:
if request_obj.department.head.id != request_obj.employee.id: if request_obj.department.head.id != request_obj.employee.id:
recipients_set.add(request_obj.department.head) recipients_set.add(request_obj.department.head)
# 6. Пользователи с правом обрабатывать заявки в этих отделах # 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: if request_obj.department_id and request_obj.department_id not in dept_ids:
dept_ids.append(request_obj.department_id) dept_ids.append(request_obj.department_id)
if dept_ids: if dept_ids:
dept_processors = Employee.objects.filter( dept_processors = (
departments_links__department_id__in=dept_ids, Employee.objects.filter(
departments_links__is_active=True, departments_links__department_id__in=dept_ids,
departments_links__role__scoped_permissions__code='can_process_requests', departments_links__is_active=True,
is_active=True departments_links__role__scoped_permissions__code="can_process_requests",
).exclude(id=request_obj.employee.id).distinct() is_active=True,
)
.exclude(id=request_obj.employee.id)
.distinct()
)
recipients_set.update(dept_processors) recipients_set.update(dept_processors)
# Определяем тип уведомления для каждого получателя # Определяем тип уведомления для каждого получателя
author_name = ( author_name = request_obj.employee.get_full_name() or request_obj.employee.username
request_obj.employee.get_full_name() or
request_obj.employee.username
)
for recipient in recipients_set: for recipient in recipients_set:
# Определяем роль получателя # Определяем роль получателя
is_primary = request_obj.recipients.filter(id=recipient.id).exists() is_primary = request_obj.recipients.filter(id=recipient.id).exists()
is_cc = request_obj.cc_users.filter(id=recipient.id).exists() is_cc = request_obj.cc_users.filter(id=recipient.id).exists()
is_approver = request_obj.approver_id == recipient.id is_approver = request_obj.approver_id == recipient.id
# Формируем заголовок и сообщение # Формируем заголовок и сообщение
if is_approver: if is_approver:
title = f'Новая заявка на согласование от {author_name}' title = f"Новая заявка на согласование от {author_name}"
notification_type = 'request_new' notification_type = "request_new"
elif is_primary: elif is_primary:
title = f'Вам адресована заявка от {author_name}' title = f"Вам адресована заявка от {author_name}"
notification_type = 'request_new' notification_type = "request_new"
elif is_cc: elif is_cc:
title = f'Вы в копии заявки от {author_name}' title = f"Вы в копии заявки от {author_name}"
notification_type = 'request_new' notification_type = "request_new"
else: else:
title = f'Новая заявка в отделе от {author_name}' title = f"Новая заявка в отделе от {author_name}"
notification_type = 'request_new' notification_type = "request_new"
message = ( message = (
f'Тип: {request_obj.get_type_display()}. ' f"Тип: {request_obj.get_type_display()}. "
f'{request_obj.comment[:100] if request_obj.comment else ""}' f"{request_obj.comment[:100] if request_obj.comment else ''}"
) )
NotificationService.create_notification( NotificationService.create_notification(
recipient=recipient, recipient=recipient,
notification_type_code=notification_type, notification_type_code=notification_type,
title=title, title=title,
message=message, message=message,
content_object=request_obj, content_object=request_obj,
action_url=f'/requests/{request_obj.id}/', action_url=f"/requests/{request_obj.id}/",
metadata={ metadata={
'request_id': request_obj.id, "request_id": request_obj.id,
'request_type': request_obj.type, "request_type": request_obj.type,
'employee_id': request_obj.employee.id, "employee_id": request_obj.employee.id,
'is_primary_recipient': is_primary, "is_primary_recipient": is_primary,
'is_cc': is_cc, "is_cc": is_cc,
'is_approver': is_approver, "is_approver": is_approver,
} },
) )
@ -246,84 +252,103 @@ def notify_status_change(request_obj, old_status, new_status):
- Всех получателей (recipients) - Всех получателей (recipients)
- Всех в копии (cc_users) - Всех в копии (cc_users)
- Сотрудников отделов (если sent_to_all_department) - Сотрудников отделов (если sent_to_all_department)
ВАЖНО: автор не получает уведомление о решении (approve/reject), ВАЖНО:
так как он видит результат на странице request_process.html - Автор (employee) не получает уведомление о решении (approve/reject)
- Approver (тот кто принял решение) не получает уведомление о своем же решении
""" """
recipients_to_notify = set() 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 # 1. Автор - НЕ уведомляем при approved/rejected
# (автор видит результат прямо на странице) if new_status not in ("approved", "rejected"):
if new_status not in ('approved', 'rejected'):
recipients_to_notify.add(request_obj.employee) recipients_to_notify.add(request_obj.employee)
# 2. Основные получатели # 2. Основные получатели (исключая тех кто в exclude_ids)
recipients_to_notify.update( 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( 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: if request_obj.sent_to_all_department:
dept_employees = Employee.objects.filter( # При approve/reject также исключаем approver
departments_links__department__in=request_obj.departments.all(), all_exclude_ids = {request_obj.employee.id}
departments_links__is_active=True, all_exclude_ids.update(exclude_ids)
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__in=all_exclude_ids)
.distinct()
)
recipients_to_notify.update(dept_employees) recipients_to_notify.update(dept_employees)
# Формируем уведомления # Формируем уведомления
for recipient in recipients_to_notify: for recipient in recipients_to_notify:
if new_status == 'approved': if new_status == "approved":
notification_type = 'request_approved' notification_type = "request_approved"
title = 'Заявка одобрена' title = "Заявка одобрена"
approver_name = ( approver_name = (
request_obj.approver.get_full_name() request_obj.approver.get_full_name()
if request_obj.approver else 'Руководитель' if request_obj.approver
else "Руководитель"
) )
message = ( message = (
f'Заявка "{request_obj.get_type_display()}" ' f'Заявка "{request_obj.get_type_display()}" '
f'одобрена пользователем {approver_name}' f"одобрена пользователем {approver_name}"
) )
elif new_status == 'rejected': elif new_status == "rejected":
notification_type = 'request_rejected' notification_type = "request_rejected"
title = 'Заявка отклонена' title = "Заявка отклонена"
approver_name = ( approver_name = (
request_obj.approver.get_full_name() request_obj.approver.get_full_name()
if request_obj.approver else 'Руководитель' if request_obj.approver
else "Руководитель"
) )
message = ( message = (
f'Заявка "{request_obj.get_type_display()}" ' f'Заявка "{request_obj.get_type_display()}" '
f'отклонена пользователем {approver_name}' f"отклонена пользователем {approver_name}"
) )
else: else:
# Общее уведомление об изменении статуса # Общее уведомление об изменении статуса
notification_type = 'request_status_changed' notification_type = "request_status_changed"
title = 'Статус заявки изменен' title = "Статус заявки изменен"
message = ( message = (
f'Статус заявки "{request_obj.get_type_display()}" ' f'Статус заявки "{request_obj.get_type_display()}" '
f'изменен: {old_status} → {new_status}' f"изменен: {old_status} → {new_status}"
) )
NotificationService.create_notification( NotificationService.create_notification(
recipient=recipient, recipient=recipient,
notification_type_code=notification_type, notification_type_code=notification_type,
title=title, title=title,
message=message, message=message,
content_object=request_obj, content_object=request_obj,
action_url=f'/requests/{request_obj.id}/', action_url=f"/requests/{request_obj.id}/",
metadata={ metadata={
'request_id': request_obj.id, "request_id": request_obj.id,
'request_type': request_obj.type, "request_type": request_obj.type,
'old_status': old_status, "old_status": old_status,
'new_status': new_status, "new_status": new_status,
'approver_id': ( "approver_id": (
request_obj.approver.id if request_obj.approver else None request_obj.approver.id if request_obj.approver else None
), ),
} },
) )

View File

@ -146,39 +146,39 @@ def is_hr_or_head(user) -> bool:
def can_process_requests(user) -> bool: def can_process_requests(user) -> bool:
"""Проверяет, может ли пользователь обрабатывать заявки вообще. """Проверяет, может ли пользователь обрабатывать заявки вообще.
True если: HR/staff, руководитель отдела, или есть модельное право. True если: HR/staff, руководитель отдела, или есть модельное право.
""" """
if is_hr(user): if is_hr(user):
return True return True
if is_department_head(user): if is_department_head(user):
return True return True
if user.has_perm('requests_app.can_process_requests'): if user.has_perm("requests_app.can_process_requests"):
return True return True
return False return False
def get_allowed_request_qs(user): def get_allowed_request_qs(user):
"""Возвращает заявки, которые пользователь может обрабатывать. """Возвращает заявки, которые пользователь может обрабатывать.
HR/staff — все заявки; HR/staff — все заявки;
Руководитель отдела — заявки сотрудников своих отделов; Руководитель отдела — заявки сотрудников своих отделов;
Получатель — заявки, где он указан как recipient. Получатель — заявки, где он указан как recipient.
Финальная проверка в request_process дополнительно проверяет, Финальная проверка в request_process дополнительно проверяет,
что пользователь является получателем конкретной заявки. что пользователь является получателем конкретной заявки.
""" """
if is_hr(user): if is_hr(user):
return BASE_REQUEST_QS return BASE_REQUEST_QS
from django.db.models import Q from django.db.models import Q
# Заявки от сотрудников отделов, где пользователь руководитель # Заявки от сотрудников отделов, где пользователь руководитель
dept_head_qs = Q(employee__departments_links__department__head=user) dept_head_qs = Q(employee__departments_links__department__head=user)
# Заявки где пользователь указан как получатель # Заявки где пользователь указан как получатель
recipient_qs = Q(recipients=user) recipient_qs = Q(recipients=user)
return BASE_REQUEST_QS.filter(dept_head_qs | recipient_qs).distinct() return BASE_REQUEST_QS.filter(dept_head_qs | recipient_qs).distinct()
@ -349,9 +349,12 @@ def request_process(request, pk):
with transaction.atomic(): with transaction.atomic():
req = get_object_or_404(allowed_qs.select_for_update(), pk=pk) req = get_object_or_404(allowed_qs.select_for_update(), pk=pk)
req.refresh_from_db() req.refresh_from_db()
# Проверка: не-staff должен быть получателем заявки # Проверка: не-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("Вы не являетесь получателем этой заявки.") raise PermissionDenied("Вы не являетесь получателем этой заявки.")
post = request.POST.copy() 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]) default_url = reverse(_detail_or_process_name(request.user), args=[pk])
return redirect(_safe_back_url(request, default_url)) return redirect(_safe_back_url(request, default_url))

View File

@ -1,6 +1,6 @@
/** /**
* Page Header Component * Page Header Component
* *
* Универсальная шапка для страниц списков с: * Универсальная шапка для страниц списков с:
* - Полупрозрачным фоном (sticky positioning) * - Полупрозрачным фоном (sticky positioning)
* - Иконкой и заголовком * - Иконкой и заголовком
@ -9,7 +9,7 @@
* - Кнопкой фильтров * - Кнопкой фильтров
* - Кнопкой создания (вытянутая на десктопе, иконка на мобильных) * - Кнопкой создания (вытянутая на десктопе, иконка на мобильных)
* - Поиском iOS-стиль * - Поиском iOS-стиль
* *
* Использование: * Использование:
* {% include "includes/components/page_header.html" with ... %} * {% include "includes/components/page_header.html" with ... %}
* <link rel="stylesheet" href="{% static 'css/components/page-header.css' %}"> * <link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
@ -22,7 +22,7 @@
z-index: 10; z-index: 10;
padding: var(--space-2) 0; /* 8px сверху/снизу */ padding: var(--space-2) 0; /* 8px сверху/снизу */
margin-bottom: var(--space-3); /* 12px отступ снизу */ margin-bottom: var(--space-3); /* 12px отступ снизу */
/* Полупрозрачный фон с градиентом */ /* Полупрозрачный фон с градиентом */
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
@ -32,9 +32,9 @@
color-mix(in srgb, var(--bs-body-bg) 40%, transparent) 70%, color-mix(in srgb, var(--bs-body-bg) 40%, transparent) 70%,
transparent 100% transparent 100%
); );
/* Убрана border-bottom - линия не нужна */ /* Убрана border-bottom - линия не нужна */
/* Анимация появления при скролле */ /* Анимация появления при скролле */
transition: background 0.2s ease, box-shadow 0.2s ease; transition: background 0.2s ease, box-shadow 0.2s ease;
} }
@ -191,7 +191,7 @@
margin-top: var(--space-2); margin-top: var(--space-2);
order: 10; order: 10;
} }
/* Кнопка фильтров переносится под поиск и прижимается к правому краю */ /* Кнопка фильтров переносится под поиск и прижимается к правому краю */
.page-filter-toggle { .page-filter-toggle {
order: 11; order: 11;
@ -199,7 +199,7 @@
margin-left: auto; margin-left: auto;
margin-right: 0 !important; margin-right: 0 !important;
} }
/* Родительский контейнер должен переносить элементы */ /* Родительский контейнер должен переносить элементы */
.page-header .d-flex.align-items-center { .page-header .d-flex.align-items-center {
flex-wrap: wrap; flex-wrap: wrap;
@ -249,7 +249,7 @@
.page-title { .page-title {
font-size: 1.5rem; font-size: 1.5rem;
} }
.page-header { .page-header {
padding: var(--space-2) 0; padding: var(--space-2) 0;
} }
@ -262,16 +262,16 @@
padding: var(--space-1) 0; padding: var(--space-1) 0;
top: var(--navbar-h, 60px); top: var(--navbar-h, 60px);
} }
.page-title { .page-title {
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 700; font-weight: 700;
} }
.page-count { .page-count {
font-size: 0.7rem; font-size: 0.7rem;
} }
.page-scope-switch { .page-scope-switch {
gap: 0.25rem; gap: 0.25rem;
} }
@ -374,7 +374,7 @@
); );
border-bottom-color: color-mix(in srgb, var(--bs-border-color), transparent 60%); border-bottom-color: color-mix(in srgb, var(--bs-border-color), transparent 60%);
} }
.page-header.scrolled { .page-header.scrolled {
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
@ -385,7 +385,7 @@
transparent 100% transparent 100%
); );
} }
.page-filters { .page-filters {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
} }

View File

@ -1,6 +1,6 @@
/** /**
* request-list.css * request-list.css
* *
* Настоящий табличный вид списка заявлений. * Настоящий табличный вид списка заявлений.
* Все поля идут в одну строку с фиксированной шириной и переносом текста. * Все поля идут в одну строку с фиксированной шириной и переносом текста.
*/ */
@ -72,7 +72,7 @@
/* Табличная сетка */ /* Табличная сетка */
#reqList .req-table-row { #reqList .req-table-row {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
48px /* Иконка */ 48px /* Иконка */
minmax(200px, 2fr) /* Заголовок (расширяемая) */ minmax(200px, 2fr) /* Заголовок (расширяемая) */
minmax(120px, 1fr) /* Автор */ minmax(120px, 1fr) /* Автор */
@ -185,7 +185,7 @@
@media (max-width: 992px) { @media (max-width: 992px) {
#reqList .req-table-header .req-table-row, #reqList .req-table-header .req-table-row,
#reqList .req-table-row { #reqList .req-table-row {
grid-template-columns: grid-template-columns:
42px /* Иконка */ 42px /* Иконка */
minmax(150px, 2fr) /* Заголовок */ minmax(150px, 2fr) /* Заголовок */
minmax(100px, 1fr) /* Автор */ minmax(100px, 1fr) /* Автор */
@ -193,12 +193,12 @@
100px /* Статус */ 100px /* Статус */
70px; /* Создано */ 70px; /* Создано */
} }
/* На планшетах скрываем колонку действий как отдельное поле */ /* На планшетах скрываем колонку действий как отдельное поле */
#reqList .req-table-header .req-cell-actions { #reqList .req-table-header .req-cell-actions {
display: none; display: none;
} }
/* Но сами кнопки остаются видимыми */ /* Но сами кнопки остаются видимыми */
#reqList .req-cell-actions { #reqList .req-cell-actions {
grid-column: 6; grid-column: 6;
@ -245,7 +245,7 @@
width: 32px; width: 32px;
height: 32px; height: 32px;
} }
/* Основной контент - заголовок занимает полную ширину минус иконка */ /* Основной контент - заголовок занимает полную ширину минус иконка */
#reqList .req-cell-title { #reqList .req-cell-title {
flex: 1 1 calc(100% - 48px); flex: 1 1 calc(100% - 48px);
@ -269,7 +269,7 @@
font-size: 0.8rem; font-size: 0.8rem;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
/* Остальная информация - автор, даты, статус */ /* Остальная информация - автор, даты, статус */
#reqList .req-cell-author, #reqList .req-cell-author,
#reqList .req-cell-dates, #reqList .req-cell-dates,
@ -300,12 +300,12 @@
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
white-space: nowrap; white-space: nowrap;
} }
/* Скрываем дату создания на мобильных */ /* Скрываем дату создания на мобильных */
#reqList .req-cell-created { #reqList .req-cell-created {
display: none; display: none;
} }
/* Кнопки действий занимают полную строку */ /* Кнопки действий занимают полную строку */
#reqList .req-cell-actions { #reqList .req-cell-actions {
display: flex !important; display: flex !important;
@ -327,7 +327,7 @@
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
font-size: 0.85rem; font-size: 0.85rem;
} }
/* Футер с комментариями занимает полную ширину */ /* Футер с комментариями занимает полную ширину */
#reqList .req-footer { #reqList .req-footer {
flex: 1 1 100%; flex: 1 1 100%;
@ -343,7 +343,7 @@
padding: 0.4rem 0.6rem; padding: 0.4rem 0.6rem;
font-size: 0.8rem; font-size: 0.8rem;
} }
/* Блок комментариев */ /* Блок комментариев */
#reqList .comments-block { #reqList .comments-block {
margin-left: 0; margin-left: 0;
@ -370,15 +370,15 @@
#reqList .req-table-row { #reqList .req-table-row {
padding: 0.625rem; padding: 0.625rem;
} }
#reqList .req-cell-title .small { #reqList .req-cell-title .small {
display: none; /* Скрываем превью комментария */ display: none; /* Скрываем превью комментария */
} }
#reqList .req-cell-dates br { #reqList .req-cell-dates br {
display: none; /* Даты в одну строку */ display: none; /* Даты в одну строку */
} }
#reqList .req-cell-dates .req-cell-content::after { #reqList .req-cell-dates .req-cell-content::after {
content: ' — '; content: ' — ';
} }

View File

@ -3,7 +3,7 @@
* @description Обработчик CRUD операций для заявлений через API * @description Обработчик CRUD операций для заявлений через API
*/ */
import { RecipientPicker } from './recipientPicker.js'; import { RecipientPicker } from "./recipientPicker.js";
/** /**
* Инициализирует обработчики CRUD операций для заявлений * Инициализирует обработчики CRUD операций для заявлений
@ -14,43 +14,41 @@ import { RecipientPicker } from './recipientPicker.js';
* @returns {Object} API с методом destroy * @returns {Object} API с методом destroy
*/ */
export function initRequestCrudHandler(options) { export function initRequestCrudHandler(options) {
const { const { apiListUrl, apiDetailBase, headers = {} } = options;
apiListUrl,
apiDetailBase,
headers = {}
} = options;
// Формы // Формы
const createForm = document.getElementById('reqCreateForm'); const createForm = document.getElementById("reqCreateForm");
const editForm = document.getElementById('reqEditForm'); const editForm = document.getElementById("reqEditForm");
const deleteForm = document.getElementById('reqDeleteForm'); const deleteForm = document.getElementById("reqDeleteForm");
const approveForm = document.getElementById('reqApproveForm'); const approveForm = document.getElementById("reqApproveForm");
const rejectForm = document.getElementById('reqRejectForm'); const rejectForm = document.getElementById("reqRejectForm");
const cancelForm = document.getElementById('reqCancelForm'); const cancelForm = document.getElementById("reqCancelForm");
const commentForm = document.getElementById('reqCommentForm'); const commentForm = document.getElementById("reqCommentForm");
if (!createForm) { if (!createForm) {
console.warn('initRequestCrudHandler: required forms not found'); console.warn("initRequestCrudHandler: required forms not found");
return { destroy: () => {} }; return { destroy: () => {} };
} }
// Инициализация RecipientPicker для создания // Инициализация RecipientPicker для создания
let createRecipientPicker = null; let createRecipientPicker = null;
const createPickerContainer = document.getElementById('createRecipientPicker'); const createPickerContainer = document.getElementById(
"createRecipientPicker"
);
if (createPickerContainer) { if (createPickerContainer) {
createRecipientPicker = new RecipientPicker(createPickerContainer, { createRecipientPicker = new RecipientPicker(createPickerContainer, {
apiUsersUrl: '/api/v1/employees/', apiUsersUrl: "/api/v1/employees/",
apiDepartmentsUrl: '/api/v1/departments/' apiDepartmentsUrl: "/api/v1/departments/",
}); });
} }
// Инициализация RecipientPicker для редактирования // Инициализация RecipientPicker для редактирования
let editRecipientPicker = null; let editRecipientPicker = null;
const editPickerContainer = document.getElementById('editRecipientPicker'); const editPickerContainer = document.getElementById("editRecipientPicker");
if (editPickerContainer) { if (editPickerContainer) {
editRecipientPicker = new RecipientPicker(editPickerContainer, { editRecipientPicker = new RecipientPicker(editPickerContainer, {
apiUsersUrl: '/api/v1/employees/', apiUsersUrl: "/api/v1/employees/",
apiDepartmentsUrl: '/api/v1/departments/' apiDepartmentsUrl: "/api/v1/departments/",
}); });
} }
@ -59,116 +57,122 @@ export function initRequestCrudHandler(options) {
*/ */
async function handleCreate(e) { async function handleCreate(e) {
e.preventDefault(); 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); const formData = new FormData(createForm);
// Добавляем данные получателей из RecipientPicker // Добавляем данные получателей из RecipientPicker
if (createRecipientPicker) { if (createRecipientPicker) {
// Валидация: для обычной отправки должны быть получатели или отделы // Валидация: для обычной отправки должны быть получатели или отделы
if (saveAs === 'submit') { if (saveAs === "submit") {
if (!createRecipientPicker.validate()) { if (!createRecipientPicker.validate()) {
return; return;
} }
// Проверка: автор не может быть получателем // Проверка: автор не может быть получателем
const recipients = createRecipientPicker.getValues(); const recipients = createRecipientPicker.getValues();
if (recipients.recipient_ids && window.currentUserId) { if (recipients.recipient_ids && window.currentUserId) {
if (recipients.recipient_ids.includes(window.currentUserId)) { if (recipients.recipient_ids.includes(window.currentUserId)) {
alert('Вы не можете отправить заявление самому себе'); alert("Вы не можете отправить заявление самому себе");
createRecipientPicker.setError('Вы не можете быть получателем'); createRecipientPicker.setError("Вы не можете быть получателем");
return; return;
} }
} }
} }
const recipients = createRecipientPicker.getValues(); const recipients = createRecipientPicker.getValues();
// Добавляем department_ids // Добавляем department_ids
if (recipients.department_ids && recipients.department_ids.length > 0) { if (recipients.department_ids && recipients.department_ids.length > 0) {
recipients.department_ids.forEach(id => { recipients.department_ids.forEach((id) => {
formData.append('department_ids', id); formData.append("department_ids", id);
}); });
} }
// Добавляем recipient_ids // Добавляем recipient_ids
if (recipients.recipient_ids && recipients.recipient_ids.length > 0) { if (recipients.recipient_ids && recipients.recipient_ids.length > 0) {
recipients.recipient_ids.forEach(id => { recipients.recipient_ids.forEach((id) => {
formData.append('recipient_ids', id); formData.append("recipient_ids", id);
}); });
} }
// Добавляем cc_user_ids // Добавляем cc_user_ids
if (recipients.cc_user_ids && recipients.cc_user_ids.length > 0) { if (recipients.cc_user_ids && recipients.cc_user_ids.length > 0) {
recipients.cc_user_ids.forEach(id => { recipients.cc_user_ids.forEach((id) => {
formData.append('cc_user_ids', id); formData.append("cc_user_ids", id);
}); });
} }
// Добавляем sent_to_all_department // Добавляем sent_to_all_department
if (recipients.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())) { for (const [key, value] of Array.from(formData.entries())) {
if (!value || value === '') { if (!value || value === "") {
formData.delete(key); formData.delete(key);
} }
} }
} }
try { 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()) { for (const [key, value] of formData.entries()) {
console.log(` ${key}:`, value); console.log(` ${key}:`, value);
} }
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: "POST",
headers: headers, headers: headers,
body: formData body: formData,
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); 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) { if (error.detail) {
errorMessage += error.detail; errorMessage += error.detail;
} else if (typeof error === 'object') { } else if (typeof error === "object") {
// Форматируем ошибки валидации // Форматируем ошибки валидации
for (const [field, messages] of Object.entries(error)) { 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}`; errorMessage += `\n${field}: ${fieldMessages}`;
} }
} }
throw new Error(errorMessage); throw new Error(errorMessage);
} }
await response.json(); await response.json();
// Показываем сообщение и перезагружаем // Показываем сообщение и перезагружаем
const message = saveAs === 'draft' ? 'Черновик сохранён.' : 'Заявление отправлено на рассмотрение.'; const message =
saveAs === "draft"
? "Черновик сохранён."
: "Заявление отправлено на рассмотрение.";
alert(message); // TODO: заменить на toast-уведомление alert(message); // TODO: заменить на toast-уведомление
// Очищаем форму и picker // Очищаем форму и picker
createForm.reset(); createForm.reset();
if (createRecipientPicker) { if (createRecipientPicker) {
createRecipientPicker.reset(); createRecipientPicker.reset();
} }
window.location.reload(); window.location.reload();
} catch (error) { } catch (error) {
alert('Не удалось создать заявление: ' + error.message); alert("Не удалось создать заявление: " + error.message);
} }
} }
@ -177,88 +181,88 @@ export function initRequestCrudHandler(options) {
*/ */
async function handleEdit(e) { async function handleEdit(e) {
e.preventDefault(); e.preventDefault();
const reqId = editForm.elements.id.value; const reqId = editForm.elements.id.value;
const saveAs = e.submitter?.value || 'submit'; const saveAs = e.submitter?.value || "submit";
const formData = new FormData(editForm); const formData = new FormData(editForm);
// Добавляем данные получателей из RecipientPicker // Добавляем данные получателей из RecipientPicker
if (editRecipientPicker) { if (editRecipientPicker) {
// Валидация: для обычной отправки должны быть получатели или отделы // Валидация: для обычной отправки должны быть получатели или отделы
if (saveAs === 'submit') { if (saveAs === "submit") {
if (!editRecipientPicker.validate()) { if (!editRecipientPicker.validate()) {
return; return;
} }
// Проверка: автор не может быть получателем // Проверка: автор не может быть получателем
const recipients = editRecipientPicker.getValues(); const recipients = editRecipientPicker.getValues();
if (recipients.recipient_ids && window.currentUserId) { if (recipients.recipient_ids && window.currentUserId) {
if (recipients.recipient_ids.includes(window.currentUserId)) { if (recipients.recipient_ids.includes(window.currentUserId)) {
alert('Вы не можете отправить заявление самому себе'); alert("Вы не можете отправить заявление самому себе");
editRecipientPicker.setError('Вы не можете быть получателем'); editRecipientPicker.setError("Вы не можете быть получателем");
return; return;
} }
} }
} }
const recipients = editRecipientPicker.getValues(); const recipients = editRecipientPicker.getValues();
// Добавляем department_ids // Добавляем department_ids
if (recipients.department_ids && recipients.department_ids.length > 0) { if (recipients.department_ids && recipients.department_ids.length > 0) {
recipients.department_ids.forEach(id => { recipients.department_ids.forEach((id) => {
formData.append('department_ids', id); formData.append("department_ids", id);
}); });
} }
// Добавляем recipient_ids // Добавляем recipient_ids
if (recipients.recipient_ids && recipients.recipient_ids.length > 0) { if (recipients.recipient_ids && recipients.recipient_ids.length > 0) {
recipients.recipient_ids.forEach(id => { recipients.recipient_ids.forEach((id) => {
formData.append('recipient_ids', id); formData.append("recipient_ids", id);
}); });
} }
// Добавляем cc_user_ids // Добавляем cc_user_ids
if (recipients.cc_user_ids && recipients.cc_user_ids.length > 0) { if (recipients.cc_user_ids && recipients.cc_user_ids.length > 0) {
recipients.cc_user_ids.forEach(id => { recipients.cc_user_ids.forEach((id) => {
formData.append('cc_user_ids', id); formData.append("cc_user_ids", id);
}); });
} }
// Добавляем sent_to_all_department // Добавляем sent_to_all_department
if (recipients.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())) { for (const [key, value] of Array.from(formData.entries())) {
if (!value || value === '') { if (!value || value === "") {
formData.delete(key); formData.delete(key);
} }
} }
try { try {
let url = `${apiDetailBase}${reqId}/`; let url = `${apiDetailBase}${reqId}/`;
if (saveAs === 'draft' || saveAs === 'submit') { if (saveAs === "draft" || saveAs === "submit") {
url += `?save_as=${saveAs}`; url += `?save_as=${saveAs}`;
} }
const response = await fetch(url, { const response = await fetch(url, {
method: 'PATCH', method: "PATCH",
headers: headers, headers: headers,
body: formData body: formData,
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.detail || 'HTTP ' + response.status); throw new Error(error.detail || "HTTP " + response.status);
} }
await response.json(); await response.json();
alert('Заявление обновлено.'); alert("Заявление обновлено.");
window.location.reload(); window.location.reload();
} catch (error) { } catch (error) {
alert('Не удалось обновить заявление: ' + error.message); alert("Не удалось обновить заявление: " + error.message);
} }
} }
@ -267,28 +271,28 @@ export function initRequestCrudHandler(options) {
*/ */
async function handleDelete(e) { async function handleDelete(e) {
e.preventDefault(); e.preventDefault();
const reqId = deleteForm.elements.id.value; const reqId = deleteForm.elements.id.value;
if (!confirm('Вы уверены, что хотите удалить это заявление?')) { if (!confirm("Вы уверены, что хотите удалить это заявление?")) {
return; return;
} }
try { try {
const response = await fetch(`${apiDetailBase}${reqId}/`, { const response = await fetch(`${apiDetailBase}${reqId}/`, {
method: 'DELETE', method: "DELETE",
headers: headers headers: headers,
}); });
if (!response.ok && response.status !== 204) { if (!response.ok && response.status !== 204) {
const error = await response.json(); 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(); window.location.reload();
} catch (error) { } catch (error) {
alert('Не удалось удалить заявление: ' + error.message); alert("Не удалось удалить заявление: " + error.message);
} }
} }
@ -297,29 +301,29 @@ export function initRequestCrudHandler(options) {
*/ */
async function handleApprove(e) { async function handleApprove(e) {
e.preventDefault(); e.preventDefault();
const reqId = approveForm.elements.id.value; const reqId = approveForm.elements.id.value;
try { try {
const response = await fetch(`${apiDetailBase}${reqId}/approve/`, { const response = await fetch(`${apiDetailBase}${reqId}/approve/`, {
method: 'POST', method: "POST",
headers: { headers: {
...headers, ...headers,
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({}) body: JSON.stringify({}),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.detail || 'HTTP ' + response.status); throw new Error(error.detail || "HTTP " + response.status);
} }
await response.json(); await response.json();
alert('Заявление одобрено.'); alert("Заявление одобрено.");
window.location.reload(); window.location.reload();
} catch (error) { } catch (error) {
alert('Не удалось одобрить заявление: ' + error.message); alert("Не удалось одобрить заявление: " + error.message);
} }
} }
@ -328,29 +332,29 @@ export function initRequestCrudHandler(options) {
*/ */
async function handleReject(e) { async function handleReject(e) {
e.preventDefault(); e.preventDefault();
const reqId = rejectForm.elements.id.value; const reqId = rejectForm.elements.id.value;
try { try {
const response = await fetch(`${apiDetailBase}${reqId}/reject/`, { const response = await fetch(`${apiDetailBase}${reqId}/reject/`, {
method: 'POST', method: "POST",
headers: { headers: {
...headers, ...headers,
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({}) body: JSON.stringify({}),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.detail || 'HTTP ' + response.status); throw new Error(error.detail || "HTTP " + response.status);
} }
await response.json(); await response.json();
alert('Заявление отклонено.'); alert("Заявление отклонено.");
window.location.reload(); window.location.reload();
} catch (error) { } catch (error) {
alert('Не удалось отклонить заявление: ' + error.message); alert("Не удалось отклонить заявление: " + error.message);
} }
} }
@ -359,33 +363,33 @@ export function initRequestCrudHandler(options) {
*/ */
async function handleCancel(e) { async function handleCancel(e) {
e.preventDefault(); e.preventDefault();
const reqId = cancelForm.elements.id.value; const reqId = cancelForm.elements.id.value;
if (!confirm('Вы уверены, что хотите отменить это заявление?')) { if (!confirm("Вы уверены, что хотите отменить это заявление?")) {
return; return;
} }
try { try {
const response = await fetch(`${apiDetailBase}${reqId}/cancel/`, { const response = await fetch(`${apiDetailBase}${reqId}/cancel/`, {
method: 'POST', method: "POST",
headers: { headers: {
...headers, ...headers,
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({}) body: JSON.stringify({}),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.detail || 'HTTP ' + response.status); throw new Error(error.detail || "HTTP " + response.status);
} }
await response.json(); await response.json();
alert('Заявление отменено.'); alert("Заявление отменено.");
window.location.reload(); window.location.reload();
} catch (error) { } catch (error) {
alert('Не удалось отменить заявление: ' + error.message); alert("Не удалось отменить заявление: " + error.message);
} }
} }
@ -394,59 +398,59 @@ export function initRequestCrudHandler(options) {
*/ */
async function handleComment(e) { async function handleComment(e) {
e.preventDefault(); e.preventDefault();
const reqId = commentForm.elements.id.value; const reqId = commentForm.elements.id.value;
const text = (commentForm.elements.text.value || '').trim(); const text = (commentForm.elements.text.value || "").trim();
if (!text) { if (!text) {
alert('Комментарий не может быть пустым.'); alert("Комментарий не может быть пустым.");
return; return;
} }
try { try {
const response = await fetch(`${apiDetailBase}${reqId}/comments/`, { const response = await fetch(`${apiDetailBase}${reqId}/comments/`, {
method: 'POST', method: "POST",
headers: { headers: {
...headers, ...headers,
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ text }) body: JSON.stringify({ text }),
}); });
if (!response.ok && response.status !== 201) { if (!response.ok && response.status !== 201) {
const error = await response.json(); const error = await response.json();
throw new Error(error.detail || 'HTTP ' + response.status); throw new Error(error.detail || "HTTP " + response.status);
} }
await response.json(); await response.json();
alert('Комментарий добавлен.'); alert("Комментарий добавлен.");
window.location.reload(); window.location.reload();
} catch (error) { } catch (error) {
alert('Не удалось добавить комментарий: ' + error.message); alert("Не удалось добавить комментарий: " + error.message);
} }
} }
// Установка обработчиков // Установка обработчиков
createForm?.addEventListener('submit', handleCreate); createForm?.addEventListener("submit", handleCreate);
editForm?.addEventListener('submit', handleEdit); editForm?.addEventListener("submit", handleEdit);
deleteForm?.addEventListener('submit', handleDelete); deleteForm?.addEventListener("submit", handleDelete);
approveForm?.addEventListener('submit', handleApprove); approveForm?.addEventListener("submit", handleApprove);
rejectForm?.addEventListener('submit', handleReject); rejectForm?.addEventListener("submit", handleReject);
cancelForm?.addEventListener('submit', handleCancel); cancelForm?.addEventListener("submit", handleCancel);
commentForm?.addEventListener('submit', handleComment); commentForm?.addEventListener("submit", handleComment);
/** /**
* Функция для удаления всех обработчиков * Функция для удаления всех обработчиков
*/ */
function destroy() { function destroy() {
createForm?.removeEventListener('submit', handleCreate); createForm?.removeEventListener("submit", handleCreate);
editForm?.removeEventListener('submit', handleEdit); editForm?.removeEventListener("submit", handleEdit);
deleteForm?.removeEventListener('submit', handleDelete); deleteForm?.removeEventListener("submit", handleDelete);
approveForm?.removeEventListener('submit', handleApprove); approveForm?.removeEventListener("submit", handleApprove);
rejectForm?.removeEventListener('submit', handleReject); rejectForm?.removeEventListener("submit", handleReject);
cancelForm?.removeEventListener('submit', handleCancel); cancelForm?.removeEventListener("submit", handleCancel);
commentForm?.removeEventListener('submit', handleComment); commentForm?.removeEventListener("submit", handleComment);
// Очистка RecipientPicker // Очистка RecipientPicker
if (createRecipientPicker) { if (createRecipientPicker) {
createRecipientPicker.destroy(); 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, destroy,
createRecipientPicker, createRecipientPicker,
editRecipientPicker editRecipientPicker,
}; };
} }
// Экспорт для совместимости с неModular кодом // Экспорт для совместимости с неModular кодом
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.initRequestCrudHandler = initRequestCrudHandler; window.initRequestCrudHandler = initRequestCrudHandler;
} }

View File

@ -19,45 +19,53 @@ export function initRequestListHandler(options) {
detailUrlTemplate, detailUrlTemplate,
userId, userId,
canProcess, canProcess,
headers = {} headers = {},
} = options; } = options;
const listElement = document.getElementById('reqListContent'); const listElement = document.getElementById("reqListContent");
const searchInput = document.getElementById('reqFilter'); const searchInput = document.getElementById("reqFilter");
if (!listElement) { if (!listElement) {
console.warn('initRequestListHandler: #reqListContent not found'); console.warn("initRequestListHandler: #reqListContent not found");
return { load: () => {}, destroy: () => {} }; return { load: () => {}, destroy: () => {} };
} }
// Текущее состояние // Текущее состояние
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
let currentView = urlParams.get('view') || ''; // 'mine' | 'addressed' | '' let currentView = urlParams.get("view") || ""; // 'mine' | 'addressed' | ''
let currentType = urlParams.get('type') || ''; let currentType = urlParams.get("type") || "";
let currentStatus = urlParams.get('status') || ''; let currentStatus = urlParams.get("status") || "";
let searchQuery = ''; // Поисковый запрос let searchQuery = ""; // Поисковый запрос
let allRequests = []; // Все загруженные заявления let allRequests = []; // Все загруженные заявления
let loading = false; let loading = false;
let hasMore = true; let hasMore = true;
let nextUrl = null; let nextUrl = null;
let totalCount = 0; 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 для бесконечной прокрутки // Intersection Observer для бесконечной прокрутки
let observerTarget = null; let observerTarget = null;
let observer = null; let observer = null;
// Мобильная оптимизация: слушаем сетевые события // Мобильная оптимизация: слушаем сетевые события
let isOnline = navigator.onLine; let isOnline = navigator.onLine;
window.addEventListener('online', () => { window.addEventListener("online", () => {
isOnline = true; isOnline = true;
console.log('Network restored'); console.log("Network restored");
}); });
window.addEventListener('offline', () => { window.addEventListener("offline", () => {
isOnline = false; isOnline = false;
console.log('Network lost'); console.log("Network lost");
listElement.innerHTML = '<div class="p-4 text-center text-warning">Потеряна связь с интернетом</div>'; listElement.innerHTML =
'<div class="p-4 text-center text-warning">Потеряна связь с интернетом</div>';
}); });
/** /**
@ -67,105 +75,108 @@ export function initRequestListHandler(options) {
*/ */
async function loadRequests(append = false) { async function loadRequests(append = false) {
if (loading || (append && !hasMore)) { if (loading || (append && !hasMore)) {
console.log('loadRequests: skipped', { loading, append, hasMore }); console.log("loadRequests: skipped", { loading, append, hasMore });
return []; return [];
} }
// Проверка сети на мобильных // Проверка сети на мобильных
if (!isOnline) { if (!isOnline) {
console.warn('loadRequests: offline'); console.warn("loadRequests: offline");
listElement.innerHTML = '<div class="p-4 text-center text-warning">Нет интернет-соединения. Проверьте подключение.</div>'; listElement.innerHTML =
'<div class="p-4 text-center text-warning">Нет интернет-соединения. Проверьте подключение.</div>';
return []; return [];
} }
loading = true; loading = true;
showLoadingSpinner(); showLoadingSpinner();
try { try {
let url; let url;
if (append && nextUrl) { if (append && nextUrl) {
url = nextUrl; url = nextUrl;
console.log('loadRequests: using nextUrl', url); console.log("loadRequests: using nextUrl", url);
} else if (append && !nextUrl) { } else if (append && !nextUrl) {
console.warn('loadRequests: append=true but no nextUrl, stopping'); console.warn("loadRequests: append=true but no nextUrl, stopping");
loading = false; loading = false;
hideLoadingSpinner(); hideLoadingSpinner();
return []; return [];
} else { } else {
// Формируем URL с параметрами фильтров // Формируем URL с параметрами фильтров
const params = new URLSearchParams(); const params = new URLSearchParams();
if (currentView === 'mine') { if (currentView === "mine") {
params.append('view', 'mine'); params.append("view", "mine");
} else if (currentView === 'addressed') { } else if (currentView === "addressed") {
params.append('addressed_to_me', 'true'); params.append("addressed_to_me", "true");
} }
if (currentType) { if (currentType) {
params.append('type', currentType); params.append("type", currentType);
} }
if (currentStatus) { if (currentStatus) {
params.append('status', currentStatus); params.append("status", currentStatus);
} }
url = `${apiListUrl}?${params}`; url = `${apiListUrl}?${params}`;
console.log('loadRequests: initial load', url); console.log("loadRequests: initial load", url);
} }
const controller = new AbortController(); const controller = new AbortController();
// На мобильных устройствах увеличиваем таймаут до 15 секунд // На мобильных устройствах увеличиваем таймаут до 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 timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(url, { const response = await fetch(url, {
headers, headers,
signal: controller.signal signal: controller.signal,
}); });
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
throw new Error('HTTP ' + response.status); throw new Error("HTTP " + response.status);
} }
const data = await response.json(); const data = await response.json();
const newRequests = data.results || []; const newRequests = data.results || [];
console.log('loadRequests: received', { console.log("loadRequests: received", {
newCount: newRequests.length, newCount: newRequests.length,
totalCount: data.count, totalCount: data.count,
hasNext: !!data.next, hasNext: !!data.next,
currentRequests: allRequests.length currentRequests: allRequests.length,
}); });
// Сохраняем метаданные // Сохраняем метаданные
totalCount = data.count || newRequests.length; totalCount = data.count || newRequests.length;
nextUrl = data.next || null; nextUrl = data.next || null;
hasMore = !!nextUrl; hasMore = !!nextUrl;
if (append) { if (append) {
allRequests = [...allRequests, ...newRequests]; allRequests = [...allRequests, ...newRequests];
console.log('loadRequests: appended, total now:', allRequests.length); console.log("loadRequests: appended, total now:", allRequests.length);
} else { } else {
allRequests = newRequests; allRequests = newRequests;
console.log('loadRequests: replaced, total now:', allRequests.length); console.log("loadRequests: replaced, total now:", allRequests.length);
} }
loading = false; loading = false;
hideLoadingSpinner(); hideLoadingSpinner();
return newRequests; return newRequests;
} catch (error) { } catch (error) {
console.error('Ошибка загрузки заявлений:', error); console.error("Ошибка загрузки заявлений:", error);
loading = false; loading = false;
hideLoadingSpinner(); hideLoadingSpinner();
// Более информативное сообщение об ошибке // Более информативное сообщение об ошибке
let errorMsg = 'Не удалось загрузить заявления'; let errorMsg = "Не удалось загрузить заявления";
if (error.name === 'AbortError') { if (error.name === "AbortError") {
errorMsg = 'Время ожидания истекло. Проверьте интернет-соединение.'; errorMsg = "Время ожидания истекло. Проверьте интернет-соединение.";
} else if (!navigator.onLine) { } else if (!navigator.onLine) {
errorMsg = 'Нет интернет-соединения.'; errorMsg = "Нет интернет-соединения.";
} }
listElement.innerHTML = ` listElement.innerHTML = `
<div class="p-4 text-center"> <div class="p-4 text-center">
<div class="text-danger mb-2">${errorMsg}</div> <div class="text-danger mb-2">${errorMsg}</div>
@ -175,16 +186,16 @@ export function initRequestListHandler(options) {
return []; return [];
} }
} }
/** /**
* Показать спиннер загрузки * Показать спиннер загрузки
*/ */
function showLoadingSpinner() { function showLoadingSpinner() {
const existing = listElement.querySelector('.loading-spinner'); const existing = listElement.querySelector(".loading-spinner");
if (existing) return; if (existing) return;
const spinner = document.createElement('div'); const spinner = document.createElement("div");
spinner.className = 'loading-spinner text-center py-4'; spinner.className = "loading-spinner text-center py-4";
spinner.innerHTML = ` spinner.innerHTML = `
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span> <span class="visually-hidden">Загрузка...</span>
@ -192,56 +203,56 @@ export function initRequestListHandler(options) {
`; `;
listElement.appendChild(spinner); listElement.appendChild(spinner);
} }
/** /**
* Скрыть спиннер загрузки * Скрыть спиннер загрузки
*/ */
function hideLoadingSpinner() { function hideLoadingSpinner() {
const spinner = listElement.querySelector('.loading-spinner'); const spinner = listElement.querySelector(".loading-spinner");
if (spinner) { if (spinner) {
spinner.remove(); spinner.remove();
} }
// Удаляем начальный спиннер из шаблона // Удаляем начальный спиннер из шаблона
const initialSpinner = document.getElementById('reqListLoading'); const initialSpinner = document.getElementById("reqListLoading");
if (initialSpinner) { if (initialSpinner) {
initialSpinner.remove(); initialSpinner.remove();
} }
} }
/** /**
* Загрузка следующей порции заявлений * Загрузка следующей порции заявлений
*/ */
async function loadMore() { async function loadMore() {
console.log('loadMore: triggered', { console.log("loadMore: triggered", {
hasMore, hasMore,
loading, loading,
nextUrl, nextUrl,
currentCount: allRequests.length currentCount: allRequests.length,
}); });
try { try {
const newRequests = await loadRequests(true); // append = true const newRequests = await loadRequests(true); // append = true
renderRequests(newRequests, true); // append = true renderRequests(newRequests, true); // append = true
} catch (error) { } catch (error) {
console.error('Failed to load more requests:', error); console.error("Failed to load more requests:", error);
} }
} }
/** /**
* Настройка Intersection Observer для бесконечной прокрутки * Настройка Intersection Observer для бесконечной прокрутки
*/ */
function setupInfiniteScroll() { function setupInfiniteScroll() {
if (!observerTarget) return; if (!observerTarget) return;
// Создаем observer с адаптивными параметрами для мобильных // Создаем observer с адаптивными параметрами для мобильных
const isMobile = /iPhone|iPad|Android|Mobile/.test(navigator.userAgent); const isMobile = /iPhone|iPad|Android|Mobile/.test(navigator.userAgent);
const rootMargin = isMobile ? '200px' : '100px'; // На мобильных загружаем раньше const rootMargin = isMobile ? "200px" : "100px"; // На мобильных загружаем раньше
observer = new IntersectionObserver( observer = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach(entry => { entries.forEach((entry) => {
if (entry.isIntersecting && hasMore && !loading) { if (entry.isIntersecting && hasMore && !loading) {
console.log('Intersection observer: triggering loadMore'); console.log("Intersection observer: triggering loadMore");
loadMore(); loadMore();
} }
}); });
@ -249,14 +260,14 @@ export function initRequestListHandler(options) {
{ {
root: null, root: null,
rootMargin, rootMargin,
threshold: 0.01 threshold: 0.01,
} }
); );
// Наблюдаем за observer target // Наблюдаем за observer target
observer.observe(observerTarget); observer.observe(observerTarget);
} }
/** /**
* Фильтрует по поисковому запросу * Фильтрует по поисковому запросу
* @param {Array} items - Список заявлений * @param {Array} items - Список заявлений
@ -264,13 +275,17 @@ export function initRequestListHandler(options) {
*/ */
function filterBySearch(items) { function filterBySearch(items) {
if (!searchQuery.trim()) return items; if (!searchQuery.trim()) return items;
const query = searchQuery.toLowerCase().trim(); const query = searchQuery.toLowerCase().trim();
return items.filter(req => { return items.filter((req) => {
const title = (req.display_title || req.title || '').toLowerCase(); const title = (req.display_title || req.title || "").toLowerCase();
const comment = (req.comment || '').toLowerCase(); const comment = (req.comment || "").toLowerCase();
const employeeName = (req.employee?.full_name || '').toLowerCase(); const employeeName = (req.employee?.full_name || "").toLowerCase();
return title.includes(query) || comment.includes(query) || employeeName.includes(query); return (
title.includes(query) ||
comment.includes(query) ||
employeeName.includes(query)
);
}); });
} }
@ -282,46 +297,48 @@ export function initRequestListHandler(options) {
function renderRequests(requests, append = false) { function renderRequests(requests, append = false) {
if (!requests || requests.length === 0) { if (!requests || requests.length === 0) {
if (!append) { if (!append) {
listElement.innerHTML = '<div class="p-4 text-center text-secondary">Нет доступных заявлений.</div>'; listElement.innerHTML =
'<div class="p-4 text-center text-secondary">Нет доступных заявлений.</div>';
} }
return; return;
} }
let items = filterBySearch(requests); let items = filterBySearch(requests);
if (items.length === 0 && !append) { if (items.length === 0 && !append) {
listElement.innerHTML = '<div class="p-4 text-center text-secondary">Нет заявлений, соответствующих фильтрам.</div>'; listElement.innerHTML =
'<div class="p-4 text-center text-secondary">Нет заявлений, соответствующих фильтрам.</div>';
return; return;
} }
console.log('renderRequests: rendering', { console.log("renderRequests: rendering", {
count: items.length, count: items.length,
append, 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) { if (append) {
// Удаляем observer target перед добавлением // Удаляем observer target перед добавлением
if (observerTarget && observerTarget.parentNode) { if (observerTarget && observerTarget.parentNode) {
observerTarget.remove(); observerTarget.remove();
} }
// Добавляем новые карточки // Добавляем новые карточки
listElement.insertAdjacentHTML('beforeend', itemsHtml); listElement.insertAdjacentHTML("beforeend", itemsHtml);
console.log('renderRequests: appended to DOM'); console.log("renderRequests: appended to DOM");
// Возвращаем observer target обратно // Возвращаем observer target обратно
if (observerTarget) { if (observerTarget) {
listElement.appendChild(observerTarget); listElement.appendChild(observerTarget);
} }
} else { } else {
listElement.innerHTML = itemsHtml; listElement.innerHTML = itemsHtml;
console.log('renderRequests: replaced DOM'); console.log("renderRequests: replaced DOM");
// Создаем observer target при первой загрузке // Создаем observer target при первой загрузке
if (hasMore && !observerTarget) { if (hasMore && !observerTarget) {
observerTarget = document.createElement('div'); observerTarget = document.createElement("div");
observerTarget.className = 'load-more-trigger'; observerTarget.className = "load-more-trigger";
observerTarget.style.height = '1px'; observerTarget.style.height = "1px";
listElement.appendChild(observerTarget); listElement.appendChild(observerTarget);
} }
} }
@ -333,42 +350,57 @@ export function initRequestListHandler(options) {
* @returns {string} HTML * @returns {string} HTML
*/ */
function renderRequestRow(req) { function renderRequestRow(req) {
const type = (req.type || '').toLowerCase(); const type = (req.type || "").toLowerCase();
const status = (req.status || '').toLowerCase(); const status = (req.status || "").toLowerCase();
// Иконка типа // Иконка типа
let typeIcon = 'bi-clipboard-check text-secondary'; let typeIcon = "bi-clipboard-check text-secondary";
if (type === 'vacation') typeIcon = 'bi-calendar-check text-primary'; if (type === "vacation") typeIcon = "bi-calendar-check text-primary";
else if (type === 'sick_leave') typeIcon = 'bi-capsule text-danger'; else if (type === "sick_leave") typeIcon = "bi-capsule text-danger";
else if (type === 'day_off') typeIcon = 'bi-umbrella-fill text-info'; 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 === "transfer") typeIcon = "bi-box-arrow-right text-info";
else if (type === 'dismissal') typeIcon = 'bi-door-open text-warning'; else if (type === "dismissal") typeIcon = "bi-door-open text-warning";
// Текст типа // Текст типа
const typeText = { const typeText =
'vacation': 'Отпуск', {
'sick_leave': 'Больничный', vacation: "Отпуск",
'day_off': 'Отгул', sick_leave: "Больничный",
'transfer': 'Перевод', day_off: "Отгул",
'dismissal': 'Увольнение', transfer: "Перевод",
'other': 'Другое' dismissal: "Увольнение",
}[type] || req.type; other: "Другое",
}[type] || req.type;
// Класс и текст статуса // Класс и текст статуса
let statusBadgeClass = 'text-bg-light'; let statusBadgeClass = "text-bg-light";
let statusText = req.status; let statusText = req.status;
if (status === 'draft') { statusBadgeClass = 'text-bg-info'; statusText = 'Черновик'; } if (status === "draft") {
else if (status === 'pending') { statusBadgeClass = 'text-bg-warning'; statusText = 'На рассмотр.'; } statusBadgeClass = "text-bg-info";
else if (status === 'approved') { statusBadgeClass = 'text-bg-success'; statusText = 'Одобрено'; } statusText = "Черновик";
else if (status === 'rejected') { statusBadgeClass = 'text-bg-danger'; statusText = 'Отклонено'; } } else if (status === "pending") {
else if (status === 'cancelled') { statusBadgeClass = 'text-bg-secondary'; statusText = 'Отменено'; } 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 для сотрудника // URL для сотрудника
const employeeUrl = `/employees/${req.employee?.id}/`; 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) { if (req.employee?.id !== userId && canProcess) {
actionButtons = ` actionButtons = `
<button type="button" class="btn btn-ghost btn-sm text-success" <button type="button" class="btn btn-ghost btn-sm text-success"
@ -387,11 +419,11 @@ export function initRequestListHandler(options) {
<button type="button" class="btn btn-ghost btn-sm text-primary" <button type="button" class="btn btn-ghost btn-sm text-primary"
data-bs-toggle="modal" data-bs-target="#reqEditModal" data-bs-toggle="modal" data-bs-target="#reqEditModal"
data-id="${req.id}" data-id="${req.id}"
data-title="${escapeHtml(req.title || '')}" data-title="${escapeHtml(req.title || "")}"
data-type="${req.type || ''}" data-type="${req.type || ""}"
data-date_from="${req.date_from || ''}" data-date_from="${req.date_from || ""}"
data-date_to="${req.date_to || ''}" data-date_to="${req.date_to || ""}"
data-comment="${escapeHtml(req.comment || '')}" data-comment="${escapeHtml(req.comment || "")}"
data-status="${status}" title="Изменить"> data-status="${status}" title="Изменить">
<i class="bi-pencil"></i> <i class="bi-pencil"></i>
</button> </button>
@ -402,7 +434,7 @@ export function initRequestListHandler(options) {
</button> </button>
`; `;
} }
return ` return `
<article class="req-row" id="req-${req.id}"> <article class="req-row" id="req-${req.id}">
<div class="req-table-row"> <div class="req-table-row">
@ -412,47 +444,73 @@ export function initRequestListHandler(options) {
<i class="${typeIcon}"></i> <i class="${typeIcon}"></i>
</div> </div>
</div> </div>
<!-- Заголовок и тип --> <!-- Заголовок и тип -->
<div class="req-cell req-cell-title"> <div class="req-cell req-cell-title">
<div class="req-cell-content"> <div class="req-cell-content">
<strong>${escapeHtml(req.title || 'Без заголовка')}</strong> <strong>${escapeHtml(req.title || "Без заголовка")}</strong>
${type ? `<span class="badge text-bg-light ms-1">${typeText}</span>` : ''} ${
${req.comment ? `<div class="small text-body-secondary mt-1">${escapeHtml(req.comment.substring(0, 100))}</div>` : ''} type
? `<span class="badge text-bg-light ms-1">${typeText}</span>`
: ""
}
${
req.comment
? `<div class="small text-body-secondary mt-1">${escapeHtml(
req.comment.substring(0, 100)
)}</div>`
: ""
}
</div> </div>
</div> </div>
<!-- Автор --> <!-- Автор -->
<div class="req-cell req-cell-author"> <div class="req-cell req-cell-author">
<div class="req-cell-content"> <div class="req-cell-content">
${req.employee ? `<a href="${employeeUrl}" class="text-decoration-none">${employeeName}</a>` : ''} ${
req.employee
? `<a href="${employeeUrl}" class="text-decoration-none">${employeeName}</a>`
: ""
}
</div> </div>
</div> </div>
<!-- Период --> <!-- Период -->
<div class="req-cell req-cell-dates"> <div class="req-cell req-cell-dates">
<div class="req-cell-content text-mono"> <div class="req-cell-content text-mono">
${req.date_from || req.date_to ? ` ${
${req.date_from ? formatDate(req.date_from) : '—'}<br> req.date_from || req.date_to
${req.date_to ? formatDate(req.date_to) : '—'} ? `
` : '<span class="text-body-secondary">—</span>'} ${req.date_from ? formatDate(req.date_from) : "—"}<br>
${req.date_to ? formatDate(req.date_to) : "—"}
`
: '<span class="text-body-secondary">—</span>'
}
</div> </div>
</div> </div>
<!-- Статус --> <!-- Статус -->
<div class="req-cell req-cell-status"> <div class="req-cell req-cell-status">
<div class="req-cell-content"> <div class="req-cell-content">
${req.status ? `<span class="badge ${statusBadgeClass}">${statusText}</span>` : ''} ${
req.status
? `<span class="badge ${statusBadgeClass}">${statusText}</span>`
: ""
}
</div> </div>
</div> </div>
<!-- Дата создания --> <!-- Дата создания -->
<div class="req-cell req-cell-created"> <div class="req-cell req-cell-created">
<div class="req-cell-content small text-body-secondary"> <div class="req-cell-content small text-body-secondary">
${req.created_at ? formatDate(req.created_at) : '<span class="text-body-secondary">—</span>'} ${
req.created_at
? formatDate(req.created_at)
: '<span class="text-body-secondary">—</span>'
}
</div> </div>
</div> </div>
<!-- Действия --> <!-- Действия -->
<div class="req-cell req-cell-actions"> <div class="req-cell req-cell-actions">
<div class="req-cell-content"> <div class="req-cell-content">
@ -470,17 +528,25 @@ export function initRequestListHandler(options) {
aria-controls="rcoll-${req.id}" aria-controls="rcoll-${req.id}"
aria-expanded="false"> aria-expanded="false">
<i class="bi-chat-dots"></i> <i class="bi-chat-dots"></i>
<span class="txt-open" data-role="count">${req.comments_count || 0}</span> <span class="txt-open" data-role="count">${
req.comments_count || 0
}</span>
<span class="txt-close">Скрыть</span> <span class="txt-close">Скрыть</span>
</button> </button>
${req.attachment_url ? ` ${
req.attachment_url
? `
<a href="${req.attachment_url}" class="btn btn-ghost btn-sm d-inline-flex align-items-center gap-1" target="_blank"> <a href="${req.attachment_url}" class="btn btn-ghost btn-sm d-inline-flex align-items-center gap-1" target="_blank">
<i class="bi-paperclip"></i><span>Вложение</span> <i class="bi-paperclip"></i><span>Вложение</span>
</a> </a>
` : ''} `
: ""
}
</footer> </footer>
<div id="rcoll-${req.id}" class="collapse" data-request-id="${req.id}" data-comments-collapse> <div id="rcoll-${req.id}" class="collapse" data-request-id="${
req.id
}" data-comments-collapse>
<div class="comments-block p-3"> <div class="comments-block p-3">
<div class="d-flex align-items-center gap-2 mb-3"> <div class="d-flex align-items-center gap-2 mb-3">
<strong class="small mb-0">Комментарии</strong> <strong class="small mb-0">Комментарии</strong>
@ -516,13 +582,13 @@ export function initRequestListHandler(options) {
* @returns {string} CSRF токен * @returns {string} CSRF токен
*/ */
function getCsrfToken() { function getCsrfToken() {
const name = 'csrftoken'; const name = "csrftoken";
let cookieValue = ''; let cookieValue = "";
if (document.cookie && document.cookie !== '') { if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(';'); const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) { for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim(); 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)); cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break; break;
} }
@ -538,12 +604,12 @@ export function initRequestListHandler(options) {
*/ */
function getStatusClass(status) { function getStatusClass(status) {
const classes = { const classes = {
'pending': 'warning', pending: "warning",
'approved': 'success', approved: "success",
'rejected': 'danger', rejected: "danger",
'cancelled': 'secondary' cancelled: "secondary",
}; };
return classes[status] || 'secondary'; return classes[status] || "secondary";
} }
/** /**
@ -553,10 +619,10 @@ export function initRequestListHandler(options) {
*/ */
function getStatusText(status) { function getStatusText(status) {
const texts = { const texts = {
'pending': 'На рассмотрении', pending: "На рассмотрении",
'approved': 'Одобрено', approved: "Одобрено",
'rejected': 'Отклонено', rejected: "Отклонено",
'cancelled': 'Отменено' cancelled: "Отменено",
}; };
return texts[status] || status; return texts[status] || status;
} }
@ -567,12 +633,12 @@ export function initRequestListHandler(options) {
* @returns {string} Отформатированная дата * @returns {string} Отформатированная дата
*/ */
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return ''; if (!dateStr) return "";
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toLocaleDateString('ru-RU', { return date.toLocaleDateString("ru-RU", {
day: '2-digit', day: "2-digit",
month: '2-digit', month: "2-digit",
year: 'numeric' year: "numeric",
}); });
} }
@ -582,8 +648,8 @@ export function initRequestListHandler(options) {
* @returns {string} Экранированная строка * @returns {string} Экранированная строка
*/ */
function escapeHtml(str) { function escapeHtml(str) {
if (!str) return ''; if (!str) return "";
const div = document.createElement('div'); const div = document.createElement("div");
div.textContent = str; div.textContent = str;
return div.innerHTML; return div.innerHTML;
} }
@ -620,7 +686,7 @@ export function initRequestListHandler(options) {
renderRequests(reqs, false); renderRequests(reqs, false);
setupInfiniteScroll(); setupInfiniteScroll();
} }
/** /**
* Обработчик поиска (работает только с уже загруженными заявлениями) * Обработчик поиска (работает только с уже загруженными заявлениями)
*/ */
@ -628,21 +694,21 @@ export function initRequestListHandler(options) {
// Применяем фильтрацию ко всем загруженным заявлениям // Применяем фильтрацию ко всем загруженным заявлениям
renderRequests(allRequests, false); renderRequests(allRequests, false);
} }
/** /**
* Настройка обработчика поиска * Настройка обработчика поиска
*/ */
function setupSearch() { function setupSearch() {
if (!searchInput) return; if (!searchInput) return;
let searchTimeout; let searchTimeout;
searchInput.addEventListener('input', (e) => { searchInput.addEventListener("input", (e) => {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchQuery = e.target.value; searchQuery = e.target.value;
// Debounce: ждем 300ms после последнего ввода // Debounce: ждем 300ms после последнего ввода
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
console.log('Search query:', searchQuery); console.log("Search query:", searchQuery);
handleSearch(); handleSearch();
}, 300); }, 300);
}); });
@ -672,11 +738,11 @@ export function initRequestListHandler(options) {
setView, setView,
setType, setType,
setStatus, setStatus,
destroy destroy,
}; };
} }
// Экспорт для совместимости // Экспорт для совместимости
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.initRequestListHandler = initRequestListHandler; window.initRequestListHandler = initRequestListHandler;
} }

File diff suppressed because it is too large Load Diff

View File

@ -191,7 +191,7 @@
{% endfor %} {% endfor %}
</div> </div>
<form class="mt-2 request-comment-form" <form class="mt-2 request-comment-form"
method="post" method="post"
action="{% url 'requests_app:request_comment_add' request_obj.pk %}?next={{ request.get_full_path|urlencode }}#comments"> action="{% url 'requests_app:request_comment_add' request_obj.pk %}?next={{ request.get_full_path|urlencode }}#comments">
@ -260,13 +260,13 @@
// Получаем обновленное содержимое // Получаем обновленное содержимое
const html = await response.text(); const html = await response.text();
// Заменяем контент страницы // Заменяем контент страницы
const parser = new DOMParser(); const parser = new DOMParser();
const newDoc = parser.parseFromString(html, 'text/html'); const newDoc = parser.parseFromString(html, 'text/html');
const newContent = newDoc.querySelector('[role="main"]') || newDoc.body; const newContent = newDoc.querySelector('[role="main"]') || newDoc.body;
const currentContent = document.querySelector('[role="main"]') || document.body; const currentContent = document.querySelector('[role="main"]') || document.body;
currentContent.innerHTML = newContent.innerHTML; currentContent.innerHTML = newContent.innerHTML;
// Показываем успех // Показываем успех

View File

@ -1,7 +1,7 @@
# Отчет: Мобильная оптимизация системы заявлений # Отчет: Мобильная оптимизация системы заявлений
**Дата:** 29 декабря 2025 г. **Дата:** 29 декабря 2025 г.
**Версия:** 1.0 **Версия:** 1.0
**Статус:** ✅ Готово к тестированию и merge **Статус:** ✅ Готово к тестированию и merge
--- ---
@ -21,7 +21,7 @@
### 1. CSS Оптимизация (`request-list.css`) ### 1. CSS Оптимизация (`request-list.css`)
**Было:** Grid template с 7 колонками, не переделывающийся на мобильных **Было:** Grid template с 7 колонками, не переделывающийся на мобильных
**Стало:** Flexbox с `flex-direction: column` на мобильных **Стало:** Flexbox с `flex-direction: column` на мобильных
```css ```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) ✓ 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 ## Статус Готовности к Merge
✅ **CSS:** Полностью переработан для мобильных ✅ **CSS:** Полностью переработан для мобильных
✅ **JavaScript:** Обработка ошибок и таймауты добавлены ✅ **JavaScript:** Обработка ошибок и таймауты добавлены
✅ **Django check:** Нет ошибок ✅ **Django check:** Нет ошибок
✅ **Миграции:** Применены ✅ **Миграции:** Применены
✅ **Git:** Коммит готов ✅ **Git:** Коммит готов
✅ **Документация:** Обновлена ✅ **Документация:** Обновлена
### Готово к: ### Готово к:
- 🔄 Pull Request Review - 🔄 Pull Request Review