feat: improve documents and procurement workflows

This commit is contained in:
lizerk 2026-07-06 10:13:27 +03:00
parent 14b42baa2c
commit 0b45888f5c
27 changed files with 2754 additions and 340 deletions

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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,
)
)

View File

@ -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",

View 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="Регламент",
),
),
]

View File

@ -103,6 +103,12 @@ class Document(models.Model):
),
)
is_regulation = models.BooleanField(
_("Регламент"),
default=False,
help_text=_("Если включено — документ отображается в разделе регламентов"),
)
departments = models.ManyToManyField(
"employees.Department",
verbose_name=_("Отделы-получатели"),

View File

@ -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,
)
# Выдача доступа другим пользователям (только загрузивший и менеджеры)

View File

@ -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):
"""Админка для заявок на закупку."""

View 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),
]

View File

@ -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):
"""Заявка на закупку.

View File

@ -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]

View File

@ -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."""

View File

@ -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
):

View File

@ -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>

View File

@ -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);
}

View File

@ -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 ── */}

View File

@ -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;

View File

@ -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

View File

@ -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 ? (
<>

View 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>
);
}

View File

@ -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 && (

View File

@ -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,

View File

@ -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 });
},

View File

@ -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) =>

View File

@ -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;