diff --git a/backend/api/v1/documents/permissions.py b/backend/api/v1/documents/permissions.py index 7af6aff5..0b44482e 100644 --- a/backend/api/v1/documents/permissions.py +++ b/backend/api/v1/documents/permissions.py @@ -23,6 +23,23 @@ def _has_any_model_perm( return False +def _user_has_document_access(user, obj) -> bool: + """Проверяет пользовательский доступ к конкретному документу.""" + if getattr(obj, "sent_to_all", False) and getattr(user, "is_active", False): + return True + + if getattr(obj, "uploaded_by_id", None) == getattr(user, "pk", None): + return True + + if obj.recipients.filter(pk=user.pk, is_active=True).exists(): + return True + + return obj.departments.filter( + employeedepartment__employee=user, + employeedepartment__is_active=True, + ).exists() + + class DocumentReadOrModelPerms(AdminOrActionOrModelPerms): """Чтение/скачивание/ознакомление: любой аутентифицированный, но доступ только к 'своим' документам (или если есть модельные права/админ). @@ -99,16 +116,14 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms): if request.method == "DELETE": return user.has_perm("documents.delete_document", obj) - # SAFE/ack/download без модельных прав → только получатели или - # sent_to_all + # SAFE/ack/download без модельных прав → только пользователи, + # которым документ доступен. if request.method in SAFE_METHODS or action in ( "acknowledge", "download", "archive", ): - if getattr(obj, "sent_to_all", False): - return True - return obj.recipients.filter(pk=user.pk, is_active=True).exists() + return _user_has_document_access(user, obj) # Небезопасные без прав — нельзя (POST custom actions) return False diff --git a/backend/api/v1/documents/serializers.py b/backend/api/v1/documents/serializers.py index 2ac20f59..19b18b97 100644 --- a/backend/api/v1/documents/serializers.py +++ b/backend/api/v1/documents/serializers.py @@ -8,6 +8,7 @@ from typing import Any, Dict, Sequence, Iterable from drf_spectacular.utils import extend_schema_field from django.http import QueryDict from django.contrib.auth import get_user_model +from django.utils.text import slugify from documents.models import Document, DocumentAcknowledgement from rest_framework import serializers @@ -153,7 +154,7 @@ class DocumentTagSerializer(serializers.Serializer): id = serializers.IntegerField(read_only=True) name = serializers.CharField(max_length=100) - slug = serializers.SlugField(required=False, max_length=100) + slug = serializers.SlugField(required=False, allow_blank=True, max_length=100) color = serializers.CharField(required=False) description = serializers.CharField(required=False, allow_blank=True) document_count = serializers.SerializerMethodField() @@ -166,6 +167,9 @@ class DocumentTagSerializer(serializers.Serializer): """Проверяет уникальность slug.""" from documents.models import DocumentTag + if not value: + return value + # Проверяем дубликаты (исключая текущий объект при обновлении) qs = DocumentTag.objects.filter(slug=value) if self.instance: @@ -176,10 +180,44 @@ class DocumentTagSerializer(serializers.Serializer): ) return value + def validate_name(self, value): + """Проверяет уникальность названия тега.""" + from documents.models import DocumentTag + + name = value.strip() + qs = DocumentTag.objects.filter(name__iexact=name) + if self.instance: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + raise serializers.ValidationError( + "Тег с таким названием уже существует." + ) + return name + + def _get_unique_slug(self, name: str) -> str: + """Генерирует уникальный slug из названия.""" + from documents.models import DocumentTag + + base_slug = slugify(name, allow_unicode=True) or "tag" + base_slug = base_slug[:90].strip("-") or "tag" + slug = base_slug + index = 2 + + while DocumentTag.objects.filter(slug=slug).exists(): + suffix = f"-{index}" + slug = f"{base_slug[:100 - len(suffix)]}{suffix}" + index += 1 + + return slug + def create(self, validated_data): """Создаёт новый тег документа.""" from documents.models import DocumentTag + if not validated_data.get("slug"): + validated_data["slug"] = self._get_unique_slug( + validated_data["name"] + ) return DocumentTag.objects.create(**validated_data) def update(self, instance, validated_data): @@ -273,6 +311,7 @@ class DocumentReadSerializer(serializers.ModelSerializer): "modified_by", "modified_at", "sent_to_all", + "is_regulation", "acknowledgement_required", # Требуется ли ознакомление "departments", "recipients", @@ -320,6 +359,7 @@ class DocumentWriteSerializer(serializers.ModelSerializer): max_length=255, help_text="Название документа. Если пусто, будет взято из имени файла.", ) + extracted_text = serializers.CharField(required=False, allow_blank=True) recipient_ids = RecipientIDsField( write_only=True, @@ -358,6 +398,7 @@ class DocumentWriteSerializer(serializers.ModelSerializer): "folder", "extracted_text", "sent_to_all", + "is_regulation", "acknowledgement_required", "department_ids", "recipient_ids", @@ -379,9 +420,6 @@ class DocumentWriteSerializer(serializers.ModelSerializer): """ file_obj = attrs.get("file") - if self.instance is None and not file_obj: - raise serializers.ValidationError({"file": "Обязательное поле."}) - if not str(attrs.get("title") or "").strip(): if file_obj: file_name = ( @@ -392,6 +430,8 @@ class DocumentWriteSerializer(serializers.ModelSerializer): 0 ].strip() attrs["title"] = default_title or file_name or "Документ" + elif self.instance is None: + attrs["title"] = "Документ" elif self.instance is not None: attrs.pop("title", None) @@ -584,7 +624,16 @@ class DocumentWriteSerializer(serializers.ModelSerializer): department_ids = validated_data.pop("department_ids", None) tag_ids = validated_data.pop("tag_ids", None) - for f in ("title", "description", "sent_to_all", "file", "folder"): + for f in ( + "title", + "description", + "extracted_text", + "sent_to_all", + "is_regulation", + "acknowledgement_required", + "file", + "folder", + ): if f in validated_data: setattr(instance, f, validated_data[f]) instance.save() diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index a1a07a34..a2eddb81 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -73,6 +73,7 @@ class DocumentViewSet(ModelViewSet): доступных текущему пользователю. Поддерживает параметр ?folder_id для фильтрации по папке. + Поддерживает параметр ?is_regulation=true для раздела регламентов. Returns: QuerySet[Document]: оптимизированный для списка без N+1. @@ -94,6 +95,16 @@ class DocumentViewSet(ModelViewSet): except (ValueError, TypeError): pass # Игнорируем некорректное значение + is_regulation = request.query_params.get("is_regulation") + if is_regulation is None: + is_regulation = request.query_params.get("regulation") + if is_regulation is not None: + normalized = is_regulation.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + qs = qs.filter(is_regulation=True) + elif normalized in {"0", "false", "no", "off"}: + qs = qs.filter(is_regulation=False) + # Если scope=mine - показываем только доступные пользователю if scope == "mine" and user and user.is_authenticated: # Документы для всех ИЛИ где пользователь получатель @@ -322,11 +333,11 @@ class DocumentViewSet(ModelViewSet): ) return Response({"ok": True, "already": not created}) - @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) + @action(detail=True, methods=["get"]) def acknowledgements(self, request, pk=None): - """Ведомость ознакомлений: доступна всем авторизованным пользователям. - Поддерживает ?search= для фильтра и отдаёт непагинированно - (или подключите пагинацию). + """Ведомость ознакомлений: доступна пользователям с доступом к документу. + + Поддерживает ?search= для фильтра и отдаёт непагинированно. """ doc = self.get_object() q = (request.query_params.get("search") or "").strip() diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index a8ccd09b..fadb7952 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -6,6 +6,7 @@ import re from datetime import date from drf_spectacular.utils import extend_schema_field +from django.contrib.auth import get_user_model from django.db import transaction from rest_framework import serializers @@ -22,12 +23,16 @@ from procurement.models import ( Equipment, EquipmentCategory, MaintenanceRecord, + ApprovalRoute, ProcurementItem, ProcurementRequest, ProcurementRequestView, + ProcurementSettings, Supplier, ) -from ..employees.serializers import EmployeeBriefSerializer +from ..employees.serializers import DepartmentBriefSerializer, EmployeeBriefSerializer + +User = get_user_model() PROBLEM_ITEM_STATUSES = { @@ -117,6 +122,25 @@ def _validate_item_quantities(attrs, instance=None): return attrs +def _validate_processing_department_available(processing_department): + if not processing_department: + return + + settings = ProcurementSettings.get_solo() + available_departments = settings.available_processing_departments.all() + if ( + available_departments.exists() + and not available_departments.filter(pk=processing_department.pk).exists() + ): + raise serializers.ValidationError( + { + "processing_department": ( + "Выберите отдел-исполнитель из доступных для закупок." + ) + } + ) + + def _request_items(obj): return list(obj.items.all()) @@ -415,6 +439,57 @@ class ProcurementItemEditSerializer(ProcurementItemCreateSerializer): ] +class ProcurementRequestCreateOptionsSerializer(serializers.Serializer): + """Опции формы создания заявки на закупку.""" + + processing_departments = DepartmentBriefSerializer(many=True) + default_processing_department = serializers.IntegerField(allow_null=True) + + +class ProcurementManualApprovalStepSerializer(serializers.Serializer): + """Ручной этап согласования при отправке заявки.""" + + priority = serializers.IntegerField(min_value=1) + approver = serializers.PrimaryKeyRelatedField( + queryset=User.objects.filter(is_active=True), + ) + step_name = serializers.CharField( + required=False, + allow_blank=True, + max_length=150, + ) + + def validate_priority(self, value): + if not ApprovalRoute.objects.filter(priority=value).exists(): + raise serializers.ValidationError( + "Выберите настроенный этап согласования." + ) + return value + + +class ProcurementManualApprovalStepsSerializer(serializers.Serializer): + """Список ручных этапов согласования.""" + + approval_steps = ProcurementManualApprovalStepSerializer( + many=True, + required=False, + allow_empty=True, + ) + + def validate_approval_steps(self, value): + priorities = [item["priority"] for item in value] + duplicate_priorities = { + priority + for priority in priorities + if priorities.count(priority) > 1 + } + if duplicate_priorities: + raise serializers.ValidationError( + "Каждый этап согласования можно выбрать только один раз." + ) + return value + + class ApprovalSerializer(serializers.ModelSerializer): """Сериализатор для согласований.""" @@ -665,6 +740,10 @@ class ProcurementRequestListSerializer( def validate(self, attrs): attrs = super().validate(attrs) + if "processing_department" in attrs: + _validate_processing_department_available( + attrs.get("processing_department") + ) items_data = attrs.get("items") if items_data is None: return attrs @@ -876,6 +955,9 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer): read_only_fields = ["id", "requestor", "status", "created_at"] def validate(self, attrs): + _validate_processing_department_available( + attrs.get("processing_department") + ) if ( not attrs.get("processing_department") and not attrs.get("description", "").strip() diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 75c7b3f5..0ca926b0 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -14,9 +14,11 @@ from procurement.constants import ( ProcurementStatus, EquipmentStatus, ProcurementItemExecutionStatus, + get_default_approval_step_name, ) from procurement.models import ( Approval, + ApprovalRoute, Budget, Equipment, EquipmentCategory, @@ -25,6 +27,7 @@ from procurement.models import ( ProcurementItem, ProcurementRequest, ProcurementRequestView, + ProcurementSettings, Supplier, ) from procurement.notifications.handlers import ( @@ -41,6 +44,7 @@ from procurement.notifications.read_service import ( ) from communications import comments_helpers from communications.models import Message +from ..employees.serializers import EmployeeBriefSerializer from procurement.services import ProcurementApprovalResolver from .permissions import ( CanApproveProcurementRequest, @@ -58,7 +62,9 @@ from .serializers import ( ProcurementDepartmentStatsSerializer, ProcurementOverviewStatsSerializer, ProcurementItemSerializer, + ProcurementManualApprovalStepsSerializer, ProcurementNotificationReadResponseSerializer, + ProcurementRequestCreateOptionsSerializer, ProcurementRequestCreateSerializer, ProcurementRequestDetailSerializer, ProcurementRequestListSerializer, @@ -85,6 +91,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): "urgency": ["exact"], "department": ["exact"], "processing_department": ["exact"], + "requestor": ["exact"], "executor": ["exact"], "fulfillment_status": ["exact"], } @@ -107,6 +114,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): "mark_all_received", "notify_arrival", "set_viewed", + "approval_options", ]: return ProcurementRequestDetailSerializer return ProcurementRequestListSerializer @@ -115,7 +123,12 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): """Выбрать права доступа в зависимости от действия.""" if self.action in ["approve", "reject"]: permission_classes = [CanApproveProcurementRequest] - elif self.action in ["submit", "set_viewed"]: + elif self.action in [ + "submit", + "set_viewed", + "create_options", + "approval_options", + ]: permission_classes = [permissions.IsAuthenticated] else: # Для create, update, delete и остальных действий @@ -138,6 +151,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): Value, ) from django.db.models.functions import Cast, Coalesce + from django.utils.dateparse import parse_date from django.utils import timezone from communications.models import Chat from notifications.models import Notification @@ -196,6 +210,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): ) scope = self.request.query_params.get("scope", None) period = self.request.query_params.get("period", None) + date_from = self.request.query_params.get("date_from", None) + date_to = self.request.query_params.get("date_to", None) participant_department_ids = ( ProcurementApprovalResolver.get_user_department_participant_ids( user, @@ -309,8 +325,142 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): start_date = now - timedelta(days=90) queryset = queryset.filter(created_at__gte=start_date) + if date_from: + parsed_date_from = parse_date(date_from) + if parsed_date_from: + queryset = queryset.filter(created_at__date__gte=parsed_date_from) + if date_to: + parsed_date_to = parse_date(date_to) + if parsed_date_to: + queryset = queryset.filter(created_at__date__lte=parsed_date_to) + return queryset.distinct() + @action(detail=False, methods=["get"], url_path="create-options") + def create_options(self, request): + """Вернуть настройки выбора отделов для формы создания заявки.""" + settings = ProcurementSettings.get_solo() + departments = settings.get_processing_departments_queryset().order_by( + "name" + ) + default_department_id = settings.default_processing_department_id + if ( + default_department_id + and not departments.filter(pk=default_department_id).exists() + ): + default_department_id = None + + serializer = ProcurementRequestCreateOptionsSerializer( + { + "processing_departments": departments, + "default_processing_department": default_department_id, + } + ) + return Response(serializer.data) + + def _approval_step_payload( + self, + *, + route, + step=None, + is_amount_applicable=False, + missing_reason=None, + ): + priority, approver, step_name = step or (route.priority, None, "") + return { + "route_id": route.id, + "priority": priority, + "step_name": step_name + or route.name + or get_default_approval_step_name( + route.priority, + resolver_type=route.resolver_type, + ), + "resolver_type": route.resolver_type, + "min_amount": route.min_amount, + "is_amount_applicable": is_amount_applicable, + "is_available": approver is not None, + "missing_reason": missing_reason, + "approver": ( + EmployeeBriefSerializer(approver).data + if approver is not None + else None + ), + } + + def _build_auto_approval_payload(self, procurement_request): + applicable_priorities = set( + procurement_request.get_required_approval_routes().values_list( + "priority", + flat=True, + ) + ) + routes = ApprovalRoute.objects.select_related("employee").order_by( + "priority", + "id", + ) + available_steps = [] + auto_steps = [] + missing_auto_steps = [] + + for route in routes: + step = ProcurementApprovalResolver.resolve_approval_step( + procurement_request, + route, + ) + missing_reason = None + if step is None: + if ( + route.resolver_type + == ApprovalRoute.ResolverType.DEPARTMENT_HEAD + ): + missing_reason = "department_head_missing" + elif ( + route.resolver_type + == ApprovalRoute.ResolverType.FIXED_EMPLOYEE + ): + missing_reason = "approver_missing_or_inactive" + + payload = self._approval_step_payload( + route=route, + step=step, + is_amount_applicable=route.priority in applicable_priorities, + missing_reason=missing_reason, + ) + available_steps.append(payload) + if payload["is_amount_applicable"]: + if payload["is_available"]: + auto_steps.append(payload) + else: + missing_auto_steps.append(payload) + + return { + "request_id": procurement_request.id, + "total_cost": procurement_request.total_cost, + "auto_steps": auto_steps, + "available_steps": available_steps, + "missing_auto_steps": missing_auto_steps, + } + + @action(detail=True, methods=["get"], url_path="approval-options") + def approval_options(self, request, pk=None): + """Вернуть автоматический и ручной наборы согласующих.""" + procurement_request = self.get_object() + if not ProcurementApprovalResolver.user_can_submit_for_approval( + request.user, + procurement_request, + ): + return Response( + {"error": "Вы не можете отправить эту заявку на согласование"}, + status=status.HTTP_403_FORBIDDEN, + ) + if procurement_request.items.count() == 0: + return Response( + {"error": "Добавьте хотя бы одну позицию в заявку"}, + status=status.HTTP_400_BAD_REQUEST, + ) + return Response(self._build_auto_approval_payload(procurement_request)) + @action(detail=True, methods=["get", "post"]) def comments(self, request, pk=None): """Список/создание комментариев для заявки на закупку.""" @@ -450,25 +600,63 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST, ) - required_priorities = ( - procurement_request.get_required_approval_priorities() + manual_serializer = ProcurementManualApprovalStepsSerializer( + data=request.data or {}, ) - if not required_priorities: + if not manual_serializer.is_valid(): return Response( - { - "error": ( - "Для этой суммы заявки не настроены " - "маршруты согласования" - ) - }, + manual_serializer.errors, status=status.HTTP_400_BAD_REQUEST, ) + manual_steps = manual_serializer.validated_data.get( + "approval_steps", + ) or [] - resolved_approvals, missing_routes = ( - ProcurementApprovalResolver.resolve_required_approvers_detailed( - procurement_request + if manual_steps: + routes_by_priority = { + route.priority: route + for route in ApprovalRoute.objects.filter( + priority__in=[ + step["priority"] for step in manual_steps + ] + ) + } + resolved_approvals = [] + for step in sorted(manual_steps, key=lambda item: item["priority"]): + route = routes_by_priority[step["priority"]] + step_name = ( + step.get("step_name", "").strip() + or route.name + or get_default_approval_step_name( + route.priority, + resolver_type=route.resolver_type, + ) + ) + resolved_approvals.append( + (route.priority, step["approver"], step_name) + ) + missing_routes = [] + else: + required_priorities = ( + procurement_request.get_required_approval_priorities() ) - ) + if not required_priorities: + return Response( + { + "error": ( + "Для этой суммы заявки не настроены " + "маршруты согласования" + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + resolved_approvals, missing_routes = ( + ProcurementApprovalResolver.resolve_required_approvers_detailed( + procurement_request + ) + ) + if missing_routes: missing_priorities = [ route["priority"] for route in missing_routes @@ -653,7 +841,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): """Получить заявки, ожидающие согласования текущим польз.""" # Находим заявки где есть pending approval для текущего юзера base_queryset = self.filter_queryset( - self.queryset.filter( + self.get_queryset().filter( status=ProcurementStatus.PENDING, ) ) diff --git a/backend/documents/admin.py b/backend/documents/admin.py index 4bf238ac..4c5999b0 100644 --- a/backend/documents/admin.py +++ b/backend/documents/admin.py @@ -70,9 +70,10 @@ class DocumentAdmin(admin.ModelAdmin): "file_info", "uploaded_at", "sent_to_all", + "is_regulation", "acknowledgement_status", ) - list_filter = ("sent_to_all", "uploaded_at", "departments") + list_filter = ("sent_to_all", "is_regulation", "uploaded_at", "departments") search_fields = ("title", "description", "file__name", "file__description") filter_horizontal = ("recipients", "departments") actions = ["send_document"] @@ -89,6 +90,7 @@ class DocumentAdmin(admin.ModelAdmin): "file", # FilerFileField с drag & drop "file_preview", # Превью файла "description", + "is_regulation", "sent_to_all", "departments", "recipients", diff --git a/backend/documents/migrations/0016_document_is_regulation.py b/backend/documents/migrations/0016_document_is_regulation.py new file mode 100644 index 00000000..b9b20168 --- /dev/null +++ b/backend/documents/migrations/0016_document_is_regulation.py @@ -0,0 +1,22 @@ +# Generated by Codex on 2026-07-06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("documents", "0015_delete_documentcomment"), + ] + + operations = [ + migrations.AddField( + model_name="document", + name="is_regulation", + field=models.BooleanField( + default=False, + help_text="Если включено — документ отображается в разделе регламентов", + verbose_name="Регламент", + ), + ), + ] diff --git a/backend/documents/models.py b/backend/documents/models.py index 6da28916..e2418ad2 100644 --- a/backend/documents/models.py +++ b/backend/documents/models.py @@ -103,6 +103,12 @@ class Document(models.Model): ), ) + is_regulation = models.BooleanField( + _("Регламент"), + default=False, + help_text=_("Если включено — документ отображается в разделе регламентов"), + ) + departments = models.ManyToManyField( "employees.Department", verbose_name=_("Отделы-получатели"), diff --git a/backend/documents/rules.py b/backend/documents/rules.py index c0eb2a3a..b310e7a5 100644 --- a/backend/documents/rules.py +++ b/backend/documents/rules.py @@ -43,10 +43,15 @@ def has_document_access(user, document): if document.sent_to_all and user.is_active: return True - # Проверка через отделы - if hasattr(user, "department") and user.department: - if document.departments.filter(id=user.department.id).exists(): - return True + if document.uploaded_by_id == getattr(user, "pk", None): + return True + + # Проверка через активные связи с отделами + if document.departments.filter( + employeedepartment__employee=user, + employeedepartment__is_active=True, + ).exists(): + return True # Прямой доступ через recipients if document.recipients.filter(id=user.id).exists(): @@ -130,10 +135,10 @@ rules.add_rule( # Отметка об ознакомлении (только те, у кого есть доступ) rules.add_rule("documents.acknowledge_document", has_document_access) -# Просмотр списка ознакомившихся (загрузивший + менеджеры) +# Просмотр списка ознакомившихся (все, у кого есть доступ к документу) rules.add_rule( "documents.view_acknowledgements_document", - is_superuser | is_document_uploader | can_manage_documents, + is_superuser | is_document_uploader | has_document_access, ) # Выдача доступа другим пользователям (только загрузивший и менеджеры) diff --git a/backend/procurement/admin.py b/backend/procurement/admin.py index c99e9d54..2b990197 100644 --- a/backend/procurement/admin.py +++ b/backend/procurement/admin.py @@ -1,6 +1,7 @@ from decimal import Decimal from django.contrib import admin +from django import forms from django.utils.html import format_html from .models import ( @@ -13,6 +14,7 @@ from .models import ( ProcurementItem, ProcurementRequest, ProcurementRequestView, + ProcurementSettings, Supplier, ) @@ -74,6 +76,70 @@ class ProcurementRequestViewAdmin(admin.ModelAdmin): readonly_fields = ["updated_at"] +class ProcurementSettingsAdminForm(forms.ModelForm): + class Meta: + model = ProcurementSettings + fields = "__all__" + + def clean(self): + cleaned_data = super().clean() + default_department = cleaned_data.get("default_processing_department") + available_departments = cleaned_data.get("available_processing_departments") + + if ( + default_department + and available_departments is not None + and available_departments.exists() + and default_department not in available_departments + ): + self.add_error( + "default_processing_department", + ( + "Отдел по умолчанию должен входить в список доступных " + "отделов-исполнителей." + ), + ) + + return cleaned_data + + +@admin.register(ProcurementSettings) +class ProcurementSettingsAdmin(admin.ModelAdmin): + """Админка настроек закупок.""" + + form = ProcurementSettingsAdminForm + list_display = [ + "name", + "default_processing_department", + "available_processing_departments_count", + "updated_at", + ] + fields = [ + "name", + "default_processing_department", + "available_processing_departments", + "updated_at", + ] + readonly_fields = ["updated_at"] + filter_horizontal = ["available_processing_departments"] + + def has_add_permission(self, request): + if ProcurementSettings.objects.exists(): + return False + return super().has_add_permission(request) + + def has_delete_permission(self, request, obj=None): + return False + + def available_processing_departments_count(self, obj): + count = obj.available_processing_departments.count() + return count or "Все отделы" + + available_processing_departments_count.short_description = ( + "Доступные отделы" + ) + + @admin.register(ProcurementRequest) class ProcurementRequestAdmin(admin.ModelAdmin): """Админка для заявок на закупку.""" diff --git a/backend/procurement/migrations/0014_procurementsettings.py b/backend/procurement/migrations/0014_procurementsettings.py new file mode 100644 index 00000000..9ff600d4 --- /dev/null +++ b/backend/procurement/migrations/0014_procurementsettings.py @@ -0,0 +1,86 @@ +# Generated by Codex on 2026-07-06 + +from django.db import migrations, models +import django.db.models.deletion + + +def seed_procurement_settings(apps, schema_editor): + ProcurementSettings = apps.get_model("procurement", "ProcurementSettings") + Department = apps.get_model("employees", "Department") + + supply_department = Department.objects.filter(name__iexact="Снабжение").first() + ProcurementSettings.objects.get_or_create( + defaults={ + "name": "Основные настройки", + "default_processing_department": supply_department, + } + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("procurement", "0013_procurementrequestview"), + ] + + operations = [ + migrations.CreateModel( + name="ProcurementSettings", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + default="Основные настройки", + max_length=100, + verbose_name="Название", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + verbose_name="Обновлено", + ), + ), + ( + "default_processing_department", + models.ForeignKey( + blank=True, + help_text="Этот отдел будет автоматически выбран в новой заявке.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="default_procurement_processing_settings", + to="employees.department", + verbose_name="Отдел-исполнитель по умолчанию", + ), + ), + ( + "available_processing_departments", + models.ManyToManyField( + blank=True, + help_text=( + "Если список пуст, в форме заявки доступны все отделы. " + "Заполните список, чтобы ограничить выбор." + ), + related_name="procurement_processing_settings", + to="employees.department", + verbose_name="Доступные отделы-исполнители", + ), + ), + ], + options={ + "verbose_name": "Настройки закупок", + "verbose_name_plural": "Настройки закупок", + }, + ), + migrations.RunPython(seed_procurement_settings, migrations.RunPython.noop), + ] diff --git a/backend/procurement/models.py b/backend/procurement/models.py index ffd172a6..58285824 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -38,6 +38,71 @@ from .constants import ( User = get_user_model() +class ProcurementSettings(models.Model): + """Настройки модуля закупок.""" + + name = models.CharField("Название", max_length=100, default="Основные настройки") + available_processing_departments = models.ManyToManyField( + Department, + blank=True, + related_name="procurement_processing_settings", + verbose_name="Доступные отделы-исполнители", + help_text=( + "Если список пуст, в форме заявки доступны все отделы. " + "Заполните список, чтобы ограничить выбор." + ), + ) + default_processing_department = models.ForeignKey( + Department, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="default_procurement_processing_settings", + verbose_name="Отдел-исполнитель по умолчанию", + help_text="Этот отдел будет автоматически выбран в новой заявке.", + ) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Настройки закупок" + verbose_name_plural = "Настройки закупок" + + def __str__(self): + return self.name + + @classmethod + def get_solo(cls): + settings = cls.objects.first() + if settings: + return settings + return cls.objects.create() + + def clean(self): + super().clean() + if ( + self.pk + and self.default_processing_department_id + and self.available_processing_departments.exists() + and not self.available_processing_departments.filter( + pk=self.default_processing_department_id + ).exists() + ): + raise ValidationError( + { + "default_processing_department": ( + "Отдел по умолчанию должен входить в список " + "доступных отделов-исполнителей." + ) + } + ) + + def get_processing_departments_queryset(self): + departments = self.available_processing_departments.all() + if departments.exists(): + return departments + return Department.objects.all() + + class ProcurementRequest(models.Model): """Заявка на закупку. diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py index 874c7c08..a00edb5d 100644 --- a/backend/procurement/notifications/handlers.py +++ b/backend/procurement/notifications/handlers.py @@ -267,9 +267,15 @@ def _approval_recipients(approval): resolver_type=ApprovalRoute.ResolverType.DEPARTMENT_HEAD, ).first() if route: - return ProcurementApprovalResolver.get_department_approval_users( + department_recipients = ProcurementApprovalResolver.get_department_approval_users( approval.request, ) + if any( + recipient.id == approval.approver_id + for recipient in department_recipients + ): + return department_recipients + return [approval.approver] return [approval.approver] diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py index f03949bc..49491cb3 100644 --- a/backend/tests/api/v1/documents/test_documents_api.py +++ b/backend/tests/api/v1/documents/test_documents_api.py @@ -28,7 +28,8 @@ from django.contrib.auth.models import Permission from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from django.utils import timezone -from documents.models import Document, DocumentAcknowledgement +from documents.models import Document, DocumentAcknowledgement, DocumentTag +from employees.models import Department, EmployeeDepartment from filer.models import File as FilerFile, Folder from rest_framework import status from rest_framework.test import APIClient @@ -162,6 +163,9 @@ def api_urls(): def _ack(pk: int) -> str: return reverse("api:v1:documents-acknowledge", args=[pk]) + def _acknowledgements(pk: int) -> str: + return reverse("api:v1:documents-acknowledgements", args=[pk]) + def _folder_detail(pk: int) -> str: return reverse("api:v1:folders-detail", args=[pk]) @@ -173,6 +177,7 @@ def api_urls(): "archive": reverse("api:v1:documents-archive"), "detail": _detail, "ack": _ack, + "acknowledgements": _acknowledgements, "folder_detail": _folder_detail, "folder_archive": _folder_archive, } @@ -474,9 +479,11 @@ class TestCreate: assert r.status_code in (200, 201), r.content assert r.json()["title"] == "derived-title" - # нет file + # file необязателен r = client.post(url, {"title": "T", "sent_to_all": True}, format="multipart") - assert r.status_code == 400 + assert r.status_code in (200, 201), r.content + assert r.json()["title"] == "T" + assert r.json()["file_url"] is None # sent_to_all=false и пустые recipient_ids → 400 r = client.post(url, {"title": "T", "sent_to_all": False}, format="multipart") @@ -639,6 +646,33 @@ class TestUpdate: assert d.title == "New" assert d.description == "new" + def test_update_search_text_and_acknowledgement_required( + self, auth_client, make_user, api_urls + ): + """PATCH обновляет текст поиска и требование ознакомления.""" + author = make_user("author-search@example.com") + editor = make_user("editor-search@example.com") + grant_perms(editor, "change_document") + client = auth_client(editor) + document = make_document( + uploaded_by=author, + acknowledgement_required=False, + ) + + response = client.patch( + api_urls["detail"](document.pk), + { + "extracted_text": "обновленный текст для поиска", + "acknowledgement_required": True, + }, + format="json", + ) + + assert response.status_code == 200 + document = Document.objects.get(pk=document.pk) + assert document.extracted_text == "обновленный текст для поиска" + assert document.acknowledgement_required is True + def test_replace_file_multipart_patch(self, auth_client, make_user, api_urls): """Заменить file (multipart PATCH) → file_url изменился.""" author = make_user("author@example.com") @@ -1083,6 +1117,86 @@ class TestAcknowledge: user=viewer, ).exists() + +class TestAcknowledgementsReport: + """Ведомость ознакомлений доступна всем, кому доступен документ.""" + + def test_department_recipient_can_view_acknowledgements_report( + self, auth_client, make_user, api_urls + ): + """Сотрудник отдела-получателя видит ведомость документа.""" + author = make_user("report-author@example.com") + viewer = make_user("report-viewer@example.com") + department = Department.objects.create(name="Report Department") + EmployeeDepartment.objects.create( + employee=viewer, + department=department, + is_active=True, + ) + document = make_document( + uploaded_by=author, + sent_to_all=False, + acknowledgement_required=True, + ) + document.departments.add(department) + DocumentAcknowledgement.objects.create(document=document, user=viewer) + + response = auth_client(viewer).get(api_urls["acknowledgements"](document.pk)) + + assert response.status_code == status.HTTP_200_OK, response.content + body = response.json() + assert body["counts"] == { + "acknowledged": 1, + "unacknowledged": 0, + "total": 1, + } + assert body["acknowledged"][0]["id"] == viewer.id + + def test_unrelated_user_cannot_view_acknowledgements_report( + self, auth_client, make_user, api_urls + ): + """Посторонний сотрудник без доступа к документу не видит ведомость.""" + author = make_user("report-author-2@example.com") + unrelated = make_user("report-unrelated@example.com") + recipient = make_user("report-recipient@example.com") + document = make_document( + uploaded_by=author, + sent_to_all=False, + acknowledgement_required=True, + recipients=[recipient], + ) + + response = auth_client(unrelated).get( + api_urls["acknowledgements"](document.pk) + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +class TestDocumentTags: + """CRUD тегов документов.""" + + def test_create_tag_without_slug_generates_unique_slug( + self, auth_client, make_user + ): + """Создание тега из формы работает без передачи slug.""" + user = make_user("tag-author@example.com") + client = auth_client(user) + url = reverse("api:v1:document-tags-list") + + response = client.post( + url, + {"name": "Регламент охраны труда", "color": "#3B82F6"}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED, response.content + body = response.json() + tag = DocumentTag.objects.get(pk=body["id"]) + assert tag.name == "Регламент охраны труда" + assert tag.slug + assert tag.color == "#3B82F6" + # -------------------- G. Сериализация и данные -------------------- class TestSerialization: @@ -1131,14 +1245,16 @@ class TestSerialization: class TestErrorsAndEdgeCases: """H. Неудачные Content-Type, perms и размер файла.""" - def test_wrong_content_type_on_create(self, auth_client, make_user, api_urls): - """Неверный Content-Type (JSON без файла) → 400 или 415 (фиксируем фактическое поведение).""" + def test_json_create_without_file(self, auth_client, make_user, api_urls): + """JSON без файла создаёт документ: файл в документах необязателен.""" uploader = make_user("uploader@example.com", staff=True) client = auth_client(uploader) r = client.post( api_urls["list"], {"title": "X", "sent_to_all": True}, format="json" ) - assert r.status_code in (400, 415) + assert r.status_code in (200, 201), r.content + assert r.json()["title"] == "X" + assert r.json()["file_url"] is None def test_put_patch_delete_without_perms(self, auth_client, make_user, api_urls): """Попытка PUT/PATCH/DELETE без прав → 403.""" diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 2e45dec0..e5c534ac 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -2,6 +2,7 @@ Тесты API для заявок на закупку (ProcurementRequest). """ +from datetime import timedelta from decimal import Decimal from unittest.mock import patch @@ -38,6 +39,7 @@ from procurement.models import ( Budget, ProcurementItem, ProcurementRequest, + ProcurementSettings, ) from procurement.notifications.handlers import notify_new_request from notifications.models import Notification, UserChannelPreferences @@ -274,6 +276,112 @@ class TestProcurementRequestList: assert response.status_code == status.HTTP_200_OK assert len(response.data['results']) == 1 + def test_filter_by_requestor_executor_and_processing_department( + self, api_client, staff_user, user, department + ): + """Фильтрация по заказчику, исполнителю и отделу-исполнителю.""" + processing_department = Department.objects.create(name="Снабжение") + other_requestor = Employee.objects.create_user( + email="other-requestor@example.com", + password="testpass123", + phone_number="+79998880001", + first_name="Другой", + last_name="Заказчик", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + executor = Employee.objects.create_user( + email="executor-filter@example.com", + password="testpass123", + phone_number="+79998880002", + first_name="Исполнитель", + last_name="Фильтр", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + matched = ProcurementRequest.objects.create( + title="Нужная заявка", + description="Описание", + department=department, + processing_department=processing_department, + requestor=user, + executor=executor, + status=ProcurementStatus.IN_PROGRESS, + ) + other = ProcurementRequest.objects.create( + title="Другая заявка", + description="Описание", + department=department, + requestor=other_requestor, + status=ProcurementStatus.DRAFT, + ) + + api_client.force_authenticate(user=staff_user) + url = reverse('api:v1:procurement:procurementrequest-list') + + for params in ( + {'requestor': user.id}, + {'executor': executor.id}, + {'processing_department': processing_department.id}, + ): + response = api_client.get(url, params) + assert response.status_code == status.HTTP_200_OK + ids = {item['id'] for item in response.data['results']} + assert matched.id in ids + assert other.id not in ids + + def test_filter_by_created_date_range( + self, api_client, staff_user, user, department + ): + """Фильтрация по диапазону дат создания.""" + now = timezone.now() + old_request = ProcurementRequest.objects.create( + title="Старая заявка", + description="Описание", + department=department, + requestor=user, + status=ProcurementStatus.DRAFT, + ) + fresh_request = ProcurementRequest.objects.create( + title="Новая заявка", + description="Описание", + department=department, + requestor=user, + status=ProcurementStatus.DRAFT, + ) + ProcurementRequest.objects.filter(pk=old_request.pk).update( + created_at=now - timedelta(days=10) + ) + ProcurementRequest.objects.filter(pk=fresh_request.pk).update( + created_at=now + ) + + api_client.force_authenticate(user=staff_user) + url = reverse('api:v1:procurement:procurementrequest-list') + + response = api_client.get( + url, + {'date_from': (now - timedelta(days=1)).date().isoformat()}, + ) + assert response.status_code == status.HTTP_200_OK + ids = {item['id'] for item in response.data['results']} + assert fresh_request.id in ids + assert old_request.id not in ids + + response = api_client.get( + url, + {'date_to': (now - timedelta(days=5)).date().isoformat()}, + ) + assert response.status_code == status.HTTP_200_OK + ids = {item['id'] for item in response.data['results']} + assert old_request.id in ids + assert fresh_request.id not in ids + + response = api_client.get(url, {'date_from': 'not-a-date'}) + assert response.status_code == status.HTTP_200_OK + # ============================================================================== # ТЕСТЫ СОЗДАНИЯ ЗАЯВОК @@ -283,6 +391,41 @@ class TestProcurementRequestList: class TestProcurementRequestCreate: """Тесты создания заявок.""" + def test_create_options_use_configured_processing_departments( + self, api_client, user + ): + """Опции формы берутся из настроек закупок.""" + supply_department = Department.objects.create(name="Снабжение") + warehouse_department = Department.objects.create(name="Склад") + hidden_department = Department.objects.create(name="Бухгалтерия") + settings = ProcurementSettings.get_solo() + settings.default_processing_department = supply_department + settings.save() + settings.available_processing_departments.set( + [supply_department, warehouse_department] + ) + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-create-options' + ) + + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['default_processing_department'] == ( + supply_department.id + ) + department_ids = { + department['id'] + for department in response.data['processing_departments'] + } + assert department_ids == { + supply_department.id, + warehouse_department.id, + } + assert hidden_department.id not in department_ids + def test_create_request_with_items( self, api_client, user, department ): @@ -393,6 +536,42 @@ class TestProcurementRequestCreate: assert procurement_request.processing_department == supply_department assert procurement_request.approvals.count() == 0 + def test_create_rejects_processing_department_outside_settings( + self, api_client, user, department + ): + """Нельзя выбрать отдел-исполнитель вне настроек закупок.""" + allowed_department = Department.objects.create(name="Снабжение") + blocked_department = Department.objects.create(name="Маркетинг") + settings = ProcurementSettings.get_solo() + settings.default_processing_department = allowed_department + settings.save() + settings.available_processing_departments.set([allowed_department]) + + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:procurementrequest-list') + + response = api_client.post( + url, + { + 'title': 'Расходники для офиса', + 'department': department.id, + 'processing_department': blocked_department.id, + 'urgency': UrgencyLevel.MEDIUM, + 'items': [ + { + 'name': 'Бумага', + 'quantity': 10, + 'unit': 'пачка', + 'estimated_unit_price': '450.00', + }, + ], + }, + format='json', + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'processing_department' in response.data + # ============================================================================== # ТЕСТЫ ДЕТАЛЬНОЙ ИНФОРМАЦИИ @@ -3509,6 +3688,77 @@ class TestProcurementRequestWorkflow: approval = procurement_request.approvals.get(priority=HEAD_PRIORITY) assert approval.approver_id == role_approver.id + def test_approval_options_include_manual_routes_outside_amount( + self, api_client, user, procurement_request + ): + """Предпросмотр показывает автоэтапы и ручные этапы вне суммы.""" + ProcurementItem.objects.create( + request=procurement_request, + name="Недорогая позиция", + quantity=1, + unit="шт", + estimated_unit_price=Decimal("100.00"), + ) + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-approval-options', + kwargs={'pk': procurement_request.id}, + ) + + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert [ + item["priority"] for item in response.data["auto_steps"] + ] == [HEAD_PRIORITY] + available_by_priority = { + item["priority"]: item + for item in response.data["available_steps"] + } + assert available_by_priority[HEAD_PRIORITY]["is_amount_applicable"] is True + assert available_by_priority[FINANCE_PRIORITY]["is_amount_applicable"] is False + assert available_by_priority[DIRECTOR_PRIORITY]["is_amount_applicable"] is False + assert available_by_priority[DIRECTOR_PRIORITY]["is_available"] is True + + def test_submit_with_manual_approver_bypasses_amount_auto_routes( + self, api_client, user, procurement_request + ): + """Ручной список используется вместо автоподбора по сумме.""" + ProcurementItem.objects.create( + request=procurement_request, + name="Недорогая позиция", + quantity=1, + unit="шт", + estimated_unit_price=Decimal("100.00"), + ) + director = Employee.objects.get(email="director@example.com") + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': procurement_request.id}, + ) + + response = api_client.post( + url, + { + "approval_steps": [ + { + "priority": DIRECTOR_PRIORITY, + "approver": director.id, + } + ] + }, + format='json', + ) + + assert response.status_code == status.HTTP_200_OK + procurement_request.refresh_from_db() + assert procurement_request.status == ProcurementStatus.PENDING + approvals = list(procurement_request.approvals.order_by("priority")) + assert len(approvals) == 1 + assert approvals[0].priority == DIRECTOR_PRIORITY + assert approvals[0].approver_id == director.id + def test_approve_notifies_only_next_stage_approver( self, api_client, department_head, procurement_request, procurement_item, monkeypatch ): diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index aa6f263c..c57249e8 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -6,6 +6,7 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "rea import dynamic from "next/dynamic"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { Document } from "@/types/api"; +import { toast } from "sonner"; import { Search, FileText, @@ -26,6 +27,8 @@ import { ChevronRight, Pencil, Trash2, + ScrollText, + Loader2, } from "lucide-react"; import { DocumentAcknowledgementsReport } from "@/components/documents/DocumentAcknowledgementsReport"; import { DocumentMetadataEditor } from "@/components/documents/DocumentMetadataEditor"; @@ -164,6 +167,7 @@ function formatFileSize(value?: number): string { type DocumentTagOption = { id: number; name: string }; type PaginatedResponse = { results: T[]; next?: string | null }; +type DocumentSection = "folders" | "regulations"; const DOCUMENTS_PAGE_SIZE = 10000; function isPaginatedResponse(value: unknown): value is PaginatedResponse { @@ -225,6 +229,7 @@ function DocumentsPageContent() { const [documents, setDocuments] = useState([]); const [folders, setFolders] = useState([]); const [selectedFolderId, setSelectedFolderId] = useState(null); + const [activeSection, setActiveSection] = useState("regulations"); const [search, setSearch] = useState(""); const [selectedTags, setSelectedTags] = useState([]); const [availableTags, setAvailableTags] = useState([]); @@ -259,6 +264,7 @@ function DocumentsPageContent() { const [createFolderParentId, setCreateFolderParentId] = useState(null); const [editingFolder, setEditingFolder] = useState(null); const [downloadingFolderId, setDownloadingFolderId] = useState(null); + const [acknowledgingDocumentId, setAcknowledgingDocumentId] = useState(null); const [selectionMode, setSelectionMode] = useState(false); // Bulk selection @@ -280,8 +286,10 @@ function DocumentsPageContent() { try { setLoading(true); setError(null); - const params: { folder_id?: number } = {}; - if (selectedFolderId !== null) { + const params: { folder_id?: number; is_regulation?: boolean } = {}; + if (activeSection === "regulations") { + params.is_regulation = true; + } else if (selectedFolderId !== null) { params.folder_id = selectedFolderId; } console.log("📁 Загрузка документов с параметрами:", params); @@ -300,7 +308,7 @@ function DocumentsPageContent() { } finally { setLoading(false); } - }, [selectedFolderId]); + }, [activeSection, selectedFolderId]); const loadFolders = async () => { try { @@ -443,10 +451,10 @@ function DocumentsPageContent() { // Find selected folder and build breadcrumb path const selectedFolder = useMemo(() => { - if (!selectedFolderId) return null; + if (activeSection !== "folders" || !selectedFolderId) return null; return folders.find((folder) => folder.id === selectedFolderId) || null; - }, [selectedFolderId, folders]); + }, [activeSection, selectedFolderId, folders]); const breadcrumbs = useMemo(() => { if (!selectedFolder) return []; @@ -480,6 +488,8 @@ function DocumentsPageContent() { }, [folders]); const visibleFolders = useMemo(() => { + if (activeSection === "regulations") return []; + const q = search.trim().toLowerCase(); return folders @@ -492,7 +502,7 @@ function DocumentsPageContent() { ); }) .sort((a, b) => a.name.localeCompare(b.name, 'ru')); - }, [folders, search, selectedFolderId]); + }, [activeSection, folders, search, selectedFolderId]); const createFolderParent = useMemo(() => { if (createFolderParentId === null) return null; @@ -685,6 +695,30 @@ function DocumentsPageContent() { } }; + const handleAcknowledgeDocument = async (documentItem: Document) => { + setAcknowledgingDocumentId(documentItem.id); + setDocumentMenuOpenId(null); + + try { + await apiClient.acknowledgeDocument(documentItem.id); + setDocuments((currentDocuments) => + currentDocuments.map((doc) => + doc.id === documentItem.id ? { ...doc, is_acknowledged: true } : doc + ) + ); + if (selectedDocument?.id === documentItem.id) { + setSelectedDocument({ ...selectedDocument, is_acknowledged: true }); + } + toast.success("Ознакомление подтверждено"); + loadDocuments(); + } catch (err) { + console.error("Ошибка подтверждения ознакомления:", err); + toast.error("Не удалось подтвердить ознакомление"); + } finally { + setAcknowledgingDocumentId(null); + } + }; + const downloadFolderArchive = async (folder: FolderNode) => { setDownloadingFolderId(folder.id); @@ -775,42 +809,59 @@ function DocumentsPageContent() {
- + {activeSection === "folders" && selectedFolderId ? ( + + + ) : null} + +
{showFolderDropdown && (
@@ -831,6 +882,7 @@ function DocumentsPageContent() { selectedFolderId={selectedFolderId} onSelectFolder={(id) => { console.log("🗂️ Выбрана папка:", id); + setActiveSection("folders"); setSelectedFolderId(id); setShowFolderDropdown(false); }} @@ -839,6 +891,20 @@ function DocumentsPageContent() {
)}
+
{/* Active Filters Tags */} @@ -1050,7 +1116,10 @@ function DocumentsPageContent() {