fix: исключен approver из получателей уведомлений о решении
- Approver (тот кто принял решение) не получает уведомление о своем действии - Исключены employee.id и approver_id из recipients при approve/reject - Упрощен signal create_request_notifications (убран лишний запрос в БД) - Теперь передается актуальный request_obj с установленным approver_id
This commit is contained in:
parent
aa6fa29e3a
commit
c0b659f95d
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db.models import TextChoices
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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: ' — ';
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
@ -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;
|
||||
|
||||
// Показываем успех
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user