feat: improve documents and procurement workflows
This commit is contained in:
parent
14b42baa2c
commit
0b45888f5c
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@ -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",
|
||||
|
||||
22
backend/documents/migrations/0016_document_is_regulation.py
Normal file
22
backend/documents/migrations/0016_document_is_regulation.py
Normal file
@ -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="Регламент",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -103,6 +103,12 @@ class Document(models.Model):
|
||||
),
|
||||
)
|
||||
|
||||
is_regulation = models.BooleanField(
|
||||
_("Регламент"),
|
||||
default=False,
|
||||
help_text=_("Если включено — документ отображается в разделе регламентов"),
|
||||
)
|
||||
|
||||
departments = models.ManyToManyField(
|
||||
"employees.Department",
|
||||
verbose_name=_("Отделы-получатели"),
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
# Выдача доступа другим пользователям (только загрузивший и менеджеры)
|
||||
|
||||
@ -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):
|
||||
"""Админка для заявок на закупку."""
|
||||
|
||||
86
backend/procurement/migrations/0014_procurementsettings.py
Normal file
86
backend/procurement/migrations/0014_procurementsettings.py
Normal file
@ -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),
|
||||
]
|
||||
@ -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):
|
||||
"""Заявка на закупку.
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
):
|
||||
|
||||
@ -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<T> = { results: T[]; next?: string | null };
|
||||
type DocumentSection = "folders" | "regulations";
|
||||
const DOCUMENTS_PAGE_SIZE = 10000;
|
||||
|
||||
function isPaginatedResponse<T>(value: unknown): value is PaginatedResponse<T> {
|
||||
@ -225,6 +229,7 @@ function DocumentsPageContent() {
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [folders, setFolders] = useState<FolderNode[]>([]);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
|
||||
const [activeSection, setActiveSection] = useState<DocumentSection>("regulations");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<number[]>([]);
|
||||
const [availableTags, setAvailableTags] = useState<DocumentTagOption[]>([]);
|
||||
@ -259,6 +264,7 @@ function DocumentsPageContent() {
|
||||
const [createFolderParentId, setCreateFolderParentId] = useState<number | null>(null);
|
||||
const [editingFolder, setEditingFolder] = useState<FolderNode | null>(null);
|
||||
const [downloadingFolderId, setDownloadingFolderId] = useState<number | null>(null);
|
||||
const [acknowledgingDocumentId, setAcknowledgingDocumentId] = useState<number | null>(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() {
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowFolderDropdown(!showFolderDropdown)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
selectedFolderId
|
||||
<div
|
||||
className={`inline-flex items-center overflow-hidden rounded-full text-xs font-medium transition ${
|
||||
activeSection === "folders" && selectedFolderId
|
||||
? 'app-pill-active'
|
||||
: 'app-pill'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
<span className="max-w-[220px] truncate">
|
||||
{selectedFolder ? selectedFolder.name : 'Все папки'}
|
||||
</span>
|
||||
{selectedFolderId ? (
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveSection("folders");
|
||||
setShowFolderDropdown(false);
|
||||
}}
|
||||
className="inline-flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-1.5 transition hover:opacity-85"
|
||||
>
|
||||
<FolderOpen size={14} className="shrink-0" />
|
||||
<span className="max-w-[220px] truncate">
|
||||
{selectedFolder ? selectedFolder.name : 'Все папки'}
|
||||
</span>
|
||||
</button>
|
||||
{activeSection === "folders" && selectedFolderId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFolderId(null);
|
||||
setActiveSection("folders");
|
||||
}}
|
||||
className={`rounded-full p-0.5 ${selectedFolderId ? 'hover:bg-sky-500' : 'hover:bg-gray-300'}`}
|
||||
className="flex h-7 w-6 shrink-0 items-center justify-center transition hover:bg-sky-500"
|
||||
title="Сбросить фильтр"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedFolderId(null);
|
||||
}
|
||||
}}
|
||||
aria-label="Сбросить фильтр папки"
|
||||
>
|
||||
<X size={12} />
|
||||
</span>
|
||||
) : (
|
||||
<ChevronDown size={12} className="opacity-70" />
|
||||
)}
|
||||
</button>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveSection("folders");
|
||||
setShowFolderDropdown((prev) => !prev);
|
||||
}}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center transition hover:bg-[var(--surface-tertiary)]"
|
||||
title="Показать папки"
|
||||
aria-label="Показать список папок"
|
||||
aria-expanded={showFolderDropdown}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={`opacity-70 transition-transform ${showFolderDropdown ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{showFolderDropdown && (
|
||||
<div className="app-menu absolute left-0 top-full z-10 mt-2 w-72 rounded-lg">
|
||||
<div className="app-divider border-b p-2">
|
||||
@ -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() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveSection("regulations");
|
||||
setSelectedFolderId(null);
|
||||
setShowFolderDropdown(false);
|
||||
}}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
activeSection === "regulations" ? "app-pill-active" : "app-pill"
|
||||
}`}
|
||||
>
|
||||
<ScrollText size={14} />
|
||||
Регламенты
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Tags */}
|
||||
@ -1050,7 +1116,10 @@ function DocumentsPageContent() {
|
||||
<FolderOpen size={14} className="app-text-muted" />
|
||||
<nav className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setSelectedFolderId(null)}
|
||||
onClick={() => {
|
||||
setActiveSection("folders");
|
||||
setSelectedFolderId(null);
|
||||
}}
|
||||
className="app-link-accent"
|
||||
>
|
||||
Все документы
|
||||
@ -1068,7 +1137,10 @@ function DocumentsPageContent() {
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedFolderId(crumb.id)}
|
||||
onClick={() => {
|
||||
setActiveSection("folders");
|
||||
setSelectedFolderId(crumb.id);
|
||||
}}
|
||||
className="app-link-accent"
|
||||
>
|
||||
{crumb.name}
|
||||
@ -1086,16 +1158,28 @@ function DocumentsPageContent() {
|
||||
<div className="space-y-3">
|
||||
{visibleFolders.length === 0 && filteredDocuments.length === 0 ? (
|
||||
<div className="app-surface-muted rounded-xl p-8 text-center">
|
||||
<FileText size={22} className="app-text-muted mx-auto mb-2" />
|
||||
<p className="app-text-muted text-sm">Документы не найдены</p>
|
||||
{activeSection === "regulations" ? (
|
||||
<ScrollText size={22} className="app-text-muted mx-auto mb-2" />
|
||||
) : (
|
||||
<FileText size={22} className="app-text-muted mx-auto mb-2" />
|
||||
)}
|
||||
<p className="app-text-muted text-sm">
|
||||
{activeSection === "regulations" ? "Регламенты не найдены" : "Документы не найдены"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="app-text-muted text-xs">
|
||||
{visibleFolders.length > 0 ? `${visibleFolders.length} пап.` : ""}
|
||||
{visibleFolders.length > 0 && filteredDocuments.length > 0 ? " · " : ""}
|
||||
{filteredDocuments.length > 0 ? `${filteredDocuments.length} док.` : ""}
|
||||
{activeSection === "regulations"
|
||||
? `${filteredDocuments.length} регл.`
|
||||
: (
|
||||
<>
|
||||
{visibleFolders.length > 0 ? `${visibleFolders.length} пап.` : ""}
|
||||
{visibleFolders.length > 0 && filteredDocuments.length > 0 ? " · " : ""}
|
||||
{filteredDocuments.length > 0 ? `${filteredDocuments.length} док.` : ""}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -1228,6 +1312,7 @@ function DocumentsPageContent() {
|
||||
const recipientsCount = doc.recipients?.length || 0;
|
||||
const departmentsCount = doc.departments?.length || 0;
|
||||
const hasPreview = Boolean(doc.file_url && canPreviewDocument(doc.file_name || doc.title));
|
||||
const isAcknowledging = acknowledgingDocumentId === doc.id;
|
||||
|
||||
// DEBUG: Проверка данных документа
|
||||
if (!authorName || !createdDate) {
|
||||
@ -1266,6 +1351,12 @@ function DocumentsPageContent() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5">
|
||||
{doc.is_regulation && (
|
||||
<span className="app-selected app-accent-text inline-flex max-w-full items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium">
|
||||
<ScrollText size={12} className="shrink-0" />
|
||||
Регламент
|
||||
</span>
|
||||
)}
|
||||
{doc.folder_path && (
|
||||
<span className="app-badge inline-flex max-w-full items-center gap-1.5 px-2.5 py-1 text-[11px] font-medium">
|
||||
<FolderOpen size={12} className="shrink-0" />
|
||||
@ -1347,7 +1438,7 @@ function DocumentsPageContent() {
|
||||
)
|
||||
) : (
|
||||
<span className="app-badge px-2.5 py-1 text-xs font-medium">
|
||||
Документ
|
||||
{doc.is_regulation ? "Регламент" : "Документ"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -1441,11 +1532,12 @@ function DocumentsPageContent() {
|
||||
{doc.acknowledgement_required && !doc.is_acknowledged && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDocumentDetailsFromMenu(doc)}
|
||||
onClick={() => void handleAcknowledgeDocument(doc)}
|
||||
disabled={isAcknowledging}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<CheckCircle size={14} />
|
||||
Ознакомиться
|
||||
{isAcknowledging ? <Loader2 size={14} className="animate-spin" /> : <CheckCircle size={14} />}
|
||||
{isAcknowledging ? "Подтверждение..." : "Ознакомиться"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@ -1512,6 +1604,79 @@ function DocumentsPageContent() {
|
||||
{doc.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-end gap-1.5">
|
||||
{doc.acknowledgement_required && !doc.is_acknowledged ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAcknowledgeDocument(doc)}
|
||||
disabled={isAcknowledging}
|
||||
title="Подтвердить ознакомление"
|
||||
className="app-action-approve inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isAcknowledging ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<CheckCircle size={14} />
|
||||
)}
|
||||
<span>{isAcknowledging ? "Подтверждение..." : "Ознакомиться"}</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{hasPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDocumentPreviewFromMenu(doc)}
|
||||
title="Предпросмотр"
|
||||
className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-lg"
|
||||
>
|
||||
<Eye size={15} />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{doc.file_url ? (
|
||||
<a
|
||||
href={doc.file_url}
|
||||
download={doc.file_name || doc.title}
|
||||
title="Скачать"
|
||||
className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-lg"
|
||||
>
|
||||
<Download size={15} />
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{doc.acknowledgement_required ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDocumentReportFromMenu(doc)}
|
||||
title="Ведомость ознакомления"
|
||||
className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-lg"
|
||||
>
|
||||
<Users size={15} />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedDocument(doc)}
|
||||
title="Детали"
|
||||
className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-lg"
|
||||
>
|
||||
<FileText size={15} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMetadataDocument(doc);
|
||||
setShowMetadataEditor(true);
|
||||
}}
|
||||
title="Редактировать"
|
||||
className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-lg"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -868,3 +868,27 @@ html[data-bs-theme="dark"] .shadow-xl {
|
||||
*::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari/Opera */
|
||||
}
|
||||
|
||||
.app-tabs-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--accent-primary) 45%, var(--surface-tertiary)) transparent;
|
||||
-ms-overflow-style: auto;
|
||||
}
|
||||
|
||||
.app-tabs-scroll::-webkit-scrollbar {
|
||||
display: block;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.app-tabs-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-tabs-scroll::-webkit-scrollbar-thumb {
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--accent-primary) 42%, var(--surface-tertiary));
|
||||
}
|
||||
|
||||
.app-tabs-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
@ -8,7 +8,15 @@ import Link from "next/link";
|
||||
import { ProcurementRequestDetailContent } from "@/components/procurement/ProcurementRequestDetailContent";
|
||||
import ProcurementStatsPanel from "@/components/procurement/ProcurementStatsPanel";
|
||||
import ProcurementSuppliersPanel from "@/components/procurement/ProcurementSuppliersPanel";
|
||||
import type { ProcurementFulfillmentStatus, ProcurementRequest, ProcurementStatus, UrgencyLevel } from "@/types/api";
|
||||
import type {
|
||||
ProcurementApprovalOptions,
|
||||
ProcurementApprovalRouteOption,
|
||||
ProcurementApprovalStepSelection,
|
||||
ProcurementFulfillmentStatus,
|
||||
ProcurementRequest,
|
||||
ProcurementStatus,
|
||||
UrgencyLevel,
|
||||
} from "@/types/api";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpDown,
|
||||
@ -37,6 +45,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
|
||||
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { formatDate, formatDateTime, formatMoney } from "@/lib/shared";
|
||||
import { useProcurementPage } from "@/hooks/useProcurementPage";
|
||||
import { Modal } from "@/components/ui";
|
||||
@ -74,6 +83,14 @@ const fulfillmentStatusClasses: Record<ProcurementFulfillmentStatus, string> = {
|
||||
const getFulfillmentStatusClass = (status?: string | null) => (
|
||||
fulfillmentStatusClasses[String(status || "").toLowerCase() as ProcurementFulfillmentStatus] ?? "app-badge"
|
||||
);
|
||||
const fulfillmentStatusOptions: { value: ProcurementFulfillmentStatus; label: string }[] = [
|
||||
{ value: "pending", label: "Не обработано" },
|
||||
{ value: "partially_ordered", label: "Частично заказано" },
|
||||
{ value: "ordered", label: "Заказано" },
|
||||
{ value: "partially_received", label: "Частично получено" },
|
||||
{ value: "completed", label: "Получено" },
|
||||
{ value: "issues", label: "Есть проблемы" },
|
||||
];
|
||||
|
||||
const getOrderedProgressClass = (ordered: number, total: number) => (
|
||||
total > 0 && ordered >= total ? "app-feedback-success" : total > 0 && ordered > 0 ? "app-selected" : "app-badge"
|
||||
@ -121,6 +138,14 @@ const fmt = formatDate;
|
||||
const fmtDateTime = formatDateTime;
|
||||
const money = formatMoney;
|
||||
type RequestActionDialogKind = "approve" | "reject" | "cancel" | "delete";
|
||||
type SubmitApprovalMode = "auto" | "manual";
|
||||
|
||||
const getApprovalRouteAmountLabel = (option: ProcurementApprovalRouteOption) => {
|
||||
if (option.min_amount === null || option.min_amount === undefined || option.min_amount === "") {
|
||||
return "для любой суммы";
|
||||
}
|
||||
return `от ${money(option.min_amount)}`;
|
||||
};
|
||||
|
||||
const getRequestListDateLabel = (request: ProcurementRequest, status: string) => {
|
||||
const createdAt = fmt(request.created_at);
|
||||
@ -339,7 +364,8 @@ export default function ProcurementPage() {
|
||||
closeModal,
|
||||
departmentFilter,
|
||||
departments,
|
||||
defaultProcessingDepartmentId,
|
||||
employees,
|
||||
processingDepartments,
|
||||
detailsCache,
|
||||
displayUserName,
|
||||
ensureRequestDetail,
|
||||
@ -387,15 +413,27 @@ export default function ProcurementPage() {
|
||||
openEdit,
|
||||
ordering,
|
||||
periodFilter,
|
||||
processingDepartmentFilter,
|
||||
requestorFilter,
|
||||
executorFilter,
|
||||
fulfillmentFilter,
|
||||
dateFromFilter,
|
||||
dateToFilter,
|
||||
removeItemRow,
|
||||
resolveUserId,
|
||||
requests,
|
||||
searchQuery,
|
||||
setDepartmentFilter,
|
||||
setProcessingDepartmentFilter,
|
||||
setRequestorFilter,
|
||||
setExecutorFilter,
|
||||
setFulfillmentFilter,
|
||||
setFiltersOpen,
|
||||
setForm,
|
||||
setOrdering,
|
||||
setPeriodFilter,
|
||||
setDateFromFilter,
|
||||
setDateToFilter,
|
||||
setScope,
|
||||
setSearchQuery,
|
||||
setStatusFilter,
|
||||
@ -416,6 +454,9 @@ export default function ProcurementPage() {
|
||||
markRequestNotificationsRead,
|
||||
toggleItemComments,
|
||||
} = useProcurementPage(user);
|
||||
const processingDepartmentOptions = processingDepartments.length > 0
|
||||
? processingDepartments
|
||||
: departments;
|
||||
|
||||
const toggleStatusFilter = useCallback((status: ProcurementStatus) => {
|
||||
setStatusFilter((current) => (
|
||||
@ -434,6 +475,13 @@ export default function ProcurementPage() {
|
||||
const [statusFilterOpen, setStatusFilterOpen] = useState(false);
|
||||
const [requestActionDialog, setRequestActionDialog] = useState<{ kind: RequestActionDialogKind; requestId: number } | null>(null);
|
||||
const [requestActionComment, setRequestActionComment] = useState("");
|
||||
const [submitApprovalRequestId, setSubmitApprovalRequestId] = useState<number | null>(null);
|
||||
const [submitApprovalOptions, setSubmitApprovalOptions] = useState<ProcurementApprovalOptions | null>(null);
|
||||
const [submitApprovalLoading, setSubmitApprovalLoading] = useState(false);
|
||||
const [submitApprovalError, setSubmitApprovalError] = useState<string | null>(null);
|
||||
const [submitApprovalMode, setSubmitApprovalMode] = useState<SubmitApprovalMode>("auto");
|
||||
const [manualApprovalPriorities, setManualApprovalPriorities] = useState<number[]>([]);
|
||||
const [manualApproverByPriority, setManualApproverByPriority] = useState<Record<number, number | null>>({});
|
||||
|
||||
const statusFilterSummary = useMemo(() => {
|
||||
if (statusFilter.length === 0) return "Все статусы";
|
||||
@ -469,6 +517,11 @@ export default function ProcurementPage() {
|
||||
return detailsCache[requestActionDialog.requestId] || requests.find((request) => request.id === requestActionDialog.requestId) || null;
|
||||
}, [detailsCache, requestActionDialog, requests]);
|
||||
|
||||
const selectedSubmitRequest = useMemo(() => {
|
||||
if (!submitApprovalRequestId) return null;
|
||||
return detailsCache[submitApprovalRequestId] || requests.find((request) => request.id === submitApprovalRequestId) || null;
|
||||
}, [detailsCache, requests, submitApprovalRequestId]);
|
||||
|
||||
const closeDetailModal = useCallback(() => {
|
||||
setDetailModalId(null);
|
||||
setDetailModalError(null);
|
||||
@ -485,6 +538,102 @@ export default function ProcurementPage() {
|
||||
setRequestActionComment("");
|
||||
}, []);
|
||||
|
||||
const closeSubmitApprovalDialog = useCallback(() => {
|
||||
setSubmitApprovalRequestId(null);
|
||||
setSubmitApprovalOptions(null);
|
||||
setSubmitApprovalError(null);
|
||||
setSubmitApprovalLoading(false);
|
||||
setSubmitApprovalMode("auto");
|
||||
setManualApprovalPriorities([]);
|
||||
setManualApproverByPriority({});
|
||||
}, []);
|
||||
|
||||
const openSubmitApprovalDialog = useCallback(async (requestId: number) => {
|
||||
setSubmitApprovalRequestId(requestId);
|
||||
setSubmitApprovalOptions(null);
|
||||
setSubmitApprovalError(null);
|
||||
setSubmitApprovalMode("auto");
|
||||
setManualApprovalPriorities([]);
|
||||
setManualApproverByPriority({});
|
||||
setSubmitApprovalLoading(true);
|
||||
|
||||
try {
|
||||
const options = await apiClient.getProcurementApprovalOptions(requestId);
|
||||
const approverMap = Object.fromEntries(
|
||||
options.available_steps.map((option) => [
|
||||
option.priority,
|
||||
option.approver?.id ?? null,
|
||||
]),
|
||||
) as Record<number, number | null>;
|
||||
setSubmitApprovalOptions(options);
|
||||
setManualApprovalPriorities(options.auto_steps.map((option) => option.priority));
|
||||
setManualApproverByPriority(approverMap);
|
||||
} catch (approvalError) {
|
||||
setSubmitApprovalError(getReadableError(approvalError, "Не удалось загрузить согласующих"));
|
||||
} finally {
|
||||
setSubmitApprovalLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleManualApprovalPriority = useCallback((priority: number) => {
|
||||
setSubmitApprovalMode("manual");
|
||||
setManualApprovalPriorities((current) => (
|
||||
current.includes(priority)
|
||||
? current.filter((item) => item !== priority)
|
||||
: [...current, priority].sort((left, right) => left - right)
|
||||
));
|
||||
}, []);
|
||||
|
||||
const submitApprovalDialog = useCallback(async () => {
|
||||
if (!submitApprovalRequestId) return;
|
||||
|
||||
let approvalSteps: ProcurementApprovalStepSelection[] | undefined;
|
||||
if (submitApprovalMode === "manual") {
|
||||
if (manualApprovalPriorities.length === 0) {
|
||||
setSubmitApprovalError("Выберите хотя бы одного согласующего.");
|
||||
return;
|
||||
}
|
||||
if (!submitApprovalOptions) {
|
||||
setSubmitApprovalError("Не удалось определить список этапов согласования.");
|
||||
return;
|
||||
}
|
||||
|
||||
const optionsByPriority = new Map(
|
||||
submitApprovalOptions.available_steps.map((option) => [option.priority, option]),
|
||||
);
|
||||
approvalSteps = manualApprovalPriorities.map((priority) => {
|
||||
const option = optionsByPriority.get(priority);
|
||||
const approver = manualApproverByPriority[priority];
|
||||
return {
|
||||
priority,
|
||||
approver: approver || 0,
|
||||
step_name: option?.step_name,
|
||||
};
|
||||
});
|
||||
|
||||
const missingStep = approvalSteps.find((step) => !step.approver);
|
||||
if (missingStep) {
|
||||
const option = optionsByPriority.get(missingStep.priority);
|
||||
setSubmitApprovalError(`Выберите согласующего для этапа «${option?.step_name || `Этап ${missingStep.priority}`}».`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitApprovalError(null);
|
||||
const success = await handleSubmit(submitApprovalRequestId, approvalSteps);
|
||||
if (success) {
|
||||
closeSubmitApprovalDialog();
|
||||
}
|
||||
}, [
|
||||
closeSubmitApprovalDialog,
|
||||
handleSubmit,
|
||||
manualApprovalPriorities,
|
||||
manualApproverByPriority,
|
||||
submitApprovalMode,
|
||||
submitApprovalOptions,
|
||||
submitApprovalRequestId,
|
||||
]);
|
||||
|
||||
const submitRequestActionDialog = useCallback(async () => {
|
||||
if (!requestActionDialog) return;
|
||||
|
||||
@ -788,19 +937,123 @@ export default function ProcurementPage() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<select value={urgencyFilter} onChange={(e) => setUrgencyFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все уровни срочности</option>
|
||||
{Object.entries(urgencyMeta).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
<select value={departmentFilter} onChange={(e) => setDepartmentFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все отделы</option>
|
||||
{departments.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</select>
|
||||
<select value={periodFilter} onChange={(e) => setPeriodFilter(e.target.value)} className="app-select rounded-lg px-3 py-2 text-sm">
|
||||
{periodOptions.map((option) => <option key={option.value || "all"} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<label className="min-w-0">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Срочность</span>
|
||||
<select value={urgencyFilter} onChange={(e) => setUrgencyFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все уровни срочности</option>
|
||||
{Object.entries(urgencyMeta).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Статус исполнения</span>
|
||||
<select value={fulfillmentFilter} onChange={(e) => setFulfillmentFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все статусы исполнения</option>
|
||||
{fulfillmentStatusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Отдел-заказчик</span>
|
||||
<select value={departmentFilter} onChange={(e) => setDepartmentFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все отделы-заказчики</option>
|
||||
{departments.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Отдел-исполнитель</span>
|
||||
<select value={processingDepartmentFilter} onChange={(e) => setProcessingDepartmentFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все отделы-исполнители</option>
|
||||
{processingDepartmentOptions.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Заказчик</span>
|
||||
<select value={requestorFilter} onChange={(e) => setRequestorFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все заказчики</option>
|
||||
{employees.map((employee) => (
|
||||
<option key={employee.id} value={employee.id}>
|
||||
{displayUserName(employee)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Исполнитель</span>
|
||||
<select value={executorFilter} onChange={(e) => setExecutorFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
|
||||
<option value="">Все исполнители</option>
|
||||
{employees.map((employee) => (
|
||||
<option key={employee.id} value={employee.id}>
|
||||
{displayUserName(employee)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Быстрый период</span>
|
||||
<select
|
||||
value={periodFilter}
|
||||
onChange={(e) => {
|
||||
setPeriodFilter(e.target.value);
|
||||
if (e.target.value) {
|
||||
setDateFromFilter("");
|
||||
setDateToFilter("");
|
||||
}
|
||||
}}
|
||||
className="app-select w-full rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
{periodOptions.map((option) => <option key={option.value || "all"} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Создано с</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFromFilter}
|
||||
onChange={(e) => {
|
||||
setDateFromFilter(e.target.value);
|
||||
if (e.target.value) setPeriodFilter("");
|
||||
}}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Создано по</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateToFilter}
|
||||
onChange={(e) => {
|
||||
setDateToFilter(e.target.value);
|
||||
if (e.target.value) setPeriodFilter("");
|
||||
}}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{activeFilterCount > 0 && (
|
||||
<button type="button" onClick={() => { setStatusFilter([]); setUrgencyFilter(""); setDepartmentFilter(""); setPeriodFilter(""); }} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium transition">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatusFilter([]);
|
||||
setUrgencyFilter("");
|
||||
setFulfillmentFilter("");
|
||||
setDepartmentFilter("");
|
||||
setProcessingDepartmentFilter("");
|
||||
setRequestorFilter("");
|
||||
setExecutorFilter("");
|
||||
setPeriodFilter("");
|
||||
setDateFromFilter("");
|
||||
setDateToFilter("");
|
||||
}}
|
||||
className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium transition"
|
||||
>
|
||||
Очистить фильтры
|
||||
</button>
|
||||
)}
|
||||
@ -1101,7 +1354,7 @@ export default function ProcurementPage() {
|
||||
isExecutor={isExecutor}
|
||||
isFinal={isFinal}
|
||||
showSecondaryActions={false}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={(id) => void openSubmitApprovalDialog(id)}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
onApprove={(id) => openRequestActionDialog("approve", id)}
|
||||
@ -1189,7 +1442,7 @@ export default function ProcurementPage() {
|
||||
isFinal={isFinal}
|
||||
showSecondaryActions={false}
|
||||
showLabels
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={(id) => void openSubmitApprovalDialog(id)}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
onApprove={handleApprove}
|
||||
@ -1238,7 +1491,7 @@ export default function ProcurementPage() {
|
||||
isExecutor={Boolean(resolveUserId(selectedRequest.executor) && user?.id && resolveUserId(selectedRequest.executor) === user.id)}
|
||||
isFinal={isFinal}
|
||||
showLabels
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={(id) => void openSubmitApprovalDialog(id)}
|
||||
onEdit={openEdit}
|
||||
onDelete={(id) => openRequestActionDialog("delete", id)}
|
||||
onApprove={(id) => openRequestActionDialog("approve", id)}
|
||||
@ -1378,6 +1631,180 @@ export default function ProcurementPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={submitApprovalRequestId !== null}
|
||||
onClose={closeSubmitApprovalDialog}
|
||||
title="Отправить на согласование"
|
||||
size="lg"
|
||||
closeOnClickOutside
|
||||
>
|
||||
<div className="space-y-4 pb-1">
|
||||
{selectedSubmitRequest ? (
|
||||
<div className="app-surface-muted rounded-xl px-4 py-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">{selectedSubmitRequest.title || "Без названия"}</p>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{getDeptName(selectedSubmitRequest)}{getRequestAmount(selectedSubmitRequest) ? ` • ${money(getRequestAmount(selectedSubmitRequest))}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{submitApprovalError || actionError ? (
|
||||
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">
|
||||
{submitApprovalError || actionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{submitApprovalLoading ? (
|
||||
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
|
||||
<Loader2 size={22} className="mx-auto mb-2 animate-spin text-sky-500" />
|
||||
<p className="app-text-muted text-sm">Загрузка согласующих...</p>
|
||||
</div>
|
||||
) : submitApprovalOptions ? (
|
||||
<>
|
||||
<div className="app-surface-muted inline-flex rounded-xl p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubmitApprovalMode("auto")}
|
||||
className={`rounded-lg px-3 py-2 text-xs font-medium transition ${
|
||||
submitApprovalMode === "auto" ? "app-pill-active" : "app-text-muted hover:bg-[var(--surface-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
По сумме
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubmitApprovalMode("manual")}
|
||||
className={`rounded-lg px-3 py-2 text-xs font-medium transition ${
|
||||
submitApprovalMode === "manual" ? "app-pill-active" : "app-text-muted hover:bg-[var(--surface-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
Ручной выбор
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{submitApprovalMode === "auto" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="app-surface-muted rounded-xl px-4 py-3">
|
||||
<p className="app-card-caption">Автоматический маршрут</p>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Этапы рассчитаны по текущей сумме заявки: {money(submitApprovalOptions.total_cost)}
|
||||
</p>
|
||||
</div>
|
||||
{submitApprovalOptions.auto_steps.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{submitApprovalOptions.auto_steps.map((option) => (
|
||||
<div key={option.priority} className="app-surface rounded-xl px-4 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">{option.step_name}</p>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Приоритет {option.priority} • {getApprovalRouteAmountLabel(option)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="app-pill-active rounded-full px-2 py-1 text-[11px] font-medium">
|
||||
По сумме
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-[var(--foreground)]">
|
||||
{displayUserName(option.approver)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-feedback-warning rounded-xl px-4 py-3 text-sm">
|
||||
Для текущей суммы не найден автоматический маршрут.
|
||||
</div>
|
||||
)}
|
||||
{submitApprovalOptions.missing_auto_steps.length > 0 ? (
|
||||
<div className="app-feedback-warning rounded-xl px-4 py-3 text-sm">
|
||||
Не все автоматические этапы можно заполнить. Проверьте настройки маршрутов или используйте ручной выбор.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="app-surface-muted rounded-xl px-4 py-3">
|
||||
<p className="app-card-caption">Ручной маршрут</p>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Выберите нужные этапы и сотрудников. Этот список будет использован вместо автоматического расчёта по сумме.
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-h-[52vh] space-y-2 overflow-y-auto pr-1">
|
||||
{submitApprovalOptions.available_steps.map((option) => {
|
||||
const selected = manualApprovalPriorities.includes(option.priority);
|
||||
return (
|
||||
<div key={option.priority} className={`rounded-xl border px-4 py-3 transition ${
|
||||
selected ? "border-[var(--accent-primary)] bg-[var(--surface-secondary)]" : "border-[var(--border)] bg-[var(--surface-primary)]"
|
||||
}`}>
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => toggleManualApprovalPriority(option.priority)}
|
||||
className="mt-1 h-4 w-4 rounded border-[var(--border-strong)] accent-[var(--accent-primary)]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--foreground)]">{option.step_name}</span>
|
||||
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">
|
||||
Приоритет {option.priority}
|
||||
</span>
|
||||
{option.is_amount_applicable ? (
|
||||
<span className="app-pill-active rounded-full px-2 py-0.5 text-[11px]">
|
||||
По сумме
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="app-text-muted mt-1 block text-xs">
|
||||
{getApprovalRouteAmountLabel(option)}
|
||||
{option.approver ? ` • по умолчанию: ${displayUserName(option.approver)}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{selected ? (
|
||||
<div className="mt-3">
|
||||
<SearchableSelectSingle
|
||||
label="Согласующий"
|
||||
placeholder="Выберите сотрудника..."
|
||||
items={employees.map((employee) => ({ id: employee.id, name: displayUserName(employee) }))}
|
||||
selectedId={manualApproverByPriority[option.priority] ?? null}
|
||||
onSelect={(id) => setManualApproverByPriority((current) => ({
|
||||
...current,
|
||||
[option.priority]: id,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl px-4 py-6 text-center">
|
||||
<p className="app-text-muted text-sm">Согласующие не загружены.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={closeSubmitApprovalDialog} className="app-action-secondary rounded-xl px-4 py-2.5 text-sm font-medium">
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submitApprovalDialog()}
|
||||
disabled={submitApprovalLoading || !submitApprovalOptions || (submitApprovalRequestId !== null && busyKey === `submit-${submitApprovalRequestId}`)}
|
||||
className="app-action-primary rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
Отправить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={requestActionDialog !== null}
|
||||
onClose={closeRequestActionDialog}
|
||||
@ -1538,10 +1965,9 @@ export default function ProcurementPage() {
|
||||
<SearchableSelectSingle
|
||||
label="Отдел-исполнитель *"
|
||||
placeholder="Выберите отдел, который обработает заявку..."
|
||||
items={departments.map((d) => ({ id: d.id, name: d.name }))}
|
||||
items={processingDepartmentOptions.map((d) => ({ id: d.id, name: d.name }))}
|
||||
selectedId={form.processing_department}
|
||||
onSelect={(id) => setForm((f) => ({ ...f, processing_department: id }))}
|
||||
disabled={Boolean(defaultProcessingDepartmentId)}
|
||||
/>
|
||||
|
||||
{/* ── Items ── */}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { X, Search, CheckCircle, AlertCircle, Users, Download } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
@ -46,11 +46,7 @@ export function DocumentAcknowledgementsReport({
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"all" | "acknowledged" | "unacknowledged">("all");
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [documentId, search]);
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await apiClient.getDocumentAcknowledgements(documentId, search || undefined);
|
||||
@ -61,7 +57,11 @@ export function DocumentAcknowledgementsReport({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [documentId, search]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (!data) return;
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
Edit,
|
||||
MessageSquare,
|
||||
Link2,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
import { DocumentAcknowledgement } from "./DocumentAcknowledgement";
|
||||
import { DocumentComments } from "./DocumentComments";
|
||||
@ -63,8 +64,17 @@ export function DocumentDetailModal({
|
||||
onViewReport,
|
||||
onNavigateToRelated,
|
||||
}: DocumentDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>("preview");
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [activeTabState, setActiveTabState] = useState<{ documentId: number | null; value: TabType }>({
|
||||
documentId: null,
|
||||
value: "preview",
|
||||
});
|
||||
const [fullscreenState, setFullscreenState] = useState<{ documentId: number | null; value: boolean }>({
|
||||
documentId: null,
|
||||
value: false,
|
||||
});
|
||||
const documentId = document?.id ?? null;
|
||||
const documentHasFile = Boolean(document?.file_url);
|
||||
const documentRequiresAcknowledgement = Boolean(document?.acknowledgement_required);
|
||||
|
||||
if (!document) return null;
|
||||
|
||||
@ -85,13 +95,26 @@ export function DocumentDetailModal({
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: "preview" as TabType, label: "Предпросмотр", icon: Eye },
|
||||
...(documentHasFile ? [{ id: "preview" as TabType, label: "Предпросмотр", icon: Eye }] : []),
|
||||
{ id: "info" as TabType, label: "Информация", icon: Info },
|
||||
{ id: "activity" as TabType, label: "Активность", icon: Activity },
|
||||
{ id: "acknowledgements" as TabType, label: "Ознакомления", icon: CheckCircle },
|
||||
...(documentRequiresAcknowledgement ? [{ id: "acknowledgements" as TabType, label: "Ознакомления", icon: CheckCircle }] : []),
|
||||
{ id: "comments" as TabType, label: "Комментарии", icon: MessageSquare },
|
||||
{ id: "related" as TabType, label: "Связанные", icon: Link2 },
|
||||
];
|
||||
const defaultTab: TabType = documentHasFile ? "preview" : "info";
|
||||
const activeTab: TabType = activeTabState.documentId === documentId
|
||||
? activeTabState.value
|
||||
: defaultTab;
|
||||
const currentActiveTab = tabs.some((tab) => tab.id === activeTab) ? activeTab : defaultTab;
|
||||
const isFullscreen = fullscreenState.documentId === documentId ? fullscreenState.value : false;
|
||||
const detailTabs = tabs.filter((tab) => tab.id !== "preview");
|
||||
const setDocumentActiveTab = (value: TabType) => {
|
||||
setActiveTabState({ documentId, value });
|
||||
};
|
||||
const setDocumentFullscreen = (value: boolean) => {
|
||||
setFullscreenState({ documentId, value });
|
||||
};
|
||||
|
||||
const renderPreview = () => {
|
||||
if (!document.file_url) {
|
||||
@ -155,7 +178,7 @@ export function DocumentDetailModal({
|
||||
<span className="hidden sm:inline">Скачать</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
onClick={() => setDocumentFullscreen(!isFullscreen)}
|
||||
className="app-action-secondary hidden items-center gap-2 rounded-lg p-2 text-sm font-medium lg:inline-flex"
|
||||
title={isFullscreen ? "Обычный режим" : "Полный экран"}
|
||||
>
|
||||
@ -168,6 +191,12 @@ export function DocumentDetailModal({
|
||||
|
||||
{/* Metadata Row - Always visible */}
|
||||
<div className="app-text-muted flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
|
||||
{document.is_regulation && (
|
||||
<div className="app-selected app-accent-text flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium">
|
||||
<ScrollText size={14} className="shrink-0" />
|
||||
<span>Регламент</span>
|
||||
</div>
|
||||
)}
|
||||
{document.file_name && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive size={14} className="app-text-muted shrink-0" />
|
||||
@ -193,15 +222,15 @@ export function DocumentDetailModal({
|
||||
|
||||
{/* Mobile Tabs Navigation */}
|
||||
<div className="app-divider app-surface-muted shrink-0 border-b px-4 sm:px-6 lg:hidden">
|
||||
<nav className="flex gap-1 overflow-x-auto">
|
||||
<nav className="app-tabs-scroll flex gap-1 overflow-x-auto overflow-y-hidden pb-1">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
const isActive = currentActiveTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex shrink-0 items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition ${
|
||||
onClick={() => setDocumentActiveTab(tab.id)}
|
||||
className={`flex shrink-0 items-center gap-2 whitespace-nowrap border-b-2 px-4 py-3 text-sm font-medium transition ${
|
||||
isActive
|
||||
? "border-[var(--accent-primary)] text-[var(--accent-primary-strong)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:border-[var(--border-strong)] hover:text-[var(--foreground)]"
|
||||
@ -220,24 +249,26 @@ export function DocumentDetailModal({
|
||||
{/* Desktop Layout: Side-by-side */}
|
||||
<div className="hidden h-full lg:flex">
|
||||
{/* Preview Panel (Left) */}
|
||||
<div className={`${isFullscreen ? "w-full" : "w-3/5"} shrink-0 border-r border-[var(--border-subtle)] transition-all`}>
|
||||
{renderPreview()}
|
||||
</div>
|
||||
{documentHasFile && (
|
||||
<div className={`${isFullscreen ? "w-full" : "w-3/5"} shrink-0 border-r border-[var(--border-subtle)] transition-all`}>
|
||||
{renderPreview()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Panel (Right) */}
|
||||
{!isFullscreen && (
|
||||
{!(documentHasFile && isFullscreen) && (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Desktop Tabs */}
|
||||
<div className="app-divider app-surface-muted shrink-0 border-b px-6">
|
||||
<nav className="flex gap-1">
|
||||
{tabs.slice(1).map((tab) => {
|
||||
<nav className="app-tabs-scroll flex gap-1 overflow-x-auto overflow-y-hidden pb-1">
|
||||
{detailTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
const isActive = currentActiveTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition ${
|
||||
onClick={() => setDocumentActiveTab(tab.id)}
|
||||
className={`flex shrink-0 items-center gap-2 whitespace-nowrap border-b-2 px-4 py-3 text-sm font-medium transition ${
|
||||
isActive
|
||||
? "border-[var(--accent-primary)] text-[var(--accent-primary-strong)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:border-[var(--border-strong)] hover:text-[var(--foreground)]"
|
||||
@ -253,7 +284,7 @@ export function DocumentDetailModal({
|
||||
|
||||
{/* Desktop Tab Content */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-[var(--surface-secondary)]">
|
||||
{activeTab === "info" && (
|
||||
{currentActiveTab === "info" && (
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
{/* Description */}
|
||||
@ -423,7 +454,7 @@ export function DocumentDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "activity" && (
|
||||
{currentActiveTab === "activity" && (
|
||||
<div className="p-6">
|
||||
<div className="app-surface rounded-xl p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-[var(--foreground)]">История активности</h3>
|
||||
@ -457,7 +488,7 @@ export function DocumentDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "acknowledgements" && (
|
||||
{currentActiveTab === "acknowledgements" && (
|
||||
<div className="p-6">
|
||||
<div className="app-surface rounded-xl p-6">
|
||||
{document.acknowledgement_required ? (
|
||||
@ -487,7 +518,7 @@ export function DocumentDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "comments" && (
|
||||
{currentActiveTab === "comments" && (
|
||||
<div className="p-6">
|
||||
<div className="app-surface rounded-xl p-6">
|
||||
<DocumentComments documentId={document.id} />
|
||||
@ -495,7 +526,7 @@ export function DocumentDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "related" && (
|
||||
{currentActiveTab === "related" && (
|
||||
<div className="p-6">
|
||||
<div className="app-surface rounded-xl p-6">
|
||||
<DocumentRelated
|
||||
@ -512,11 +543,11 @@ export function DocumentDetailModal({
|
||||
|
||||
{/* Mobile Layout: Tabs */}
|
||||
<div className="h-full overflow-y-auto bg-[var(--surface-secondary)] lg:hidden">
|
||||
{activeTab === "preview" && (
|
||||
{currentActiveTab === "preview" && (
|
||||
<div className="h-full min-h-[400px]">{renderPreview()}</div>
|
||||
)}
|
||||
|
||||
{activeTab === "info" && (
|
||||
{currentActiveTab === "info" && (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="space-y-4">
|
||||
{/* Description */}
|
||||
@ -686,7 +717,7 @@ export function DocumentDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "activity" && (
|
||||
{currentActiveTab === "activity" && (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="app-surface rounded-xl p-4 sm:p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-[var(--foreground)]">История активности</h3>
|
||||
@ -720,7 +751,7 @@ export function DocumentDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "acknowledgements" && (
|
||||
{currentActiveTab === "acknowledgements" && (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="app-surface rounded-xl p-4 sm:p-6">
|
||||
{document.acknowledgement_required ? (
|
||||
@ -750,7 +781,7 @@ export function DocumentDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "comments" && (
|
||||
{currentActiveTab === "comments" && (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="app-surface rounded-xl p-4 sm:p-6">
|
||||
<DocumentComments documentId={document.id} />
|
||||
@ -758,7 +789,7 @@ export function DocumentDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "related" && (
|
||||
{currentActiveTab === "related" && (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="app-surface rounded-xl p-4 sm:p-6">
|
||||
<DocumentRelated
|
||||
|
||||
@ -1,11 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal } from '@/components/ui';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { Folder, Tag as TagIcon, Loader2 } from 'lucide-react';
|
||||
import type { Document } from '@/types/api';
|
||||
import {
|
||||
AlertCircle,
|
||||
Building2,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
Folder,
|
||||
Loader2,
|
||||
ScrollText,
|
||||
Tag as TagIcon,
|
||||
Upload,
|
||||
Users,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type { Department, Document, User } from '@/types/api';
|
||||
import { DocumentTagQuickCreate, type QuickDocumentTag } from './DocumentTagQuickCreate';
|
||||
|
||||
type DocumentTagOption = NonNullable<Document["tags"]>[number];
|
||||
|
||||
interface FolderOption {
|
||||
id: number;
|
||||
name: string;
|
||||
parent_id?: number | null;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface FolderOptionRow {
|
||||
folder: FolderOption;
|
||||
level: number;
|
||||
}
|
||||
|
||||
interface DocumentUpdatePayload {
|
||||
title?: string;
|
||||
description?: string;
|
||||
extracted_text?: string;
|
||||
file?: File;
|
||||
tag_ids?: number[];
|
||||
folder?: number | null;
|
||||
is_regulation?: boolean;
|
||||
sent_to_all?: boolean;
|
||||
acknowledgement_required?: boolean;
|
||||
recipient_ids?: number[];
|
||||
department_ids?: number[];
|
||||
}
|
||||
|
||||
interface DocumentMetadataEditorProps {
|
||||
isOpen: boolean;
|
||||
@ -14,208 +55,596 @@ interface DocumentMetadataEditorProps {
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
type ListResponse<T> = T[] | { results?: T[] };
|
||||
|
||||
function normalizeList<T>(response: ListResponse<T>): T[] {
|
||||
return Array.isArray(response) ? response : response.results || [];
|
||||
}
|
||||
|
||||
function arraysEqual(a: number[], b: number[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort((left, right) => left - right);
|
||||
const sortedB = [...b].sort((left, right) => left - right);
|
||||
return sortedA.every((value, index) => value === sortedB[index]);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} Б`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} МБ`;
|
||||
}
|
||||
|
||||
function getEmployeeLabel(employee: User): string {
|
||||
const name = [employee.last_name, employee.first_name, employee.patronymic]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
return employee.email ? `${name || employee.email} (${employee.email})` : name || `ID ${employee.id}`;
|
||||
}
|
||||
|
||||
function buildFolderOptionRows(folders: FolderOption[]): FolderOptionRow[] {
|
||||
const childrenByParent = new Map<number | null, FolderOption[]>();
|
||||
|
||||
folders.forEach((folder) => {
|
||||
const parentId = folder.parent_id ?? null;
|
||||
const children = childrenByParent.get(parentId) || [];
|
||||
children.push(folder);
|
||||
childrenByParent.set(parentId, children);
|
||||
});
|
||||
|
||||
childrenByParent.forEach((children) => {
|
||||
children.sort((a, b) => a.name.localeCompare(b.name, 'ru'));
|
||||
});
|
||||
|
||||
const rows: FolderOptionRow[] = [];
|
||||
const appendChildren = (parentId: number | null, level: number) => {
|
||||
(childrenByParent.get(parentId) || []).forEach((folder) => {
|
||||
rows.push({ folder, level });
|
||||
appendChildren(folder.id, level + 1);
|
||||
});
|
||||
};
|
||||
|
||||
appendChildren(null, 0);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function DocumentMetadataEditor({
|
||||
isOpen,
|
||||
onClose,
|
||||
document,
|
||||
onUpdate,
|
||||
}: DocumentMetadataEditorProps) {
|
||||
// Form state
|
||||
const [title, setTitle] = useState(document.title || '');
|
||||
const [description, setDescription] = useState(document.description || '');
|
||||
const [extractedText, setExtractedText] = useState(document.extracted_text || '');
|
||||
const [replacementFile, setReplacementFile] = useState<File | null>(null);
|
||||
const [selectedTags, setSelectedTags] = useState<number[]>([]);
|
||||
const [selectedFolder, setSelectedFolder] = useState<number | null>(document.folder?.id || null);
|
||||
const [isRegulation, setIsRegulation] = useState(Boolean(document.is_regulation));
|
||||
const [sentToAll, setSentToAll] = useState(Boolean(document.sent_to_all ?? true));
|
||||
const [acknowledgementRequired, setAcknowledgementRequired] = useState(Boolean(document.acknowledgement_required));
|
||||
const [selectedDepartments, setSelectedDepartments] = useState<number[]>([]);
|
||||
const [selectedRecipients, setSelectedRecipients] = useState<number[]>([]);
|
||||
|
||||
// Data lists
|
||||
const [documentTags, setDocumentTags] = useState<any[]>([]);
|
||||
const [folders, setFolders] = useState<any[]>([]);
|
||||
const [documentTags, setDocumentTags] = useState<DocumentTagOption[]>([]);
|
||||
const [folders, setFolders] = useState<FolderOption[]>([]);
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [employees, setEmployees] = useState<User[]>([]);
|
||||
|
||||
// Loading states
|
||||
const [loadingTags, setLoadingTags] = useState(false);
|
||||
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||
const [loadingReferences, setLoadingReferences] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load current document tags
|
||||
const folderOptionRows = useMemo(() => buildFolderOptionRows(folders), [folders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && document) {
|
||||
// Load current tags
|
||||
if (document.tags && Array.isArray(document.tags)) {
|
||||
setSelectedTags(document.tags.map((t: any) => t.id));
|
||||
}
|
||||
}
|
||||
if (!isOpen) return;
|
||||
|
||||
setTitle(document.title || '');
|
||||
setDescription(document.description || '');
|
||||
setExtractedText(document.extracted_text || '');
|
||||
setReplacementFile(null);
|
||||
setSelectedTags((document.tags || []).map((tag) => tag.id));
|
||||
setSelectedFolder(document.folder?.id || null);
|
||||
setIsRegulation(Boolean(document.is_regulation));
|
||||
setSentToAll(Boolean(document.sent_to_all ?? true));
|
||||
setAcknowledgementRequired(Boolean(document.acknowledgement_required));
|
||||
setSelectedDepartments((document.departments || []).map((department) => department.id));
|
||||
setSelectedRecipients((document.recipients || []).map((recipient) => recipient.id));
|
||||
setError(null);
|
||||
}, [isOpen, document]);
|
||||
|
||||
// Load reference data
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadAllData();
|
||||
}
|
||||
if (!isOpen) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadReferenceData = async () => {
|
||||
setLoadingReferences(true);
|
||||
try {
|
||||
const [tagsResponse, foldersResponse, departmentsResponse, employeesResponse] = await Promise.all([
|
||||
apiClient.getDocumentTags({ limit: 1000 }),
|
||||
apiClient.getFolders({ limit: 1000 }),
|
||||
apiClient.getDepartments({ limit: 1000 }),
|
||||
apiClient.getEmployees({ limit: 1000, is_active: true, ordering: 'last_name,first_name' }),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setDocumentTags(normalizeList<DocumentTagOption>(tagsResponse));
|
||||
setFolders(normalizeList<FolderOption>(foldersResponse));
|
||||
setDepartments(normalizeList<Department>(departmentsResponse));
|
||||
setEmployees(normalizeList<User>(employeesResponse));
|
||||
} catch (err) {
|
||||
console.error('Error loading document edit references:', err);
|
||||
if (!cancelled) {
|
||||
toast.error('Не удалось загрузить справочники для редактирования');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingReferences(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadReferenceData();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const loadAllData = async () => {
|
||||
// Load tags
|
||||
try {
|
||||
setLoadingTags(true);
|
||||
const tagsResponse = await apiClient.getDocumentTags();
|
||||
setDocumentTags(tagsResponse.results || tagsResponse);
|
||||
} catch (err) {
|
||||
console.error('Error loading tags:', err);
|
||||
} finally {
|
||||
setLoadingTags(false);
|
||||
}
|
||||
|
||||
// Load folders
|
||||
try {
|
||||
setLoadingFolders(true);
|
||||
const foldersResponse = await apiClient.getFolders({});
|
||||
setFolders(foldersResponse.results || foldersResponse);
|
||||
} catch (err) {
|
||||
console.error('Error loading folders:', err);
|
||||
} finally {
|
||||
setLoadingFolders(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Prepare update data
|
||||
const updateData: any = {};
|
||||
|
||||
// Tags
|
||||
const currentTagIds = (document.tags || []).map((t: any) => t.id).sort();
|
||||
const newTagIds = [...selectedTags].sort();
|
||||
if (JSON.stringify(currentTagIds) !== JSON.stringify(newTagIds)) {
|
||||
updateData.tag_ids = selectedTags;
|
||||
}
|
||||
|
||||
// Folder
|
||||
if (selectedFolder !== (document.folder?.id || null)) {
|
||||
updateData.folder = selectedFolder;
|
||||
}
|
||||
|
||||
// If nothing changed
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
toast.info('Нет изменений для сохранения');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send update request
|
||||
await apiClient.updateDocument(document.id, updateData);
|
||||
|
||||
toast.success('Метаданные документа обновлены');
|
||||
onUpdate?.();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
console.error('Error updating document metadata:', err);
|
||||
const errorMsg = err.response?.data?.detail || 'Не удалось обновить метаданные';
|
||||
setError(errorMsg);
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTag = (tagId: number) => {
|
||||
setSelectedTags((prev) =>
|
||||
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreatedTag = (tag: QuickDocumentTag) => {
|
||||
setDocumentTags((prev) => {
|
||||
const next = prev.some((item) => item.id === tag.id) ? prev : [...prev, tag];
|
||||
return [...next].sort((left, right) => left.name.localeCompare(right.name, 'ru'));
|
||||
});
|
||||
setSelectedTags((prev) => (prev.includes(tag.id) ? prev : [...prev, tag.id]));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const nextTitle = title.trim();
|
||||
setError(null);
|
||||
|
||||
if (!nextTitle) {
|
||||
setError('Укажите название документа');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sentToAll && selectedDepartments.length === 0 && selectedRecipients.length === 0) {
|
||||
setError('Выберите хотя бы один отдел или сотрудника либо включите доступ для всей компании');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const updateData: DocumentUpdatePayload = {};
|
||||
const currentTagIds = (document.tags || []).map((tag) => tag.id);
|
||||
const currentDepartmentIds = (document.departments || []).map((department) => department.id);
|
||||
const currentRecipientIds = (document.recipients || []).map((recipient) => recipient.id);
|
||||
const currentFolder = document.folder?.id || null;
|
||||
const currentSentToAll = Boolean(document.sent_to_all ?? true);
|
||||
|
||||
if (nextTitle !== document.title) {
|
||||
updateData.title = nextTitle;
|
||||
}
|
||||
|
||||
if (description !== (document.description || '')) {
|
||||
updateData.description = description;
|
||||
}
|
||||
|
||||
if (extractedText !== (document.extracted_text || '')) {
|
||||
updateData.extracted_text = extractedText;
|
||||
}
|
||||
|
||||
if (replacementFile) {
|
||||
updateData.file = replacementFile;
|
||||
}
|
||||
|
||||
if (selectedFolder !== currentFolder) {
|
||||
updateData.folder = selectedFolder;
|
||||
}
|
||||
|
||||
if (isRegulation !== Boolean(document.is_regulation)) {
|
||||
updateData.is_regulation = isRegulation;
|
||||
}
|
||||
|
||||
if (sentToAll !== currentSentToAll) {
|
||||
updateData.sent_to_all = sentToAll;
|
||||
}
|
||||
|
||||
if (acknowledgementRequired !== Boolean(document.acknowledgement_required)) {
|
||||
updateData.acknowledgement_required = acknowledgementRequired;
|
||||
}
|
||||
|
||||
if (!arraysEqual(currentTagIds, selectedTags)) {
|
||||
updateData.tag_ids = selectedTags;
|
||||
}
|
||||
|
||||
if (!sentToAll) {
|
||||
if (sentToAll !== currentSentToAll || !arraysEqual(currentDepartmentIds, selectedDepartments)) {
|
||||
updateData.department_ids = selectedDepartments;
|
||||
}
|
||||
|
||||
if (sentToAll !== currentSentToAll || !arraysEqual(currentRecipientIds, selectedRecipients)) {
|
||||
updateData.recipient_ids = selectedRecipients;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
toast.info('Нет изменений для сохранения');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
await apiClient.updateDocument(document.id, updateData);
|
||||
|
||||
toast.success('Документ обновлён');
|
||||
onUpdate?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Error updating document:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Не удалось обновить документ';
|
||||
setError(errorMsg);
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Редактирование метаданных"
|
||||
size="lg"
|
||||
title="Редактирование документа"
|
||||
size="xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
{error && (
|
||||
<div className="app-feedback-danger rounded-lg p-3">
|
||||
<div className="app-feedback-danger flex items-start gap-2 rounded-lg p-3">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document info */}
|
||||
<div className="app-surface-muted rounded-lg p-3">
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">{document.title}</p>
|
||||
<p className="app-text-muted mt-1 text-xs">ID: {document.id}</p>
|
||||
</div>
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--foreground)]">
|
||||
<FileText className="h-4 w-4" />
|
||||
Основное
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="mb-2 flex items-center gap-2 text-sm font-medium text-[var(--foreground)]">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
Теги
|
||||
</label>
|
||||
{loadingTags ? (
|
||||
<div className="app-surface flex items-center justify-center rounded-md py-8">
|
||||
<Loader2 className="app-text-muted h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : documentTags.length === 0 ? (
|
||||
<div className="app-surface-muted app-text-muted rounded-md p-3 text-center text-sm">
|
||||
Нет доступных тегов
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-surface max-h-48 space-y-2 overflow-y-auto rounded-md p-3">
|
||||
{documentTags.map((tag) => (
|
||||
<label
|
||||
key={tag.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded p-2 transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTags.includes(tag.id)}
|
||||
onChange={() => toggleTag(tag.id)}
|
||||
disabled={isSaving}
|
||||
className="h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)]"
|
||||
/>
|
||||
<span className="text-sm text-[var(--foreground)]">{tag.name}</span>
|
||||
{tag.color && (
|
||||
<span
|
||||
className="ml-auto h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(240px,320px)]">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="documentEditTitle" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
id="documentEditTitle"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
disabled={isSaving}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="Название документа"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="documentEditDescription" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
id="documentEditDescription"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
disabled={isSaving}
|
||||
rows={4}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="Описание документа"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-surface-muted rounded-lg p-3 text-sm">
|
||||
<p className="font-medium text-[var(--foreground)]">ID: {document.id}</p>
|
||||
<div className="app-text-muted mt-2 space-y-1 text-xs">
|
||||
<p>Текущий файл: {document.file_name || 'не прикреплён'}</p>
|
||||
{document.file_size ? <p>Размер: {formatFileSize(document.file_size)}</p> : null}
|
||||
{document.folder_path ? <p>Папка: {document.folder_path}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--foreground)]">
|
||||
<Upload className="h-4 w-4" />
|
||||
Файл документа
|
||||
</div>
|
||||
|
||||
<div className="app-surface-muted rounded-lg p-3">
|
||||
<label
|
||||
htmlFor="documentEditFile"
|
||||
className="app-action-secondary inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Выбрать новый файл
|
||||
</label>
|
||||
<input
|
||||
id="documentEditFile"
|
||||
type="file"
|
||||
onChange={(event) => setReplacementFile(event.target.files?.[0] || null)}
|
||||
disabled={isSaving}
|
||||
className="sr-only"
|
||||
/>
|
||||
<p className="app-text-muted mt-2 text-xs">
|
||||
Если файл не выбрать, текущий файл останется без изменений.
|
||||
</p>
|
||||
|
||||
{replacementFile && (
|
||||
<div className="app-selected app-accent-text mt-3 flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm">
|
||||
<span className="min-w-0 truncate">
|
||||
Новый файл: {replacementFile.name} ({formatFileSize(replacementFile.size)})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplacementFile(null)}
|
||||
disabled={isSaving}
|
||||
className="app-action-secondary inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
|
||||
title="Убрать выбранный файл"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="documentEditExtractedText" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
Текст для поиска
|
||||
</label>
|
||||
<textarea
|
||||
id="documentEditExtractedText"
|
||||
value={extractedText}
|
||||
onChange={(event) => setExtractedText(event.target.value)}
|
||||
disabled={isSaving}
|
||||
rows={5}
|
||||
className="app-input w-full rounded-lg px-3 py-2 font-mono text-sm"
|
||||
placeholder="Распознанный или вручную добавленный текст документа"
|
||||
/>
|
||||
<p className="app-text-muted mt-1 text-xs">Символов: {extractedText.length}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--foreground)]">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
Категоризация
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="documentEditFolder" className="mb-1.5 flex items-center gap-2 text-sm font-medium text-[var(--foreground)]">
|
||||
<Folder className="h-4 w-4" />
|
||||
Папка
|
||||
</label>
|
||||
<select
|
||||
id="documentEditFolder"
|
||||
value={selectedFolder || ''}
|
||||
onChange={(event) => setSelectedFolder(event.target.value ? Number(event.target.value) : null)}
|
||||
disabled={loadingReferences || isSaving}
|
||||
className="app-select w-full rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Без папки (корень)</option>
|
||||
{folderOptionRows.map(({ folder, level }) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{`${'— '.repeat(level)}${folder.name}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div id="document-edit-tags-label" className="mb-1.5 flex items-center gap-2 text-sm font-medium text-[var(--foreground)]">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
Теги
|
||||
</div>
|
||||
<DocumentTagQuickCreate
|
||||
existingTags={documentTags}
|
||||
disabled={loadingReferences || isSaving}
|
||||
onCreated={handleCreatedTag}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="document-edit-tags-label"
|
||||
className="app-surface-muted flex min-h-12 flex-wrap items-center gap-2 rounded-lg p-2"
|
||||
>
|
||||
{loadingReferences ? (
|
||||
<span className="app-text-muted px-1 text-sm">Загрузка тегов...</span>
|
||||
) : documentTags.length === 0 ? (
|
||||
<span className="app-text-muted px-1 text-sm">Нет доступных тегов</span>
|
||||
) : (
|
||||
documentTags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
disabled={isSaving}
|
||||
className={`inline-flex max-w-full items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition disabled:opacity-50 ${
|
||||
selectedTags.includes(tag.id)
|
||||
? 'app-badge app-badge-accent'
|
||||
: 'app-badge hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]'
|
||||
}`}
|
||||
aria-pressed={selectedTags.includes(tag.id)}
|
||||
>
|
||||
{tag.color && (
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{tag.name}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<p className="app-text-muted mt-1 text-xs">Выбрано: {selectedTags.length}</p>
|
||||
</div>
|
||||
|
||||
<div className="app-surface-muted flex items-start gap-3 rounded-lg p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="metadataIsRegulation"
|
||||
checked={isRegulation}
|
||||
onChange={(event) => setIsRegulation(event.target.checked)}
|
||||
disabled={isSaving}
|
||||
className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)] disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="metadataIsRegulation" className="block cursor-pointer text-sm font-medium text-[var(--foreground)]">
|
||||
<ScrollText className="mr-1 inline h-4 w-4" />
|
||||
Регламент
|
||||
</label>
|
||||
<p className="app-text-muted mt-0.5 text-xs">
|
||||
Документ будет отображаться в отдельном разделе регламентов.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--foreground)]">
|
||||
<Users className="h-4 w-4" />
|
||||
Доступ и ознакомление
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<div className="app-surface-muted flex items-start gap-3 rounded-lg p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="metadataSentToAll"
|
||||
checked={sentToAll}
|
||||
onChange={(event) => {
|
||||
setSentToAll(event.target.checked);
|
||||
if (event.target.checked) {
|
||||
setSelectedDepartments([]);
|
||||
setSelectedRecipients([]);
|
||||
}
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)] disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="metadataSentToAll" className="block cursor-pointer text-sm font-medium text-[var(--foreground)]">
|
||||
Для всей компании
|
||||
</label>
|
||||
<p className="app-text-muted mt-0.5 text-xs">Документ доступен всем активным сотрудникам.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-surface-muted flex items-start gap-3 rounded-lg p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="metadataAcknowledgementRequired"
|
||||
checked={acknowledgementRequired}
|
||||
onChange={(event) => setAcknowledgementRequired(event.target.checked)}
|
||||
disabled={isSaving}
|
||||
className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)] disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="metadataAcknowledgementRequired" className="block cursor-pointer text-sm font-medium text-[var(--foreground)]">
|
||||
<CheckCircle className="mr-1 inline h-4 w-4" />
|
||||
Требуется ознакомление
|
||||
</label>
|
||||
<p className="app-text-muted mt-0.5 text-xs">Получатели должны подтвердить прочтение документа.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!sentToAll && (
|
||||
<div className="app-surface-muted grid grid-cols-1 gap-4 rounded-lg p-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="metadataDepartments" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
<Building2 className="mr-1 inline h-4 w-4" />
|
||||
Отделы
|
||||
</label>
|
||||
<select
|
||||
id="metadataDepartments"
|
||||
multiple
|
||||
value={selectedDepartments.map(String)}
|
||||
onChange={(event) => {
|
||||
const values = Array.from(event.target.selectedOptions, (option) => Number(option.value));
|
||||
setSelectedDepartments(values);
|
||||
}}
|
||||
disabled={loadingReferences || isSaving}
|
||||
className="app-select w-full rounded-lg px-3 py-2 text-sm"
|
||||
size={7}
|
||||
>
|
||||
{departments.length === 0 ? (
|
||||
<option disabled>Нет доступных отделов</option>
|
||||
) : (
|
||||
departments.map((department) => (
|
||||
<option key={department.id} value={department.id}>
|
||||
{department.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<p className="app-text-muted mt-1 text-xs">Удерживайте Ctrl/Cmd для выбора нескольких.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="metadataRecipients" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
<Users className="mr-1 inline h-4 w-4" />
|
||||
Сотрудники
|
||||
</label>
|
||||
<select
|
||||
id="metadataRecipients"
|
||||
multiple
|
||||
value={selectedRecipients.map(String)}
|
||||
onChange={(event) => {
|
||||
const values = Array.from(event.target.selectedOptions, (option) => Number(option.value));
|
||||
setSelectedRecipients(values);
|
||||
}}
|
||||
disabled={loadingReferences || isSaving}
|
||||
className="app-select w-full rounded-lg px-3 py-2 text-sm"
|
||||
size={7}
|
||||
>
|
||||
{employees.length === 0 ? (
|
||||
<option disabled>Нет доступных сотрудников</option>
|
||||
) : (
|
||||
employees.map((employee) => (
|
||||
<option key={employee.id} value={employee.id}>
|
||||
{getEmployeeLabel(employee)}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<p className="app-text-muted mt-1 text-xs">Можно выбрать сотрудников напрямую, отделы или оба варианта.</p>
|
||||
</div>
|
||||
|
||||
{selectedDepartments.length === 0 && selectedRecipients.length === 0 && (
|
||||
<div className="app-feedback-warning flex items-start gap-2 rounded-lg p-3 text-xs lg:col-span-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>Выберите хотя бы один отдел или сотрудника.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Выбрано: {selectedTags.length} {selectedTags.length === 1 ? 'тег' : 'тегов'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Folder */}
|
||||
<div>
|
||||
<label className="mb-2 flex items-center gap-2 text-sm font-medium text-[var(--foreground)]">
|
||||
<Folder className="h-4 w-4" />
|
||||
Папка
|
||||
</label>
|
||||
<select
|
||||
value={selectedFolder || ''}
|
||||
onChange={(e) => setSelectedFolder(e.target.value ? Number(e.target.value) : null)}
|
||||
disabled={loadingFolders || isSaving}
|
||||
className="app-select w-full rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Без папки (корень)</option>
|
||||
{folders.map((folder) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{folder.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="app-divider flex items-center justify-end gap-3 border-t pt-4">
|
||||
<div className="app-divider sticky bottom-0 z-10 -mx-4 flex items-center justify-end gap-3 border-t bg-[var(--surface-elevated)] px-4 pt-4 sm:-mx-6 sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className="app-action-secondary rounded-md px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="app-action-secondary rounded-lg px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
@ -223,7 +652,7 @@ export function DocumentMetadataEditor({
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="app-action-primary flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="app-action-primary flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
|
||||
155
frontend/src/components/documents/DocumentTagQuickCreate.tsx
Normal file
155
frontend/src/components/documents/DocumentTagQuickCreate.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Loader2, Plus, X } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface QuickDocumentTag {
|
||||
id: number;
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface DocumentTagQuickCreateProps {
|
||||
existingTags: QuickDocumentTag[];
|
||||
disabled?: boolean;
|
||||
onCreated: (tag: QuickDocumentTag) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TAG_COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#64748B'];
|
||||
|
||||
export function DocumentTagQuickCreate({
|
||||
existingTags,
|
||||
disabled = false,
|
||||
onCreated,
|
||||
className = '',
|
||||
}: DocumentTagQuickCreateProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState(TAG_COLORS[0]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setColor(TAG_COLORS[0]);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const normalizedName = name.trim();
|
||||
|
||||
if (!normalizedName) {
|
||||
toast.error('Введите название тега');
|
||||
return;
|
||||
}
|
||||
|
||||
const isDuplicate = existingTags.some(
|
||||
(tag) => tag.name.trim().toLowerCase() === normalizedName.toLowerCase()
|
||||
);
|
||||
if (isDuplicate) {
|
||||
toast.error('Тег с таким названием уже существует');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const created = await apiClient.createDocumentTag({
|
||||
name: normalizedName,
|
||||
color,
|
||||
});
|
||||
onCreated(created);
|
||||
resetForm();
|
||||
toast.success('Тег создан');
|
||||
} catch (error) {
|
||||
console.error('Error creating document tag:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Не удалось создать тег');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
disabled={disabled}
|
||||
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Создать тег
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={`app-surface-muted grid grid-cols-1 gap-3 rounded-lg p-3 sm:grid-cols-[minmax(0,1fr)_auto] ${className}`}
|
||||
>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
disabled={disabled || isCreating}
|
||||
maxLength={100}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="Название нового тега"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(event) => setColor(event.target.value)}
|
||||
disabled={disabled || isCreating}
|
||||
className="h-8 w-10 cursor-pointer rounded-lg border border-[var(--border-strong)] bg-transparent p-1 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Цвет тега"
|
||||
/>
|
||||
{TAG_COLORS.map((tagColor) => (
|
||||
<button
|
||||
key={tagColor}
|
||||
type="button"
|
||||
onClick={() => setColor(tagColor)}
|
||||
disabled={disabled || isCreating}
|
||||
className={`h-7 w-7 rounded-full border transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
color.toLowerCase() === tagColor.toLowerCase()
|
||||
? 'border-[var(--accent-primary)] ring-2 ring-[color:var(--accent-soft-strong)]'
|
||||
: 'border-[var(--border-strong)]'
|
||||
}`}
|
||||
style={{ backgroundColor: tagColor }}
|
||||
title={tagColor}
|
||||
aria-label={`Выбрать цвет ${tagColor}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || isCreating || !name.trim()}
|
||||
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
Добавить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
disabled={isCreating}
|
||||
className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Отмена"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -12,11 +12,13 @@ import {
|
||||
Users,
|
||||
Building2,
|
||||
CheckCircle,
|
||||
ScrollText,
|
||||
Tag as TagIcon,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { processDocument, needsProcessing, type ProcessingProgress } from "@/lib/document-utils";
|
||||
import { DocumentTagQuickCreate, type QuickDocumentTag } from "./DocumentTagQuickCreate";
|
||||
|
||||
interface Department {
|
||||
id: number;
|
||||
@ -143,6 +145,7 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
// Metadata fields
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(currentFolderId ?? null);
|
||||
const [selectedTags, setSelectedTags] = useState<number[]>([]);
|
||||
const [isRegulation, setIsRegulation] = useState(false);
|
||||
|
||||
// Data for selects
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
@ -323,15 +326,18 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreatedTag = (tag: QuickDocumentTag) => {
|
||||
setDocumentTags((prev) => {
|
||||
const next = prev.some((item) => item.id === tag.id) ? prev : [...prev, tag];
|
||||
return [...next].sort((left, right) => left.name.localeCompare(right.name, "ru"));
|
||||
});
|
||||
setSelectedTags((prev) => (prev.includes(tag.id) ? prev : [...prev, tag.id]));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (uploadItems.length === 0) {
|
||||
setError("Выберите хотя бы один файл");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate recipients if sent_to_all is false
|
||||
if (!sentToAll && selectedDepartments.length === 0 && selectedRecipients.length === 0) {
|
||||
setError("Укажите получателей или отделы, либо выберите 'Отправить всем'");
|
||||
@ -341,28 +347,45 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
for (const item of uploadItems) {
|
||||
const documentTitle =
|
||||
uploadItems.length === 1
|
||||
? title.trim() || getDefaultDocumentTitle(item.file.name)
|
||||
: getDefaultDocumentTitle(item.file.name);
|
||||
|
||||
if (uploadItems.length === 0) {
|
||||
await apiClient.createDocument({
|
||||
title: documentTitle,
|
||||
title: title.trim() || undefined,
|
||||
description,
|
||||
file: getFileForUpload(item),
|
||||
extracted_text: item.extractedText || undefined,
|
||||
folder_id: selectedFolderId ?? undefined,
|
||||
sent_to_all: sentToAll,
|
||||
is_regulation: isRegulation,
|
||||
department_ids: sentToAll ? undefined : selectedDepartments,
|
||||
recipient_ids: sentToAll ? undefined : selectedRecipients,
|
||||
acknowledgement_required: acknowledgementRequired,
|
||||
tag_ids: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
});
|
||||
} else {
|
||||
for (const item of uploadItems) {
|
||||
const documentTitle =
|
||||
uploadItems.length === 1
|
||||
? title.trim() || getDefaultDocumentTitle(item.file.name)
|
||||
: getDefaultDocumentTitle(item.file.name);
|
||||
|
||||
await apiClient.createDocument({
|
||||
title: documentTitle,
|
||||
description,
|
||||
file: getFileForUpload(item),
|
||||
extracted_text: item.extractedText || undefined,
|
||||
folder_id: selectedFolderId ?? undefined,
|
||||
sent_to_all: sentToAll,
|
||||
is_regulation: isRegulation,
|
||||
department_ids: sentToAll ? undefined : selectedDepartments,
|
||||
recipient_ids: sentToAll ? undefined : selectedRecipients,
|
||||
acknowledgement_required: acknowledgementRequired,
|
||||
tag_ids: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(
|
||||
uploadItems.length > 1
|
||||
uploadItems.length === 0
|
||||
? "Документ успешно создан"
|
||||
: uploadItems.length > 1
|
||||
? `${uploadItems.length} документов успешно загружено`
|
||||
: "Документ успешно загружен"
|
||||
);
|
||||
@ -373,6 +396,7 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
setUploadItems([]);
|
||||
setSelectedFolderId(currentFolderId ?? null);
|
||||
setSentToAll(true);
|
||||
setIsRegulation(false);
|
||||
setAcknowledgementRequired(false);
|
||||
setSelectedDepartments([]);
|
||||
setSelectedRecipients([]);
|
||||
@ -402,7 +426,7 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
Файлы <span className="text-red-500">*</span>
|
||||
Файлы
|
||||
</label>
|
||||
|
||||
<div
|
||||
@ -423,7 +447,7 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
: "Перетащите файлы или нажмите для выбора"}
|
||||
</p>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Можно выбрать несколько файлов любых форматов
|
||||
Можно выбрать несколько файлов любых форматов или создать документ без файла
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -516,11 +540,13 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="По умолчанию — название файла"
|
||||
placeholder={uploadItems.length === 0 ? "Название документа" : "По умолчанию — название файла"}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Поле необязательное. Если оставить пустым, будет использовано имя файла без расширения.
|
||||
{uploadItems.length === 0
|
||||
? 'Поле необязательное. Если оставить пустым, будет использовано название "Документ".'
|
||||
: "Поле необязательное. Если оставить пустым, будет использовано имя файла без расширения."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -585,9 +611,15 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<div id="document-upload-tags-label" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
<div id="document-upload-tags-label" className="mb-1.5 text-sm font-medium text-[var(--foreground)]">
|
||||
Теги
|
||||
</div>
|
||||
<DocumentTagQuickCreate
|
||||
existingTags={documentTags}
|
||||
disabled={loadingDocumentTags || isSubmitting}
|
||||
onCreated={handleCreatedTag}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="document-upload-tags-label"
|
||||
@ -636,6 +668,26 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Regulation flag */}
|
||||
<div className="app-surface-muted flex items-start gap-3 rounded-lg p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isRegulation"
|
||||
checked={isRegulation}
|
||||
onChange={(e) => setIsRegulation(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)]"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="isRegulation" className="block cursor-pointer text-sm font-medium text-[var(--foreground)]">
|
||||
<ScrollText size={14} className="mr-1 inline" />
|
||||
Регламент
|
||||
</label>
|
||||
<p className="app-text-muted mt-0.5 text-xs">
|
||||
Документ будет отображаться в отдельном разделе регламентов.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Извлеченный текст */}
|
||||
{uploadItems.length === 1 && uploadItems[0].extractedText && (
|
||||
<div>
|
||||
@ -806,16 +858,20 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
|
||||
<div className="app-divider sticky bottom-0 z-10 -mx-4 flex gap-3 border-t bg-[var(--surface-elevated)] px-4 py-3 sm:-mx-6 sm:px-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isProcessing || uploadItems.length === 0}
|
||||
disabled={isSubmitting || isProcessing}
|
||||
className="app-action-primary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting
|
||||
? "Загрузка..."
|
||||
? uploadItems.length === 0
|
||||
? "Создание..."
|
||||
: "Загрузка..."
|
||||
: isProcessing
|
||||
? "Обработка..."
|
||||
: uploadItems.length > 1
|
||||
? `Загрузить ${uploadItems.length} документов`
|
||||
: "Загрузить документ"}
|
||||
: uploadItems.length === 1
|
||||
? "Загрузить документ"
|
||||
: "Создать документ"}
|
||||
</button>
|
||||
|
||||
{onCancel && (
|
||||
|
||||
@ -9,6 +9,7 @@ import { displayUserName, extractNextPage, loadAllPages } from "@/lib/shared";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
Department,
|
||||
ProcurementApprovalStepSelection,
|
||||
ProcurementComment,
|
||||
ProcurementItem,
|
||||
ProcurementItemComment,
|
||||
@ -144,6 +145,9 @@ export function useProcurementPage(user: User | null) {
|
||||
|
||||
const [requests, setRequests] = useState<ProcurementRequest[]>([]);
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [processingDepartments, setProcessingDepartments] = useState<Department[]>([]);
|
||||
const [defaultProcessingDepartmentId, setDefaultProcessingDepartmentId] = useState<number | null>(null);
|
||||
const [employees, setEmployees] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -166,7 +170,13 @@ export function useProcurementPage(user: User | null) {
|
||||
const [statusFilter, setStatusFilter] = useState<ProcurementStatus[]>([]);
|
||||
const [urgencyFilter, setUrgencyFilter] = useState("");
|
||||
const [departmentFilter, setDepartmentFilter] = useState("");
|
||||
const [processingDepartmentFilter, setProcessingDepartmentFilter] = useState("");
|
||||
const [requestorFilter, setRequestorFilter] = useState("");
|
||||
const [executorFilter, setExecutorFilter] = useState("");
|
||||
const [fulfillmentFilter, setFulfillmentFilter] = useState("");
|
||||
const [periodFilter, setPeriodFilter] = useState("");
|
||||
const [dateFromFilter, setDateFromFilter] = useState("");
|
||||
const [dateToFilter, setDateToFilter] = useState("");
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [ordering, setOrdering] = useState("-created_at");
|
||||
const [activeSection, setActiveSection] = useState<ProcurementSection>("requests");
|
||||
@ -238,11 +248,30 @@ export function useProcurementPage(user: User | null) {
|
||||
else if (statusFilter.length > 1) params.status__in = statusFilter.join(",");
|
||||
if (urgencyFilter) params.urgency = urgencyFilter;
|
||||
if (departmentFilter) params.department = departmentFilter;
|
||||
if (processingDepartmentFilter) params.processing_department = processingDepartmentFilter;
|
||||
if (requestorFilter) params.requestor = requestorFilter;
|
||||
if (executorFilter) params.executor = executorFilter;
|
||||
if (fulfillmentFilter) params.fulfillment_status = fulfillmentFilter;
|
||||
if (periodFilter) params.period = periodFilter;
|
||||
if (dateFromFilter) params.date_from = dateFromFilter;
|
||||
if (dateToFilter) params.date_to = dateToFilter;
|
||||
if (deferredSearchQuery.trim()) params.search = deferredSearchQuery.trim();
|
||||
return params;
|
||||
},
|
||||
[deferredSearchQuery, departmentFilter, periodFilter, scope, statusFilter, urgencyFilter],
|
||||
[
|
||||
dateFromFilter,
|
||||
dateToFilter,
|
||||
deferredSearchQuery,
|
||||
departmentFilter,
|
||||
executorFilter,
|
||||
fulfillmentFilter,
|
||||
periodFilter,
|
||||
processingDepartmentFilter,
|
||||
requestorFilter,
|
||||
scope,
|
||||
statusFilter,
|
||||
urgencyFilter,
|
||||
],
|
||||
);
|
||||
|
||||
const buildScopeCountParams = useCallback(
|
||||
@ -257,11 +286,29 @@ export function useProcurementPage(user: User | null) {
|
||||
else if (statusFilter.length > 1) params.status__in = statusFilter.join(",");
|
||||
if (urgencyFilter) params.urgency = urgencyFilter;
|
||||
if (departmentFilter) params.department = departmentFilter;
|
||||
if (processingDepartmentFilter) params.processing_department = processingDepartmentFilter;
|
||||
if (requestorFilter) params.requestor = requestorFilter;
|
||||
if (executorFilter) params.executor = executorFilter;
|
||||
if (fulfillmentFilter) params.fulfillment_status = fulfillmentFilter;
|
||||
if (periodFilter) params.period = periodFilter;
|
||||
if (dateFromFilter) params.date_from = dateFromFilter;
|
||||
if (dateToFilter) params.date_to = dateToFilter;
|
||||
if (deferredSearchQuery.trim()) params.search = deferredSearchQuery.trim();
|
||||
return params;
|
||||
},
|
||||
[deferredSearchQuery, departmentFilter, periodFilter, statusFilter, urgencyFilter],
|
||||
[
|
||||
dateFromFilter,
|
||||
dateToFilter,
|
||||
deferredSearchQuery,
|
||||
departmentFilter,
|
||||
executorFilter,
|
||||
fulfillmentFilter,
|
||||
periodFilter,
|
||||
processingDepartmentFilter,
|
||||
requestorFilter,
|
||||
statusFilter,
|
||||
urgencyFilter,
|
||||
],
|
||||
);
|
||||
|
||||
const loadPage1 = useCallback(async () => {
|
||||
@ -350,12 +397,55 @@ export function useProcurementPage(user: User | null) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const defaultProcessingDepartmentId = useMemo(() => {
|
||||
const procurementDepartment = departments.find(
|
||||
(department) => department.name.trim().toLocaleLowerCase("ru-RU") === "снабжение",
|
||||
);
|
||||
return procurementDepartment?.id ?? null;
|
||||
}, [departments]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const options = await apiClient.getProcurementRequestCreateOptions();
|
||||
if (!cancelled) {
|
||||
setProcessingDepartments(options.processing_departments || []);
|
||||
setDefaultProcessingDepartmentId(options.default_processing_department ?? null);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setProcessingDepartments([]);
|
||||
setDefaultProcessingDepartmentId(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const allEmployees = await loadAllPages<User>((params) =>
|
||||
apiClient.getEmployees({
|
||||
...params,
|
||||
is_active: true,
|
||||
ordering: "last_name,first_name",
|
||||
})
|
||||
);
|
||||
if (!cancelled) {
|
||||
setEmployees(allEmployees);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setEmployees([]);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@ -904,9 +994,9 @@ export function useProcurementPage(user: User | null) {
|
||||
}
|
||||
}, [markRequestNotificationsRead, updateCachedItem]);
|
||||
|
||||
const handleSubmit = useCallback((id: number) => doAction(
|
||||
const handleSubmit = useCallback((id: number, approvalSteps?: ProcurementApprovalStepSelection[]) => doAction(
|
||||
`submit-${id}`,
|
||||
() => apiClient.submitProcurementRequest(id),
|
||||
() => apiClient.submitProcurementRequest(id, approvalSteps),
|
||||
id,
|
||||
"Заявка отправлена на согласование.",
|
||||
), [doAction]);
|
||||
@ -1119,7 +1209,13 @@ export function useProcurementPage(user: User | null) {
|
||||
statusFilter.length > 0,
|
||||
urgencyFilter,
|
||||
departmentFilter,
|
||||
processingDepartmentFilter,
|
||||
requestorFilter,
|
||||
executorFilter,
|
||||
fulfillmentFilter,
|
||||
periodFilter,
|
||||
dateFromFilter,
|
||||
dateToFilter,
|
||||
].filter(Boolean).length;
|
||||
const isFinal = useCallback((status?: string) => ["completed", "rejected", "cancelled"].includes(String(status || "").toLowerCase()), []);
|
||||
|
||||
@ -1128,6 +1224,8 @@ export function useProcurementPage(user: User | null) {
|
||||
canSupplierManage,
|
||||
requests,
|
||||
departments,
|
||||
processingDepartments,
|
||||
employees,
|
||||
defaultProcessingDepartmentId,
|
||||
loading,
|
||||
loadingMore,
|
||||
@ -1147,8 +1245,20 @@ export function useProcurementPage(user: User | null) {
|
||||
setUrgencyFilter,
|
||||
departmentFilter,
|
||||
setDepartmentFilter,
|
||||
processingDepartmentFilter,
|
||||
setProcessingDepartmentFilter,
|
||||
requestorFilter,
|
||||
setRequestorFilter,
|
||||
executorFilter,
|
||||
setExecutorFilter,
|
||||
fulfillmentFilter,
|
||||
setFulfillmentFilter,
|
||||
periodFilter,
|
||||
setPeriodFilter,
|
||||
dateFromFilter,
|
||||
setDateFromFilter,
|
||||
dateToFilter,
|
||||
setDateToFilter,
|
||||
filtersOpen,
|
||||
setFiltersOpen,
|
||||
ordering,
|
||||
|
||||
@ -46,7 +46,7 @@ async function fetchDownload(
|
||||
|
||||
export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, requestRaw?: RawRequestFn) {
|
||||
return {
|
||||
getDocuments: (params?: { search?: string; type?: string; status?: string; page?: number; page_size?: number; limit?: number; folder_id?: number }) => {
|
||||
getDocuments: (params?: { search?: string; type?: string; status?: string; page?: number; page_size?: number; limit?: number; folder_id?: number; is_regulation?: boolean }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.search) qp.append('search', params.search);
|
||||
if (params?.type) qp.append('type', params.type);
|
||||
@ -55,30 +55,38 @@ export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, re
|
||||
const pageSize = params?.page_size ?? params?.limit;
|
||||
if (pageSize) qp.append('page_size', pageSize.toString());
|
||||
if (params?.folder_id !== undefined) qp.append('folder_id', params.folder_id.toString());
|
||||
if (params?.is_regulation !== undefined) qp.append('is_regulation', String(params.is_regulation));
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/documents/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getDocument: (id: number) => request(`/api/v1/documents/${id}/`),
|
||||
createDocument: (data: { title?: string; description?: string; file: File | Blob; extracted_text?: string; sent_to_all?: boolean; recipient_ids?: number[]; department_ids?: number[]; folder_id?: number | null; acknowledgement_required?: boolean; tag_ids?: number[] }) => {
|
||||
createDocument: (data: { title?: string; description?: string; file?: File | Blob; extracted_text?: string; sent_to_all?: boolean; is_regulation?: boolean; recipient_ids?: number[]; department_ids?: number[]; folder_id?: number | null; acknowledgement_required?: boolean; tag_ids?: number[] }) => {
|
||||
const fd = new FormData();
|
||||
if (data.title?.trim()) fd.append('title', data.title.trim());
|
||||
if (data.description) fd.append('description', data.description);
|
||||
if (data.extracted_text) fd.append('extracted_text', data.extracted_text);
|
||||
if (data.folder_id) fd.append('folder', String(data.folder_id));
|
||||
fd.append('sent_to_all', String(data.sent_to_all ?? true));
|
||||
fd.append('is_regulation', String(data.is_regulation ?? false));
|
||||
if (data.acknowledgement_required !== undefined) fd.append('acknowledgement_required', String(data.acknowledgement_required));
|
||||
data.recipient_ids?.forEach(id => fd.append('recipient_ids', String(id)));
|
||||
data.department_ids?.forEach(id => fd.append('department_ids', String(id)));
|
||||
data.tag_ids?.forEach(id => fd.append('tag_ids', String(id)));
|
||||
fd.append('file', data.file);
|
||||
if (data.file) fd.append('file', data.file);
|
||||
return request('/api/v1/documents/', { method: 'POST', body: fd });
|
||||
},
|
||||
updateDocument: (id: number, data: { title?: string; description?: string; file?: File; tag_ids?: number[]; folder?: number | null }) => {
|
||||
updateDocument: (id: number, data: { title?: string; description?: string; extracted_text?: string; file?: File; tag_ids?: number[]; folder?: number | null; is_regulation?: boolean; sent_to_all?: boolean; acknowledgement_required?: boolean; recipient_ids?: number[]; department_ids?: number[] }) => {
|
||||
const fd = new FormData();
|
||||
if (data.title !== undefined) fd.append('title', data.title);
|
||||
if (data.description !== undefined) fd.append('description', data.description);
|
||||
if (data.extracted_text !== undefined) fd.append('extracted_text', data.extracted_text);
|
||||
if (data.file) fd.append('file', data.file);
|
||||
if (data.folder !== undefined) fd.append('folder', data.folder === null ? '' : String(data.folder));
|
||||
if (data.is_regulation !== undefined) fd.append('is_regulation', String(data.is_regulation));
|
||||
if (data.sent_to_all !== undefined) fd.append('sent_to_all', String(data.sent_to_all));
|
||||
if (data.acknowledgement_required !== undefined) fd.append('acknowledgement_required', String(data.acknowledgement_required));
|
||||
data.department_ids?.forEach(departmentId => fd.append('department_ids', String(departmentId)));
|
||||
data.recipient_ids?.forEach(recipientId => fd.append('recipient_ids', String(recipientId)));
|
||||
data.tag_ids?.forEach(tagId => fd.append('tag_ids', String(tagId)));
|
||||
return request(`/api/v1/documents/${id}/`, { method: 'PATCH', body: fd });
|
||||
},
|
||||
|
||||
@ -1,14 +1,22 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { ProcurementApprovalOptions, ProcurementApprovalStepSelection, ProcurementCreateOptions } from '@/types/api';
|
||||
import { buildQuery, type RequestFn } from './utils';
|
||||
|
||||
export function createProcurementApi(request: RequestFn) {
|
||||
return {
|
||||
getProcurementRequests: (params?: Record<string, string | number>) => request(`/api/v1/procurement/requests/${buildQuery(params)}`),
|
||||
getProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/`),
|
||||
getProcurementRequestCreateOptions: (): Promise<ProcurementCreateOptions> => request('/api/v1/procurement/requests/create-options/'),
|
||||
createProcurementRequest: (data: Record<string, any>) => request('/api/v1/procurement/requests/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
updateProcurementRequest: (id: number, data: Record<string, any>) => request(`/api/v1/procurement/requests/${id}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
deleteProcurementRequest: (id: number): Promise<void> => request(`/api/v1/procurement/requests/${id}/`, { method: 'DELETE' }),
|
||||
submitProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/submit/`, { method: 'POST' }),
|
||||
getProcurementApprovalOptions: (id: number): Promise<ProcurementApprovalOptions> => request(`/api/v1/procurement/requests/${id}/approval-options/`),
|
||||
submitProcurementRequest: (id: number, approvalSteps?: ProcurementApprovalStepSelection[]) =>
|
||||
request(`/api/v1/procurement/requests/${id}/submit/`, {
|
||||
method: 'POST',
|
||||
headers: approvalSteps ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: approvalSteps ? JSON.stringify({ approval_steps: approvalSteps }) : undefined,
|
||||
}),
|
||||
approveProcurementRequest: (id: number, comment?: string) =>
|
||||
request(`/api/v1/procurement/requests/${id}/approve/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ comment: comment || '' }) }),
|
||||
rejectProcurementRequest: (id: number, comment?: string) =>
|
||||
|
||||
@ -179,6 +179,11 @@ export interface Department {
|
||||
role_only_count?: number;
|
||||
}
|
||||
|
||||
export interface ProcurementCreateOptions {
|
||||
processing_departments: Pick<Department, "id" | "name">[];
|
||||
default_processing_department: number | null;
|
||||
}
|
||||
|
||||
export interface DepartmentMemberRole {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -283,6 +288,7 @@ export interface Document {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
extracted_text?: string;
|
||||
file?: string;
|
||||
file_url?: string;
|
||||
file_name?: string;
|
||||
@ -305,6 +311,7 @@ export interface Document {
|
||||
modified_by?: User;
|
||||
modified_at?: string;
|
||||
sent_to_all?: boolean;
|
||||
is_regulation?: boolean;
|
||||
recipients?: User[];
|
||||
departments?: { id: number; name: string }[];
|
||||
acknowledgements?: DocumentAcknowledgement[];
|
||||
@ -689,6 +696,32 @@ export interface ProcurementApproval {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ProcurementApprovalStepSelection {
|
||||
priority: number;
|
||||
approver: number;
|
||||
step_name?: string;
|
||||
}
|
||||
|
||||
export interface ProcurementApprovalRouteOption {
|
||||
route_id: number;
|
||||
priority: number;
|
||||
step_name: string;
|
||||
resolver_type: "department_head" | "fixed_employee" | string;
|
||||
min_amount?: string | number | null;
|
||||
is_amount_applicable: boolean;
|
||||
is_available: boolean;
|
||||
missing_reason?: string | null;
|
||||
approver?: User | null;
|
||||
}
|
||||
|
||||
export interface ProcurementApprovalOptions {
|
||||
request_id: number;
|
||||
total_cost: string | number;
|
||||
auto_steps: ProcurementApprovalRouteOption[];
|
||||
available_steps: ProcurementApprovalRouteOption[];
|
||||
missing_auto_steps: ProcurementApprovalRouteOption[];
|
||||
}
|
||||
|
||||
export interface ProcurementRequest {
|
||||
id: number;
|
||||
title: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user