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,10 +4,10 @@ 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):

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)
@ -146,17 +146,13 @@ 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:
@ -168,15 +164,11 @@ class RequestViewSet(viewsets.ModelViewSet):
# Заявки отделов с 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 = (
@ -197,14 +189,12 @@ 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)
@ -224,11 +214,9 @@ class RequestViewSet(viewsets.ModelViewSet):
# Фильтр "только адресованные мне" # Фильтр "только адресованные мне"
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)
@ -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(
f"[RequestViewSet.create] Request data: {request.data}"
) )
logger.error(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()
@ -30,22 +30,18 @@ def create_request_notifications(sender, instance, created, **kwargs):
""" """
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
new_status = request_obj.status
if old_status != new_status: if old_status != new_status:
notify_status_change(request_obj, old_status, new_status) # Передаем текущий request_obj с актуальным approver_id
except Request.DoesNotExist: notify_status_change(request_obj, old_status, new_status)
pass
@receiver(pre_save, sender=Request) @receiver(pre_save, sender=Request)
@ -100,11 +96,15 @@ def create_comment_notification(sender, instance, created, **kwargs):
# Если 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)
@ -112,25 +112,26 @@ def create_comment_notification(sender, instance, created, **kwargs):
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):
""" """
Отправляет уведомление о новой заявке: Отправляет уведомление о новой заявке:
@ -154,11 +155,15 @@ def notify_new_request(request_obj):
# 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)
@ -177,25 +182,26 @@ def notify_new_request(request_obj):
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:
# Определяем роль получателя # Определяем роль получателя
@ -205,21 +211,21 @@ def notify_new_request(request_obj):
# Формируем заголовок и сообщение # Формируем заголовок и сообщение
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(
@ -228,15 +234,15 @@ def notify_new_request(request_obj):
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,
} },
) )
@ -247,67 +253,86 @@ def notify_status_change(request_obj, old_status, new_status):
- Всех в копии (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(
@ -316,14 +341,14 @@ def notify_status_change(request_obj, old_status, new_status):
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

@ -153,7 +153,7 @@ def can_process_requests(user) -> bool:
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
@ -351,7 +351,10 @@ def request_process(request, 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

@ -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/",
}); });
} }
@ -60,13 +58,13 @@ 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;
} }
@ -75,8 +73,8 @@ export function initRequestCrudHandler(options) {
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;
} }
} }
@ -86,67 +84,70 @@ export function initRequestCrudHandler(options) {
// Добавляем 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}`;
} }
} }
@ -157,7 +158,10 @@ export function initRequestCrudHandler(options) {
await response.json(); await response.json();
// Показываем сообщение и перезагружаем // Показываем сообщение и перезагружаем
const message = saveAs === 'draft' ? 'Черновик сохранён.' : 'Заявление отправлено на рассмотрение.'; const message =
saveAs === "draft"
? "Черновик сохранён."
: "Заявление отправлено на рассмотрение.";
alert(message); // TODO: заменить на toast-уведомление alert(message); // TODO: заменить на toast-уведомление
// Очищаем форму и picker // Очищаем форму и picker
@ -168,7 +172,7 @@ export function initRequestCrudHandler(options) {
window.location.reload(); window.location.reload();
} catch (error) { } catch (error) {
alert('Не удалось создать заявление: ' + error.message); alert("Не удалось создать заявление: " + error.message);
} }
} }
@ -179,13 +183,13 @@ export function initRequestCrudHandler(options) {
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;
} }
@ -194,8 +198,8 @@ export function initRequestCrudHandler(options) {
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;
} }
} }
@ -205,60 +209,60 @@ export function initRequestCrudHandler(options) {
// Добавляем 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);
} }
} }
@ -270,25 +274,25 @@ export function initRequestCrudHandler(options) {
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);
} }
} }
@ -302,24 +306,24 @@ export function initRequestCrudHandler(options) {
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);
} }
} }
@ -333,24 +337,24 @@ export function initRequestCrudHandler(options) {
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);
} }
} }
@ -362,30 +366,30 @@ export function initRequestCrudHandler(options) {
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);
} }
} }
@ -396,56 +400,56 @@ export function initRequestCrudHandler(options) {
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) {
@ -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,30 +19,37 @@ 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;
@ -50,14 +57,15 @@ export function initRequestListHandler(options) {
// Мобильная оптимизация: слушаем сетевые события // Мобильная оптимизация: слушаем сетевые события
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,14 +75,15 @@ 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 [];
} }
@ -85,55 +94,57 @@ export function initRequestListHandler(options) {
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,
}); });
// Сохраняем метаданные // Сохраняем метаданные
@ -143,10 +154,10 @@ export function initRequestListHandler(options) {
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;
@ -154,16 +165,16 @@ export function initRequestListHandler(options) {
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 = `
@ -180,11 +191,11 @@ export function initRequestListHandler(options) {
* Показать спиннер загрузки * Показать спиннер загрузки
*/ */
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>
@ -197,12 +208,12 @@ export function initRequestListHandler(options) {
* Скрыть спиннер загрузки * Скрыть спиннер загрузки
*/ */
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();
} }
@ -212,18 +223,18 @@ export function initRequestListHandler(options) {
* Загрузка следующей порции заявлений * Загрузка следующей порции заявлений
*/ */
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);
} }
} }
@ -235,13 +246,13 @@ export function initRequestListHandler(options) {
// Создаем 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,7 +260,7 @@ export function initRequestListHandler(options) {
{ {
root: null, root: null,
rootMargin, rootMargin,
threshold: 0.01 threshold: 0.01,
} }
); );
@ -266,11 +277,15 @@ export function initRequestListHandler(options) {
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,7 +297,8 @@ 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;
} }
@ -290,17 +306,18 @@ export function initRequestListHandler(options) {
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 перед добавлением
@ -308,20 +325,20 @@ export function initRequestListHandler(options) {
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>
@ -416,40 +448,66 @@ export function initRequestListHandler(options) {
<!-- Заголовок и тип --> <!-- Заголовок и тип -->
<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>
@ -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;
} }
@ -636,13 +702,13 @@ export function initRequestListHandler(options) {
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