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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -191,7 +191,7 @@
{% endfor %}
</div>
<form class="mt-2 request-comment-form"
method="post"
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 parser = new DOMParser();
const newDoc = parser.parseFromString(html, 'text/html');
const newContent = newDoc.querySelector('[role="main"]') || newDoc.body;
const currentContent = document.querySelector('[role="main"]') || document.body;
currentContent.innerHTML = newContent.innerHTML;
// Показываем успех

View File

@ -1,7 +1,7 @@
# Отчет: Мобильная оптимизация системы заявлений
**Дата:** 29 декабря 2025 г.
**Версия:** 1.0
**Дата:** 29 декабря 2025 г.
**Версия:** 1.0
**Статус:** ✅ Готово к тестированию и merge
---
@ -21,7 +21,7 @@
### 1. CSS Оптимизация (`request-list.css`)
**Было:** Grid template с 7 колонками, не переделывающийся на мобильных
**Было:** Grid template с 7 колонками, не переделывающийся на мобильных
**Стало:** Flexbox с `flex-direction: column` на мобильных
```css
@ -137,9 +137,9 @@ const rootMargin = isMobile ? '200px' : '100px'; // На мобильных з
### Тестирование
✅ Enum включает все 6 типов
✅ Миграция применена успешно
✅ Все формы отображают новый тип
✅ Enum включает все 6 типов
✅ Миграция применена успешно
✅ Все формы отображают новый тип
✅ Фильтрация работает корректно
---
@ -156,7 +156,7 @@ const rootMargin = isMobile ? '200px' : '100px'; // На мобильных з
✓ backend/requests_app/migrations/0009_alter_request_type.py (NEW)
```
**Git Commit:** `c857ab9`
**Git Commit:** `c857ab9`
**Сообщение:** "📱 Мобильная оптимизация и новый тип заявления 'Отгул'"
---
@ -186,12 +186,12 @@ const rootMargin = isMobile ? '200px' : '100px'; // На мобильных з
## Статус Готовности к Merge
✅ **CSS:** Полностью переработан для мобильных
✅ **JavaScript:** Обработка ошибок и таймауты добавлены
✅ **Django check:** Нет ошибок
✅ **Миграции:** Применены
✅ **Git:** Коммит готов
✅ **Документация:** Обновлена
✅ **CSS:** Полностью переработан для мобильных
✅ **JavaScript:** Обработка ошибок и таймауты добавлены
✅ **Django check:** Нет ошибок
✅ **Миграции:** Применены
✅ **Git:** Коммит готов
✅ **Документация:** Обновлена
### Готово к:
- 🔄 Pull Request Review