feat: expand documents tasks and procurement workflows

This commit is contained in:
lizerk 2026-07-17 14:09:06 +03:00
parent b9598c2731
commit df2377431d
85 changed files with 8602 additions and 942 deletions

View File

@ -74,23 +74,6 @@ class FilerFileField(serializers.Field):
if not isinstance(data, UploadedFile):
raise serializers.ValidationError("Ожидается файл для загрузки.")
# Проверка размера файла
from django.conf import settings
from django.template.defaultfilters import filesizeformat
limit = getattr(settings, "DATA_UPLOAD_MAX_MEMORY_SIZE", None)
if limit:
size = getattr(data, "size", None)
if size is None:
raise serializers.ValidationError(
"Невозможно определить размер файла"
)
if int(size) > int(limit):
human = filesizeformat(limit)
raise serializers.ValidationError(
f"Файл слишком большой: > {human}."
)
# Получаем пользователя из контекста
request = self.context.get("request")
owner = getattr(request, "user", None) if request else None

View File

@ -62,6 +62,8 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms):
"acknowledge",
"download",
"archive",
"comments",
"document_comment",
):
return bool(request.user and request.user.is_authenticated)
@ -108,6 +110,9 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms):
if action in ("add_related", "remove_related"):
return user.has_perm("documents.change_document", obj)
if action in ("comments", "document_comment"):
return _user_has_document_access(user, obj)
# PUT/PATCH → проверяем django-rules change_document
if request.method in ("PUT", "PATCH"):
return user.has_perm("documents.change_document", obj)
@ -122,6 +127,8 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms):
"acknowledge",
"download",
"archive",
"comments",
"document_comment",
):
return _user_has_document_access(user, obj)

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.db.models import Q
from django.utils.text import slugify
from documents.models import Document, DocumentAcknowledgement
from rest_framework import serializers
@ -281,6 +282,12 @@ class DocumentReadSerializer(serializers.ModelSerializer):
modified_by = EmployeeBriefSerializer(read_only=True)
recipients = EmployeeBriefSerializer(many=True, read_only=True)
departments = DepartmentBriefSerializer(many=True, read_only=True)
acknowledgement_recipients = EmployeeBriefSerializer(
many=True, read_only=True
)
acknowledgement_departments = DepartmentBriefSerializer(
many=True, read_only=True
)
folder = FolderBriefSerializer(read_only=True)
folder_path = serializers.CharField(read_only=True)
tags = DocumentTagSerializer(many=True, read_only=True)
@ -293,6 +300,10 @@ class DocumentReadSerializer(serializers.ModelSerializer):
source="file.size", read_only=True, allow_null=True
)
is_acknowledged = serializers.SerializerMethodField()
acknowledgement_required_for_user = serializers.SerializerMethodField()
acknowledged_count = serializers.SerializerMethodField()
acknowledgement_total = serializers.SerializerMethodField()
comments_count = serializers.SerializerMethodField()
linked_tasks = serializers.SerializerMethodField()
class Meta:
@ -314,8 +325,15 @@ class DocumentReadSerializer(serializers.ModelSerializer):
"sent_to_all",
"is_regulation",
"acknowledgement_required", # Требуется ли ознакомление
"acknowledgement_for_all",
"acknowledgement_required_for_user",
"acknowledged_count",
"acknowledgement_total",
"comments_count",
"departments",
"recipients",
"acknowledgement_departments",
"acknowledgement_recipients",
"file_url",
"file_name",
"file_size",
@ -343,6 +361,51 @@ class DocumentReadSerializer(serializers.ModelSerializer):
document=obj, user=request.user
).exists()
def get_acknowledgement_required_for_user(self, obj: Document) -> bool:
"""Нужно ли текущему сотруднику подтверждать ознакомление."""
request = self.context.get("request")
if not request or not request.user.is_authenticated:
return False
audience_ids = getattr(obj, "_acknowledgement_user_ids", None)
if audience_ids is not None:
return request.user.pk in audience_ids
from documents.audience import user_requires_document_acknowledgement
return user_requires_document_acknowledgement(obj, request.user)
def get_acknowledged_count(self, obj: Document) -> int:
"""Количество сотрудников из аудитории, уже ознакомившихся."""
attached = getattr(obj, "_acknowledged_count", None)
if attached is not None:
return int(attached)
from documents.audience import document_acknowledgement_audience
audience = document_acknowledgement_audience(obj)
return obj.acknowledgements.filter(user__in=audience).count()
def get_acknowledgement_total(self, obj: Document) -> int:
"""Размер аудитории обязательного ознакомления."""
attached = getattr(obj, "_acknowledgement_total", None)
if attached is not None:
return int(attached)
from documents.audience import document_acknowledgement_audience
return document_acknowledgement_audience(obj).count()
def get_comments_count(self, obj: Document) -> int:
"""Количество неудалённых комментариев документа."""
attached = getattr(obj, "_comments_count", None)
if attached is not None:
return int(attached)
from communications.comments_helpers import get_comment_count
return get_comment_count(obj)
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_linked_tasks(self, obj: Document) -> list[dict]:
prefetched = getattr(obj, "_linked_task_payloads", None)
@ -432,6 +495,18 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
help_text="Список ID отделов-получателей.",
)
acknowledgement_recipient_ids = RecipientIDsField(
write_only=True,
required=False,
help_text="Список ID сотрудников, обязанных ознакомиться.",
)
acknowledgement_department_ids = RecipientIDsField(
write_only=True,
required=False,
help_text="Список ID отделов, обязанных ознакомиться.",
)
tag_ids = RecipientIDsField(
write_only=True,
required=False,
@ -456,8 +531,11 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
"sent_to_all",
"is_regulation",
"acknowledgement_required",
"acknowledgement_for_all",
"department_ids",
"recipient_ids",
"acknowledgement_department_ids",
"acknowledgement_recipient_ids",
"tag_ids",
)
@ -491,9 +569,27 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
elif self.instance is not None:
attrs.pop("title", None)
sent_to_all = attrs.get("sent_to_all", True)
recipient_ids = attrs.get("recipient_ids", [])
department_ids = attrs.get("department_ids", [])
instance = self.instance
sent_to_all = attrs.get(
"sent_to_all",
instance.sent_to_all if instance is not None else True,
)
recipient_ids = set(
attrs.get(
"recipient_ids",
instance.recipients.values_list("pk", flat=True)
if instance is not None
else [],
)
)
department_ids = set(
attrs.get(
"department_ids",
instance.departments.values_list("pk", flat=True)
if instance is not None
else [],
)
)
if sent_to_all is False and not recipient_ids and not department_ids:
raise serializers.ValidationError(
@ -504,6 +600,77 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
)
}
)
acknowledgement_required = attrs.get(
"acknowledgement_required",
instance.acknowledgement_required if instance is not None else True,
)
acknowledgement_for_all = attrs.get(
"acknowledgement_for_all",
instance.acknowledgement_for_all if instance is not None else True,
)
acknowledgement_recipient_ids = set(
attrs.get(
"acknowledgement_recipient_ids",
instance.acknowledgement_recipients.values_list("pk", flat=True)
if instance is not None
else [],
)
)
acknowledgement_department_ids = set(
attrs.get(
"acknowledgement_department_ids",
instance.acknowledgement_departments.values_list("pk", flat=True)
if instance is not None
else [],
)
)
if (
acknowledgement_required
and not acknowledgement_for_all
and not acknowledgement_recipient_ids
and not acknowledgement_department_ids
):
raise serializers.ValidationError(
{
"acknowledgement_recipient_ids": (
"Выберите сотрудников, отделы или включите "
"ознакомление для всех с доступом."
)
}
)
if acknowledgement_required and not acknowledgement_for_all and not sent_to_all:
invalid_departments = acknowledgement_department_ids - department_ids
if invalid_departments:
raise serializers.ValidationError(
{
"acknowledgement_department_ids": (
"Отдел для ознакомления должен иметь доступ к документу."
)
}
)
allowed_recipients = User.objects.filter(is_active=True).filter(
Q(pk__in=recipient_ids)
| Q(
departments_links__department_id__in=department_ids,
departments_links__is_active=True,
)
).values_list("pk", flat=True)
invalid_recipients = acknowledgement_recipient_ids - set(
allowed_recipients
)
if invalid_recipients:
raise serializers.ValidationError(
{
"acknowledgement_recipient_ids": (
"Сотрудник для ознакомления должен иметь доступ "
"к документу."
)
}
)
return attrs
def _set_departments(
@ -574,6 +741,22 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
logger.info(f"[serializers] Set {count} recipients for doc={doc.id}")
return count
def _set_acknowledgement_departments(
self, doc: Document, department_ids: Sequence[int]
) -> int:
from employees.models import Department
departments = Department.objects.filter(id__in=set(department_ids))
doc.acknowledgement_departments.set(departments)
return departments.count()
def _set_acknowledgement_recipients(
self, doc: Document, recipient_ids: Sequence[int]
) -> int:
users = User.objects.filter(is_active=True, id__in=set(recipient_ids))
doc.acknowledgement_recipients.set(users)
return users.count()
def _set_tags(self, doc: Document, tag_ids: Sequence[int]) -> int:
"""Привязывает теги по списку ID.
@ -618,6 +801,12 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
"""
recipient_ids = validated_data.pop("recipient_ids", [])
department_ids = validated_data.pop("department_ids", [])
acknowledgement_recipient_ids = validated_data.pop(
"acknowledgement_recipient_ids", []
)
acknowledgement_department_ids = validated_data.pop(
"acknowledgement_department_ids", []
)
tag_ids = validated_data.pop("tag_ids", [])
request = self.context.get("request")
uploader = getattr(request, "user", None)
@ -632,17 +821,29 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
# Создаём документ - сигналы из notification_signals.py сработают
# автоматически
doc = Document.objects.create( # type: ignore[arg-type]
doc = Document( # type: ignore[arg-type]
uploaded_by=uploader,
**validated_data,
)
doc._suppress_document_notifications = True
doc.save()
logger.info(
f"[serializers] Document created id={doc.id} "
f"sent_to_all={doc.sent_to_all}"
)
# Для индивидуальной рассылки устанавливаем получателей
# Это вызовет m2m_changed сигнал
if (
doc.acknowledgement_required
and not doc.acknowledgement_for_all
):
self._set_acknowledgement_departments(
doc, acknowledgement_department_ids
)
self._set_acknowledgement_recipients(
doc, acknowledgement_recipient_ids
)
# Для индивидуальной доступности устанавливаем получателей.
if validated_data.get("sent_to_all") is False:
if department_ids:
logger.info(
@ -661,6 +862,11 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
logger.info(f"[serializers] Setting tags for doc={doc.id}")
self._set_tags(doc, tag_ids)
doc._suppress_document_notifications = False
from documents.notifications.handlers import notify_document_audience
notify_document_audience(doc)
logger.info(f"[serializers] Document creation complete id={doc.id}")
return doc
@ -678,6 +884,12 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
"""
recipient_ids = validated_data.pop("recipient_ids", None)
department_ids = validated_data.pop("department_ids", None)
acknowledgement_recipient_ids = validated_data.pop(
"acknowledgement_recipient_ids", None
)
acknowledgement_department_ids = validated_data.pop(
"acknowledgement_department_ids", None
)
tag_ids = validated_data.pop("tag_ids", None)
for f in (
@ -687,6 +899,7 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
"sent_to_all",
"is_regulation",
"acknowledgement_required",
"acknowledgement_for_all",
"file",
"folder",
):
@ -694,6 +907,19 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
setattr(instance, f, validated_data[f])
instance.save()
if not instance.acknowledgement_required or instance.acknowledgement_for_all:
instance.acknowledgement_recipients.clear()
instance.acknowledgement_departments.clear()
else:
if acknowledgement_department_ids is not None:
self._set_acknowledgement_departments(
instance, acknowledgement_department_ids
)
if acknowledgement_recipient_ids is not None:
self._set_acknowledgement_recipients(
instance, acknowledgement_recipient_ids
)
# очищаем получателей и отделы при sent_to_all=true
if "sent_to_all" in validated_data and instance.sent_to_all:
instance.recipients.clear()

View File

@ -15,6 +15,10 @@ from documents.models import (
DocumentAcknowledgement,
DocumentTag,
)
from documents.audience import (
document_acknowledgement_audience,
user_requires_document_acknowledgement,
)
from easy_thumbnails.files import get_thumbnailer
from filer.models import Folder
import reversion
@ -51,7 +55,12 @@ class DocumentViewSet(ModelViewSet):
queryset: QuerySet[Document] = (
Document.objects.all()
.select_related("uploaded_by")
.prefetch_related("recipients")
.prefetch_related(
"recipients",
"departments",
"acknowledgement_recipients",
"acknowledgement_departments",
)
)
permission_classes = [DocumentReadOrModelPerms]
parser_classes = (MultiPartParser, FormParser, JSONParser)
@ -202,20 +211,123 @@ class DocumentViewSet(ModelViewSet):
for document in documents:
document._linked_task_payloads = mapping.get(document.id, [])
def _attach_document_metrics(self, documents: list[Document]) -> None:
"""Пакетно рассчитывает комментарии и состояние ознакомления."""
if not documents:
return
from communications.models import Message
from employees.models import EmployeeDepartment
document_ids = [document.id for document in documents]
active_user_ids = set(
User.objects.filter(is_active=True).values_list("id", flat=True)
)
department_ids = {
department.id
for document in documents
for department in (
list(document.departments.all())
+ list(document.acknowledgement_departments.all())
)
}
employees_by_department: dict[int, set[int]] = {
department_id: set() for department_id in department_ids
}
if department_ids:
for department_id, employee_id in EmployeeDepartment.objects.filter(
department_id__in=department_ids,
is_active=True,
employee__is_active=True,
).values_list("department_id", "employee_id"):
employees_by_department.setdefault(department_id, set()).add(
employee_id
)
acknowledged_by_document: dict[int, set[int]] = {
document_id: set() for document_id in document_ids
}
for document_id, user_id in DocumentAcknowledgement.objects.filter(
document_id__in=document_ids,
user__is_active=True,
).values_list("document_id", "user_id"):
acknowledged_by_document.setdefault(document_id, set()).add(user_id)
document_content_type = ContentType.objects.get_for_model(Document)
comments_by_document = {
row["chat__context_object_id"]: row["total"]
for row in Message.objects.filter(
chat__type="comments",
chat__context_content_type=document_content_type,
chat__context_object_id__in=document_ids,
is_deleted=False,
)
.values("chat__context_object_id")
.annotate(total=Count("id"))
}
for document in documents:
if document.sent_to_all:
access_ids = active_user_ids
else:
access_ids = {
recipient.id
for recipient in document.recipients.all()
if recipient.id in active_user_ids
}
for department in document.departments.all():
access_ids.update(
employees_by_department.get(department.id, set())
)
if not document.acknowledgement_required:
acknowledgement_ids: set[int] = set()
elif document.acknowledgement_for_all:
acknowledgement_ids = set(access_ids)
else:
acknowledgement_ids = {
recipient.id
for recipient in document.acknowledgement_recipients.all()
if recipient.id in active_user_ids
}
for department in document.acknowledgement_departments.all():
acknowledgement_ids.update(
employees_by_department.get(department.id, set())
)
acknowledgement_ids.intersection_update(access_ids)
document._acknowledgement_user_ids = acknowledgement_ids
document._acknowledgement_total = len(acknowledgement_ids)
document._acknowledged_count = len(
acknowledgement_ids
& acknowledged_by_document.get(document.id, set())
)
document._comments_count = comments_by_document.get(document.id, 0)
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
self._attach_linked_task_payloads(list(page))
serializer = self.get_serializer(page, many=True)
documents = list(page)
self._attach_linked_task_payloads(documents)
self._attach_document_metrics(documents)
serializer = self.get_serializer(documents, many=True)
return self.get_paginated_response(serializer.data)
documents = list(queryset)
self._attach_linked_task_payloads(documents)
self._attach_document_metrics(documents)
serializer = self.get_serializer(documents, many=True)
return Response(serializer.data)
def retrieve(self, request, *args, **kwargs):
document = self.get_object()
self._attach_linked_task_payloads([document])
self._attach_document_metrics([document])
serializer = self.get_serializer(document)
return Response(serializer.data)
@transaction.atomic
def create(self, request, *args, **kwargs):
"""Создаёт документ и возвращает read-схему + recipient_count.
@ -236,6 +348,7 @@ class DocumentViewSet(ModelViewSet):
doc = serializer.save()
self._attach_document_metrics([doc])
read = DocumentReadSerializer(doc, context={"request": request})
data = dict(read.data)
@ -263,6 +376,7 @@ class DocumentViewSet(ModelViewSet):
serializer.is_valid(raise_exception=True)
doc = serializer.save()
self._attach_document_metrics([doc])
read = DocumentReadSerializer(doc, context={"request": request})
return Response(read.data, status=status.HTTP_200_OK)
@ -395,11 +509,166 @@ class DocumentViewSet(ModelViewSet):
},
status=status.HTTP_400_BAD_REQUEST,
)
if not user_requires_document_acknowledgement(doc, request.user):
return Response(
{
"detail": (
"Для вас подтверждение ознакомления с этим "
"документом не требуется."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
obj, created = DocumentAcknowledgement.objects.get_or_create(
document=doc, user=request.user
)
return Response({"ok": True, "already": not created})
def _document_comment_payload(self, document, message):
"""Формат комментария документа для frontend."""
return {
"id": message.id,
"document": document.id,
"author": EmployeeBriefSerializer(
message.author,
context={"request": self.request},
).data,
"text": message.content,
"parent": message.reply_to_id,
"created_at": message.created_at,
"updated_at": message.edited_at or message.created_at,
"is_edited": message.is_edited,
"replies_count": message.direct_replies.filter(
is_deleted=False
).count(),
"can_edit": message.author_id == self.request.user.id,
"can_delete": bool(
message.author_id == self.request.user.id
or self.request.user.is_staff
or self.request.user.is_superuser
),
}
@action(detail=True, methods=["get", "post"], url_path="comments")
def comments(self, request, pk=None):
"""Получение и создание комментариев документа через Communications."""
from communications import comments_helpers
from communications.models import Message
document = self.get_object()
if request.method in ("GET", "HEAD"):
messages = comments_helpers.get_comments(document)
return Response(
[
self._document_comment_payload(document, message)
for message in messages
]
)
text = str(request.data.get("text") or "").strip()
if not text:
return Response(
{"text": ["Это поле не может быть пустым."]},
status=status.HTTP_400_BAD_REQUEST,
)
reply_to = None
parent_id = request.data.get("parent_id") or request.data.get("parent")
if parent_id:
chat = comments_helpers.get_comments_chat_if_exists(document)
if chat is None:
return Response(
{"parent_id": ["Родительский комментарий не найден."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
reply_to = Message.objects.get(
id=parent_id,
chat=chat,
is_deleted=False,
)
except (Message.DoesNotExist, ValueError, TypeError):
return Response(
{"parent_id": ["Родительский комментарий не найден."]},
status=status.HTTP_400_BAD_REQUEST,
)
message = comments_helpers.create_comment(
document,
request.user,
text,
reply_to=reply_to,
)
return Response(
self._document_comment_payload(document, message),
status=status.HTTP_201_CREATED,
)
@action(
detail=True,
methods=["patch", "delete"],
url_path=r"comments/(?P<comment_id>\d+)",
url_name="document-comment",
)
def document_comment(self, request, pk=None, comment_id=None):
"""Редактирование автором и мягкое удаление комментария."""
from communications import comments_helpers
from communications.models import Message
document = self.get_object()
chat = comments_helpers.get_comments_chat_if_exists(document)
if chat is None:
return Response(
{"detail": "Комментарий не найден."},
status=status.HTTP_404_NOT_FOUND,
)
try:
message = Message.objects.get(
id=comment_id,
chat=chat,
is_deleted=False,
)
except Message.DoesNotExist:
return Response(
{"detail": "Комментарий не найден."},
status=status.HTTP_404_NOT_FOUND,
)
if request.method == "PATCH":
if message.author_id != request.user.id:
return Response(
{"detail": "Редактировать комментарий может только его автор."},
status=status.HTTP_403_FORBIDDEN,
)
text = str(request.data.get("text") or "").strip()
if not text:
return Response(
{"text": ["Это поле не может быть пустым."]},
status=status.HTTP_400_BAD_REQUEST,
)
comments_helpers.update_comment(
message,
text,
edited_by=request.user,
)
return Response(self._document_comment_payload(document, message))
if not (
message.author_id == request.user.id
or request.user.is_staff
or request.user.is_superuser
):
return Response(
{"detail": "Нет прав на удаление комментария."},
status=status.HTTP_403_FORBIDDEN,
)
comments_helpers.delete_comment(
message,
deleted_by=request.user,
soft_delete=True,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get"])
def acknowledgements(self, request, pk=None):
"""Ведомость ознакомлений: доступна пользователям с доступом к документу.
@ -408,19 +677,7 @@ class DocumentViewSet(ModelViewSet):
"""
doc = self.get_object()
q = (request.query_params.get("search") or "").strip()
base = User.objects.filter(is_active=True)
if not doc.sent_to_all:
# Собираем ID получателей из recipients и departments
recipient_ids = set(doc.recipients.values_list("pk", flat=True))
# Добавляем активных сотрудников из отделов
for department in doc.departments.all():
recipient_ids.update(
emp.id for emp in department.active_employees
)
base = base.filter(pk__in=recipient_ids)
base = document_acknowledgement_audience(doc)
if q:
base = base.filter(

View File

@ -25,6 +25,8 @@ from procurement.models import (
MaintenanceRecord,
ApprovalRoute,
ProcurementItem,
ProcurementItemAttachment,
ProcurementRequestActivity,
ProcurementRequest,
ProcurementRequestView,
ProcurementSettings,
@ -42,6 +44,28 @@ PROBLEM_ITEM_STATUSES = {
ProcurementItemExecutionStatus.DEFECTIVE,
}
class ProcurementRequestActivitySerializer(serializers.ModelSerializer):
actor = EmployeeBriefSerializer(read_only=True)
action_display = serializers.CharField(
source="get_action_display",
read_only=True,
)
class Meta:
model = ProcurementRequestActivity
fields = [
"id",
"action",
"action_display",
"actor",
"object_kind",
"object_id",
"metadata",
"created_at",
]
read_only_fields = fields
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
UNIT_DISPLAY_LABELS = {
"шт": "штуки",
@ -316,6 +340,34 @@ class ProcurementRequestSummaryMixin(serializers.Serializer):
]
class ProcurementItemAttachmentSerializer(serializers.ModelSerializer):
uploaded_by_name = serializers.CharField(
source="uploaded_by.get_full_name",
read_only=True,
)
download_url = serializers.SerializerMethodField()
class Meta:
model = ProcurementItemAttachment
fields = [
"id",
"file_name",
"file_size",
"mime_type",
"uploaded_by",
"uploaded_by_name",
"created_at",
"download_url",
]
read_only_fields = fields
def get_download_url(self, obj):
return (
f"/api/v1/procurement/items/{obj.item_id}/"
f"attachments/{obj.id}/"
)
class ProcurementItemSerializer(serializers.ModelSerializer):
"""Сериализатор для позиций заявки."""
@ -350,6 +402,11 @@ class ProcurementItemSerializer(serializers.ModelSerializer):
)
execution_status_display = serializers.SerializerMethodField()
comments_count = serializers.SerializerMethodField()
attachments = ProcurementItemAttachmentSerializer(many=True, read_only=True)
attachments_count = serializers.IntegerField(
source="attachments.count",
read_only=True,
)
@extend_schema_field(serializers.IntegerField())
def get_comments_count(self, obj):
@ -387,6 +444,8 @@ class ProcurementItemSerializer(serializers.ModelSerializer):
"executor_comment",
"initial_comment",
"comments_count",
"attachments",
"attachments_count",
"equipment",
"created_at",
]
@ -396,6 +455,8 @@ class ProcurementItemSerializer(serializers.ModelSerializer):
"total_price",
"execution_status_display",
"comments_count",
"attachments",
"attachments_count",
]
def validate(self, attrs):
@ -451,6 +512,7 @@ class ProcurementItemCreateSerializer(serializers.ModelSerializer):
class Meta:
model = ProcurementItem
fields = [
"id",
"name",
"description",
"quantity",
@ -466,6 +528,7 @@ class ProcurementItemCreateSerializer(serializers.ModelSerializer):
"executor_comment",
"initial_comment",
]
read_only_fields = ["id"]
def validate(self, attrs):
return _validate_item_quantities(attrs, self.instance)
@ -873,6 +936,7 @@ class ProcurementRequestDetailSerializer(
)
is_editable = serializers.BooleanField(read_only=True)
comments_count = serializers.IntegerField(read_only=True, default=0)
activity_count = serializers.SerializerMethodField()
can_current_user_approve = serializers.SerializerMethodField()
can_current_user_submit_for_approval = serializers.SerializerMethodField()
can_current_user_start_work = serializers.SerializerMethodField()
@ -905,6 +969,10 @@ class ProcurementRequestDetailSerializer(
user = getattr(request, "user", None)
return ProcurementApprovalResolver.user_can_process_items(user, obj)
@extend_schema_field(serializers.IntegerField())
def get_activity_count(self, obj):
return obj.activities.count()
class Meta:
model = ProcurementRequest
fields = [
@ -944,6 +1012,7 @@ class ProcurementRequestDetailSerializer(
"required_approval_priorities",
"is_editable",
"comments_count",
"activity_count",
"linked_tasks",
"can_current_user_approve",
"can_current_user_submit_for_approval",
@ -980,6 +1049,7 @@ class ProcurementRequestDetailSerializer(
"processing_department_name",
"fulfillment_status_display",
"comments_count",
"activity_count",
"linked_tasks",
"can_current_user_approve",
"can_current_user_submit_for_approval",

View File

@ -1,12 +1,17 @@
"""ViewSets для API модуля закупок."""
from pathlib import Path
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.http import FileResponse, Http404
from drf_spectacular.utils import OpenApiParameter, extend_schema
from django.db.models import Q
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser
from rest_framework.response import Response
from employees.models import Department, EmployeeDepartment
@ -26,11 +31,15 @@ from procurement.models import (
EquipmentTransferLog,
MaintenanceRecord,
ProcurementItem,
ProcurementItemAttachment,
ProcurementActivityAction,
ProcurementActivityObjectKind,
ProcurementRequest,
ProcurementRequestView,
ProcurementSettings,
Supplier,
)
from procurement.activity import create_procurement_activity
from procurement.notifications.handlers import (
notify_executor_reassigned,
notify_item_comment,
@ -63,11 +72,13 @@ from .serializers import (
ProcurementDepartmentStatsSerializer,
ProcurementOverviewStatsSerializer,
ProcurementItemSerializer,
ProcurementItemAttachmentSerializer,
ProcurementManualApprovalStepsSerializer,
ProcurementNotificationReadResponseSerializer,
ProcurementRequestCreateOptionsSerializer,
ProcurementRequestCreateSerializer,
ProcurementRequestDetailSerializer,
ProcurementRequestActivitySerializer,
ProcurementRequestListSerializer,
SupplierSerializer,
)
@ -81,7 +92,10 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
"processing_department",
"requestor",
"executor",
).prefetch_related("items", "approvals__approver")
).prefetch_related(
"items__attachments__uploaded_by",
"approvals__approver",
)
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
@ -116,10 +130,50 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
"notify_arrival",
"set_viewed",
"approval_options",
"activity",
]:
return ProcurementRequestDetailSerializer
return ProcurementRequestListSerializer
@action(detail=True, methods=["get"], url_path="activity")
def activity(self, request, pk=None):
procurement_request = self.get_object()
queryset = procurement_request.activities.select_related("actor").order_by(
"-created_at",
"-id",
)
serializer = ProcurementRequestActivitySerializer(queryset, many=True)
return Response(serializer.data)
def perform_update(self, serializer):
tracked_fields = [
"title",
"description",
"department_id",
"processing_department_id",
"urgency",
"actual_cost",
]
before = {
field: getattr(serializer.instance, field)
for field in tracked_fields
}
procurement_request = serializer.save()
changed_fields = [
field
for field in tracked_fields
if before[field] != getattr(procurement_request, field)
]
if changed_fields:
create_procurement_activity(
procurement_request,
self.request.user,
ProcurementActivityAction.UPDATED,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=procurement_request.pk,
metadata={"fields": changed_fields},
)
def get_permissions(self):
"""Выбрать права доступа в зависимости от действия."""
if self.action in ["approve", "reject"]:
@ -573,6 +627,14 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
message,
actor=request.user,
)
create_procurement_activity(
procurement_request,
request.user,
ProcurementActivityAction.COMMENT_ADDED,
object_kind=ProcurementActivityObjectKind.COMMENT,
object_id=message.pk,
metadata={"comment_text": message.content},
)
author_ser = EmployeeBriefSerializer(message.author)
return Response(
{
@ -626,6 +688,14 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
deleted_by=request.user,
soft_delete=True,
)
create_procurement_activity(
procurement_request,
request.user,
ProcurementActivityAction.COMMENT_REMOVED,
object_kind=ProcurementActivityObjectKind.COMMENT,
object_id=message.pk,
metadata={"comment_text": message.content},
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["post"])
@ -1042,9 +1112,24 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
)
procurement_request.executor = request.user
procurement_request.started_at = timezone.now()
procurement_request.save(
update_fields=["executor", "started_at", "updated_at"],
procurement_request._notification_actor = request.user
update_fields = ["executor", "updated_at"]
if procurement_request.started_at is None:
procurement_request.started_at = timezone.now()
update_fields.append("started_at")
procurement_request.save(update_fields=update_fields)
create_procurement_activity(
procurement_request,
request.user,
ProcurementActivityAction.EXECUTOR_REASSIGNED,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=procurement_request.pk,
metadata={
"previous_executor": previous_executor.get_full_name(),
"previous_executor_id": previous_executor.pk,
"new_executor": request.user.get_full_name(),
"new_executor_id": request.user.pk,
},
)
notify_executor_reassigned(
procurement_request,
@ -1183,6 +1268,14 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
procurement_request._notification_actor = request.user
procurement_request.recalculate_fulfillment_status(save=True)
procurement_request.refresh_from_db()
create_procurement_activity(
procurement_request,
request.user,
ProcurementActivityAction.ALL_ITEMS_RECEIVED,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=procurement_request.pk,
metadata={"items_count": len(items_to_update)},
)
serializer = self.get_serializer(procurement_request)
return Response(serializer.data)
@ -1208,6 +1301,13 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
)
notify_request_arrival(procurement_request, actor=request.user)
create_procurement_activity(
procurement_request,
request.user,
ProcurementActivityAction.ARRIVAL_NOTIFIED,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=procurement_request.pk,
)
from django.contrib.contenttypes.models import ContentType
from notifications.models import Notification
@ -1333,6 +1433,16 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
class ProcurementItemViewSet(viewsets.ModelViewSet):
"""ViewSet для позиций заявок."""
EXECUTION_FIELDS = {
"actual_unit_price",
"execution_status",
"executor_comment",
"expected_delivery_dates",
"links",
"ordered_quantity",
"received_quantity",
}
NOTIFICATION_TRACKED_FIELDS = [
"name",
"quantity",
@ -1449,12 +1559,122 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
"confirm_received",
"cancel_received",
"cancel_issue",
"attachments",
"attachment_detail",
]:
permission_classes = [permissions.IsAuthenticated]
else:
permission_classes = [CanManageProcurementRequest]
return [permission() for permission in permission_classes]
def _check_item_read_permission(self, item):
from rest_framework.exceptions import PermissionDenied
if not self._can_access_item_comments(self.request.user, item.request):
raise PermissionDenied("Нет доступа к этой позиции")
@action(
detail=True,
methods=["get", "post"],
parser_classes=[MultiPartParser],
url_path="attachments",
)
def attachments(self, request, pk=None):
item = self.get_object()
self._check_item_read_permission(item)
if request.method == "GET":
serializer = ProcurementItemAttachmentSerializer(
item.attachments.select_related("uploaded_by").all(),
many=True,
context=self.get_serializer_context(),
)
return Response(serializer.data)
self._check_item_edit_permission(item)
files = request.FILES.getlist("files")
if not files:
return Response(
{"files": "Выберите хотя бы один файл."},
status=status.HTTP_400_BAD_REQUEST,
)
created = [
ProcurementItemAttachment.objects.create(
item=item,
file=uploaded_file,
file_name=Path(uploaded_file.name).name,
file_size=uploaded_file.size,
mime_type=uploaded_file.content_type or "",
uploaded_by=request.user,
)
for uploaded_file in files
]
for attachment in created:
create_procurement_activity(
item.request,
request.user,
ProcurementActivityAction.ATTACHMENT_ADDED,
object_kind=ProcurementActivityObjectKind.ATTACHMENT,
object_id=attachment.pk,
metadata={
"item_id": item.pk,
"item_name": item.name,
"file_name": attachment.file_name,
},
)
serializer = ProcurementItemAttachmentSerializer(
created,
many=True,
context=self.get_serializer_context(),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(
detail=True,
methods=["get", "delete"],
url_path=r"attachments/(?P<attachment_id>\d+)",
)
def attachment_detail(self, request, pk=None, attachment_id=None):
item = self.get_object()
self._check_item_read_permission(item)
try:
attachment = item.attachments.get(pk=attachment_id)
except ProcurementItemAttachment.DoesNotExist as exc:
raise Http404 from exc
if request.method == "DELETE":
self._check_item_edit_permission(item)
attachment_id_value = attachment.pk
file_name = attachment.file_name
attachment.delete()
create_procurement_activity(
item.request,
request.user,
ProcurementActivityAction.ATTACHMENT_REMOVED,
object_kind=ProcurementActivityObjectKind.ATTACHMENT,
object_id=attachment_id_value,
metadata={
"item_id": item.pk,
"item_name": item.name,
"file_name": file_name,
},
)
return Response(status=status.HTTP_204_NO_CONTENT)
if not attachment.file or not attachment.file.name:
raise Http404
try:
file_handle = attachment.file.open("rb")
except FileNotFoundError as exc:
raise Http404 from exc
return FileResponse(
file_handle,
as_attachment=True,
filename=attachment.file_name,
content_type=attachment.mime_type or "application/octet-stream",
)
def _can_use_item_quick_action(self, user, procurement_request):
return (
procurement_request.requestor_id == user.id
@ -1498,6 +1718,19 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
return ProcurementItemExecutionStatus.ORDERED
return ProcurementItemExecutionStatus.PENDING
def _record_item_update(self, item, user, changed_fields):
create_procurement_activity(
item.request,
user,
ProcurementActivityAction.ITEM_UPDATED,
object_kind=ProcurementActivityObjectKind.ITEM,
object_id=item.pk,
metadata={
"item_name": item.name,
"fields": list(changed_fields),
},
)
def _reopen_completed_request(self, procurement_request, user):
if procurement_request.status != ProcurementStatus.COMPLETED:
return
@ -1596,15 +1829,59 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
# Нет прав
raise PermissionDenied("Вы не можете изменять позиции в этой заявке")
def _starts_execution(self, item, validated_data):
"""Проверить, меняются ли данные из блока исполнения позиции."""
return any(
field in validated_data
and getattr(item, field) != validated_data[field]
for field in self.EXECUTION_FIELDS
)
def _start_request_for_execution(self, procurement_request):
"""Назначить исполнителя при первом заполнении исполнения позиции."""
user = self.request.user
if (
procurement_request.status
not in {ProcurementStatus.APPROVED, ProcurementStatus.WAITING}
or procurement_request.executor_id
or not self._can_process_request_items(user, procurement_request)
or not ProcurementApprovalResolver.user_can_start_work(
user,
procurement_request,
)
):
return
procurement_request.executor = user
procurement_request.status = ProcurementStatus.IN_PROGRESS
if procurement_request.started_at is None:
procurement_request.started_at = timezone.now()
procurement_request._notification_actor = user
procurement_request.save(
update_fields=[
"executor",
"status",
"started_at",
"updated_at",
]
)
@transaction.atomic
def perform_update(self, serializer):
"""Проверяем права при обновлении позиции."""
item = self.get_object()
procurement_request = ProcurementRequest.objects.select_for_update().get(
pk=item.request_id,
)
item.request = procurement_request
serializer.instance.request = procurement_request
self._check_item_edit_permission(item)
procurement_request = item.request
before = {
field: getattr(item, field)
for field in self.NOTIFICATION_TRACKED_FIELDS
}
if self._starts_execution(item, serializer.validated_data):
self._start_request_for_execution(procurement_request)
procurement_request._notification_actor = self.request.user
updated_item = serializer.save()
after = {
@ -1622,6 +1899,11 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
actor=self.request.user,
changed_fields=changed_fields,
)
self._record_item_update(
updated_item,
self.request.user,
changed_fields,
)
@action(detail=True, methods=["post"])
def report_issue(self, request, pk=None):
@ -1662,6 +1944,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
actor=user,
changed_fields=["execution_status"],
)
self._record_item_update(item, user, ["execution_status"])
if procurement_request.requestor_id == user.id:
notify_item_issue_reported(item, actor=user)
@ -1739,6 +2022,11 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
"received_quantity",
],
)
self._record_item_update(
item,
user,
["execution_status", "ordered_quantity", "received_quantity"],
)
item.refresh_from_db()
serializer = self.get_serializer(item)
@ -1771,6 +2059,7 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
actor=user,
changed_fields=["execution_status"],
)
self._record_item_update(item, user, ["execution_status"])
item.refresh_from_db()
serializer = self.get_serializer(item)
@ -1849,6 +2138,11 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
"received_quantity",
],
)
self._record_item_update(
item,
user,
["execution_status", "ordered_quantity", "received_quantity"],
)
item.refresh_from_db()
serializer = self.get_serializer(item)
@ -1907,6 +2201,18 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
message,
actor=request.user,
)
create_procurement_activity(
item.request,
request.user,
ProcurementActivityAction.COMMENT_ADDED,
object_kind=ProcurementActivityObjectKind.COMMENT,
object_id=message.pk,
metadata={
"item_id": item.pk,
"item_name": item.name,
"comment_text": message.content,
},
)
author_ser = EmployeeBriefSerializer(message.author)
return Response(
{
@ -1967,6 +2273,18 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
deleted_by=request.user,
soft_delete=True,
)
create_procurement_activity(
item.request,
request.user,
ProcurementActivityAction.COMMENT_REMOVED,
object_kind=ProcurementActivityObjectKind.COMMENT,
object_id=message.pk,
metadata={
"item_id": item.pk,
"item_name": item.name,
"comment_text": message.content,
},
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["post"])

View File

@ -465,6 +465,9 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
if calendar_id:
qs = qs.filter(calendar_id=calendar_id)
if search := self.request.query_params.get("search", "").strip():
qs = qs.filter(Q(title__icontains=search) | Q(description__icontains=search))
# Фильтр по диапазону дат
start_str = self.request.query_params.get("start")
end_str = self.request.query_params.get("end")

View File

@ -1,6 +1,9 @@
from urllib.parse import urlsplit
from django.db.models import Max
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils import timezone
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
@ -37,8 +40,12 @@ from tasks.access import (
from tasks.models import (
Task,
TaskActivity,
TaskAttachment,
TaskBoard,
TaskBoardAccessScope,
TaskChecklistItem,
TaskColumn,
TaskExternalLink,
TaskLabel,
TaskLinkedObject,
TaskLinkedObjectKind,
@ -162,6 +169,9 @@ class TaskSerializer(serializers.ModelSerializer):
linked_attendance_records_count = serializers.SerializerMethodField()
linked_objects_count = serializers.SerializerMethodField()
comments_count = serializers.SerializerMethodField()
attachments_count = serializers.SerializerMethodField()
checklist_total = serializers.SerializerMethodField()
checklist_completed = serializers.SerializerMethodField()
class Meta:
model = Task
@ -196,6 +206,9 @@ class TaskSerializer(serializers.ModelSerializer):
"linked_attendance_records_count",
"linked_objects_count",
"comments_count",
"attachments_count",
"checklist_total",
"checklist_completed",
"created_at",
"updated_at",
]
@ -219,6 +232,9 @@ class TaskSerializer(serializers.ModelSerializer):
"linked_attendance_records_count",
"linked_objects_count",
"comments_count",
"attachments_count",
"checklist_total",
"checklist_completed",
"created_at",
"updated_at",
]
@ -307,7 +323,7 @@ class TaskSerializer(serializers.ModelSerializer):
def get_linked_objects_count(self, obj):
if hasattr(obj, "linked_objects_count"):
return obj.linked_objects_count
return obj.linked_objects.count()
return obj.linked_objects.count() + obj.external_links.count()
@extend_schema_field(serializers.IntegerField())
def get_comments_count(self, obj):
@ -315,6 +331,29 @@ class TaskSerializer(serializers.ModelSerializer):
return obj.comments_count
return get_comment_count(obj)
@extend_schema_field(serializers.IntegerField())
def get_attachments_count(self, obj):
if hasattr(obj, "attachments_count"):
return obj.attachments_count
return obj.attachments.count()
@extend_schema_field(serializers.IntegerField())
def get_checklist_total(self, obj):
if hasattr(obj, "checklist_total"):
return obj.checklist_total
return obj.checklist_items.count()
@extend_schema_field(serializers.IntegerField())
def get_checklist_completed(self, obj):
if hasattr(obj, "checklist_completed"):
return obj.checklist_completed
prefetched = getattr(obj, "_prefetched_objects_cache", {}).get(
"checklist_items"
)
if prefetched is not None:
return sum(item.is_completed for item in prefetched)
return obj.checklist_items.filter(is_completed=True).count()
def validate(self, attrs):
request = self.context.get("request")
user = getattr(request, "user", None)
@ -401,6 +440,7 @@ class TaskBoardSerializer(serializers.ModelSerializer):
"id",
"name",
"description",
"access_scope",
"created_by",
"members",
"member_details",
@ -416,6 +456,28 @@ class TaskBoardSerializer(serializers.ModelSerializer):
"updated_at",
]
def validate(self, attrs):
attrs = super().validate(attrs)
access_scope = attrs.get("access_scope")
if access_scope is None:
has_members = bool(attrs.get("members"))
has_departments = bool(attrs.get("departments"))
if has_members or has_departments:
access_scope = TaskBoardAccessScope.RESTRICTED
elif "members" in attrs and "departments" in attrs:
access_scope = TaskBoardAccessScope.ALL
else:
access_scope = getattr(
self.instance,
"access_scope",
TaskBoardAccessScope.ALL,
)
attrs["access_scope"] = access_scope
if access_scope != TaskBoardAccessScope.RESTRICTED:
attrs["members"] = []
attrs["departments"] = []
return attrs
@extend_schema_field(serializers.BooleanField())
def get_is_default_for_current_user(self, obj):
request = self.context.get("request")
@ -572,6 +634,89 @@ class TaskActivitySerializer(serializers.ModelSerializer):
read_only_fields = fields
class TaskAttachmentSerializer(serializers.ModelSerializer):
uploaded_by = EmployeeBriefSerializer(read_only=True)
download_url = serializers.SerializerMethodField()
class Meta:
model = TaskAttachment
fields = [
"id",
"task",
"file_name",
"file_size",
"mime_type",
"uploaded_by",
"created_at",
"download_url",
]
read_only_fields = fields
@extend_schema_field(serializers.URLField())
def get_download_url(self, obj):
url = reverse(
"api:v1:tasks:task-attachment-download",
kwargs={"pk": obj.task_id, "attachment_id": obj.id},
)
request = self.context.get("request")
return request.build_absolute_uri(url) if request else url
class TaskChecklistItemSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)
completed_by = EmployeeBriefSerializer(read_only=True)
class Meta:
model = TaskChecklistItem
fields = [
"id",
"task",
"title",
"position",
"is_completed",
"created_by",
"completed_by",
"completed_at",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"task",
"created_by",
"completed_by",
"completed_at",
"created_at",
"updated_at",
]
def validate_title(self, value):
value = value.strip()
if not value:
raise serializers.ValidationError("Введите текст пункта чек-листа.")
return value
class TaskExternalLinkSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)
class Meta:
model = TaskExternalLink
fields = ["id", "task", "url", "title", "created_by", "created_at"]
read_only_fields = ["id", "task", "created_by", "created_at"]
def validate_url(self, value):
value = value.strip()
if urlsplit(value).scheme.lower() not in {"http", "https"}:
raise serializers.ValidationError(
"Разрешены только ссылки с протоколом http или https."
)
return value
def validate_title(self, value):
return value.strip()
class TaskLinkedCalendarEventSerializer(serializers.ModelSerializer):
created_by = EmployeeBriefSerializer(read_only=True)
event = serializers.SerializerMethodField()

View File

@ -1,5 +1,10 @@
import mimetypes
from pathlib import Path
from urllib.parse import urlsplit
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import (
Count,
IntegerField,
@ -10,6 +15,9 @@ from django.db.models import (
Value,
)
from django.db.models.functions import Coalesce
from django.http import FileResponse
from django.shortcuts import get_object_or_404
from django.utils import timezone
from attendance.models import AttendanceRecord
from communications import comments_helpers
from communications.models import Message
@ -19,7 +27,7 @@ from feed.models import Post
from guests.models import Guest, GuestVisit
from procurement.models import ProcurementRequest
from requests_app.models import Request as EmployeeRequest
from rest_framework import filters, status, viewsets
from rest_framework import filters, parsers, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@ -41,8 +49,11 @@ from tasks.models import (
Task,
TaskActivity,
TaskActivityAction,
TaskActivityObjectKind,
TaskAttachment,
TaskBoard,
TaskColumn,
TaskExternalLink,
TaskLabel,
TaskLinkedObject,
TaskLinkedObjectKind,
@ -63,8 +74,11 @@ from tasks.realtime import (
from .serializers import (
EmployeeBriefSerializer,
TaskActivitySerializer,
TaskAttachmentSerializer,
TaskBoardSerializer,
TaskChecklistItemSerializer,
TaskColumnSerializer,
TaskExternalLinkSerializer,
TaskLabelSerializer,
TaskLinkedCalendarEventSerializer,
TaskLinkedAttendanceRecordSerializer,
@ -123,6 +137,22 @@ def normalize_column_positions(column):
Task.objects.filter(id=task_id).update(position=(index + 1) * 1000)
def send_task_checklist_update(task, event, item_id):
send_task_board_update(
task.board,
event,
"checklist_item",
item_id,
extra={
"task_id": task.id,
"checklist_total": task.checklist_items.count(),
"checklist_completed": task.checklist_items.filter(
is_completed=True
).count(),
},
)
class TaskBoardViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = TaskBoardSerializer
@ -141,6 +171,9 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
"tasks__assignee",
"tasks__labels",
"tasks__linked_objects",
"tasks__external_links",
"tasks__attachments",
"tasks__checklist_items",
)
.annotate(tasks_count=Count("tasks", distinct=True))
.distinct()
@ -330,7 +363,7 @@ class TaskViewSet(viewsets.ModelViewSet):
)
)
.select_related("board", "column", "created_by", "assignee")
.prefetch_related("labels")
.prefetch_related("labels", "checklist_items")
.annotate(
linked_posts_count=Count(
"linked_objects",
@ -402,11 +435,21 @@ class TaskViewSet(viewsets.ModelViewSet):
),
distinct=True,
),
linked_objects_count=Count("linked_objects", distinct=True),
linked_objects_count=(
Count("linked_objects", distinct=True)
+ Count("external_links", distinct=True)
),
comments_count=Coalesce(
Subquery(comments_subquery, output_field=IntegerField()),
Value(0),
),
attachments_count=Count("attachments", distinct=True),
checklist_total=Count("checklist_items", distinct=True),
checklist_completed=Count(
"checklist_items",
filter=Q(checklist_items__is_completed=True),
distinct=True,
),
)
)
params = self.request.query_params
@ -471,6 +514,49 @@ class TaskViewSet(viewsets.ModelViewSet):
instance.delete()
send_task_board_update(board, "deleted", "task", task_id)
def _move_task(self, task, column, position, actor):
old_column = task.column
if position in (None, ""):
max_position = (
Task.objects.filter(column=column).exclude(id=task.id).aggregate(
Max("position")
)["position__max"]
or 0
)
position = max_position + 1000
task.column = column
task.position = position
task.save(
update_fields=["column", "position", "completed_at", "updated_at"]
)
normalize_column_positions(column)
if old_column.id != column.id:
normalize_column_positions(old_column)
task.refresh_from_db()
create_task_activity(
task,
actor,
TaskActivityAction.MOVED,
metadata={
"from_column_id": old_column.id,
"from_column": old_column.name,
"to_column_id": column.id,
"to_column": column.name,
},
)
notify_task_moved(task, actor, old_column, column)
send_task_board_update(
task.board,
"moved",
"task",
task.id,
extra={"column_id": task.column_id},
)
return task
@action(detail=True, methods=["post"])
def move(self, request, pk=None):
task = self.get_object()
@ -494,15 +580,8 @@ class TaskViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
old_column = task.column
if position in (None, ""):
max_position = (
Task.objects.filter(column=column).exclude(id=task.id).aggregate(
Max("position")
)["position__max"]
or 0
)
position = max_position + 1000
position = None
else:
try:
position = int(position)
@ -512,37 +591,88 @@ class TaskViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
task.column = column
task.position = position
task.save(update_fields=["column", "position", "completed_at", "updated_at"])
normalize_column_positions(column)
if old_column.id != column.id:
normalize_column_positions(old_column)
task.refresh_from_db()
task = self._move_task(task, column, position, request.user)
serializer = self.get_serializer(task)
create_task_activity(
task,
request.user,
TaskActivityAction.MOVED,
metadata={
"from_column_id": old_column.id,
"from_column": old_column.name,
"to_column_id": column.id,
"to_column": column.name,
},
)
notify_task_moved(task, request.user, old_column, column)
send_task_board_update(
task.board,
"moved",
"task",
task.id,
extra={"column_id": task.column_id},
)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def claim(self, request, pk=None):
visible_task = self.get_object()
with transaction.atomic():
task = (
Task.objects.select_for_update()
.select_related("board", "column", "assignee")
.get(id=visible_task.id)
)
if task.completed_at or task.column.is_done:
return Response(
{"detail": "Нельзя взять в работу завершённую задачу."},
status=status.HTTP_400_BAD_REQUEST,
)
if task.assignee_id:
if task.assignee_id == request.user.id:
return Response(self.get_serializer(task).data)
return Response(
{"detail": "У задачи уже есть исполнитель."},
status=status.HTTP_409_CONFLICT,
)
task.assignee = request.user
task.save(update_fields=["assignee", "updated_at"])
create_task_activity(
task,
request.user,
TaskActivityAction.CLAIMED,
metadata={"assignee_id": request.user.id},
)
notify_task_updated(
task,
request.user,
{"assignee_id": None},
{"assignee_id": request.user.id},
)
send_task_board_update(
task.board,
"claimed",
"task",
task.id,
extra={"assignee_id": request.user.id},
)
return Response(self.get_serializer(task).data)
@action(detail=True, methods=["post"])
def complete(self, request, pk=None):
task = self.get_object()
if task.assignee_id != request.user.id:
return Response(
{"detail": "Завершить задачу может только назначенный исполнитель."},
status=status.HTTP_403_FORBIDDEN,
)
if task.column.is_done:
return Response(self.get_serializer(task).data)
done_column = (
TaskColumn.objects.filter(
board_id=task.board_id,
is_done=True,
is_archived=False,
)
.order_by("position", "id")
.first()
)
if done_column is None:
return Response(
{"detail": "На доске не настроена завершающая колонка."},
status=status.HTTP_400_BAD_REQUEST,
)
task = self._move_task(task, done_column, None, request.user)
return Response(self.get_serializer(task).data)
@action(detail=True, methods=["get"], url_path="activity")
def activity(self, request, pk=None):
task = self.get_object()
@ -553,12 +683,343 @@ class TaskViewSet(viewsets.ModelViewSet):
serializer = TaskActivitySerializer(queryset, many=True)
return Response(serializer.data)
@action(
detail=True,
methods=["get", "post"],
url_path="attachments",
parser_classes=[parsers.MultiPartParser, parsers.FormParser],
)
def attachments(self, request, pk=None):
task = self.get_object()
if request.method in ("GET", "HEAD"):
queryset = task.attachments.select_related("uploaded_by")
serializer = TaskAttachmentSerializer(
queryset,
many=True,
context=self.get_serializer_context(),
)
return Response(serializer.data)
files = request.FILES.getlist("files")
if not files and request.FILES.get("file"):
files = [request.FILES["file"]]
if not files:
return Response(
{"files": ["Выберите хотя бы один файл."]},
status=status.HTTP_400_BAD_REQUEST,
)
created = []
for uploaded in files:
file_name = Path(uploaded.name.replace("\\", "/")).name[:255]
attachment = TaskAttachment.objects.create(
task=task,
file=uploaded,
file_name=file_name,
file_size=uploaded.size,
mime_type=(uploaded.content_type or "")[:255],
uploaded_by=request.user,
)
created.append(attachment)
create_task_activity(
task,
request.user,
TaskActivityAction.ATTACHMENT_ADDED,
object_id=attachment.id,
metadata={"file_name": attachment.file_name},
)
send_task_board_update(
task.board,
"attachments_updated",
"task",
task.id,
extra={"attachments_count": task.attachments.count()},
)
serializer = TaskAttachmentSerializer(
created,
many=True,
context=self.get_serializer_context(),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(
detail=True,
methods=["get"],
url_path=r"attachments/(?P<attachment_id>\d+)/download",
url_name="attachment-download",
)
def attachment_download(self, request, pk=None, attachment_id=None):
task = self.get_object()
attachment = get_object_or_404(
task.attachments,
id=attachment_id,
)
try:
file_handle = attachment.file.open("rb")
except (FileNotFoundError, OSError):
return Response(
{"detail": "Файл не найден в хранилище."},
status=status.HTTP_404_NOT_FOUND,
)
content_type = attachment.mime_type or (
mimetypes.guess_type(attachment.file_name)[0]
or "application/octet-stream"
)
response = FileResponse(
file_handle,
as_attachment=True,
filename=attachment.file_name,
content_type=content_type,
)
response["X-Content-Type-Options"] = "nosniff"
return response
@action(
detail=True,
methods=["delete"],
url_path=r"attachments/(?P<attachment_id>\d+)",
url_name="attachment-detail",
)
def attachment_detail(self, request, pk=None, attachment_id=None):
task = self.get_object()
attachment = get_object_or_404(
task.attachments,
id=attachment_id,
)
attachment_id = attachment.id
file_name = attachment.file_name
attachment.delete()
create_task_activity(
task,
request.user,
TaskActivityAction.ATTACHMENT_REMOVED,
object_id=attachment_id,
metadata={"file_name": file_name},
)
send_task_board_update(
task.board,
"attachments_updated",
"task",
task.id,
extra={"attachments_count": task.attachments.count()},
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get", "post"], url_path="checklist")
def checklist(self, request, pk=None):
task = self.get_object()
if request.method in ("GET", "HEAD"):
serializer = TaskChecklistItemSerializer(
task.checklist_items.select_related("created_by", "completed_by"),
many=True,
)
return Response(serializer.data)
serializer = TaskChecklistItemSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
position = serializer.validated_data.get("position")
if position is None:
max_position = (
task.checklist_items.aggregate(Max("position"))["position__max"] or 0
)
position = max_position + 1000
is_completed = serializer.validated_data.get("is_completed", False)
item = serializer.save(
task=task,
created_by=request.user,
position=position,
completed_by=request.user if is_completed else None,
completed_at=timezone.now() if is_completed else None,
)
create_task_activity(
task,
request.user,
TaskActivityAction.CHECKLIST_ITEM_ADDED,
object_kind=TaskActivityObjectKind.CHECKLIST_ITEM,
object_id=item.id,
metadata={
"item_title": item.title,
"is_completed": item.is_completed,
},
)
send_task_checklist_update(task, "checklist_updated", item.id)
return Response(
TaskChecklistItemSerializer(item).data,
status=status.HTTP_201_CREATED,
)
@action(
detail=True,
methods=["patch", "delete"],
url_path=r"checklist/(?P<item_id>\d+)",
url_name="checklist-item-detail",
)
def checklist_item_detail(self, request, pk=None, item_id=None):
task = self.get_object()
item = get_object_or_404(task.checklist_items, id=item_id)
if request.method == "DELETE":
item_id = item.id
item_title = item.title
item.delete()
create_task_activity(
task,
request.user,
TaskActivityAction.CHECKLIST_ITEM_REMOVED,
object_kind=TaskActivityObjectKind.CHECKLIST_ITEM,
object_id=item_id,
metadata={"item_title": item_title},
)
send_task_checklist_update(task, "checklist_updated", item_id)
return Response(status=status.HTTP_204_NO_CONTENT)
serializer = TaskChecklistItemSerializer(
item,
data=request.data,
partial=True,
)
serializer.is_valid(raise_exception=True)
previous_title = item.title
previous_completed = item.is_completed
changed_fields = [
field
for field, value in serializer.validated_data.items()
if getattr(item, field) != value
]
if not changed_fields:
return Response(TaskChecklistItemSerializer(item).data)
completion_metadata = {}
if "is_completed" in serializer.validated_data:
is_completed = serializer.validated_data["is_completed"]
completion_metadata = {
"completed_by": request.user if is_completed else None,
"completed_at": timezone.now() if is_completed else None,
}
item = serializer.save(**completion_metadata)
if previous_completed != item.is_completed:
activity_action = (
TaskActivityAction.CHECKLIST_ITEM_COMPLETED
if item.is_completed
else TaskActivityAction.CHECKLIST_ITEM_REOPENED
)
else:
activity_action = TaskActivityAction.CHECKLIST_ITEM_UPDATED
create_task_activity(
task,
request.user,
activity_action,
object_kind=TaskActivityObjectKind.CHECKLIST_ITEM,
object_id=item.id,
metadata={
"item_title": item.title,
"previous_title": previous_title,
"is_completed": item.is_completed,
"fields": changed_fields,
},
)
send_task_checklist_update(task, "checklist_updated", item.id)
return Response(TaskChecklistItemSerializer(item).data)
@action(detail=True, methods=["get", "post"], url_path="external-links")
def external_links(self, request, pk=None):
task = self.get_object()
if request.method in ("GET", "HEAD"):
serializer = TaskExternalLinkSerializer(
task.external_links.select_related("created_by"),
many=True,
)
return Response(serializer.data)
input_serializer = TaskExternalLinkSerializer(data=request.data)
input_serializer.is_valid(raise_exception=True)
url = input_serializer.validated_data["url"]
title = input_serializer.validated_data.get("title", "")
default_title = title or urlsplit(url).netloc or url
link, created = TaskExternalLink.objects.get_or_create(
task=task,
url=url,
defaults={
"title": default_title,
"created_by": request.user,
},
)
if not created and title and title != link.title:
link.title = title
link.save(update_fields=["title"])
if created:
create_task_activity(
task,
request.user,
TaskActivityAction.LINKED,
object_kind=TaskActivityObjectKind.EXTERNAL_LINK,
object_id=link.id,
metadata={
"object_label": link.title or link.url,
"object_type": "Ссылка",
"url": link.url,
},
)
send_task_board_update(
task.board,
"linked",
"external_link",
link.id,
extra={"task_id": task.id, "link_id": link.id},
)
serializer = TaskExternalLinkSerializer(link)
return Response(
serializer.data,
status=(
status.HTTP_201_CREATED if created else status.HTTP_200_OK
),
)
@action(
detail=True,
methods=["delete"],
url_path=r"external-links/(?P<link_id>\d+)",
url_name="external-link-detail",
)
def external_link_detail(self, request, pk=None, link_id=None):
task = self.get_object()
link = get_object_or_404(task.external_links, id=link_id)
link_id = link.id
title = link.title or link.url
url = link.url
link.delete()
create_task_activity(
task,
request.user,
TaskActivityAction.UNLINKED,
object_kind=TaskActivityObjectKind.EXTERNAL_LINK,
object_id=link_id,
metadata={
"object_label": title,
"object_type": "Ссылка",
"url": url,
},
)
send_task_board_update(
task.board,
"unlinked",
"external_link",
link_id,
extra={"task_id": task.id, "link_id": link_id},
)
return Response(status=status.HTTP_204_NO_CONTENT)
def _comment_payload(self, task, message):
return {
"id": message.id,
"task": task.id,
"author": EmployeeBriefSerializer(message.author).data,
"text": message.content,
"is_edited": message.is_edited,
"edited_at": message.edited_at,
"created_at": message.created_at,
}
@ -584,6 +1045,14 @@ class TaskViewSet(viewsets.ModelViewSet):
author=request.user,
content=text,
)
create_task_activity(
task,
request.user,
TaskActivityAction.COMMENT_ADDED,
object_kind=TaskActivityObjectKind.COMMENT,
object_id=message.id,
metadata={"comment_text": message.content},
)
notify_task_comment(task, message)
send_task_board_update(
task.board,
@ -599,8 +1068,9 @@ class TaskViewSet(viewsets.ModelViewSet):
@action(
detail=True,
methods=["delete"],
methods=["patch", "delete"],
url_path=r"comments/(?P<comment_id>\d+)",
url_name="delete-comment",
)
def delete_comment(self, request, pk=None, comment_id=None):
task = self.get_object()
@ -623,6 +1093,49 @@ class TaskViewSet(viewsets.ModelViewSet):
status=status.HTTP_404_NOT_FOUND,
)
if request.method == "PATCH":
if message.author_id != request.user.id:
return Response(
{"detail": "Редактировать комментарий может только его автор."},
status=status.HTTP_403_FORBIDDEN,
)
text = request.data.get("text", "").strip()
if not text:
return Response(
{"text": ["Это поле не может быть пустым."]},
status=status.HTTP_400_BAD_REQUEST,
)
previous_text = message.content
if text == previous_text:
return Response(self._comment_payload(task, message))
comments_helpers.update_comment(
message,
text,
edited_by=request.user,
)
create_task_activity(
task,
request.user,
TaskActivityAction.COMMENT_EDITED,
object_kind=TaskActivityObjectKind.COMMENT,
object_id=message.id,
metadata={
"previous_text": previous_text,
"comment_text": message.content,
},
)
send_task_board_update(
task.board,
"comment_edited",
"task",
task.id,
extra={"comment_id": message.id},
)
return Response(self._comment_payload(task, message))
if not (
request.user.is_staff
or request.user.is_superuser
@ -633,11 +1146,20 @@ class TaskViewSet(viewsets.ModelViewSet):
status=status.HTTP_403_FORBIDDEN,
)
comment_text = message.content
comments_helpers.delete_comment(
message=message,
deleted_by=request.user,
soft_delete=True,
)
create_task_activity(
task,
request.user,
TaskActivityAction.COMMENT_REMOVED,
object_kind=TaskActivityObjectKind.COMMENT,
object_id=message.id,
metadata={"comment_text": comment_text},
)
send_task_board_update(
task.board,
"comment_deleted",

View File

@ -25,7 +25,7 @@ from typing import Optional
from django.contrib.contenttypes.models import ContentType
from django.db.models import QuerySet
from .models import Chat, Message, MessageAttachment
from .models import Chat, Message, MessageAttachment, MessageEditHistory
def get_or_create_comments_chat(
@ -261,7 +261,10 @@ def delete_comment(message: Message, deleted_by, soft_delete: bool = True):
def update_comment(
message: Message, new_content: str, mark_edited: bool = True
message: Message,
new_content: str,
mark_edited: bool = True,
edited_by=None,
):
"""
Обновить текст комментария.
@ -270,12 +273,23 @@ def update_comment(
message: Message - комментарий для обновления
new_content: str - новый текст
mark_edited: bool - пометить как отредактированный
edited_by: User - автор изменения для истории редактирования
Examples:
>>> update_comment(comment, "Новый текст")
"""
from django.utils import timezone
if message.content == new_content:
return message
if mark_edited:
MessageEditHistory.objects.create(
message=message,
previous_content=message.content,
edited_by=edited_by,
)
message.content = new_content
if mark_edited:
@ -285,6 +299,8 @@ def update_comment(
else:
message.save(update_fields=["content"])
return message
def get_comments_chat_if_exists(obj) -> Optional[Chat]:
"""

View File

@ -75,7 +75,12 @@ class DocumentAdmin(admin.ModelAdmin):
)
list_filter = ("sent_to_all", "is_regulation", "uploaded_at", "departments")
search_fields = ("title", "description", "file__name", "file__description")
filter_horizontal = ("recipients", "departments")
filter_horizontal = (
"recipients",
"departments",
"acknowledgement_recipients",
"acknowledgement_departments",
)
actions = ["send_document"]
# Для django-reversion
@ -112,7 +117,14 @@ class DocumentAdmin(admin.ModelAdmin):
(
_("Ознакомление"),
{
"fields": ("recipients_summary", "pending_list"),
"fields": (
"acknowledgement_required",
"acknowledgement_for_all",
"acknowledgement_departments",
"acknowledgement_recipients",
"recipients_summary",
"pending_list",
),
},
),
)
@ -236,14 +248,15 @@ class DocumentAdmin(admin.ModelAdmin):
def acknowledgement_status(self, obj):
"""Статус ознакомления (N из M)."""
recipients = set(self.get_recipients_qs(obj))
recipients = set(self.get_acknowledgement_recipients_qs(obj))
total = len(recipients)
if total == 0:
return "—"
acked_count = DocumentAcknowledgement.objects.filter(
document=obj
document=obj,
user__in=recipients,
).count()
if acked_count == total:
@ -286,6 +299,12 @@ class DocumentAdmin(admin.ModelAdmin):
return list(recipients_set)
def get_acknowledgement_recipients_qs(self, obj):
"""Возвращает сотрудников, обязанных ознакомиться."""
from documents.audience import document_acknowledgement_audience
return list(document_acknowledgement_audience(obj))
def recipients_summary(self, obj):
"""Краткий список получателей."""
recipients = self.get_recipients_qs(obj)
@ -303,7 +322,7 @@ class DocumentAdmin(admin.ModelAdmin):
def pending_list(self, obj):
"""Список сотрудников, которые ещё не ознакомились."""
recipients = set(self.get_recipients_qs(obj))
recipients = set(self.get_acknowledgement_recipients_qs(obj))
acked_ids = set(
DocumentAcknowledgement.objects.filter(document=obj).values_list(
"user_id", flat=True

View File

@ -0,0 +1,51 @@
"""Единые правила вычисления аудиторий документа."""
from django.contrib.auth import get_user_model
from django.db.models import Q, QuerySet
from .models import Document
Employee = get_user_model()
def document_access_audience(document: Document) -> QuerySet:
"""Активные сотрудники, которым документ адресован для просмотра."""
employees = Employee.objects.filter(is_active=True)
if document.sent_to_all:
return employees
return employees.filter(
Q(pk__in=document.recipients.values("pk"))
| Q(
departments_links__department__in=document.departments.all(),
departments_links__is_active=True,
)
).distinct()
def document_acknowledgement_audience(document: Document) -> QuerySet:
"""Активные сотрудники, обязанные ознакомиться с документом."""
access_audience = document_access_audience(document)
if not document.acknowledgement_required:
return access_audience.none()
if document.acknowledgement_for_all:
return access_audience
selected = Employee.objects.filter(is_active=True).filter(
Q(pk__in=document.acknowledgement_recipients.values("pk"))
| Q(
departments_links__department__in=(
document.acknowledgement_departments.all()
),
departments_links__is_active=True,
)
)
return selected.filter(pk__in=access_audience.values("pk")).distinct()
def user_requires_document_acknowledgement(document: Document, user) -> bool:
"""Нужно ли конкретному сотруднику подтверждать ознакомление."""
if not getattr(user, "is_authenticated", False):
return False
return document_acknowledgement_audience(document).filter(pk=user.pk).exists()

View File

@ -0,0 +1,57 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("documents", "0016_document_is_regulation"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name="document",
name="acknowledgement_required",
field=models.BooleanField(
default=True,
help_text="Если включено — сотрудники должны подтвердить ознакомление",
verbose_name="Требуется ознакомление",
),
),
migrations.AddField(
model_name="document",
name="acknowledgement_for_all",
field=models.BooleanField(
default=True,
help_text=(
"Если включено — ознакомиться должны все сотрудники, "
"которым доступен документ"
),
verbose_name="Ознакомление для всех с доступом",
),
),
migrations.AddField(
model_name="document",
name="acknowledgement_departments",
field=models.ManyToManyField(
blank=True,
help_text=(
"Сотрудники выбранных отделов должны ознакомиться с документом"
),
related_name="documents_requiring_acknowledgement",
to="employees.department",
verbose_name="Отделы для ознакомления",
),
),
migrations.AddField(
model_name="document",
name="acknowledgement_recipients",
field=models.ManyToManyField(
blank=True,
help_text="Выбранные сотрудники должны ознакомиться с документом",
related_name="documents_requiring_acknowledgement",
to=settings.AUTH_USER_MODEL,
verbose_name="Сотрудники для ознакомления",
),
),
]

View File

@ -97,12 +97,21 @@ class Document(models.Model):
# Требование ознакомления
acknowledgement_required = models.BooleanField(
_("Требуется ознакомление"),
default=False,
default=True,
help_text=_(
"Если включено — сотрудники должны подтвердить ознакомление"
),
)
acknowledgement_for_all = models.BooleanField(
_("Ознакомление для всех с доступом"),
default=True,
help_text=_(
"Если включено — ознакомиться должны все сотрудники, "
"которым доступен документ"
),
)
is_regulation = models.BooleanField(
_("Регламент"),
default=False,
@ -128,6 +137,24 @@ class Document(models.Model):
),
related_name="document_recipients",
)
acknowledgement_departments = models.ManyToManyField(
"employees.Department",
verbose_name=_("Отделы для ознакомления"),
blank=True,
related_name="documents_requiring_acknowledgement",
help_text=_(
"Сотрудники выбранных отделов должны ознакомиться с документом"
),
)
acknowledgement_recipients = models.ManyToManyField(
User,
verbose_name=_("Сотрудники для ознакомления"),
blank=True,
related_name="documents_requiring_acknowledgement",
help_text=_(
"Выбранные сотрудники должны ознакомиться с документом"
),
)
# Связанные документы
related_documents = models.ManyToManyField(

View File

@ -27,9 +27,19 @@ logger = logging.getLogger(__name__)
Employee = get_user_model()
def _document_ready_payload(document, uploader_name):
def _document_ready_payload(document, uploader_name, recipient=None):
"""Собирает единый текст и данные уведомления о новом документе."""
acknowledgement_required = bool(document.acknowledgement_required)
if recipient is None:
acknowledgement_required = bool(
document.acknowledgement_required
and document.acknowledgement_for_all
)
else:
from documents.audience import user_requires_document_acknowledgement
acknowledgement_required = user_requires_document_acknowledgement(
document, recipient
)
return {
"description": MessageTemplates.document_ready(
uploader_name,
@ -64,7 +74,7 @@ def notify_document_ready(document, recipient):
)
uploader_name = get_uploader_name(document.uploaded_by)
payload = _document_ready_payload(document, uploader_name)
payload = _document_ready_payload(document, uploader_name, recipient)
notify.send(
sender=document.uploaded_by,
@ -101,10 +111,11 @@ def notify_all_employees(document):
# Создаём уведомления
created_count = 0
uploader_name = get_uploader_name(document.uploaded_by)
payload = _document_ready_payload(document, uploader_name)
for employee in active_employees:
try:
payload = _document_ready_payload(
document, uploader_name, employee
)
notify.send(
sender=document.uploaded_by,
recipient=employee,
@ -169,11 +180,10 @@ def notify_specific_users(document, user_ids):
# Уведомляем получателей
created_count = 0
uploader_name = get_uploader_name(document.uploaded_by)
payload = _document_ready_payload(document, uploader_name)
for user_id in user_ids:
try:
user = Employee.objects.get(id=user_id)
payload = _document_ready_payload(document, uploader_name, user)
logger.debug(f"[handlers] Creating notification for user={user_id}")
notify.send(
sender=document.uploaded_by,
@ -254,10 +264,11 @@ def notify_department_employees(document, department_ids):
# Создаём уведомления
created_count = 0
uploader_name = get_uploader_name(document.uploaded_by)
payload = _document_ready_payload(document, uploader_name)
for employee in all_employees:
try:
payload = _document_ready_payload(
document, uploader_name, employee
)
notify.send(
sender=document.uploaded_by,
recipient=employee,
@ -278,6 +289,22 @@ def notify_department_employees(document, department_ids):
logger.info(f"[handlers] Created {created_count} notifications")
def notify_document_audience(document):
"""Уведомляет аудиторию доступа после полной настройки документа."""
if document.sent_to_all:
notify_all_employees(document)
return
from documents.audience import document_access_audience
recipient_ids = set(
document_access_audience(document).values_list("pk", flat=True)
)
if document.uploaded_by_id:
recipient_ids.discard(document.uploaded_by_id)
notify_specific_users(document, recipient_ids)
def notify_all_acknowledged(document, total_recipients, acknowledged_count):
"""
Уведомляет загрузившего о том, что все ознакомились с документом.

View File

@ -41,6 +41,9 @@ def create_document_notification(sender, instance, created, **kwargs):
f"created={created} sent_to_all={instance.sent_to_all}"
)
if getattr(instance, "_suppress_document_notifications", False):
return
if not created:
return
@ -73,6 +76,9 @@ def notify_specific_recipients(sender, instance, action, pk_set, **kwargs):
f"action={action} pk_set={pk_set} sent_to_all={instance.sent_to_all}"
)
if getattr(instance, "_suppress_document_notifications", False):
return
if action != "post_add":
logger.info(f"[signals] Skipping (action={action} != 'post_add')")
return
@ -101,6 +107,9 @@ def notify_department_employees_signal(
f"id={instance.pk} action={action} pk_set={pk_set}"
)
if getattr(instance, "_suppress_document_notifications", False):
return
if action != "post_add":
logger.info(f"[signals] Skipping (action={action} != 'post_add')")
return
@ -134,23 +143,15 @@ def check_all_acknowledged(sender, instance, created, **kwargs):
)
return
# Определяем общее количество получателей
if document.sent_to_all:
total_recipients = Employee.objects.filter(is_active=True).count()
else:
# Считаем получателей из recipients + departments
recipients_count = document.recipients.count()
from documents.audience import document_acknowledgement_audience
# Добавляем сотрудников из отделов
department_employees = set()
for department in document.departments.all():
department_employees.update(department.active_employees)
# Исключаем дубликаты с recipients
total_recipients = recipients_count + len(department_employees)
acknowledgement_audience = document_acknowledgement_audience(document)
total_recipients = acknowledgement_audience.count()
# Проверяем количество ознакомившихся
acknowledged_count = document.acknowledgements.count()
acknowledged_count = document.acknowledgements.filter(
user__in=acknowledgement_audience
).count()
# Если все ознакомились - уведомляем загрузившего
if (

View File

@ -104,6 +104,16 @@ def has_acknowledged_document(user, document):
).exists()
@rules.predicate
def requires_document_acknowledgement(user, document):
"""Пользователь входит в отдельную аудиторию ознакомления."""
if document is None:
return False
from documents.audience import user_requires_document_acknowledgement
return user_requires_document_acknowledgement(document, user)
# -----------------------------------------------------------------------------
# ПРАВИЛА (rules)
# -----------------------------------------------------------------------------
@ -133,7 +143,10 @@ rules.add_rule(
)
# Отметка об ознакомлении (только те, у кого есть доступ)
rules.add_rule("documents.acknowledge_document", has_document_access)
rules.add_rule(
"documents.acknowledge_document",
has_document_access & requires_document_acknowledgement,
)
# Просмотр списка ознакомившихся (все, у кого есть доступ к документу)
rules.add_rule(

View File

@ -12,6 +12,58 @@ except ImportError:
Image = None # type: ignore
def _encode_jpeg(image, quality: int) -> bytes:
"""Кодирует JPEG, обходя ограничения буфера libjpeg для сложных фото."""
options = {
"format": "JPEG",
"quality": quality,
"subsampling": 0,
}
buffer = io.BytesIO()
try:
image.save(
buffer,
optimize=True,
progressive=True,
**options,
)
except OSError:
# Некоторые сборки libjpeg не могут оптимизировать высокоэнтропийное
# изображение через BytesIO. Обычный JPEG остаётся совместимым с AD.
buffer = io.BytesIO()
image.save(
buffer,
optimize=False,
progressive=False,
**options,
)
return buffer.getvalue()
def _best_jpeg_within_limit(
image,
*,
min_quality: int,
max_quality: int,
max_bytes: int,
) -> bytes | None:
"""Возвращает JPEG максимального качества в заданном лимите."""
best = None
low, high = min_quality, max_quality
while low <= high:
quality = (low + high) // 2
encoded = _encode_jpeg(image, quality)
if len(encoded) <= max_bytes:
best = encoded
low = quality + 1
else:
high = quality - 1
return best
def normalize_avatar_to_jpeg(
data: bytes, *, size_px: int = 384, max_kb: int = 100
) -> bytes:
@ -42,6 +94,10 @@ def normalize_avatar_to_jpeg(
"""
if not data:
raise ValueError("avatar: пустые данные")
if size_px <= 0:
raise ValueError("avatar: размер изображения должен быть положительным")
if max_kb <= 0:
raise ValueError("avatar: лимит размера должен быть положительным")
if Image is None:
raise RuntimeError(
"Pillow не установлен. Установите 'Pillow' для обработки аватаров."
@ -86,46 +142,46 @@ def normalize_avatar_to_jpeg(
# вместо resize, который растягивает изображение
img.thumbnail((size_px, size_px), Image.Resampling.LANCZOS)
# Бинарный поиск по качеству для соблюдения max_kb
# КАЧЕСТВО: Повышен минимум до 75, используется progressive и 4:4:4
lo, hi = 75, 98
best = None
while lo <= hi:
mid = (lo + hi) // 2
buf = io.BytesIO()
# МАКСИМАЛЬНОЕ КАЧЕСТВО:
# - progressive: лучше сжатие, плавная загрузка
# - subsampling=0: 4:4:4 (без потери цветности, лучше для лиц)
# - optimize: оптимизация таблиц Хаффмана
img.save(
buf,
format="JPEG",
quality=mid,
optimize=True,
progressive=True,
subsampling=0, # 4:4:4 - без потери цветности
)
kb = len(buf.getvalue()) // 1024
if kb <= max_kb:
best = buf.getvalue()
lo = mid + 1
else:
hi = mid - 1
max_bytes = max_kb * 1024
working = img
if best is None:
# минимально возможное (с теми же настройками качества)
buf = io.BytesIO()
img.save(
buf,
format="JPEG",
quality=75,
optimize=True,
progressive=True,
subsampling=0,
# Сначала сохраняем высокое качество. Если сложное изображение не
# помещается, уменьшаем разрешение, не нарушая жёсткий лимит LDAP.
while True:
best = _best_jpeg_within_limit(
working,
min_quality=75,
max_quality=98,
max_bytes=max_bytes,
)
best = buf.getvalue()
if best is not None:
return best
return best
if max(working.size) <= 64:
break
next_size = tuple(max(1, int(side * 0.85)) for side in working.size)
working = working.resize(next_size, Image.Resampling.LANCZOS)
# Аварийный диапазон качества нужен только для нетипично маленького
# лимита или высокоэнтропийного изображения.
while True:
best = _best_jpeg_within_limit(
working,
min_quality=20,
max_quality=74,
max_bytes=max_bytes,
)
if best is not None:
return best
if max(working.size) <= 1:
raise RuntimeError(
"avatar: невозможно уложить JPEG в заданный лимит"
)
next_size = tuple(max(1, int(side * 0.75)) for side in working.size)
working = working.resize(next_size, Image.Resampling.LANCZOS)
except Exception as exc: # pragma: no cover
raise RuntimeError(f"avatar: ошибка нормализации: {exc}") from exc

View File

@ -6,8 +6,6 @@ from pathlib import Path
from celery.schedules import crontab
from dotenv import load_dotenv
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
@ -243,11 +241,11 @@ MEDIA_URL = "/media/"
# ├── chat_attachments/ ← Вложения в чате
# └── temp/ ← Временные файлы
# Лимиты загрузки файлов
# По умолчанию Django ограничивает загрузку до 2.5MB
# Увеличиваем до 10MB для аватаров и документов
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10 MB
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10 MB
# Параметры обработки загрузок
# Ограничивает только нефайловые данные запроса. Размер файлов не ограничен.
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
# Крупные и небольшие файлы сразу пишутся во временное хранилище, а не в RAM.
FILE_UPLOAD_MAX_MEMORY_SIZE = 0
# -----------------------------------------------------------------------------
# ЛОГИРОВАНИЕ

View File

@ -17,17 +17,6 @@ from .constants import (
Employee = get_user_model()
# --- helpers: валидаторы размера файлов ---
def validate_file_size(max_mb: int):
max_bytes = max_mb * 1024 * 1024
def _v(f):
if f and getattr(f, "size", 0) > max_bytes:
raise ValidationError(f"Файл превышает {max_mb} MB.")
return _v
IMAGE_EXTS = ["jpg", "jpeg", "png", "gif", "webp"]
FILE_EXTS = [
"pdf",
@ -49,12 +38,13 @@ FILE_EXTS = [
@deconstructible
class FileSizeValidator:
"""Оставлен как no-op для совместимости со старыми миграциями."""
def __init__(self, max_mb: int):
self.max_mb = int(max_mb)
def __call__(self, f):
if f and getattr(f, "size", 0) > self.max_mb * 1024 * 1024:
raise ValidationError(f"Файл превышает {self.max_mb} MB.")
return None
def __eq__(self, other):
return (

View File

@ -0,0 +1,21 @@
from .models import ProcurementRequestActivity
def create_procurement_activity(
procurement_request,
actor,
action,
*,
object_kind="",
object_id=None,
metadata=None,
):
"""Write one immutable entry to the procurement request audit trail."""
return ProcurementRequestActivity.objects.create(
request=procurement_request,
actor=actor if getattr(actor, "is_authenticated", False) else None,
action=action,
object_kind=object_kind or "",
object_id=object_id,
metadata=metadata or {},
)

View File

@ -9,5 +9,6 @@ class ProcurementConfig(AppConfig):
def ready(self):
"""Подключение сигналов при загрузке приложения."""
import procurement.notifications.signals # noqa: F401
import procurement.signals # noqa: F401
# django-rules: регистрация предикатов и правил доступа
import procurement.rules # noqa: F401

View File

@ -0,0 +1,35 @@
# Generated by Django 5.2.4 on 2026-07-17 07:34
import django.db.models.deletion
import procurement.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('procurement', '0014_procurementsettings'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ProcurementItemAttachment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(max_length=500, upload_to=procurement.models.procurement_item_attachment_upload_to, verbose_name='Файл')),
('file_name', models.TextField(verbose_name='Имя файла')),
('file_size', models.BigIntegerField(default=0, verbose_name='Размер файла')),
('mime_type', models.CharField(blank=True, max_length=255, verbose_name='MIME-тип')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='procurement.procurementitem', verbose_name='Позиция')),
('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='procurement_item_attachments', to=settings.AUTH_USER_MODEL, verbose_name='Загрузил')),
],
options={
'verbose_name': 'Вложение позиции закупки',
'verbose_name_plural': 'Вложения позиций закупки',
'ordering': ['created_at', 'id'],
},
),
]

View File

@ -0,0 +1,91 @@
# Generated by Django 5.2.4 on 2026-07-17 10:53
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def backfill_procurement_activity(apps, schema_editor):
ProcurementRequest = apps.get_model("procurement", "ProcurementRequest")
Activity = apps.get_model("procurement", "ProcurementRequestActivity")
activities = []
activity_times = []
for procurement_request in ProcurementRequest.objects.iterator(chunk_size=500):
entries = [
(
"created",
procurement_request.created_at,
procurement_request.requestor_id,
),
(
"submitted",
procurement_request.submitted_at,
procurement_request.requestor_id,
),
(
"started",
procurement_request.started_at,
procurement_request.executor_id,
),
(
"completed",
procurement_request.completed_at,
procurement_request.executor_id,
),
]
for action, created_at, actor_id in entries:
if created_at is None:
continue
activities.append(
Activity(
request_id=procurement_request.pk,
actor_id=actor_id,
action=action,
object_kind="request",
object_id=procurement_request.pk,
metadata={"backfilled": True},
)
)
activity_times.append(created_at)
if not activities:
return
Activity.objects.bulk_create(activities, batch_size=500)
for activity, created_at in zip(activities, activity_times):
activity.created_at = created_at
Activity.objects.bulk_update(activities, ["created_at"], batch_size=500)
class Migration(migrations.Migration):
dependencies = [
('procurement', '0015_procurementitemattachment'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ProcurementRequestActivity',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('created', 'Создал заявку'), ('updated', 'Изменил заявку'), ('submitted', 'Отправил на согласование'), ('stage_approved', 'Согласовал этап'), ('stage_rejected', 'Отклонил этап согласования'), ('approved', 'Заявка согласована'), ('started', 'Взял заявку в работу'), ('executor_reassigned', 'Сменил исполнителя'), ('completed', 'Завершил заявку'), ('rejected', 'Отклонил заявку'), ('cancelled', 'Отменил заявку'), ('item_updated', 'Изменил исполнение позиции'), ('all_items_received', 'Отметил все позиции полученными'), ('attachment_added', 'Добавил файл'), ('attachment_removed', 'Удалил файл'), ('comment_added', 'Добавил комментарий'), ('comment_removed', 'Удалил комментарий'), ('arrival_notified', 'Уведомил о поступлении')], max_length=32, verbose_name='Действие')),
('object_kind', models.CharField(blank=True, choices=[('request', 'Заявка'), ('approval', 'Согласование'), ('item', 'Позиция'), ('attachment', 'Файл'), ('comment', 'Комментарий')], max_length=32, verbose_name='Тип объекта')),
('object_id', models.PositiveBigIntegerField(blank=True, null=True, verbose_name='ID объекта')),
('metadata', models.JSONField(blank=True, default=dict, verbose_name='Метаданные')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='procurement_activities', to=settings.AUTH_USER_MODEL, verbose_name='Инициатор')),
('request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='procurement.procurementrequest', verbose_name='Заявка')),
],
options={
'verbose_name': 'История заявки на закупку',
'verbose_name_plural': 'История заявок на закупку',
'ordering': ['-created_at', '-id'],
'indexes': [models.Index(fields=['request', '-created_at'], name='procurement_request_4b22f4_idx')],
},
),
migrations.RunPython(
backfill_procurement_activity,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -308,6 +308,77 @@ class ProcurementRequest(models.Model):
return new_status
class ProcurementActivityObjectKind(models.TextChoices):
REQUEST = "request", "Заявка"
APPROVAL = "approval", "Согласование"
ITEM = "item", "Позиция"
ATTACHMENT = "attachment", "Файл"
COMMENT = "comment", "Комментарий"
class ProcurementActivityAction(models.TextChoices):
CREATED = "created", "Создал заявку"
UPDATED = "updated", "Изменил заявку"
SUBMITTED = "submitted", "Отправил на согласование"
STAGE_APPROVED = "stage_approved", "Согласовал этап"
STAGE_REJECTED = "stage_rejected", "Отклонил этап согласования"
APPROVED = "approved", "Заявка согласована"
STARTED = "started", "Взял заявку в работу"
EXECUTOR_REASSIGNED = "executor_reassigned", "Сменил исполнителя"
COMPLETED = "completed", "Завершил заявку"
REJECTED = "rejected", "Отклонил заявку"
CANCELLED = "cancelled", "Отменил заявку"
ITEM_UPDATED = "item_updated", "Изменил исполнение позиции"
ALL_ITEMS_RECEIVED = "all_items_received", "Отметил все позиции полученными"
ATTACHMENT_ADDED = "attachment_added", "Добавил файл"
ATTACHMENT_REMOVED = "attachment_removed", "Удалил файл"
COMMENT_ADDED = "comment_added", "Добавил комментарий"
COMMENT_REMOVED = "comment_removed", "Удалил комментарий"
ARRIVAL_NOTIFIED = "arrival_notified", "Уведомил о поступлении"
class ProcurementRequestActivity(models.Model):
request = models.ForeignKey(
ProcurementRequest,
on_delete=models.CASCADE,
related_name="activities",
verbose_name="Заявка",
)
actor = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="procurement_activities",
verbose_name="Инициатор",
)
action = models.CharField(
"Действие",
max_length=32,
choices=ProcurementActivityAction.choices,
)
object_kind = models.CharField(
"Тип объекта",
max_length=32,
choices=ProcurementActivityObjectKind.choices,
blank=True,
)
object_id = models.PositiveBigIntegerField("ID объекта", null=True, blank=True)
metadata = models.JSONField("Метаданные", default=dict, blank=True)
created_at = models.DateTimeField("Создано", auto_now_add=True)
class Meta:
verbose_name = "История заявки на закупку"
verbose_name_plural = "История заявок на закупку"
ordering = ["-created_at", "-id"]
indexes = [
models.Index(fields=["request", "-created_at"]),
]
def __str__(self):
return f"{self.request} - {self.action}"
class ProcurementRequestView(models.Model):
"""Персональная отметка просмотра заявки пользователем."""
@ -513,6 +584,7 @@ class ProcurementItem(models.Model):
{"received_quantity": "Не может быть больше количества позиции"}
)
@staticmethod
def _mark_update_field(update_fields, field):
if update_fields is not None:
@ -589,6 +661,48 @@ class ProcurementItem(models.Model):
return result
def procurement_item_attachment_upload_to(instance, filename):
"""Keep item attachments grouped while discarding client-side paths."""
from pathlib import Path
safe_name = Path(filename).name
return f"procurement/item_attachments/{instance.item_id}/{safe_name}"
class ProcurementItemAttachment(models.Model):
"""Файл, прикреплённый к отдельной позиции заявки."""
item = models.ForeignKey(
ProcurementItem,
on_delete=models.CASCADE,
related_name="attachments",
verbose_name="Позиция",
)
file = models.FileField(
"Файл",
upload_to=procurement_item_attachment_upload_to,
max_length=500,
)
file_name = models.TextField("Имя файла")
file_size = models.BigIntegerField("Размер файла", default=0)
mime_type = models.CharField("MIME-тип", max_length=255, blank=True)
uploaded_by = models.ForeignKey(
User,
on_delete=models.PROTECT,
related_name="procurement_item_attachments",
verbose_name="Загрузил",
)
created_at = models.DateTimeField("Создано", auto_now_add=True)
class Meta:
verbose_name = "Вложение позиции закупки"
verbose_name_plural = "Вложения позиций закупки"
ordering = ["created_at", "id"]
def __str__(self):
return self.file_name
class ApprovalRoute(models.Model):
"""Правило получения согласующего для этапа с заданным приоритетом."""

View File

@ -0,0 +1,98 @@
from django.db import transaction
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from .activity import create_procurement_activity
from .constants import ApprovalStatus, ProcurementStatus
from .models import (
Approval,
ProcurementActivityAction,
ProcurementActivityObjectKind,
ProcurementItemAttachment,
ProcurementRequest,
)
REQUEST_STATUS_ACTIONS = {
ProcurementStatus.PENDING: ProcurementActivityAction.SUBMITTED,
ProcurementStatus.APPROVED: ProcurementActivityAction.APPROVED,
ProcurementStatus.WAITING: ProcurementActivityAction.APPROVED,
ProcurementStatus.IN_PROGRESS: ProcurementActivityAction.STARTED,
ProcurementStatus.COMPLETED: ProcurementActivityAction.COMPLETED,
ProcurementStatus.REJECTED: ProcurementActivityAction.REJECTED,
ProcurementStatus.CANCELLED: ProcurementActivityAction.CANCELLED,
}
@receiver(post_save, sender=ProcurementRequest)
def record_procurement_request_activity(sender, instance, created, **kwargs):
actor = getattr(instance, "_notification_actor", None)
if created:
create_procurement_activity(
instance,
actor or instance.requestor,
ProcurementActivityAction.CREATED,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=instance.pk,
metadata={"title": instance.title},
)
return
old_status = getattr(instance, "_original_status", instance.status)
if old_status == instance.status:
return
action = REQUEST_STATUS_ACTIONS.get(instance.status)
if action is None:
return
create_procurement_activity(
instance,
actor,
action,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=instance.pk,
metadata={
"old_status": old_status,
"new_status": instance.status,
"reason": getattr(instance, "cancellation_reason", ""),
},
)
@receiver(post_save, sender=Approval)
def record_procurement_approval_activity(sender, instance, created, **kwargs):
if created:
return
old_status = getattr(instance, "_original_approval_status", instance.status)
if old_status == instance.status:
return
action = {
ApprovalStatus.APPROVED: ProcurementActivityAction.STAGE_APPROVED,
ApprovalStatus.REJECTED: ProcurementActivityAction.STAGE_REJECTED,
}.get(instance.status)
if action is None:
return
create_procurement_activity(
instance.request,
instance.approver,
action,
object_kind=ProcurementActivityObjectKind.APPROVAL,
object_id=instance.pk,
metadata={
"step_name": instance.step_name,
"priority": instance.priority,
"comment": instance.comment,
},
)
@receiver(post_delete, sender=ProcurementItemAttachment)
def delete_procurement_item_attachment_file(sender, instance, **kwargs):
if not instance.file or not instance.file.name:
return
storage = instance.file.storage
name = instance.file.name
transaction.on_commit(
lambda: storage.delete(name) if storage.exists(name) else None
)

View File

@ -2,7 +2,6 @@
from django.conf import settings
MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10 MB
SAFE_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"]
_ALLOWED_EXTS_DOTTED = {
e if str(e).startswith(".") else f".{e}"

View File

@ -0,0 +1,20 @@
# Generated by Django 5.2.4 on 2026-07-16 15:09
import django.core.validators
import requests_app.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('requests_app', '0012_add_maternity_request_type'),
]
operations = [
migrations.AlterField(
model_name='request',
name='attachment',
field=models.FileField(blank=True, null=True, upload_to='requests/attachments/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'jpg', 'jpeg', 'png']), requests_app.models.attachment_mime_validator], verbose_name='Вложение'),
),
]

View File

@ -10,7 +10,6 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from .constants import (
MAX_ATTACHMENT_SIZE,
SAFE_EXTENSIONS,
SAFE_MIME_TYPES,
)
@ -20,12 +19,8 @@ logger = logging.getLogger(__name__)
def attachment_file_size(value):
"""Ограничение размера вложения (10 МБ)."""
if value and getattr(value, "size", 0) > MAX_ATTACHMENT_SIZE:
mb = MAX_ATTACHMENT_SIZE // (1024 * 1024)
raise ValidationError(
_("Размер файла не должен превышать %(mb)s МБ."), params={"mb": mb}
)
"""Оставлено для совместимости со старыми миграциями."""
return None
def attachment_mime_validator(value):
@ -142,7 +137,6 @@ class Request(models.Model):
null=True,
validators=[
FileExtensionValidator(allowed_extensions=SAFE_EXTENSIONS),
attachment_file_size,
attachment_mime_validator,
],
)

View File

@ -64,7 +64,6 @@ const DEFAULT_CONFIG = {
cancelButton: 'Отмена',
errorTooSmall: 'Изображение слишком маленькое. Минимальный размер: ',
errorInvalidFormat: 'Неподдерживаемый формат. Используйте JPG, PNG или GIF.',
errorFileSize: 'Файл слишком большой. Максимум 10 МБ.',
}
};
@ -230,13 +229,6 @@ export class AvatarCropper {
return;
}
// Валидация размера (10 МБ)
if (file.size > 10 * 1024 * 1024) {
alert(this.config.texts.errorFileSize);
event.target.value = '';
return;
}
this.currentFile = file;
// Проверяем размер изображения

View File

@ -8,7 +8,7 @@ from requests_app.models import Request as EmployeeRequest
from schedule.models import Event
from scheduling.services import user_can_view_calendar
from .models import TaskBoard
from .models import TaskBoard, TaskBoardAccessScope
def get_user_department_ids(user) -> list[int]:
@ -39,10 +39,15 @@ def task_board_access_q(user):
department_ids = get_user_department_ids(user)
q = Q(created_by=user)
q |= Q(members=user)
non_private_q = ~Q(access_scope=TaskBoardAccessScope.PRIVATE)
q |= non_private_q & Q(members=user)
if department_ids:
q |= Q(departments__in=department_ids)
q |= Q(members__isnull=True, departments__isnull=True)
q |= non_private_q & Q(departments__in=department_ids)
q |= Q(
access_scope=TaskBoardAccessScope.ALL,
members__isnull=True,
departments__isnull=True,
)
return q
@ -53,13 +58,16 @@ def user_can_access_task_board(user, board: TaskBoard | None) -> bool:
return True
if board.created_by_id == user.id:
return True
if board.access_scope == TaskBoardAccessScope.PRIVATE:
return False
if board.members.filter(id=user.id).exists():
return True
department_ids = get_user_department_ids(user)
if department_ids and board.departments.filter(id__in=department_ids).exists():
return True
return (
not board.members.exists()
return bool(
board.access_scope == TaskBoardAccessScope.ALL
and not board.members.exists()
and not board.departments.exists()
)

View File

@ -3,8 +3,11 @@ from django.contrib import admin
from .models import (
Task,
TaskActivity,
TaskAttachment,
TaskBoard,
TaskChecklistItem,
TaskColumn,
TaskExternalLink,
TaskLabel,
TaskLinkedObject,
TaskUserSettings,
@ -25,8 +28,14 @@ class TaskLabelInline(admin.TabularInline):
@admin.register(TaskBoard)
class TaskBoardAdmin(admin.ModelAdmin):
list_display = ["name", "created_by", "is_archived", "created_at"]
list_filter = ["is_archived", "created_at"]
list_display = [
"name",
"created_by",
"access_scope",
"is_archived",
"created_at",
]
list_filter = ["access_scope", "is_archived", "created_at"]
search_fields = ["name", "description"]
filter_horizontal = ["members", "departments"]
inlines = [TaskColumnInline, TaskLabelInline]
@ -62,6 +71,29 @@ class TaskAdmin(admin.ModelAdmin):
filter_horizontal = ["labels"]
@admin.register(TaskAttachment)
class TaskAttachmentAdmin(admin.ModelAdmin):
list_display = ["file_name", "task", "uploaded_by", "file_size", "created_at"]
list_filter = ["created_at"]
search_fields = ["file_name", "task__title", "uploaded_by__email"]
autocomplete_fields = ["task", "uploaded_by"]
@admin.register(TaskChecklistItem)
class TaskChecklistItemAdmin(admin.ModelAdmin):
list_display = [
"title",
"task",
"position",
"is_completed",
"completed_by",
"updated_at",
]
list_filter = ["is_completed", "created_at", "updated_at"]
search_fields = ["title", "task__title", "created_by__email"]
autocomplete_fields = ["task", "created_by", "completed_by"]
@admin.register(TaskLinkedObject)
class TaskLinkedObjectAdmin(admin.ModelAdmin):
list_display = ["task", "kind", "content_type", "object_id", "created_by", "created_at"]
@ -69,6 +101,14 @@ class TaskLinkedObjectAdmin(admin.ModelAdmin):
search_fields = ["task__title", "created_by__email", "object_id"]
@admin.register(TaskExternalLink)
class TaskExternalLinkAdmin(admin.ModelAdmin):
list_display = ["title", "url", "task", "created_by", "created_at"]
list_filter = ["created_at"]
search_fields = ["title", "url", "task__title", "created_by__email"]
autocomplete_fields = ["task", "created_by"]
@admin.register(TaskActivity)
class TaskActivityAdmin(admin.ModelAdmin):
list_display = ["task", "action", "actor", "object_kind", "object_id", "created_at"]

View File

@ -8,3 +8,4 @@ class TasksConfig(AppConfig):
def ready(self):
import tasks.notifications.signals # noqa: F401
import tasks.signals # noqa: F401

View File

@ -0,0 +1,40 @@
# Generated by Django 5.2.4 on 2026-07-16 14:54
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0012_taskusersettings'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='taskactivity',
name='action',
field=models.CharField(choices=[('created', 'Создал задачу'), ('updated', 'Обновил задачу'), ('moved', 'Переместил задачу'), ('linked', 'Связал объект'), ('unlinked', 'Убрал связь'), ('attachment_added', 'Добавил файл'), ('attachment_removed', 'Удалил файл')], max_length=32, verbose_name='Действие'),
),
migrations.CreateModel(
name='TaskAttachment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to='task_attachments/%Y/%m/%d/', verbose_name='Файл')),
('file_name', models.CharField(max_length=255, verbose_name='Название файла')),
('file_size', models.PositiveBigIntegerField(verbose_name='Размер файла (байты)')),
('mime_type', models.CharField(blank=True, max_length=255, verbose_name='MIME-тип')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Загружено')),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='tasks.task', verbose_name='Задача')),
('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='uploaded_task_attachments', to=settings.AUTH_USER_MODEL, verbose_name='Загрузил')),
],
options={
'verbose_name': 'Вложение задачи',
'verbose_name_plural': 'Вложения задач',
'ordering': ['created_at', 'id'],
'indexes': [models.Index(fields=['task', 'created_at'], name='tasks_taska_task_id_3aa34c_idx')],
},
),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 5.2.4 on 2026-07-16 15:52
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0013_alter_taskactivity_action_taskattachment'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='taskactivity',
name='object_kind',
field=models.CharField(blank=True, choices=[('post', 'Новость'), ('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости'), ('external_link', 'Внешняя ссылка')], max_length=32, verbose_name='Тип объекта'),
),
migrations.CreateModel(
name='TaskExternalLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(max_length=2048, verbose_name='Ссылка')),
('title', models.CharField(blank=True, max_length=255, verbose_name='Название')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_task_external_links', to=settings.AUTH_USER_MODEL, verbose_name='Добавил')),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_links', to='tasks.task', verbose_name='Задача')),
],
options={
'verbose_name': 'Внешняя ссылка задачи',
'verbose_name_plural': 'Внешние ссылки задач',
'ordering': ['-created_at', '-id'],
'indexes': [models.Index(fields=['task', 'created_at'], name='tasks_taske_task_id_d49775_idx')],
'constraints': [models.UniqueConstraint(fields=('task', 'url'), name='uniq_task_external_link')],
},
),
]

View File

@ -0,0 +1,47 @@
# Generated by Django 5.2.4 on 2026-07-16 17:55
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0014_alter_taskactivity_object_kind_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='taskactivity',
name='action',
field=models.CharField(choices=[('created', 'Создал задачу'), ('updated', 'Обновил задачу'), ('moved', 'Переместил задачу'), ('linked', 'Связал объект'), ('unlinked', 'Убрал связь'), ('attachment_added', 'Добавил файл'), ('attachment_removed', 'Удалил файл'), ('checklist_item_added', 'Добавил пункт чек-листа'), ('checklist_item_updated', 'Изменил пункт чек-листа'), ('checklist_item_completed', 'Выполнил пункт чек-листа'), ('checklist_item_reopened', 'Вернул пункт чек-листа в работу'), ('checklist_item_removed', 'Удалил пункт чек-листа')], max_length=32, verbose_name='Действие'),
),
migrations.AlterField(
model_name='taskactivity',
name='object_kind',
field=models.CharField(blank=True, choices=[('post', 'Новость'), ('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости'), ('external_link', 'Внешняя ссылка'), ('checklist_item', 'Пункт чек-листа')], max_length=32, verbose_name='Тип объекта'),
),
migrations.CreateModel(
name='TaskChecklistItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=500, verbose_name='Пункт')),
('position', models.PositiveIntegerField(default=0, verbose_name='Позиция')),
('is_completed', models.BooleanField(default=False, verbose_name='Выполнен')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Выполнен')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')),
('completed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='completed_task_checklist_items', to=settings.AUTH_USER_MODEL, verbose_name='Отметил выполненным')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_task_checklist_items', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checklist_items', to='tasks.task', verbose_name='Задача')),
],
options={
'verbose_name': 'Пункт чек-листа задачи',
'verbose_name_plural': 'Пункты чек-листа задач',
'ordering': ['position', 'id'],
'indexes': [models.Index(fields=['task', 'position'], name='tasks_taskc_task_id_0eaca5_idx'), models.Index(fields=['task', 'is_completed'], name='tasks_taskc_task_id_950de8_idx')],
},
),
]

View File

@ -0,0 +1,43 @@
# Generated by Django 5.2.4 on 2026-07-17 05:29
from django.db import migrations, models
def preserve_restricted_boards(apps, schema_editor):
TaskBoard = apps.get_model("tasks", "TaskBoard")
restricted_ids = TaskBoard.objects.filter(
models.Q(members__isnull=False) | models.Q(departments__isnull=False)
).values_list("id", flat=True)
TaskBoard.objects.filter(id__in=restricted_ids).update(access_scope="restricted")
def restore_legacy_access(apps, schema_editor):
TaskBoard = apps.get_model("tasks", "TaskBoard")
TaskBoard.objects.filter(access_scope="restricted").update(access_scope="all")
class Migration(migrations.Migration):
dependencies = [
("tasks", "0015_alter_taskactivity_action_and_more"),
]
operations = [
migrations.AddField(
model_name="taskboard",
name="access_scope",
field=models.CharField(
choices=[
("all", "Для всех"),
("private", "Для себя"),
("restricted", "Выборочно"),
],
default="all",
max_length=16,
verbose_name="Доступ",
),
),
migrations.RunPython(
preserve_restricted_boards,
restore_legacy_access,
),
]

View File

@ -0,0 +1,59 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("tasks", "0016_taskboard_access_scope"),
]
operations = [
migrations.AlterField(
model_name="taskactivity",
name="action",
field=models.CharField(
choices=[
("created", "Создал задачу"),
("updated", "Обновил задачу"),
("moved", "Переместил задачу"),
("linked", "Связал объект"),
("unlinked", "Убрал связь"),
("attachment_added", "Добавил файл"),
("attachment_removed", "Удалил файл"),
("checklist_item_added", "Добавил пункт чек-листа"),
("checklist_item_updated", "Изменил пункт чек-листа"),
("checklist_item_completed", "Выполнил пункт чек-листа"),
("checklist_item_reopened", "Вернул пункт чек-листа в работу"),
("checklist_item_removed", "Удалил пункт чек-листа"),
("comment_added", "Добавил комментарий"),
("comment_edited", "Изменил комментарий"),
("comment_removed", "Удалил комментарий"),
],
max_length=32,
verbose_name="Действие",
),
),
migrations.AlterField(
model_name="taskactivity",
name="object_kind",
field=models.CharField(
blank=True,
choices=[
("post", "Новость"),
("message", "Сообщение"),
("calendar_event", "Календарное событие"),
("document", "Документ"),
("request", "Заявление"),
("procurement_request", "Заявка на закупку"),
("employee", "Сотрудник"),
("guest", "Гость"),
("guest_visit", "Заявка на гостевой визит"),
("attendance_record", "Запись посещаемости"),
("external_link", "Внешняя ссылка"),
("checklist_item", "Пункт чек-листа"),
("comment", "Комментарий"),
],
max_length=32,
verbose_name="Тип объекта",
),
),
]

View File

@ -0,0 +1,36 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("tasks", "0017_task_comment_activity"),
]
operations = [
migrations.AlterField(
model_name="taskactivity",
name="action",
field=models.CharField(
choices=[
("created", "Создал задачу"),
("updated", "Обновил задачу"),
("claimed", "Взял задачу в работу"),
("moved", "Переместил задачу"),
("linked", "Связал объект"),
("unlinked", "Убрал связь"),
("attachment_added", "Добавил файл"),
("attachment_removed", "Удалил файл"),
("checklist_item_added", "Добавил пункт чек-листа"),
("checklist_item_updated", "Изменил пункт чек-листа"),
("checklist_item_completed", "Выполнил пункт чек-листа"),
("checklist_item_reopened", "Вернул пункт чек-листа в работу"),
("checklist_item_removed", "Удалил пункт чек-листа"),
("comment_added", "Добавил комментарий"),
("comment_edited", "Изменил комментарий"),
("comment_removed", "Удалил комментарий"),
],
max_length=32,
verbose_name="Действие",
),
),
]

View File

@ -13,6 +13,12 @@ class TaskPriority(models.TextChoices):
CRITICAL = "critical", "Критический"
class TaskBoardAccessScope(models.TextChoices):
ALL = "all", "Для всех"
PRIVATE = "private", "Для себя"
RESTRICTED = "restricted", "Выборочно"
class TaskBoard(models.Model):
name = models.CharField("Название", max_length=255)
description = models.TextField("Описание", blank=True)
@ -34,6 +40,12 @@ class TaskBoard(models.Model):
related_name="task_boards",
verbose_name="Отделы",
)
access_scope = models.CharField(
"Доступ",
max_length=16,
choices=TaskBoardAccessScope.choices,
default=TaskBoardAccessScope.ALL,
)
is_archived = models.BooleanField("Архивная", default=False)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
@ -199,6 +211,79 @@ class Task(models.Model):
super().save(*args, **kwargs)
class TaskAttachment(models.Model):
task = models.ForeignKey(
Task,
on_delete=models.CASCADE,
related_name="attachments",
verbose_name="Задача",
)
file = models.FileField(
"Файл",
upload_to="task_attachments/%Y/%m/%d/",
)
file_name = models.CharField("Название файла", max_length=255)
file_size = models.PositiveBigIntegerField("Размер файла (байты)")
mime_type = models.CharField("MIME-тип", max_length=255, blank=True)
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="uploaded_task_attachments",
verbose_name="Загрузил",
)
created_at = models.DateTimeField("Загружено", auto_now_add=True)
class Meta:
verbose_name = "Вложение задачи"
verbose_name_plural = "Вложения задач"
ordering = ["created_at", "id"]
indexes = [models.Index(fields=["task", "created_at"])]
def __str__(self):
return f"{self.task}: {self.file_name}"
class TaskChecklistItem(models.Model):
task = models.ForeignKey(
Task,
on_delete=models.CASCADE,
related_name="checklist_items",
verbose_name="Задача",
)
title = models.CharField("Пункт", max_length=500)
position = models.PositiveIntegerField("Позиция", default=0)
is_completed = models.BooleanField("Выполнен", default=False)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_task_checklist_items",
verbose_name="Создал",
)
completed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="completed_task_checklist_items",
verbose_name="Отметил выполненным",
)
completed_at = models.DateTimeField("Выполнен", null=True, blank=True)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Пункт чек-листа задачи"
verbose_name_plural = "Пункты чек-листа задач"
ordering = ["position", "id"]
indexes = [
models.Index(fields=["task", "position"]),
models.Index(fields=["task", "is_completed"]),
]
def __str__(self):
return f"{self.task}: {self.title}"
class TaskLinkedObjectKind(models.TextChoices):
POST = "post", "Новость"
MESSAGE = "message", "Сообщение"
@ -212,12 +297,45 @@ class TaskLinkedObjectKind(models.TextChoices):
ATTENDANCE_RECORD = "attendance_record", "Запись посещаемости"
class TaskActivityObjectKind(models.TextChoices):
POST = "post", "Новость"
MESSAGE = "message", "Сообщение"
CALENDAR_EVENT = "calendar_event", "Календарное событие"
DOCUMENT = "document", "Документ"
REQUEST = "request", "Заявление"
PROCUREMENT_REQUEST = "procurement_request", "Заявка на закупку"
EMPLOYEE = "employee", "Сотрудник"
GUEST = "guest", "Гость"
GUEST_VISIT = "guest_visit", "Заявка на гостевой визит"
ATTENDANCE_RECORD = "attendance_record", "Запись посещаемости"
EXTERNAL_LINK = "external_link", "Внешняя ссылка"
CHECKLIST_ITEM = "checklist_item", "Пункт чек-листа"
COMMENT = "comment", "Комментарий"
class TaskActivityAction(models.TextChoices):
CREATED = "created", "Создал задачу"
UPDATED = "updated", "Обновил задачу"
CLAIMED = "claimed", "Взял задачу в работу"
MOVED = "moved", "Переместил задачу"
LINKED = "linked", "Связал объект"
UNLINKED = "unlinked", "Убрал связь"
ATTACHMENT_ADDED = "attachment_added", "Добавил файл"
ATTACHMENT_REMOVED = "attachment_removed", "Удалил файл"
CHECKLIST_ITEM_ADDED = "checklist_item_added", "Добавил пункт чек-листа"
CHECKLIST_ITEM_UPDATED = "checklist_item_updated", "Изменил пункт чек-листа"
CHECKLIST_ITEM_COMPLETED = (
"checklist_item_completed",
"Выполнил пункт чек-листа",
)
CHECKLIST_ITEM_REOPENED = (
"checklist_item_reopened",
"Вернул пункт чек-листа в работу",
)
CHECKLIST_ITEM_REMOVED = "checklist_item_removed", "Удалил пункт чек-листа"
COMMENT_ADDED = "comment_added", "Добавил комментарий"
COMMENT_EDITED = "comment_edited", "Изменил комментарий"
COMMENT_REMOVED = "comment_removed", "Удалил комментарий"
class TaskActivity(models.Model):
@ -243,7 +361,7 @@ class TaskActivity(models.Model):
object_kind = models.CharField(
"Тип объекта",
max_length=32,
choices=TaskLinkedObjectKind.choices,
choices=TaskActivityObjectKind.choices,
blank=True,
)
object_id = models.PositiveBigIntegerField("ID объекта", null=True, blank=True)
@ -307,3 +425,36 @@ class TaskLinkedObject(models.Model):
def __str__(self):
return f"{self.task} -> {self.kind}:{self.object_id}"
class TaskExternalLink(models.Model):
task = models.ForeignKey(
Task,
on_delete=models.CASCADE,
related_name="external_links",
verbose_name="Задача",
)
url = models.URLField("Ссылка", max_length=2048)
title = models.CharField("Название", max_length=255, blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_task_external_links",
verbose_name="Добавил",
)
created_at = models.DateTimeField("Создано", auto_now_add=True)
class Meta:
verbose_name = "Внешняя ссылка задачи"
verbose_name_plural = "Внешние ссылки задач"
ordering = ["-created_at", "-id"]
constraints = [
models.UniqueConstraint(
fields=["task", "url"],
name="uniq_task_external_link",
)
]
indexes = [models.Index(fields=["task", "created_at"])]
def __str__(self):
return f"{self.task} -> {self.title or self.url}"

View File

@ -9,7 +9,7 @@ from django.db.models import Q
from employees.models import Department, EmployeeDepartment, RoleAssignment
from .models import TaskBoard
from .models import TaskBoard, TaskBoardAccessScope
logger = logging.getLogger(__name__)
@ -20,16 +20,27 @@ def get_task_board_recipient_ids(board: TaskBoard) -> set[int]:
User = get_user_model()
admin_q = Q(is_staff=True) | Q(is_superuser=True)
is_private = board.access_scope == TaskBoardAccessScope.PRIVATE
if not board.members.exists() and not board.departments.exists():
if (
not is_private
and board.access_scope == TaskBoardAccessScope.ALL
and not board.members.exists()
and not board.departments.exists()
):
return set(
User.objects.filter(is_active=True)
.values_list("id", flat=True)
)
department_ids = list(board.departments.values_list("id", flat=True))
department_ids = (
[]
if is_private
else list(board.departments.values_list("id", flat=True))
)
recipient_ids = {board.created_by_id}
recipient_ids.update(board.members.values_list("id", flat=True))
if not is_private:
recipient_ids.update(board.members.values_list("id", flat=True))
if department_ids:
recipient_ids.update(

15
backend/tasks/signals.py Normal file
View File

@ -0,0 +1,15 @@
from django.db import transaction
from django.db.models.signals import post_delete
from django.dispatch import receiver
from .models import TaskAttachment
@receiver(post_delete, sender=TaskAttachment)
def delete_task_attachment_file(sender, instance, **kwargs):
if not instance.file or not instance.file.name:
return
storage = instance.file.storage
name = instance.file.name
transaction.on_commit(lambda: storage.delete(name) if storage.exists(name) else None)

View File

@ -173,6 +173,15 @@ def api_urls():
def _acknowledgements(pk: int) -> str:
return reverse("api:v1:documents-acknowledgements", args=[pk])
def _comments(pk: int) -> str:
return reverse("api:v1:documents-comments", args=[pk])
def _comment_detail(pk: int, comment_id: int) -> str:
return reverse(
"api:v1:documents-document-comment",
args=[pk, comment_id],
)
def _folder_detail(pk: int) -> str:
return reverse("api:v1:folders-detail", args=[pk])
@ -185,6 +194,8 @@ def api_urls():
"detail": _detail,
"ack": _ack,
"acknowledgements": _acknowledgements,
"comments": _comments,
"comment_detail": _comment_detail,
"folder_detail": _folder_detail,
"folder_archive": _folder_archive,
}
@ -1038,10 +1049,6 @@ class TestDocumentNotifications:
def fake_notify_send(**kwargs):
calls.append(kwargs)
monkeypatch.setattr(
"documents.notifications.handlers.notify.send",
fake_notify_send,
)
author = make_user("required-document-author@example.com")
viewer = make_user("required-document-viewer@example.com")
doc = make_document(
@ -1049,6 +1056,11 @@ class TestDocumentNotifications:
uploaded_by=author,
sent_to_all=False,
acknowledgement_required=True,
recipients=[viewer],
)
monkeypatch.setattr(
"documents.notifications.handlers.notify.send",
fake_notify_send,
)
notify_document_ready(doc, viewer)
@ -1208,6 +1220,197 @@ class TestAcknowledge:
user=viewer,
).exists()
def test_new_document_defaults_to_all_access_and_all_acknowledgement(
self, auth_client, make_user, api_urls
):
"""Новый документ без настроек доступен всем и требует ознакомления от всех."""
author = make_user("default-audience-author@example.com")
response = auth_client(author).post(
api_urls["list"],
{"title": "Общий регламент"},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED, response.content
document = Document.objects.get(pk=response.json()["id"])
assert document.sent_to_all is True
assert document.acknowledgement_required is True
assert document.acknowledgement_for_all is True
assert response.json()["acknowledgement_required_for_user"] is True
def test_selective_acknowledgement_is_independent_from_access(
self, auth_client, make_user, api_urls
):
"""Доступный документ подтверждают только сотрудники из отдельной аудитории."""
author = make_user("selective-author@example.com")
required_user = make_user("selective-required@example.com")
access_only_user = make_user("selective-access-only@example.com")
response = auth_client(author).post(
api_urls["list"],
{
"title": "Выборочное ознакомление",
"sent_to_all": False,
"recipient_ids": [required_user.id, access_only_user.id],
"acknowledgement_required": True,
"acknowledgement_for_all": False,
"acknowledgement_recipient_ids": [required_user.id],
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED, response.content
document_id = response.json()["id"]
assert [
recipient["id"]
for recipient in response.json()["acknowledgement_recipients"]
] == [required_user.id]
access_only_detail = auth_client(access_only_user).get(
api_urls["detail"](document_id)
)
assert access_only_detail.status_code == status.HTTP_200_OK
assert access_only_detail.json()["acknowledgement_required_for_user"] is False
assert access_only_detail.json()["acknowledged_count"] == 0
assert access_only_detail.json()["acknowledgement_total"] == 1
rejected = auth_client(access_only_user).post(api_urls["ack"](document_id), {})
assert rejected.status_code == status.HTTP_400_BAD_REQUEST
required_detail = auth_client(required_user).get(api_urls["detail"](document_id))
assert required_detail.json()["acknowledgement_required_for_user"] is True
acknowledged = auth_client(required_user).post(api_urls["ack"](document_id), {})
assert acknowledged.status_code == status.HTTP_200_OK
refreshed_detail = auth_client(required_user).get(
api_urls["detail"](document_id)
)
assert refreshed_detail.json()["acknowledged_count"] == 1
assert refreshed_detail.json()["acknowledgement_total"] == 1
report = auth_client(access_only_user).get(
api_urls["acknowledgements"](document_id)
)
assert report.status_code == status.HTTP_200_OK
assert report.json()["counts"] == {
"acknowledged": 1,
"unacknowledged": 0,
"total": 1,
}
assert report.json()["acknowledged"][0]["id"] == required_user.id
def test_selective_acknowledgement_cannot_exceed_document_access(
self, auth_client, make_user, api_urls
):
"""Нельзя обязать ознакомиться сотрудника без доступа к документу."""
author = make_user("invalid-audience-author@example.com")
recipient = make_user("invalid-audience-recipient@example.com")
outsider = make_user("invalid-audience-outsider@example.com")
response = auth_client(author).post(
api_urls["list"],
{
"title": "Некорректная аудитория",
"sent_to_all": False,
"recipient_ids": [recipient.id],
"acknowledgement_required": True,
"acknowledgement_for_all": False,
"acknowledgement_recipient_ids": [outsider.id],
},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "acknowledgement_recipient_ids" in response.json()
def test_edit_replaces_acknowledgement_audience_without_changing_access(
self, auth_client, make_user, api_urls
):
"""Редактирование меняет только отдельную аудиторию ознакомления."""
author = make_user("edit-audience-author@example.com")
grant_perms(author, "change_document")
first_recipient = make_user("edit-audience-first@example.com")
second_recipient = make_user("edit-audience-second@example.com")
document = make_document(
uploaded_by=author,
sent_to_all=False,
acknowledgement_required=True,
recipients=[first_recipient, second_recipient],
)
document.acknowledgement_for_all = False
document.save(update_fields=["acknowledgement_for_all"])
document.acknowledgement_recipients.set([first_recipient])
response = auth_client(author).patch(
api_urls["detail"](document.pk),
{"acknowledgement_recipient_ids": [second_recipient.id]},
format="json",
)
assert response.status_code == status.HTTP_200_OK, response.content
document.refresh_from_db()
assert set(document.recipients.values_list("id", flat=True)) == {
first_recipient.id,
second_recipient.id,
}
assert list(
document.acknowledgement_recipients.values_list("id", flat=True)
) == [second_recipient.id]
assert [
recipient["id"]
for recipient in response.json()["acknowledgement_recipients"]
] == [second_recipient.id]
def test_document_comments_crud_and_counter(
self, auth_client, make_user, api_urls
):
"""Комментарии документа работают через Communications и попадают в счётчик."""
author = make_user("document-comment-author@example.com")
other_user = make_user("document-comment-other@example.com")
document = make_document(
uploaded_by=author,
sent_to_all=True,
)
client = auth_client(author)
empty_detail = client.get(api_urls["detail"](document.pk))
assert empty_detail.json()["comments_count"] == 0
created = client.post(
api_urls["comments"](document.pk),
{"text": "Первый комментарий"},
format="json",
)
assert created.status_code == status.HTTP_201_CREATED, created.content
comment_id = created.json()["id"]
assert created.json()["text"] == "Первый комментарий"
detail = client.get(api_urls["detail"](document.pk))
assert detail.json()["comments_count"] == 1
edited = client.patch(
api_urls["comment_detail"](document.pk, comment_id),
{"text": "Исправленный комментарий"},
format="json",
)
assert edited.status_code == status.HTTP_200_OK
assert edited.json()["text"] == "Исправленный комментарий"
assert edited.json()["is_edited"] is True
forbidden = auth_client(other_user).patch(
api_urls["comment_detail"](document.pk, comment_id),
{"text": "Чужая правка"},
format="json",
)
assert forbidden.status_code == status.HTTP_403_FORBIDDEN
deleted = client.delete(
api_urls["comment_detail"](document.pk, comment_id)
)
assert deleted.status_code == status.HTTP_204_NO_CONTENT
assert client.get(api_urls["detail"](document.pk)).json()[
"comments_count"
] == 0
class TestAcknowledgementsReport:
"""Ведомость ознакомлений доступна всем, кому доступен документ."""
@ -1379,10 +1582,10 @@ class TestErrorsAndEdgeCases:
assert client.get(api_urls["list"]).status_code == 200
assert client.get(api_urls["detail"](d.pk)).status_code == 404
def test_big_file_over_limit_if_configured(
self, auth_client, make_user, api_urls, settings
def test_big_file_is_accepted(
self, auth_client, make_user, api_urls
):
"""Большой файл > лимита → ожидаем 413 или 400 (если лимит включён); иначе 201."""
"""Размер файла сам по себе не блокирует загрузку."""
uploader = make_user("uploader@example.com", staff=True)
client = auth_client(uploader)
@ -1392,10 +1595,7 @@ class TestErrorsAndEdgeCases:
{"title": "Big", "file": big, "sent_to_all": True},
format="multipart",
)
# django-filer не проверяет DATA_UPLOAD_MAX_MEMORY_SIZE при загрузке
# Лимит применяется на уровне Django request parserа, но во время тестов это может не работать
# Просто проверяем, что запрос корректно обработан
assert r.status_code in (200, 201, 400, 413)
assert r.status_code == 201
# -------------------- I. Производительность/фильтры --------------------

View File

@ -9,6 +9,7 @@ from unittest.mock import patch
import pytest
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from django.utils.dateparse import parse_datetime
from django.utils import timezone
@ -37,8 +38,11 @@ from procurement.models import (
Approval,
ApprovalRoute,
Budget,
ProcurementActivityAction,
ProcurementItem,
ProcurementItemAttachment,
ProcurementRequest,
ProcurementRequestActivity,
ProcurementSettings,
)
from procurement.notifications.handlers import notify_new_request
@ -605,6 +609,10 @@ class TestProcurementRequestCreate:
assert response.data['title'] == 'Закупка мониторов'
assert response.data['requestor'] == user.id
assert response.data['status'] == ProcurementStatus.DRAFT
assert [item['id'] for item in response.data['items']] == list(
ProcurementRequest.objects.get(pk=response.data['id'])
.items.values_list('id', flat=True)
)
# Проверяем что позиции созданы
request_id = response.data['id']
@ -834,6 +842,17 @@ class TestProcurementRequestComments:
after = api_client.get(list_url)
after_item = next(item for item in after.data['results'] if item['id'] == procurement_request.id)
assert after_item['comments_count'] == 0
assert list(
procurement_request.activities.filter(
action__in=[
ProcurementActivityAction.COMMENT_ADDED,
ProcurementActivityAction.COMMENT_REMOVED,
]
).values_list("action", flat=True)
) == [
ProcurementActivityAction.COMMENT_REMOVED,
ProcurementActivityAction.COMMENT_ADDED,
]
class TestProcurementItemComments:
@ -912,6 +931,18 @@ class TestProcurementItemComments:
if item['id'] == procurement_item.id
)
assert after_item['comments_count'] == 0
comment_activities = procurement_item.request.activities.filter(
action__in=[
ProcurementActivityAction.COMMENT_ADDED,
ProcurementActivityAction.COMMENT_REMOVED,
],
object_id=comment_id,
)
assert list(comment_activities.values_list("action", flat=True)) == [
ProcurementActivityAction.COMMENT_REMOVED,
ProcurementActivityAction.COMMENT_ADDED,
]
assert comment_activities.first().metadata["item_id"] == procurement_item.id
def test_outsider_cannot_comment_item(
self, api_client, procurement_item
@ -992,6 +1023,123 @@ class TestProcurementItemComments:
assert response.data['item'] == item.id
class TestProcurementItemAttachments:
def test_archive_attachment_lifecycle(
self,
api_client,
user,
procurement_item,
settings,
tmp_path,
):
settings.MEDIA_ROOT = tmp_path
api_client.force_authenticate(user=user)
attachments_url = reverse(
'api:v1:procurement:procurementitem-attachments',
kwargs={'pk': procurement_item.id},
)
archive_content = b'PK\x03\x04procurement archive content'
response = api_client.post(
attachments_url,
{
'files': SimpleUploadedFile(
'specification.zip',
archive_content,
content_type='application/zip',
),
},
format='multipart',
)
assert response.status_code == status.HTTP_201_CREATED
assert response.data[0]['file_name'] == 'specification.zip'
assert response.data[0]['mime_type'] == 'application/zip'
attachment_id = response.data[0]['id']
assert ProcurementItemAttachment.objects.filter(pk=attachment_id).exists()
request_detail = api_client.get(
reverse(
'api:v1:procurement:procurementrequest-detail',
kwargs={'pk': procurement_item.request_id},
)
)
detail_item = next(
item for item in request_detail.data['items']
if item['id'] == procurement_item.id
)
assert detail_item['attachments_count'] == 1
assert detail_item['attachments'][0]['id'] == attachment_id
attachment_url = reverse(
'api:v1:procurement:procurementitem-attachment-detail',
kwargs={
'pk': procurement_item.id,
'attachment_id': attachment_id,
},
)
download_response = api_client.get(attachment_url)
assert download_response.status_code == status.HTTP_200_OK
assert b''.join(download_response.streaming_content) == archive_content
delete_response = api_client.delete(attachment_url)
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
assert not ProcurementItemAttachment.objects.filter(pk=attachment_id).exists()
attachment_activities = procurement_item.request.activities.filter(
action__in=[
ProcurementActivityAction.ATTACHMENT_ADDED,
ProcurementActivityAction.ATTACHMENT_REMOVED,
],
object_id=attachment_id,
)
assert list(attachment_activities.values_list("action", flat=True)) == [
ProcurementActivityAction.ATTACHMENT_REMOVED,
ProcurementActivityAction.ATTACHMENT_ADDED,
]
assert attachment_activities.first().metadata["file_name"] == "specification.zip"
def test_outsider_cannot_access_attachment(
self,
api_client,
procurement_item,
user,
settings,
tmp_path,
):
settings.MEDIA_ROOT = tmp_path
attachment = ProcurementItemAttachment.objects.create(
item=procurement_item,
file=SimpleUploadedFile('private.txt', b'private'),
file_name='private.txt',
file_size=7,
mime_type='text/plain',
uploaded_by=user,
)
outsider = Employee.objects.create_user(
email='attachment-outsider@example.com',
password='testpass123',
phone_number='+79997777773',
first_name='Чужой',
last_name='Получатель',
is_active=True,
email_verified=True,
send_activation_email=False,
)
api_client.force_authenticate(user=outsider)
response = api_client.get(
reverse(
'api:v1:procurement:procurementitem-attachment-detail',
kwargs={
'pk': procurement_item.id,
'attachment_id': attachment.id,
},
)
)
assert response.status_code == status.HTTP_403_FORBIDDEN
# ==============================================================================
# ТЕСТЫ ОБНОВЛЕНИЯ ЗАЯВОК
# ==============================================================================
@ -1039,6 +1187,67 @@ class TestProcurementRequestUpdate:
assert response.status_code == status.HTTP_403_FORBIDDEN
class TestProcurementRequestActivity:
def test_activity_endpoint_returns_creation_and_update_history(
self,
api_client,
user,
procurement_request,
):
api_client.force_authenticate(user=user)
detail_url = reverse(
"api:v1:procurement:procurementrequest-detail",
kwargs={"pk": procurement_request.pk},
)
activity_url = reverse(
"api:v1:procurement:procurementrequest-activity",
kwargs={"pk": procurement_request.pk},
)
update_response = api_client.patch(
detail_url,
{"title": "Новое название истории"},
format="json",
)
assert update_response.status_code == status.HTTP_200_OK
response = api_client.get(activity_url)
assert response.status_code == status.HTTP_200_OK
assert [entry["action"] for entry in response.data[:2]] == [
ProcurementActivityAction.UPDATED,
ProcurementActivityAction.CREATED,
]
assert response.data[0]["actor"]["id"] == user.id
assert response.data[0]["metadata"]["fields"] == ["title"]
detail_response = api_client.get(detail_url)
assert detail_response.status_code == status.HTTP_200_OK
assert detail_response.data["activity_count"] == 2
def test_outsider_cannot_read_activity(
self,
api_client,
procurement_request,
):
outsider = Employee.objects.create_user(
email="activity-outsider@example.com",
password="testpass123",
phone_number="+79998888777",
is_active=True,
email_verified=True,
send_activation_email=False,
)
api_client.force_authenticate(user=outsider)
url = reverse(
"api:v1:procurement:procurementrequest-activity",
kwargs={"pk": procurement_request.pk},
)
response = api_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
# ==============================================================================
# ТЕСТЫ АДРЕСНЫХ ЗАЯВОК В ОТДЕЛ-ИСПОЛНИТЕЛЬ
# ==============================================================================
@ -1542,6 +1751,11 @@ class TestProcessingDepartmentWorkflow:
processing_request.refresh_from_db()
assert processing_request.executor == supply_user
assert processing_request.status == ProcurementStatus.IN_PROGRESS
started_activity = ProcurementRequestActivity.objects.filter(
request=processing_request,
action=ProcurementActivityAction.STARTED,
).latest("created_at")
assert started_activity.actor == supply_user
api_client.force_authenticate(user=user)
edit_response = api_client.patch(
@ -1598,11 +1812,17 @@ class TestProcessingDepartmentWorkflow:
processing_request.refresh_from_db()
assert processing_request.executor == supply_replacement_user
assert processing_request.status == ProcurementStatus.IN_PROGRESS
assert processing_request.started_at > previous_started_at
assert processing_request.started_at == previous_started_at
assert response.data["executor"] == supply_replacement_user.id
assert response.data["executor_name"] == (
supply_replacement_user.get_full_name()
)
reassigned_activity = ProcurementRequestActivity.objects.get(
request=processing_request,
action=ProcurementActivityAction.EXECUTOR_REASSIGNED,
)
assert reassigned_activity.actor == supply_replacement_user
assert reassigned_activity.metadata["previous_executor_id"] == supply_user.id
def test_available_scope_includes_reassignable_processing_request(
self, api_client, supply_user, supply_replacement_user,
@ -1865,6 +2085,110 @@ class TestProcessingDepartmentWorkflow:
item.refresh_from_db()
assert item.execution_status == ProcurementItemExecutionStatus.RECEIVED
assert item.received_quantity == item.quantity
actions = set(
processing_request.activities.values_list("action", flat=True)
)
assert ProcurementActivityAction.ITEM_UPDATED in actions
assert ProcurementActivityAction.ALL_ITEMS_RECEIVED in actions
def test_filling_execution_automatically_starts_request(
self, api_client, supply_user, processing_request,
procurement_item_factory
):
item = procurement_item_factory(
request=processing_request,
quantity=3,
)
api_client.force_authenticate(user=supply_user)
item_url = reverse(
'api:v1:procurement:procurementitem-detail',
kwargs={'pk': item.id},
)
response = api_client.patch(
item_url,
{
'actual_unit_price': '125.00',
'ordered_quantity': 1,
},
format='json',
)
assert response.status_code == status.HTTP_200_OK
processing_request.refresh_from_db()
assert processing_request.status == ProcurementStatus.IN_PROGRESS
assert processing_request.executor == supply_user
assert processing_request.started_at is not None
item_activity = processing_request.activities.filter(
action=ProcurementActivityAction.ITEM_UPDATED,
object_id=item.id,
).latest("created_at")
assert item_activity.actor == supply_user
assert set(item_activity.metadata["fields"]) == {
"actual_unit_price",
"execution_status",
"ordered_quantity",
}
def test_later_execution_updates_keep_original_start_date(
self, api_client, supply_user, processing_request,
procurement_item_factory
):
item = procurement_item_factory(
request=processing_request,
quantity=3,
)
original_started_at = timezone.now() - timedelta(days=2)
processing_request.status = ProcurementStatus.IN_PROGRESS
processing_request.executor = supply_user
processing_request.started_at = original_started_at
processing_request.save(
update_fields=[
'status',
'executor',
'started_at',
'updated_at',
]
)
api_client.force_authenticate(user=supply_user)
response = api_client.patch(
reverse(
'api:v1:procurement:procurementitem-detail',
kwargs={'pk': item.id},
),
{'ordered_quantity': 2},
format='json',
)
assert response.status_code == status.HTTP_200_OK
processing_request.refresh_from_db()
assert processing_request.started_at == original_started_at
def test_regular_item_edit_does_not_start_request(
self, api_client, user, processing_request,
procurement_item_factory
):
item = procurement_item_factory(
request=processing_request,
name='Исходное название',
)
api_client.force_authenticate(user=user)
response = api_client.patch(
reverse(
'api:v1:procurement:procurementitem-detail',
kwargs={'pk': item.id},
),
{'name': 'Уточнённое название'},
format='json',
)
assert response.status_code == status.HTTP_200_OK
processing_request.refresh_from_db()
assert processing_request.status == ProcurementStatus.WAITING
assert processing_request.executor is None
assert processing_request.started_at is None
def test_author_can_edit_waiting_request_with_items_before_work(
self, api_client, user, department, supply_department,

View File

@ -4,6 +4,7 @@ from datetime import date, timedelta
import pytest
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
@ -19,7 +20,15 @@ from procurement.constants import ProcurementStatus, UrgencyLevel
from procurement.models import ProcurementRequest
from requests_app.models import Request as EmployeeRequest
from schedule.models import Calendar, CalendarRelation, Event
from tasks.models import Task, TaskBoard, TaskColumn, TaskUserSettings
from tasks.models import (
Task,
TaskAttachment,
TaskBoard,
TaskChecklistItem,
TaskColumn,
TaskExternalLink,
TaskUserSettings,
)
from tasks.realtime import get_task_board_recipient_ids
pytestmark = pytest.mark.django_db
@ -180,6 +189,182 @@ def test_create_task_and_move_to_done_column(api_client, user):
assert task.completed_at is not None
def test_member_can_claim_unassigned_task(api_client, user):
claimant = make_user("task-claimant@example.com", "+79994440095")
other_member = make_user("task-claim-other@example.com", "+79994440096")
board = TaskBoard.objects.create(name="Доска самоназначения", created_by=user)
board.members.add(claimant, other_member)
source_column = TaskColumn.objects.create(
board=board,
name="Новые",
position=1000,
)
done_column = TaskColumn.objects.create(
board=board,
name="Готово",
position=2000,
is_done=True,
)
task = Task.objects.create(
board=board,
column=source_column,
title="Свободная задача",
created_by=user,
)
claim_url = reverse("api:v1:tasks:task-claim", kwargs={"pk": task.id})
api_client.force_authenticate(user=claimant)
with (
patch("api.v1.tasks.views.notify_task_updated") as notify_updated,
patch("api.v1.tasks.views.send_task_board_update") as send_update,
):
claim_response = api_client.post(claim_url)
assert claim_response.status_code == status.HTTP_200_OK
assert claim_response.data["assignee"]["id"] == claimant.id
assert claim_response.data["column"] == source_column.id
task.refresh_from_db()
assert task.assignee_id == claimant.id
assert task.column_id == source_column.id
claimed_activity = task.activities.get(action="claimed")
assert claimed_activity.actor_id == claimant.id
assert claimed_activity.metadata == {"assignee_id": claimant.id}
notify_updated.assert_called_once()
send_update.assert_called_once()
send_args, send_kwargs = send_update.call_args
assert send_args[1:4] == ("claimed", "task", task.id)
assert send_kwargs["extra"] == {"assignee_id": claimant.id}
with (
patch("api.v1.tasks.views.notify_task_updated") as notify_updated,
patch("api.v1.tasks.views.send_task_board_update") as send_update,
):
repeated_response = api_client.post(claim_url)
assert repeated_response.status_code == status.HTTP_200_OK
assert task.activities.filter(action="claimed").count() == 1
notify_updated.assert_not_called()
send_update.assert_not_called()
api_client.force_authenticate(user=other_member)
conflict_response = api_client.post(claim_url)
assert conflict_response.status_code == status.HTTP_409_CONFLICT
assert conflict_response.data["detail"] == "У задачи уже есть исполнитель."
task.refresh_from_db()
assert task.assignee_id == claimant.id
completed_task = Task.objects.create(
board=board,
column=done_column,
title="Завершённая задача",
created_by=user,
)
completed_response = api_client.post(
reverse("api:v1:tasks:task-claim", kwargs={"pk": completed_task.id})
)
assert completed_response.status_code == status.HTTP_400_BAD_REQUEST
assert completed_response.data["detail"] == "Нельзя взять в работу завершённую задачу."
completed_task.refresh_from_db()
assert completed_task.assignee_id is None
def test_only_assignee_can_complete_task_in_first_done_column(api_client, user):
assignee = make_user("task-assignee@example.com", "+79994440093")
member = make_user("task-member@example.com", "+79994440094")
board = TaskBoard.objects.create(name="Доска завершения", created_by=user)
board.members.add(assignee, member)
source_column = TaskColumn.objects.create(
board=board,
name="В работе",
position=1000,
)
first_done_column = TaskColumn.objects.create(
board=board,
name="Готово",
position=2000,
is_done=True,
)
TaskColumn.objects.create(
board=board,
name="Архив завершенных",
position=3000,
is_done=True,
)
task = Task.objects.create(
board=board,
column=source_column,
title="Завершить исполнителем",
created_by=user,
assignee=assignee,
)
complete_url = reverse("api:v1:tasks:task-complete", kwargs={"pk": task.id})
api_client.force_authenticate(user=member)
forbidden_response = api_client.post(complete_url)
assert forbidden_response.status_code == status.HTTP_403_FORBIDDEN
task.refresh_from_db()
assert task.column_id == source_column.id
assert task.completed_at is None
api_client.force_authenticate(user=assignee)
with (
patch("api.v1.tasks.views.notify_task_moved") as notify_moved,
patch("api.v1.tasks.views.send_task_board_update") as send_update,
):
complete_response = api_client.post(complete_url)
assert complete_response.status_code == status.HTTP_200_OK
assert complete_response.data["column"] == first_done_column.id
assert complete_response.data["completed_at"] is not None
task.refresh_from_db()
assert task.column_id == first_done_column.id
assert task.completed_at is not None
moved_activity = task.activities.get(action="moved")
assert moved_activity.actor_id == assignee.id
assert moved_activity.metadata == {
"from_column_id": source_column.id,
"from_column": source_column.name,
"to_column_id": first_done_column.id,
"to_column": first_done_column.name,
}
notify_moved.assert_called_once()
send_update.assert_called_once()
with (
patch("api.v1.tasks.views.notify_task_moved") as notify_moved,
patch("api.v1.tasks.views.send_task_board_update") as send_update,
):
repeated_response = api_client.post(complete_url)
assert repeated_response.status_code == status.HTTP_200_OK
assert task.activities.filter(action="moved").count() == 1
notify_moved.assert_not_called()
send_update.assert_not_called()
def test_assignee_cannot_complete_task_without_done_column(api_client, user):
board = TaskBoard.objects.create(name="Доска без финала", created_by=user)
column = TaskColumn.objects.create(board=board, name="В работе", position=1000)
task = Task.objects.create(
board=board,
column=column,
title="Некуда завершать",
created_by=user,
assignee=user,
)
api_client.force_authenticate(user=user)
response = api_client.post(
reverse("api:v1:tasks:task-complete", kwargs={"pk": task.id})
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data["detail"] == "На доске не настроена завершающая колонка."
task.refresh_from_db()
assert task.column_id == column.id
assert task.completed_at is None
def test_task_rejects_column_from_another_board(api_client, user):
api_client.force_authenticate(user=user)
first = api_client.get(reverse("api:v1:tasks:task-board-default")).data
@ -207,6 +392,308 @@ def test_task_rejects_column_from_another_board(api_client, user):
assert "column" in response.data
def test_task_attachments_follow_board_access_and_support_full_lifecycle(
api_client,
user,
tmp_path,
settings,
django_capture_on_commit_callbacks,
):
settings.MEDIA_ROOT = tmp_path
member = make_user("task-file-member@example.com", "+79994440101")
outsider = make_user("task-file-outsider@example.com", "+79994440102")
board = TaskBoard.objects.create(name="Доска с файлами", created_by=user)
board.members.add(member)
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
task = Task.objects.create(
board=board,
column=column,
title="Задача с файлами",
created_by=user,
)
attachments_url = reverse(
"api:v1:tasks:task-attachments",
kwargs={"pk": task.id},
)
api_client.force_authenticate(user=member)
upload_response = api_client.post(
attachments_url,
{
"files": [
SimpleUploadedFile(
"brief.txt",
b"task brief",
content_type="text/plain",
),
SimpleUploadedFile(
"report.pdf",
b"pdf content",
content_type="application/pdf",
),
SimpleUploadedFile(
"logs.tar.gz",
b"archive content",
content_type="application/gzip",
),
]
},
format="multipart",
)
assert upload_response.status_code == status.HTTP_201_CREATED
assert [item["file_name"] for item in upload_response.data] == [
"brief.txt",
"report.pdf",
"logs.tar.gz",
]
assert all(item["uploaded_by"]["id"] == member.id for item in upload_response.data)
assert TaskAttachment.objects.filter(task=task).count() == 3
task_response = api_client.get(
reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id})
)
assert task_response.status_code == status.HTTP_200_OK
assert task_response.data["attachments_count"] == 3
attachment = TaskAttachment.objects.get(task=task, file_name="brief.txt")
stored_name = attachment.file.name
download_url = reverse(
"api:v1:tasks:task-attachment-download",
kwargs={"pk": task.id, "attachment_id": attachment.id},
)
download_response = api_client.get(download_url)
assert download_response.status_code == status.HTTP_200_OK
assert b"".join(download_response.streaming_content) == b"task brief"
assert download_response["X-Content-Type-Options"] == "nosniff"
assert "attachment" in download_response["Content-Disposition"]
api_client.force_authenticate(user=outsider)
assert api_client.get(attachments_url).status_code == status.HTTP_404_NOT_FOUND
assert api_client.get(download_url).status_code == status.HTTP_404_NOT_FOUND
api_client.force_authenticate(user=member)
delete_url = reverse(
"api:v1:tasks:task-attachment-detail",
kwargs={"pk": task.id, "attachment_id": attachment.id},
)
with django_capture_on_commit_callbacks(execute=True):
delete_response = api_client.delete(delete_url)
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
assert not TaskAttachment.objects.filter(id=attachment.id).exists()
assert not attachment.file.storage.exists(stored_name)
def test_task_attachment_has_no_size_limit(
api_client,
user,
settings,
):
settings.TASK_ATTACHMENT_MAX_SIZE = 4
board = TaskBoard.objects.create(name="Доска лимитов", created_by=user)
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
task = Task.objects.create(
board=board,
column=column,
title="Проверить лимит файла",
created_by=user,
)
api_client.force_authenticate(user=user)
response = api_client.post(
reverse("api:v1:tasks:task-attachments", kwargs={"pk": task.id}),
{
"files": SimpleUploadedFile(
"large.txt",
b"12345",
content_type="text/plain",
)
},
format="multipart",
)
assert response.status_code == status.HTTP_201_CREATED
assert TaskAttachment.objects.filter(task=task, file_name="large.txt").exists()
def test_task_external_links_follow_board_access_and_support_lifecycle(
api_client,
user,
):
member = make_user("task-link-member@example.com", "+79994440103")
outsider = make_user("task-link-outsider@example.com", "+79994440104")
board = TaskBoard.objects.create(name="Доска со ссылками", created_by=user)
board.members.add(member)
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
task = Task.objects.create(
board=board,
column=column,
title="Задача со ссылкой",
created_by=user,
)
links_url = reverse(
"api:v1:tasks:task-external-links",
kwargs={"pk": task.id},
)
api_client.force_authenticate(user=member)
invalid_response = api_client.post(
links_url,
{"url": "javascript:alert(1)", "title": "Опасная ссылка"},
format="json",
)
assert invalid_response.status_code == status.HTTP_400_BAD_REQUEST
create_response = api_client.post(
links_url,
{"url": "https://example.com/service/item/42", "title": "Карточка сервиса"},
format="json",
)
assert create_response.status_code == status.HTTP_201_CREATED
assert create_response.data["title"] == "Карточка сервиса"
assert create_response.data["created_by"]["id"] == member.id
link_id = create_response.data["id"]
assert TaskExternalLink.objects.filter(task=task).count() == 1
duplicate_response = api_client.post(
links_url,
{"url": "https://example.com/service/item/42", "title": "Обновленное название"},
format="json",
)
assert duplicate_response.status_code == status.HTTP_200_OK
assert duplicate_response.data["title"] == "Обновленное название"
assert TaskExternalLink.objects.filter(task=task).count() == 1
task_response = api_client.get(
reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id})
)
assert task_response.status_code == status.HTTP_200_OK
assert task_response.data["linked_objects_count"] == 1
api_client.force_authenticate(user=outsider)
assert api_client.get(links_url).status_code == status.HTTP_404_NOT_FOUND
api_client.force_authenticate(user=member)
delete_response = api_client.delete(
reverse(
"api:v1:tasks:task-external-link-detail",
kwargs={"pk": task.id, "link_id": link_id},
)
)
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
assert not TaskExternalLink.objects.filter(id=link_id).exists()
def test_task_checklist_follows_board_access_and_supports_full_lifecycle(
api_client,
):
owner = make_user("task-checklist-owner@example.com", "+79994440105")
member = make_user("task-checklist-member@example.com", "+79994440106")
outsider = make_user("task-checklist-outsider@example.com", "+79994440107")
board = TaskBoard.objects.create(name="Доска с чек-листом", created_by=owner)
board.members.add(member)
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
task = Task.objects.create(
board=board,
column=column,
title="Подготовить релиз",
created_by=owner,
)
checklist_url = reverse(
"api:v1:tasks:task-checklist",
kwargs={"pk": task.id},
)
api_client.force_authenticate(user=member)
invalid_response = api_client.post(
checklist_url,
{"title": " "},
format="json",
)
assert invalid_response.status_code == status.HTTP_400_BAD_REQUEST
first_response = api_client.post(
checklist_url,
{"title": " Проверить миграции "},
format="json",
)
second_response = api_client.post(
checklist_url,
{"title": "Обновить документацию"},
format="json",
)
assert first_response.status_code == status.HTTP_201_CREATED
assert second_response.status_code == status.HTTP_201_CREATED
assert first_response.data["title"] == "Проверить миграции"
assert first_response.data["position"] == 1000
assert second_response.data["position"] == 2000
assert first_response.data["created_by"]["id"] == member.id
item_id = first_response.data["id"]
item_url = reverse(
"api:v1:tasks:task-checklist-item-detail",
kwargs={"pk": task.id, "item_id": item_id},
)
complete_response = api_client.patch(
item_url,
{"is_completed": True},
format="json",
)
assert complete_response.status_code == status.HTTP_200_OK
assert complete_response.data["is_completed"] is True
assert complete_response.data["completed_by"]["id"] == member.id
assert complete_response.data["completed_at"] is not None
completed_at = complete_response.data["completed_at"]
rename_response = api_client.patch(
item_url,
{"title": "Проверить миграции на стенде"},
format="json",
)
assert rename_response.status_code == status.HTTP_200_OK
assert rename_response.data["title"] == "Проверить миграции на стенде"
assert rename_response.data["completed_by"]["id"] == member.id
assert rename_response.data["completed_at"] == completed_at
list_response = api_client.get(checklist_url)
assert list_response.status_code == status.HTTP_200_OK
assert [item["title"] for item in list_response.data] == [
"Проверить миграции на стенде",
"Обновить документацию",
]
detail_response = api_client.get(
reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id})
)
assert detail_response.status_code == status.HTTP_200_OK
assert detail_response.data["checklist_total"] == 2
assert detail_response.data["checklist_completed"] == 1
api_client.force_authenticate(user=outsider)
assert api_client.get(checklist_url).status_code == status.HTTP_404_NOT_FOUND
assert (
api_client.patch(item_url, {"is_completed": False}, format="json").status_code
== status.HTTP_404_NOT_FOUND
)
api_client.force_authenticate(user=member)
delete_response = api_client.delete(item_url)
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
assert not TaskChecklistItem.objects.filter(id=item_id).exists()
activity_response = api_client.get(
reverse("api:v1:tasks:task-activity", kwargs={"pk": task.id})
)
assert activity_response.status_code == status.HTTP_200_OK
assert {
"checklist_item_added",
"checklist_item_completed",
"checklist_item_updated",
"checklist_item_removed",
}.issubset({item["action"] for item in activity_response.data})
def test_create_board_accepts_members_and_departments(api_client, user):
member = make_user("task-member@example.com", "+79994440002")
department = Department.objects.create(name="Проектный отдел")
@ -224,6 +711,7 @@ def test_create_board_accepts_members_and_departments(api_client, user):
)
assert response.status_code == status.HTTP_201_CREATED
assert response.data["access_scope"] == "restricted"
assert response.data["members"] == [member.id]
assert response.data["member_details"][0]["id"] == member.id
assert response.data["departments"] == [department.id]
@ -231,6 +719,61 @@ def test_create_board_accepts_members_and_departments(api_client, user):
assert TaskColumn.objects.filter(board_id=response.data["id"]).count() == 4
def test_private_board_is_available_only_to_owner_and_admin(api_client):
owner = make_user("task-private-owner@example.com", "+79994440901")
selected_member = make_user("task-private-member@example.com", "+79994440902")
department_member = make_user("task-private-dept@example.com", "+79994440903")
outsider = make_user("task-private-outsider@example.com", "+79994440904")
admin = make_user(
"task-private-admin@example.com",
"+79994440905",
is_staff=True,
)
department = Department.objects.create(name="Приватный отдел")
EmployeeDepartment.objects.create(
employee=department_member,
department=department,
is_active=True,
)
api_client.force_authenticate(user=owner)
create_response = api_client.post(
reverse("api:v1:tasks:task-board-list"),
{
"name": "Личная доска",
"access_scope": "private",
"members": [selected_member.id],
"departments": [department.id],
},
format="json",
)
assert create_response.status_code == status.HTTP_201_CREATED
assert create_response.data["access_scope"] == "private"
assert create_response.data["members"] == []
assert create_response.data["departments"] == []
board = TaskBoard.objects.get(id=create_response.data["id"])
assert get_task_board_recipient_ids(board) == {owner.id, admin.id}
for hidden_user in (selected_member, department_member, outsider):
api_client.force_authenticate(user=hidden_user)
list_response = api_client.get(reverse("api:v1:tasks:task-board-list"))
assert list_response.status_code == status.HTTP_200_OK
assert board.id not in {
item["id"] for item in list_response.data["results"]
}
detail_response = api_client.get(
reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
)
assert detail_response.status_code == status.HTTP_404_NOT_FOUND
api_client.force_authenticate(user=admin)
admin_response = api_client.get(
reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
)
assert admin_response.status_code == status.HTTP_200_OK
def test_board_member_can_access_private_board(api_client):
owner = make_user("task-owner@example.com", "+79994440003")
member = make_user("task-member-2@example.com", "+79994440004")
@ -1256,14 +1799,65 @@ def test_task_comments_use_communications_and_update_count(api_client, user):
message = Message.objects.get(chat=chat)
assert message.content == "Нужно уточнить детали"
assert message.author == user
added_activity = task.activities.get(action="comment_added")
assert added_activity.actor == user
assert added_activity.object_kind == "comment"
assert added_activity.object_id == message.id
assert added_activity.metadata == {"comment_text": "Нужно уточнить детали"}
comment_detail_url = reverse(
"api:v1:tasks:task-delete-comment",
kwargs={"pk": task.id, "comment_id": message.id},
)
other_user = make_user("task-comment-editor@example.com", "+79994440027")
api_client.force_authenticate(user=other_user)
forbidden_edit_response = api_client.patch(
comment_detail_url,
{"text": "Чужое изменение"},
format="json",
)
assert forbidden_edit_response.status_code == status.HTTP_403_FORBIDDEN
api_client.force_authenticate(user=user)
empty_edit_response = api_client.patch(
comment_detail_url,
{"text": ""},
format="json",
)
assert empty_edit_response.status_code == status.HTTP_400_BAD_REQUEST
edit_response = api_client.patch(
comment_detail_url,
{"text": "Детали уже уточнены"},
format="json",
)
assert edit_response.status_code == status.HTTP_200_OK
assert edit_response.data["text"] == "Детали уже уточнены"
assert edit_response.data["is_edited"] is True
assert edit_response.data["edited_at"] is not None
message.refresh_from_db()
assert message.content == "Детали уже уточнены"
edit_history = message.edit_history_records.get()
assert edit_history.previous_content == "Нужно уточнить детали"
assert edit_history.edited_by == user
edited_activity = task.activities.get(action="comment_edited")
assert edited_activity.actor == user
assert edited_activity.object_kind == "comment"
assert edited_activity.object_id == message.id
assert edited_activity.metadata == {
"previous_text": "Нужно уточнить детали",
"comment_text": "Детали уже уточнены",
}
list_response = api_client.get(
reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id})
)
assert list_response.status_code == status.HTTP_200_OK
assert [comment["text"] for comment in list_response.data] == [
"Нужно уточнить детали"
"Детали уже уточнены"
]
assert list_response.data[0]["is_edited"] is True
detail_response = api_client.get(
reverse("api:v1:tasks:task-detail", kwargs={"pk": task.id})
@ -1271,14 +1865,26 @@ def test_task_comments_use_communications_and_update_count(api_client, user):
assert detail_response.data["comments_count"] == 1
delete_response = api_client.delete(
reverse(
"api:v1:tasks:task-delete-comment",
kwargs={"pk": task.id, "comment_id": message.id},
)
comment_detail_url
)
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
message.refresh_from_db()
assert message.is_deleted is True
removed_activity = task.activities.get(action="comment_removed")
assert removed_activity.actor == user
assert removed_activity.object_kind == "comment"
assert removed_activity.object_id == message.id
assert removed_activity.metadata == {"comment_text": "Детали уже уточнены"}
activity_response = api_client.get(
reverse("api:v1:tasks:task-activity", kwargs={"pk": task.id})
)
assert activity_response.status_code == status.HTTP_200_OK
assert [item["action"] for item in activity_response.data] == [
"comment_removed",
"comment_edited",
"comment_added",
]
assert api_client.get(
reverse("api:v1:tasks:task-comments", kwargs={"pk": task.id})

View File

@ -0,0 +1,34 @@
import io
import random
from PIL import Image
from employees.ldap.utils.image_utils import normalize_avatar_to_jpeg
def _noisy_png(size: int = 768) -> bytes:
pixels = random.Random(42).randbytes(size * size * 3)
image = Image.frombytes("RGB", (size, size), pixels)
output = io.BytesIO()
image.save(output, format="PNG")
return output.getvalue()
def test_normalize_avatar_enforces_ldap_byte_limit():
result = normalize_avatar_to_jpeg(_noisy_png(), size_px=384, max_kb=100)
assert len(result) <= 100 * 1024
image = Image.open(io.BytesIO(result))
assert image.format == "JPEG"
assert image.width <= 384
assert image.height <= 384
def test_normalize_avatar_rejects_invalid_output_limit():
try:
normalize_avatar_to_jpeg(b"image", max_kb=0)
except ValueError as exc:
assert "лимит размера" in str(exc)
else:
raise AssertionError("Ожидалась ошибка для нулевого лимита")

View File

@ -40,6 +40,7 @@ import { BulkActionsToolbar, useDocumentSelection } from "@/components/documents
import { TagManagementModal } from "@/components/documents/tags";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { Modal } from "@/components/ui";
import { canPreviewDocument, getDocumentFileExtension } from "@/lib/document-preview";
// Динамический импорт компонентов с PDF обработкой (избегаем SSR ошибок с DOMMatrix)
const DocumentUploadForm = dynamic(
@ -68,99 +69,6 @@ function formatDate(value?: string): string {
});
}
const DOCUMENT_PREVIEW_EXTENSIONS = new Set([
"pdf",
"jpg",
"jpeg",
"png",
"gif",
"webp",
"svg",
"bmp",
"ico",
"avif",
"docx",
"xls",
"xlsx",
"xlsm",
"xlsb",
"ods",
"csv",
"tsv",
"txt",
"text",
"md",
"markdown",
"json",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"js",
"jsx",
"ts",
"tsx",
"mjs",
"cjs",
"yml",
"yaml",
"ini",
"conf",
"cfg",
"log",
"sql",
"py",
"java",
"c",
"cpp",
"h",
"hpp",
"cs",
"go",
"rs",
"php",
"rb",
"kt",
"kts",
"swift",
"sh",
"bash",
"zsh",
"ps1",
"bat",
"cmd",
"properties",
"env",
"rtf",
"mp4",
"webm",
"ogg",
"ogv",
"mov",
"m4v",
"mp3",
"wav",
"oga",
"m4a",
"aac",
"flac",
]);
const DOCUMENT_PREVIEW_FILE_NAMES = new Set(["dockerfile", "makefile", "readme", "license", ".gitignore", ".env"]);
function getDocumentFileExtension(fileName?: string | null): string {
const cleanName = (fileName || "").toLowerCase().split("?")[0].split("#")[0];
return cleanName.match(/\.([^.]+)$/)?.[1] || "";
}
function canPreviewDocument(fileName?: string | null): boolean {
const lowerName = (fileName || "").toLowerCase();
const extension = getDocumentFileExtension(fileName);
return DOCUMENT_PREVIEW_EXTENSIONS.has(extension) || DOCUMENT_PREVIEW_FILE_NAMES.has(lowerName);
}
function formatFileSize(value?: number): string {
if (!value || value <= 0) return "";
if (value < 1024) return `${value} Б`;
@ -1322,6 +1230,8 @@ function DocumentsPageContent() {
const departmentsCount = doc.departments?.length || 0;
const hasPreview = Boolean(doc.file_url && canPreviewDocument(doc.file_name || doc.title));
const isAcknowledging = acknowledgingDocumentId === doc.id;
const acknowledgementRequiredForUser =
doc.acknowledgement_required_for_user ?? doc.acknowledgement_required;
// DEBUG: Проверка данных документа
if (!authorName || !createdDate) {
@ -1434,7 +1344,7 @@ function DocumentsPageContent() {
<div className="flex shrink-0 items-start gap-2">
<div className="text-right">
{doc.acknowledgement_required ? (
{acknowledgementRequiredForUser ? (
doc.is_acknowledged ? (
<span className="app-feedback-success inline-flex rounded-full px-2.5 py-1 text-xs font-medium">
Ознакомлен
@ -1538,7 +1448,7 @@ function DocumentsPageContent() {
</button>
)}
{doc.acknowledgement_required && !doc.is_acknowledged && (
{acknowledgementRequiredForUser && !doc.is_acknowledged && (
<button
type="button"
onClick={() => void handleAcknowledgeDocument(doc)}
@ -1546,7 +1456,7 @@ function DocumentsPageContent() {
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)]"
>
{isAcknowledging ? <Loader2 size={14} className="animate-spin" /> : <CheckCircle size={14} />}
{isAcknowledging ? "Подтверждение..." : "Ознакомиться"}
{isAcknowledging ? "Подтверждение..." : "Ознакомился(лась)"}
</button>
)}
@ -1641,7 +1551,7 @@ function DocumentsPageContent() {
)}
<div className="mt-3 flex flex-wrap items-center justify-end gap-1.5">
{doc.acknowledgement_required && !doc.is_acknowledged ? (
{acknowledgementRequiredForUser && !doc.is_acknowledged ? (
<button
type="button"
onClick={() => void handleAcknowledgeDocument(doc)}
@ -1654,7 +1564,7 @@ function DocumentsPageContent() {
) : (
<CheckCircle size={14} />
)}
<span>{isAcknowledging ? "Подтверждение..." : "Ознакомиться"}</span>
<span>{isAcknowledging ? "Подтверждение..." : "Ознакомился(лась)"}</span>
</button>
) : null}
@ -1732,7 +1642,7 @@ function DocumentsPageContent() {
isOpen={showUploadForm}
onClose={() => setShowUploadForm(false)}
title="Загрузить документ"
size="lg"
size="xl"
>
<DocumentUploadForm
currentFolderId={selectedFolderId}

View File

@ -19,6 +19,8 @@
--danger-soft: rgba(239, 68, 68, 0.12);
--danger-foreground: #dc2626;
--warning-soft: rgba(245, 158, 11, 0.15);
--regulation-border: #a9822f;
--regulation-soft: rgba(169, 130, 47, 0.1);
--app-shell-gradient: radial-gradient(circle at top, rgba(125, 211, 252, 0.12), transparent 32%), linear-gradient(180deg, #f5f9ff 0%, #eef3fb 46%, #f4f7fb 100%);
--header-surface: rgba(255, 255, 255, 0.84);
--shadow-card: 0 12px 30px -22px rgba(15, 23, 42, 0.16), 0 3px 10px -8px rgba(15, 23, 42, 0.08);
@ -51,6 +53,8 @@ html[data-bs-theme="dark"] {
--danger-soft: rgba(239, 68, 68, 0.16);
--danger-foreground: #f87171;
--warning-soft: rgba(245, 158, 11, 0.18);
--regulation-border: #d0ad58;
--regulation-soft: rgba(208, 173, 88, 0.12);
--app-shell-gradient: radial-gradient(circle at top, rgba(30, 167, 255, 0.08), transparent 24%), linear-gradient(180deg, #10161d 0%, #111922 38%, #0b1016 100%);
--header-surface: rgba(14, 20, 28, 0.88);
--shadow-card: 0 20px 48px -30px rgba(2, 6, 23, 0.72);

View File

@ -199,12 +199,6 @@ export default function ChatSettingsPage() {
const file = e.target.files?.[0];
if (!file) return;
// Проверка размера (макс 5MB)
if (file.size > 5 * 1024 * 1024) {
alert("Файл слишком большой. Максимальный размер: 5MB");
return;
}
// Проверка типа
if (!file.type.startsWith("image/")) {
alert("Можно загружать только изображения");

View File

@ -389,10 +389,6 @@ export default function MessagesPage() {
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 5 * 1024 * 1024) { // 5MB
alert('Файл слишком большой. Максимум 5MB');
return;
}
if (!file.type.startsWith('image/')) {
alert('Можно загружать только изображения');
return;

View File

@ -1,9 +1,15 @@
"use client";
import Link from "next/link";
import { ChevronRight, Link2, Pencil, Pin, Plus, Trash2 } from "lucide-react";
import dynamic from "next/dynamic";
import { ChevronRight, Link2, Newspaper, Pencil, Pin, Plus, ScrollText, Trash2 } from "lucide-react";
import { AppShell } from "../components/AppShell";
import { FeedPostCard } from "@/components/feed/FeedPostCard";
import { FeedRegulationCard } from "@/components/feed/FeedRegulationCard";
import { DocumentAcknowledgementsReport } from "@/components/documents/DocumentAcknowledgementsReport";
import { DocumentDetailModal } from "@/components/documents/DocumentDetailModal";
import { DocumentMetadataEditor } from "@/components/documents/DocumentMetadataEditor";
import { DocumentTaskLinks } from "@/components/documents/DocumentTaskLinks";
import { Modal } from "@/components/ui";
import { PostCommentsModal } from "@/components/feed/PostCommentsModal";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
@ -11,12 +17,29 @@ import { RelatedTaskLinks } from "@/components/tasks/RelatedTaskLinks";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { useNotifications } from "@/contexts/NotificationsContext";
import { apiClient } from "@/lib/api";
import { userProfileLink } from "@/lib/shared";
import { getDocumentFileExtension } from "@/lib/document-preview";
import { loadAllPages, userProfileLink } from "@/lib/shared";
import { resolveMediaUrl } from "@/lib/url";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { Comment, Post } from "@/types/api";
import type { Comment, Document, Post } from "@/types/api";
import { useUser } from "@/contexts/UserContext";
import { toast } from "sonner";
const EnhancedPDFViewer = dynamic(
() => import("@/components/documents/viewer").then((mod) => ({ default: mod.EnhancedPDFViewer })),
{ ssr: false },
);
const DocumentPreview = dynamic(
() => import("@/components/documents/DocumentPreview").then((mod) => ({ default: mod.DocumentPreview })),
{ ssr: false },
);
const DocumentUploadForm = dynamic(
() => import("@/components/documents/DocumentUploadForm").then((mod) => ({ default: mod.DocumentUploadForm })),
{ ssr: false },
);
type LikeUser = {
id: number;
@ -27,6 +50,24 @@ type LikeUser = {
};
type PostSourceFilter = "all" | "company" | `department:${number}`;
type FeedFilterKey = PostSourceFilter | "regulations";
type FeedEntry =
| { kind: "post"; post: Post; timestamp: string }
| { kind: "regulation"; document: Document; timestamp: string };
function postMatchesSource(post: Post, source: PostSourceFilter) {
if (source === "all") return true;
if (source === "company") return post.type !== "department";
return post.department_id === Number(source.split(":")[1]);
}
function regulationMatchesSource(document: Document, source: PostSourceFilter) {
if (source === "all") return true;
if (source === "company") return Boolean(document.sent_to_all);
const departmentId = Number(source.split(":")[1]);
return (document.departments || []).some((department) => department.id === departmentId);
}
export default function Home() {
return (
@ -54,14 +95,29 @@ function HomePageContent() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [posts, setPosts] = useState<Post[]>([]);
const [regulations, setRegulations] = useState<Document[]>([]);
const [selectedRegulation, setSelectedRegulation] = useState<Document | null>(null);
const [selectedRegulationInitialTab, setSelectedRegulationInitialTab] = useState<"comments" | undefined>();
const [metadataRegulation, setMetadataRegulation] = useState<Document | null>(null);
const [taskLinkRegulation, setTaskLinkRegulation] = useState<Document | null>(null);
const [regulationPreviewFile, setRegulationPreviewFile] = useState<{ url: string; name: string } | null>(null);
const [regulationPdfFile, setRegulationPdfFile] = useState<{ url: string; name: string } | null>(null);
const [acknowledgingRegulationId, setAcknowledgingRegulationId] = useState<number | null>(null);
const [acknowledgementsReport, setAcknowledgementsReport] = useState<{
documentId: number;
documentTitle: string;
} | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [likeBusyId, setLikeBusyId] = useState<number | null>(null);
const [sourceFilter, setSourceFilter] = useState<PostSourceFilter>("all");
const [regulationsOnly, setRegulationsOnly] = useState(false);
const [commentsOpen, setCommentsOpen] = useState(false);
const [activePost, setActivePost] = useState<Post | null>(null);
const [createPostOpen, setCreatePostOpen] = useState(false);
const [createRegulationOpen, setCreateRegulationOpen] = useState(false);
const [createMenuOpen, setCreateMenuOpen] = useState(false);
const [createType, setCreateType] = useState<"company" | "department">("company");
const [createDepartmentId, setCreateDepartmentId] = useState<string>("");
const [createTitle, setCreateTitle] = useState("");
@ -80,6 +136,7 @@ function HomePageContent() {
const [postMenuOpenId, setPostMenuOpenId] = useState<number | null>(null);
const [taskLinkPost, setTaskLinkPost] = useState<Post | null>(null);
const postMenuRef = useRef<HTMLDivElement | null>(null);
const createMenuRef = useRef<HTMLDivElement | null>(null);
const formatUserName = (u: LikeUser) => {
const composed = `${u.last_name || ""} ${u.first_name || ""}`.trim();
@ -228,6 +285,80 @@ function HomePageContent() {
setPosts(sortPostsPinnedFirst(response.results));
}, [sortPostsPinnedFirst]);
const refreshRegulations = useCallback(async () => {
const items = await loadAllPages<Document>((pagination) => (
apiClient.getDocuments({
...pagination,
scope: "mine",
is_regulation: true,
})
));
setRegulations(items);
}, []);
const handleAcknowledgeRegulation = async (documentItem: Document) => {
if (acknowledgingRegulationId !== null) return;
setAcknowledgingRegulationId(documentItem.id);
try {
await apiClient.acknowledgeDocument(documentItem.id);
setRegulations((current) => current.map((document) => (
document.id === documentItem.id ? { ...document, is_acknowledged: true } : document
)));
if (selectedRegulation?.id === documentItem.id) {
setSelectedRegulation({ ...selectedRegulation, is_acknowledged: true });
}
toast.success("Ознакомление подтверждено");
void refreshRegulations();
} catch (err) {
console.error("Ошибка подтверждения ознакомления:", err);
toast.error("Не удалось подтвердить ознакомление");
} finally {
setAcknowledgingRegulationId(null);
}
};
const openRegulation = (document: Document) => {
setSelectedRegulationInitialTab(undefined);
setSelectedRegulation(document);
};
const openRegulationComments = (document: Document) => {
setSelectedRegulationInitialTab("comments");
setSelectedRegulation(document);
};
const openRegulationPreview = (document: Document) => {
if (!document.file_url) return;
const previewFile = {
url: document.file_url,
name: document.file_name || document.title,
};
if (getDocumentFileExtension(previewFile.name) === "pdf") {
setRegulationPdfFile(previewFile);
} else {
setRegulationPreviewFile(previewFile);
}
};
const deleteRegulation = async (document: Document) => {
if (!window.confirm(`Удалить документ "${document.title}"?`)) return;
try {
await apiClient.deleteDocument(document.id);
setRegulations((current) => current.filter((item) => item.id !== document.id));
if (selectedRegulation?.id === document.id) {
setSelectedRegulation(null);
setSelectedRegulationInitialTab(undefined);
}
toast.success("Документ удалён");
} catch (err) {
console.error("Ошибка удаления документа:", err);
toast.error("Не удалось удалить документ");
}
};
const postSourceCounts = useMemo(() => {
const departmentCounts = new Map<number, { id: number; name: string; total: number }>();
let companyCount = 0;
@ -249,30 +380,72 @@ function HomePageContent() {
}
}
for (const document of regulations) {
if (document.sent_to_all) companyCount += 1;
for (const department of document.departments || []) {
const current = departmentCounts.get(department.id);
if (current) {
current.total += 1;
} else {
departmentCounts.set(department.id, {
id: department.id,
name: department.name,
total: 1,
});
}
}
}
const departments = Array.from(departmentCounts.values()).sort((left, right) => {
if (right.total !== left.total) return right.total - left.total;
return left.name.localeCompare(right.name, "ru");
});
return {
all: posts.length,
all: posts.length + regulations.length,
company: companyCount,
regulations: regulations.length,
departments,
};
}, [posts]);
}, [posts, regulations]);
const postSourceUnreadCounts = useMemo(() => {
const departmentUnread = new Map<number, number>();
let companyUnread = 0;
let regulationsUnread = 0;
let companyRegulationsUnread = 0;
const departmentRegulationsUnread = new Map<number, number>();
const regulationsById = new Map(regulations.map((document) => [document.id, document]));
for (const notification of notifications) {
if ((notification.verb || "") !== "feed_new_post") continue;
const rawData = notification.data;
const data = rawData && typeof rawData === "object"
? rawData as Record<string, unknown>
: null;
if ((notification.verb || "") === "document_ready") {
const rawDocumentId = data?.document_id;
const documentId = typeof rawDocumentId === "number"
? rawDocumentId
: typeof rawDocumentId === "string"
? Number(rawDocumentId)
: null;
const regulation = documentId !== null ? regulationsById.get(documentId) : undefined;
if (regulation) {
regulationsUnread += 1;
if (regulation.sent_to_all) companyRegulationsUnread += 1;
for (const department of regulation.departments || []) {
departmentRegulationsUnread.set(
department.id,
(departmentRegulationsUnread.get(department.id) || 0) + 1,
);
}
}
continue;
}
if ((notification.verb || "") !== "feed_new_post") continue;
const postType = typeof data?.post_type === "string" ? data.post_type : "";
const departmentId =
typeof data?.department_id === "number"
@ -288,25 +461,43 @@ function HomePageContent() {
}
}
const departmentEntries = Array.from(departmentUnread.entries()).map(([id, unread]) => ({
const departmentIds = new Set([
...departmentUnread.keys(),
...departmentRegulationsUnread.keys(),
]);
const departmentEntries = Array.from(departmentIds).map((id) => ({
id,
unread,
unread: (departmentUnread.get(id) || 0) + (departmentRegulationsUnread.get(id) || 0),
regulationsUnread: departmentRegulationsUnread.get(id) || 0,
}));
return {
all: companyUnread + departmentEntries.reduce((sum, entry) => sum + entry.unread, 0),
company: companyUnread,
all: companyUnread + regulationsUnread + Array.from(departmentUnread.values()).reduce((sum, unread) => sum + unread, 0),
company: companyUnread + companyRegulationsUnread,
regulations: regulationsUnread,
companyRegulations: companyRegulationsUnread,
departments: departmentEntries,
};
}, [notifications]);
}, [notifications, regulations]);
const postSourceOptions = useMemo(() => {
const unreadByDepartment = new Map(
postSourceUnreadCounts.departments.map((entry) => [entry.id, entry.unread]),
);
const options: Array<{ key: PostSourceFilter; label: string; total: number; unread: number }> = [
const departmentRegulationsUnread = new Map(
postSourceUnreadCounts.departments.map((entry) => [entry.id, entry.regulationsUnread]),
);
const scopedRegulations = regulations.filter((document) => regulationMatchesSource(document, sourceFilter));
const scopedRegulationsUnread = sourceFilter === "all"
? postSourceUnreadCounts.regulations
: sourceFilter === "company"
? postSourceUnreadCounts.companyRegulations
: departmentRegulationsUnread.get(Number(sourceFilter.split(":")[1])) || 0;
const options: Array<{ key: FeedFilterKey; label: string; total: number; unread: number }> = [
{ key: "all", label: "Все", total: postSourceCounts.all, unread: postSourceUnreadCounts.all },
{ key: "regulations", label: "Регламенты", total: scopedRegulations.length, unread: scopedRegulationsUnread },
{ key: "company", label: "Компания", total: postSourceCounts.company, unread: postSourceUnreadCounts.company },
];
@ -319,24 +510,38 @@ function HomePageContent() {
});
}
return options.filter(({ key, total }) => key === "all" || total > 0);
}, [postSourceCounts, postSourceUnreadCounts]);
return options.filter(({ key, total }) => key === "all" || key === "regulations" || total > 0);
}, [postSourceCounts, postSourceUnreadCounts, regulations, sourceFilter]);
const filteredPosts = useMemo(() => {
if (sourceFilter === "company") {
return posts.filter((post) => post.type !== "department");
}
if (sourceFilter.startsWith("department:")) {
const departmentId = Number(sourceFilter.split(":")[1]);
return posts.filter((post) => post.department_id === departmentId);
}
return posts;
}, [posts, sourceFilter]);
const filteredEntries = useMemo<FeedEntry[]>(() => {
const postEntries: FeedEntry[] = posts.map((post) => ({
kind: "post",
post,
timestamp: post.created_at,
}));
const regulationEntries: FeedEntry[] = regulations.map((document) => ({
kind: "regulation",
document,
timestamp: document.uploaded_at || document.created_at,
}));
return [...postEntries, ...regulationEntries].filter((entry) => {
if (regulationsOnly && entry.kind !== "regulation") return false;
return entry.kind === "post"
? postMatchesSource(entry.post, sourceFilter)
: regulationMatchesSource(entry.document, sourceFilter);
}).sort((left, right) => {
const leftPinned = left.kind === "post" && left.post.pinned ? 1 : 0;
const rightPinned = right.kind === "post" && right.post.pinned ? 1 : 0;
if (leftPinned !== rightPinned) return rightPinned - leftPinned;
return new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime();
});
}, [posts, regulations, regulationsOnly, sourceFilter]);
useEffect(() => {
async function loadPosts() {
try {
await refreshPosts();
await Promise.all([refreshPosts(), refreshRegulations()]);
} catch (err: unknown) {
console.error('Ошибка загрузки ленты:', err);
setError('Не удалось загрузить ленту');
@ -345,7 +550,7 @@ function HomePageContent() {
}
}
loadPosts();
}, [refreshPosts]);
}, [refreshPosts, refreshRegulations]);
useEffect(() => {
if (postMenuOpenId === null) return;
@ -371,6 +576,26 @@ function HomePageContent() {
};
}, [postMenuOpenId]);
useEffect(() => {
if (!createMenuOpen) return;
const handlePointerDown = (event: MouseEvent) => {
if (createMenuRef.current && !createMenuRef.current.contains(event.target as Node)) {
setCreateMenuOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setCreateMenuOpen(false);
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleEscape);
};
}, [createMenuOpen]);
useEffect(() => {
if (createType !== "department") return;
if (createDepartmentId) return;
@ -528,6 +753,7 @@ function HomePageContent() {
}, [activePost?.id, commentsOpen, linkedPostId, posts]);
const openCreatePostModal = () => {
setCreateMenuOpen(false);
setCreateError(null);
setEditingPostId(null);
setCreateTitle("");
@ -543,6 +769,11 @@ function HomePageContent() {
setCreatePostOpen(true);
};
const openCreateRegulationModal = () => {
setCreateMenuOpen(false);
setCreateRegulationOpen(true);
};
const openEditPostModal = (post: Post) => {
setCreateError(null);
setEditingPostId(post.id);
@ -651,13 +882,20 @@ function HomePageContent() {
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{postSourceOptions.map(({ key, label, total, unread }) => {
const active = sourceFilter === key;
const active = key === "regulations" ? regulationsOnly : sourceFilter === key;
return (
return (
<button
key={key}
type="button"
onClick={() => setSourceFilter(key)}
aria-pressed={active}
onClick={() => {
if (key === "regulations") {
setRegulationsOnly((current) => !current);
} else {
setSourceFilter(key);
}
}}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
active ? "app-pill-active" : "app-pill"
}`}
@ -681,11 +919,11 @@ function HomePageContent() {
})}
</div>
{posts.length === 0 ? (
{posts.length === 0 && regulations.length === 0 ? (
<div className="app-surface-muted rounded-2xl p-8 text-center">
<p className="app-text-muted text-sm">Пока нет постов в ленте</p>
<p className="app-text-muted text-sm">Пока нет публикаций в ленте</p>
</div>
) : filteredPosts.length === 0 ? (
) : filteredEntries.length === 0 ? (
<div className="app-surface-muted rounded-2xl p-8 text-center">
<p className="text-sm font-medium text-[var(--foreground)]">
Публикации не найдены
@ -695,7 +933,31 @@ function HomePageContent() {
</p>
</div>
) : (
filteredPosts.map((post) => {
filteredEntries.map((entry) => {
if (entry.kind === "regulation") {
return (
<FeedRegulationCard
key={`regulation-${entry.document.id}`}
currentUserId={user?.id}
document={entry.document}
isAcknowledging={acknowledgingRegulationId === entry.document.id}
onAcknowledge={(document) => void handleAcknowledgeRegulation(document)}
onDelete={(document) => void deleteRegulation(document)}
onEdit={setMetadataRegulation}
onLinkTask={setTaskLinkRegulation}
onMove={setMetadataRegulation}
onOpen={openRegulation}
onOpenComments={openRegulationComments}
onPreview={openRegulationPreview}
onOpenAcknowledgements={(document) => setAcknowledgementsReport({
documentId: document.id,
documentTitle: document.title,
})}
/>
);
}
const post = entry.post;
// Форматируем дату
const postDate = new Date(post.created_at);
const now = new Date();
@ -710,7 +972,7 @@ function HomePageContent() {
return (
<FeedPostCard
key={post.id}
key={`post-${post.id}`}
authorHref={post.author ? userProfileLink(post.author, user?.id) : null}
post={post}
authorSubtitle={
@ -854,20 +1116,65 @@ function HomePageContent() {
)}
</div>
{canCreatePost && !createPostOpen ? (
<div className="pointer-events-none fixed bottom-[calc(env(safe-area-inset-bottom)+5.5rem)] right-4 z-30 lg:bottom-6 lg:right-[max(2rem,calc((100vw-72rem)/2+21.5rem))]">
{user && !createPostOpen && !createRegulationOpen ? (
<div
ref={createMenuRef}
className="pointer-events-none fixed bottom-[calc(env(safe-area-inset-bottom)+5.5rem)] right-4 z-30 flex flex-col items-end gap-2 lg:bottom-6 lg:right-[max(2rem,calc((100vw-72rem)/2+21.5rem))]"
>
{createMenuOpen ? (
<div className="flex flex-col items-end gap-2" role="menu" aria-label="Создать в ленте">
{canCreatePost ? (
<button
type="button"
role="menuitem"
onClick={openCreatePostModal}
className="app-action-primary pointer-events-auto inline-flex h-10 items-center gap-2 rounded-full px-4 text-sm font-medium shadow-[var(--shadow-card)]"
>
<Newspaper size={17} />
Новость
</button>
) : null}
<button
type="button"
role="menuitem"
onClick={openCreateRegulationModal}
className="app-action-primary pointer-events-auto inline-flex h-10 items-center gap-2 rounded-full px-4 text-sm font-medium shadow-[var(--shadow-card)]"
>
<ScrollText size={17} />
Регламент
</button>
</div>
) : null}
<button
type="button"
onClick={openCreatePostModal}
onClick={() => setCreateMenuOpen((current) => !current)}
className="app-action-primary pointer-events-auto inline-flex h-12 w-12 items-center justify-center rounded-full p-0 leading-none shadow-[var(--shadow-card)] transition active:scale-[0.98]"
title="Создать публикацию"
aria-label="Создать публикацию"
title={createMenuOpen ? "Закрыть меню создания" : "Создать"}
aria-label={createMenuOpen ? "Закрыть меню создания" : "Открыть меню создания"}
aria-expanded={createMenuOpen}
aria-haspopup="menu"
>
<Plus size={22} />
<Plus size={22} className={`transition-transform duration-200 ${createMenuOpen ? "rotate-45" : ""}`} />
</button>
</div>
) : null}
<Modal
isOpen={createRegulationOpen}
onClose={() => setCreateRegulationOpen(false)}
title="Создать регламент"
size="xl"
>
<DocumentUploadForm
defaultIsRegulation
onCancel={() => setCreateRegulationOpen(false)}
onSuccess={() => {
setCreateRegulationOpen(false);
void refreshRegulations();
}}
/>
</Modal>
<Modal
isOpen={createPostOpen}
onClose={() => setCreatePostOpen(false)}
@ -979,6 +1286,85 @@ function HomePageContent() {
post={activePost}
/>
<DocumentDetailModal
key={selectedRegulation ? `${selectedRegulation.id}-${selectedRegulationInitialTab || "default"}` : "regulation-detail"}
document={selectedRegulation}
isOpen={Boolean(selectedRegulation)}
initialTab={selectedRegulationInitialTab}
onClose={() => {
setSelectedRegulation(null);
setSelectedRegulationInitialTab(undefined);
}}
onUpdate={() => {
void refreshRegulations();
if (selectedRegulation) {
void apiClient.getDocument(selectedRegulation.id).then(setSelectedRegulation);
}
}}
onEditMetadata={() => {
if (selectedRegulation) setMetadataRegulation(selectedRegulation);
}}
onViewReport={() => {
if (!selectedRegulation) return;
setAcknowledgementsReport({
documentId: selectedRegulation.id,
documentTitle: selectedRegulation.title,
});
}}
onNavigateToRelated={(documentId) => {
setSelectedRegulationInitialTab(undefined);
void apiClient.getDocument(documentId).then(setSelectedRegulation);
}}
/>
{metadataRegulation ? (
<DocumentMetadataEditor
isOpen
document={metadataRegulation}
onClose={() => setMetadataRegulation(null)}
onUpdate={() => {
void refreshRegulations();
if (selectedRegulation?.id === metadataRegulation.id) {
void apiClient.getDocument(metadataRegulation.id).then(setSelectedRegulation);
}
}}
/>
) : null}
{taskLinkRegulation ? (
<DocumentTaskLinks
document={taskLinkRegulation}
variant="dialog"
open
onClose={() => setTaskLinkRegulation(null)}
onLinked={() => void refreshRegulations()}
/>
) : null}
{regulationPreviewFile ? (
<DocumentPreview
fileUrl={regulationPreviewFile.url}
fileName={regulationPreviewFile.name}
onClose={() => setRegulationPreviewFile(null)}
/>
) : null}
{regulationPdfFile ? (
<EnhancedPDFViewer
fileUrl={regulationPdfFile.url}
fileName={regulationPdfFile.name}
onClose={() => setRegulationPdfFile(null)}
/>
) : null}
{acknowledgementsReport ? (
<DocumentAcknowledgementsReport
documentId={acknowledgementsReport.documentId}
documentTitle={acknowledgementsReport.documentTitle}
onClose={() => setAcknowledgementsReport(null)}
/>
) : null}
{taskLinkPost ? (
<RelatedTaskLinks
key={`post-task-link-${taskLinkPost.id}`}

View File

@ -34,6 +34,7 @@ import {
Loader2,
MessageSquare,
Package,
Paperclip,
Pencil,
Play,
Plus,
@ -455,6 +456,9 @@ export default function ProcurementPage() {
handleDeleteComment,
handleAddItemComment,
handleDeleteItemComment,
handleDownloadItemAttachment,
handleUploadItemAttachments,
handleDeleteItemAttachment,
markRequestNotificationsRead,
toggleItemComments,
} = useProcurementPage(user);
@ -1518,6 +1522,9 @@ export default function ProcurementPage() {
onItemCommentDraftChange={(itemId, value) => setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))}
onAddItemComment={handleAddItemComment}
onDeleteItemComment={handleDeleteItemComment}
onDownloadItemAttachment={handleDownloadItemAttachment}
onUploadItemAttachments={handleUploadItemAttachments}
onDeleteItemAttachment={handleDeleteItemAttachment}
footer={(
<ProcurementRequestActionButtons
request={resolvedDetail}
@ -1695,6 +1702,9 @@ export default function ProcurementPage() {
onItemCommentDraftChange={(itemId, value) => setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))}
onAddItemComment={handleAddItemComment}
onDeleteItemComment={handleDeleteItemComment}
onDownloadItemAttachment={handleDownloadItemAttachment}
onUploadItemAttachments={handleUploadItemAttachments}
onDeleteItemAttachment={handleDeleteItemAttachment}
/>
<div className="app-surface rounded-xl p-4">
@ -2227,6 +2237,77 @@ export default function ProcurementPage() {
</div>
))}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="app-text-muted text-xs font-medium">Файлы</span>
<label className="app-link-accent inline-flex cursor-pointer items-center gap-1 text-xs font-medium">
<Paperclip size={12} /> Прикрепить
<input
type="file"
multiple
className="hidden"
onChange={(event) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) {
updateItemRow(idx, { pending_files: [...it.pending_files, ...files] });
}
event.target.value = "";
}}
/>
</label>
</div>
{it.attachments.length > 0 || it.pending_files.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{it.attachments.map((attachment) => (
<span
key={`attachment-${attachment.id}`}
className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]"
>
<button
type="button"
className="inline-flex min-w-0 items-center gap-1"
onClick={() => void handleDownloadItemAttachment(it.id!, attachment)}
>
<Paperclip size={11} className="shrink-0" />
<span className="max-w-48 truncate">{attachment.file_name}</span>
</button>
<button
type="button"
title="Удалить файл"
onClick={() => updateItemRow(idx, {
attachments: it.attachments.filter((current) => current.id !== attachment.id),
removed_attachment_ids: [...it.removed_attachment_ids, attachment.id],
})}
className="app-action-ghost inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
>
<X size={10} />
</button>
</span>
))}
{it.pending_files.map((file, fileIndex) => (
<span
key={`pending-${file.name}-${file.lastModified}-${fileIndex}`}
className="app-selected inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]"
>
<Paperclip size={11} className="shrink-0" />
<span className="max-w-48 truncate">{file.name}</span>
<button
type="button"
title="Убрать файл"
onClick={() => updateItemRow(idx, {
pending_files: it.pending_files.filter((_, currentIndex) => currentIndex !== fileIndex),
})}
className="app-action-ghost inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
>
<X size={10} />
</button>
</span>
))}
</div>
) : (
<p className="app-text-muted text-[11px]">Файлы не прикреплены</p>
)}
</div>
</div>
</div>
))}

View File

@ -95,12 +95,6 @@ export default function Register() {
const file = e.target.files?.[0];
if (!file) return;
// Проверка размера (макс 10MB для исходного файла)
if (file.size > 10 * 1024 * 1024) {
setError('Размер файла не должен превышать 10MB');
return;
}
// Проверка типа
if (!file.type.startsWith('image/')) {
setError('Выберите изображение');

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@ import { MobileLeftDrawer, MobileCalendarDrawer } from "@/components/layout/Mobi
import { usePwa } from "@/contexts/PwaContext";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import { NAV_NOTIFICATION_CATEGORIES } from "@/lib/navigation-notifications";
import type { GuestVisit, GuestVisitComment } from "@/types/api";
type AppShellProps = {
@ -50,17 +51,17 @@ type PaginatedResponse<T> = {
};
const navItems = [
{ href: "/", label: "Лента", icon: HomeIcon, category: "Новости" },
{ href: "/messages", label: "Сообщения", icon: MessageSquare, category: "Сообщения" },
{ href: "/", label: "Лента", icon: HomeIcon, category: NAV_NOTIFICATION_CATEGORIES.feed },
{ href: "/messages", label: "Сообщения", icon: MessageSquare, category: NAV_NOTIFICATION_CATEGORIES.messages },
{ href: "/employees", label: "Сотрудники", icon: Users },
{ href: "/departments", label: "Отделы", icon: Building2 },
{ href: "/attendance", label: "Посещаемость", icon: CalendarCheck },
{ href: "/requests", label: "Заявления", icon: FileSignature, category: "Заявки" },
{ href: "/guests", label: "Гости", icon: UserRoundPlus, category: "Гости" },
{ href: "/requests", label: "Заявления", icon: FileSignature, category: NAV_NOTIFICATION_CATEGORIES.requests },
{ href: "/guests", label: "Гости", icon: UserRoundPlus, category: NAV_NOTIFICATION_CATEGORIES.guests },
// { href: "/equipment", label: "Оборудование", icon: Monitor },
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: "Закупки", autoReadOnNavigate: false },
{ href: "/tasks", label: "Доска", icon: Kanban, category: "Доска" },
{ href: "/documents", label: "Документы", icon: FileText, category: "Документы" },
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: NAV_NOTIFICATION_CATEGORIES.procurement, autoReadOnNavigate: false },
{ href: "/tasks", label: "Доска", icon: Kanban, category: NAV_NOTIFICATION_CATEGORIES.tasks },
{ href: "/documents", label: "Документы", icon: FileText, category: NAV_NOTIFICATION_CATEGORIES.documents },
// { href: "/finances", label: "Финансы", icon: Wallet },
];

View File

@ -36,11 +36,18 @@ export function DocumentAcknowledgement({
const acknowledgements = document.acknowledgements || [];
const isAcknowledged = document.is_acknowledged || false;
const acknowledgementRequiredForUser =
document.acknowledgement_required_for_user ?? document.acknowledgement_required;
return (
<div className="space-y-3">
{/* Acknowledge Button/Status */}
{!isAcknowledged ? (
{!acknowledgementRequiredForUser ? (
<div className="app-surface-muted flex items-center gap-2 rounded-lg px-3 py-2 text-sm">
<CheckCircle size={16} className="app-text-muted shrink-0" />
<span>Для вас подтверждение ознакомления не требуется</span>
</div>
) : !isAcknowledged ? (
<div className="app-feedback-warning rounded-xl p-4">
<div className="mb-3 flex items-start gap-3">
<CheckCircle size={20} className="mt-0.5 shrink-0" />
@ -59,7 +66,7 @@ export function DocumentAcknowledgement({
disabled={isSubmitting}
className="app-action-primary w-full rounded-lg px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? "Подтверждение..." : "Подтвердить прочтение"}
{isSubmitting ? "Подтверждение..." : "Ознакомился(лась)"}
</button>
</div>
) : (

View File

@ -0,0 +1,243 @@
"use client";
import { useMemo, useState } from "react";
import { Search } from "lucide-react";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import type { Department, User } from "@/types/api";
export type DocumentAudienceMode = "all" | "none" | "restricted";
type DocumentAudienceSelectorProps = {
kind: "access" | "acknowledgement";
mode: DocumentAudienceMode;
onModeChange: (mode: DocumentAudienceMode) => void;
employees: User[];
departments: Department[];
selectedEmployeeIds: number[];
selectedDepartmentIds: number[];
onSelectedEmployeeIdsChange: (ids: number[]) => void;
onSelectedDepartmentIdsChange: (ids: number[]) => void;
loading?: boolean;
disabled?: boolean;
};
function employeeName(employee: User) {
return [employee.last_name, employee.first_name, employee.patronymic]
.filter(Boolean)
.join(" ")
.trim() || employee.email || `Сотрудник #${employee.id}`;
}
function employeeInitials(employee: User) {
return `${employee.last_name?.[0] || ""}${employee.first_name?.[0] || ""}`.toUpperCase() || "С";
}
function normalizeSearch(value: string) {
return value.trim().toLocaleLowerCase("ru");
}
export function DocumentAudienceSelector({
kind,
mode,
onModeChange,
employees,
departments,
selectedEmployeeIds,
selectedDepartmentIds,
onSelectedEmployeeIdsChange,
onSelectedDepartmentIdsChange,
loading = false,
disabled = false,
}: DocumentAudienceSelectorProps) {
const [employeeSearch, setEmployeeSearch] = useState("");
const [departmentSearch, setDepartmentSearch] = useState("");
const isAcknowledgement = kind === "acknowledgement";
const options: Array<{ value: DocumentAudienceMode; label: string }> = isAcknowledgement
? [
{ value: "all", label: "Для всех с доступом" },
{ value: "none", label: "Не требуется" },
{ value: "restricted", label: "Выборочно" },
]
: [
{ value: "all", label: "Для всех" },
{ value: "restricted", label: "Выборочно" },
];
const filteredEmployees = useMemo(() => {
const query = normalizeSearch(employeeSearch);
if (!query) return employees;
return employees.filter((employee) => (
`${employeeName(employee)} ${employee.email || ""}`.toLocaleLowerCase("ru").includes(query)
));
}, [employeeSearch, employees]);
const filteredDepartments = useMemo(() => {
const query = normalizeSearch(departmentSearch);
if (!query) return departments;
return departments.filter((department) => department.name.toLocaleLowerCase("ru").includes(query));
}, [departmentSearch, departments]);
const toggleEmployee = (employeeId: number) => {
onSelectedEmployeeIdsChange(
selectedEmployeeIds.includes(employeeId)
? selectedEmployeeIds.filter((id) => id !== employeeId)
: [...selectedEmployeeIds, employeeId],
);
};
const toggleDepartment = (departmentId: number) => {
onSelectedDepartmentIdsChange(
selectedDepartmentIds.includes(departmentId)
? selectedDepartmentIds.filter((id) => id !== departmentId)
: [...selectedDepartmentIds, departmentId],
);
};
return (
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold text-[var(--foreground)]">
{isAcknowledgement ? "Требование ознакомления" : "Доступ к документу"}
</h3>
<p className="app-text-muted mt-1 text-xs">
{isAcknowledgement
? "Выберите сотрудников, которые должны подтвердить ознакомление."
: "Выберите сотрудников, которые смогут открыть документ."}
</p>
</div>
<div className={`grid gap-2 ${options.length === 3 ? "sm:grid-cols-3" : "sm:grid-cols-2"}`}>
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onModeChange(option.value)}
disabled={disabled}
aria-pressed={mode === option.value}
className={`rounded-xl border px-3 py-2 text-left text-sm font-medium transition disabled:opacity-50 ${
mode === option.value
? "app-selected border-[var(--accent-primary)]"
: "border-[var(--border-subtle)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)]"
}`}
>
{option.label}
</button>
))}
</div>
{mode === "restricted" ? (
<div className="grid gap-3 md:grid-cols-2">
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="app-text-muted text-xs font-medium">Сотрудники</span>
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">
{selectedEmployeeIds.length}
</span>
</div>
<div className="relative mb-2">
<Search
size={14}
className="app-text-muted pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2"
/>
<input
value={employeeSearch}
onChange={(event) => setEmployeeSearch(event.target.value)}
disabled={disabled}
className="app-input w-full rounded-xl py-2 pl-8 pr-3 text-xs"
placeholder="Поиск сотрудников"
aria-label={`${isAcknowledgement ? "Ознакомление" : "Доступ"}: поиск сотрудников`}
/>
</div>
<div className="tasks-column-scroll app-surface-muted max-h-52 space-y-1 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-2">
{loading ? (
<p className="app-text-muted px-2 py-4 text-center text-xs">Загрузка сотрудников...</p>
) : filteredEmployees.length > 0 ? (
filteredEmployees.map((employee) => {
const name = employeeName(employee);
return (
<label
key={employee.id}
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition hover:bg-[var(--surface-elevated)]"
>
<input
type="checkbox"
checked={selectedEmployeeIds.includes(employee.id)}
onChange={() => toggleEmployee(employee.id)}
disabled={disabled}
className="h-4 w-4 shrink-0"
/>
<RequestAvatar
alt={name}
fallback={employeeInitials(employee)}
src={employee.avatar}
size="sm"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-[var(--foreground)]">{name}</span>
{employee.email ? (
<span className="app-text-muted block truncate text-[10px]">{employee.email}</span>
) : null}
</span>
</label>
);
})
) : (
<p className="app-text-muted px-2 py-4 text-center text-xs">Сотрудники не найдены</p>
)}
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="app-text-muted text-xs font-medium">Отделы</span>
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">
{selectedDepartmentIds.length}
</span>
</div>
<div className="relative mb-2">
<Search
size={14}
className="app-text-muted pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2"
/>
<input
value={departmentSearch}
onChange={(event) => setDepartmentSearch(event.target.value)}
disabled={disabled}
className="app-input w-full rounded-xl py-2 pl-8 pr-3 text-xs"
placeholder="Поиск отделов"
aria-label={`${isAcknowledgement ? "Ознакомление" : "Доступ"}: поиск отделов`}
/>
</div>
<div className="tasks-column-scroll app-surface-muted max-h-52 space-y-1 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-2">
{loading ? (
<p className="app-text-muted px-2 py-4 text-center text-xs">Загрузка отделов...</p>
) : filteredDepartments.length > 0 ? (
filteredDepartments.map((department) => (
<label
key={department.id}
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition hover:bg-[var(--surface-elevated)]"
>
<input
type="checkbox"
checked={selectedDepartmentIds.includes(department.id)}
onChange={() => toggleDepartment(department.id)}
disabled={disabled}
className="h-4 w-4 shrink-0"
/>
<span className="min-w-0 truncate text-xs font-medium text-[var(--foreground)]">
{department.name}
</span>
</label>
))
) : (
<p className="app-text-muted px-2 py-4 text-center text-xs">Отделы не найдены</p>
)}
</div>
</div>
</div>
) : null}
</section>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { MessageCircle, Edit2, CornerDownRight } from "lucide-react";
import { apiClient } from "@/lib/api";
import { toast } from "sonner";
@ -24,9 +24,10 @@ interface Comment {
interface DocumentCommentsProps {
documentId: number;
onCommentsChanged?: () => void;
}
export function DocumentComments({ documentId }: DocumentCommentsProps) {
export function DocumentComments({ documentId, onCommentsChanged }: DocumentCommentsProps) {
const { user } = useUser();
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
@ -36,11 +37,7 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
const [editText, setEditText] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
loadComments();
}, [documentId]);
const loadComments = async () => {
const loadComments = useCallback(async () => {
try {
setLoading(true);
const response = await apiClient.getDocumentComments(documentId);
@ -75,7 +72,11 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
} finally {
setLoading(false);
}
};
}, [documentId]);
useEffect(() => {
void loadComments();
}, [loadComments]);
const handleSubmitComment = async () => {
if (!newComment.trim()) return;
@ -92,6 +93,7 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
setNewComment("");
setReplyTo(null);
await loadComments();
onCommentsChanged?.();
} catch (error) {
console.error("Ошибка добавления комментария:", error);
toast.error("Не удалось добавить комментарий");
@ -105,7 +107,7 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
setSubmitting(true);
try {
await apiClient.updateDocumentComment(id, editText);
await apiClient.updateDocumentComment(documentId, id, editText);
toast.success("Комментарий обновлен");
setEditingId(null);
setEditText("");
@ -122,9 +124,10 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
if (!window.confirm("Удалить комментарий?")) return;
try {
await apiClient.deleteDocumentComment(id);
await apiClient.deleteDocumentComment(documentId, id);
toast.success("Комментарий удален");
await loadComments();
onCommentsChanged?.();
} catch (error) {
console.error("Ошибка удаления комментария:", error);
toast.error("Не удалось удалить комментарий");

View File

@ -47,6 +47,7 @@ const DocumentPreviewPane = dynamic(
interface DocumentDetailModalProps {
document: Document | null;
isOpen: boolean;
initialTab?: TabType;
onClose: () => void;
onUpdate?: () => void;
onEditMetadata?: () => void;
@ -59,6 +60,7 @@ type TabType = "preview" | "info" | "activity" | "acknowledgements" | "comments"
export function DocumentDetailModal({
document,
isOpen,
initialTab,
onClose,
onUpdate,
onEditMetadata,
@ -103,7 +105,11 @@ export function DocumentDetailModal({
{ id: "comments" as TabType, label: "Комментарии", icon: MessageSquare },
{ id: "related" as TabType, label: "Связанные", icon: Link2 },
];
const defaultTab: TabType = documentHasFile ? "preview" : "info";
const defaultTab: TabType = initialTab && tabs.some((tab) => tab.id === initialTab)
? initialTab
: documentHasFile
? "preview"
: "info";
const activeTab: TabType = activeTabState.documentId === documentId
? activeTabState.value
: defaultTab;
@ -522,7 +528,10 @@ export function DocumentDetailModal({
{currentActiveTab === "comments" && (
<div className="p-6">
<div className="app-surface rounded-xl p-6">
<DocumentComments documentId={document.id} />
<DocumentComments
documentId={document.id}
onCommentsChanged={() => onUpdate?.()}
/>
</div>
</div>
)}
@ -790,7 +799,10 @@ export function DocumentDetailModal({
{currentActiveTab === "comments" && (
<div className="p-4 sm:p-6">
<div className="app-surface rounded-xl p-4 sm:p-6">
<DocumentComments documentId={document.id} />
<DocumentComments
documentId={document.id}
onCommentsChanged={() => onUpdate?.()}
/>
</div>
</div>
)}

View File

@ -6,19 +6,20 @@ import { apiClient } from '@/lib/api';
import { toast } from 'sonner';
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';
import {
DocumentAudienceSelector,
type DocumentAudienceMode,
} from './DocumentAudienceSelector';
type DocumentTagOption = NonNullable<Document["tags"]>[number];
@ -44,8 +45,11 @@ interface DocumentUpdatePayload {
is_regulation?: boolean;
sent_to_all?: boolean;
acknowledgement_required?: boolean;
acknowledgement_for_all?: boolean;
recipient_ids?: number[];
department_ids?: number[];
acknowledgement_recipient_ids?: number[];
acknowledgement_department_ids?: number[];
}
interface DocumentMetadataEditorProps {
@ -75,14 +79,6 @@ function formatFileSize(bytes?: number): string {
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[]>();
@ -123,9 +119,17 @@ export function DocumentMetadataEditor({
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 [acknowledgementMode, setAcknowledgementMode] = useState<DocumentAudienceMode>(
!document.acknowledgement_required
? 'none'
: document.acknowledgement_for_all === false
? 'restricted'
: 'all'
);
const [selectedDepartments, setSelectedDepartments] = useState<number[]>([]);
const [selectedRecipients, setSelectedRecipients] = useState<number[]>([]);
const [acknowledgementDepartments, setAcknowledgementDepartments] = useState<number[]>([]);
const [acknowledgementRecipients, setAcknowledgementRecipients] = useState<number[]>([]);
const [documentTags, setDocumentTags] = useState<DocumentTagOption[]>([]);
const [folders, setFolders] = useState<FolderOption[]>([]);
@ -149,9 +153,21 @@ export function DocumentMetadataEditor({
setSelectedFolder(document.folder?.id || null);
setIsRegulation(Boolean(document.is_regulation));
setSentToAll(Boolean(document.sent_to_all ?? true));
setAcknowledgementRequired(Boolean(document.acknowledgement_required));
setAcknowledgementMode(
!document.acknowledgement_required
? 'none'
: document.acknowledgement_for_all === false
? 'restricted'
: 'all'
);
setSelectedDepartments((document.departments || []).map((department) => department.id));
setSelectedRecipients((document.recipients || []).map((recipient) => recipient.id));
setAcknowledgementDepartments(
(document.acknowledgement_departments || []).map((department) => department.id)
);
setAcknowledgementRecipients(
(document.acknowledgement_recipients || []).map((recipient) => recipient.id)
);
setError(null);
}, [isOpen, document]);
@ -193,6 +209,30 @@ export function DocumentMetadataEditor({
};
}, [isOpen]);
const acknowledgementEmployees = useMemo(() => {
if (sentToAll) return employees;
const departmentIds = new Set(selectedDepartments);
const recipientIds = new Set(selectedRecipients);
return employees.filter((employee) => (
recipientIds.has(employee.id)
|| (employee.departments || []).some((department) => departmentIds.has(department.id))
));
}, [employees, selectedDepartments, selectedRecipients, sentToAll]);
const acknowledgementDepartmentOptions = useMemo(() => {
if (sentToAll) return departments;
const departmentIds = new Set(selectedDepartments);
return departments.filter((department) => departmentIds.has(department.id));
}, [departments, selectedDepartments, sentToAll]);
useEffect(() => {
if (sentToAll) return;
const allowedEmployeeIds = new Set(acknowledgementEmployees.map((employee) => employee.id));
const allowedDepartmentIds = new Set(selectedDepartments);
setAcknowledgementRecipients((current) => current.filter((id) => allowedEmployeeIds.has(id)));
setAcknowledgementDepartments((current) => current.filter((id) => allowedDepartmentIds.has(id)));
}, [acknowledgementEmployees, selectedDepartments, sentToAll]);
const toggleTag = (tagId: number) => {
setSelectedTags((prev) =>
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]
@ -221,6 +261,15 @@ export function DocumentMetadataEditor({
return;
}
if (
acknowledgementMode === 'restricted'
&& acknowledgementDepartments.length === 0
&& acknowledgementRecipients.length === 0
) {
setError('Выберите хотя бы один отдел или сотрудника для ознакомления');
return;
}
setIsSaving(true);
try {
@ -228,6 +277,12 @@ export function DocumentMetadataEditor({
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 currentAcknowledgementDepartmentIds = (document.acknowledgement_departments || []).map(
(department) => department.id
);
const currentAcknowledgementRecipientIds = (document.acknowledgement_recipients || []).map(
(recipient) => recipient.id
);
const currentFolder = document.folder?.id || null;
const currentSentToAll = Boolean(document.sent_to_all ?? true);
@ -259,9 +314,14 @@ export function DocumentMetadataEditor({
updateData.sent_to_all = sentToAll;
}
const acknowledgementRequired = acknowledgementMode !== 'none';
const acknowledgementForAll = acknowledgementMode === 'all';
if (acknowledgementRequired !== Boolean(document.acknowledgement_required)) {
updateData.acknowledgement_required = acknowledgementRequired;
}
if (acknowledgementForAll !== Boolean(document.acknowledgement_for_all ?? true)) {
updateData.acknowledgement_for_all = acknowledgementForAll;
}
if (!arraysEqual(currentTagIds, selectedTags)) {
updateData.tag_ids = selectedTags;
@ -277,6 +337,15 @@ export function DocumentMetadataEditor({
}
}
if (acknowledgementMode === 'restricted') {
if (!arraysEqual(currentAcknowledgementDepartmentIds, acknowledgementDepartments)) {
updateData.acknowledgement_department_ids = acknowledgementDepartments;
}
if (!arraysEqual(currentAcknowledgementRecipientIds, acknowledgementRecipients)) {
updateData.acknowledgement_recipient_ids = acknowledgementRecipients;
}
}
if (Object.keys(updateData).length === 0) {
toast.info('Нет изменений для сохранения');
onClose();
@ -518,125 +587,36 @@ export function DocumentMetadataEditor({
</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" />
Доступ и ознакомление
<section className="space-y-6">
<DocumentAudienceSelector
kind="access"
mode={sentToAll ? 'all' : 'restricted'}
onModeChange={(mode) => setSentToAll(mode === 'all')}
employees={employees}
departments={departments}
selectedEmployeeIds={selectedRecipients}
selectedDepartmentIds={selectedDepartments}
onSelectedEmployeeIdsChange={setSelectedRecipients}
onSelectedDepartmentIdsChange={setSelectedDepartments}
loading={loadingReferences}
disabled={isSaving}
/>
<div className="app-divider border-t pt-5">
<DocumentAudienceSelector
kind="acknowledgement"
mode={acknowledgementMode}
onModeChange={setAcknowledgementMode}
employees={acknowledgementEmployees}
departments={acknowledgementDepartmentOptions}
selectedEmployeeIds={acknowledgementRecipients}
selectedDepartmentIds={acknowledgementDepartments}
onSelectedEmployeeIdsChange={setAcknowledgementRecipients}
onSelectedDepartmentIdsChange={setAcknowledgementDepartments}
loading={loadingReferences}
disabled={isSaving}
/>
</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>
)}
</section>
<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">

View File

@ -9,28 +9,18 @@ import {
AlertCircle,
Loader2,
FolderOpen,
Users,
Building2,
CheckCircle,
ScrollText,
Tag as TagIcon,
} from "lucide-react";
import { apiClient } from "@/lib/api";
import type { Department, User } from "@/types/api";
import { toast } from "sonner";
import { processDocument, needsProcessing, type ProcessingProgress } from "@/lib/document-utils";
import { DocumentTagQuickCreate, type QuickDocumentTag } from "./DocumentTagQuickCreate";
interface Department {
id: number;
name: string;
}
interface User {
id: number;
first_name: string;
last_name: string;
email?: string;
}
import {
DocumentAudienceSelector,
type DocumentAudienceMode,
} from "./DocumentAudienceSelector";
interface DocumentTagOption {
id: number;
@ -54,6 +44,7 @@ interface DocumentUploadFormProps {
onSuccess?: () => void;
onCancel?: () => void;
currentFolderId?: number | null;
defaultIsRegulation?: boolean;
}
interface UploadFileItem {
@ -127,7 +118,12 @@ const STAGE_MESSAGES: Record<string, string> = {
complete: "Обработка завершена",
};
export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: DocumentUploadFormProps) {
export function DocumentUploadForm({
onSuccess,
onCancel,
currentFolderId,
defaultIsRegulation = false,
}: DocumentUploadFormProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [uploadItems, setUploadItems] = useState<UploadFileItem[]>([]);
@ -138,14 +134,16 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
// New fields for extended functionality
const [sentToAll, setSentToAll] = useState(true);
const [acknowledgementRequired, setAcknowledgementRequired] = useState(false);
const [acknowledgementMode, setAcknowledgementMode] = useState<DocumentAudienceMode>("all");
const [selectedDepartments, setSelectedDepartments] = useState<number[]>([]);
const [selectedRecipients, setSelectedRecipients] = useState<number[]>([]);
const [acknowledgementDepartments, setAcknowledgementDepartments] = useState<number[]>([]);
const [acknowledgementRecipients, setAcknowledgementRecipients] = useState<number[]>([]);
// Metadata fields
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(currentFolderId ?? null);
const [selectedTags, setSelectedTags] = useState<number[]>([]);
const [isRegulation, setIsRegulation] = useState(false);
const [isRegulation, setIsRegulation] = useState(defaultIsRegulation);
// Data for selects
const [departments, setDepartments] = useState<Department[]>([]);
@ -178,7 +176,11 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
try {
setLoadingEmployees(true);
const empResponse = await apiClient.getEmployees({ limit: 1000 });
const empResponse = await apiClient.getEmployees({
limit: 1000,
is_active: true,
ordering: "last_name,first_name",
});
setEmployees(empResponse.results || empResponse);
} catch (err) {
console.error("Ошибка загрузки сотрудников:", err);
@ -211,6 +213,30 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
loadData();
}, []);
const acknowledgementEmployees = useMemo(() => {
if (sentToAll) return employees;
const departmentIds = new Set(selectedDepartments);
const recipientIds = new Set(selectedRecipients);
return employees.filter((employee) => (
recipientIds.has(employee.id)
|| (employee.departments || []).some((department) => departmentIds.has(department.id))
));
}, [employees, selectedDepartments, selectedRecipients, sentToAll]);
const acknowledgementDepartmentOptions = useMemo(() => {
if (sentToAll) return departments;
const departmentIds = new Set(selectedDepartments);
return departments.filter((department) => departmentIds.has(department.id));
}, [departments, selectedDepartments, sentToAll]);
useEffect(() => {
if (sentToAll) return;
const allowedEmployeeIds = new Set(acknowledgementEmployees.map((employee) => employee.id));
const allowedDepartmentIds = new Set(selectedDepartments);
setAcknowledgementRecipients((current) => current.filter((id) => allowedEmployeeIds.has(id)));
setAcknowledgementDepartments((current) => current.filter((id) => allowedDepartmentIds.has(id)));
}, [acknowledgementEmployees, selectedDepartments, sentToAll]);
const onDrop = useCallback(async (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return;
@ -230,7 +256,7 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
setIsProcessing(true);
setProcessingProgress({
stage: "compressing",
stage: "extracting_text",
progress: 0,
message: "Начало обработки...",
});
@ -244,7 +270,6 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
try {
const result = await processDocument(item.file, {
enableOCR: true,
enableCompression: true,
enableTextExtraction: true,
enableThumbnail: true,
onProgress: (progress) => {
@ -340,7 +365,16 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
// Validate recipients if sent_to_all is false
if (!sentToAll && selectedDepartments.length === 0 && selectedRecipients.length === 0) {
setError("Укажите получателей или отделы, либо выберите 'Отправить всем'");
setError("Выберите хотя бы один отдел или сотрудника с доступом к документу");
return;
}
if (
acknowledgementMode === "restricted"
&& acknowledgementDepartments.length === 0
&& acknowledgementRecipients.length === 0
) {
setError("Выберите хотя бы один отдел или сотрудника для ознакомления");
return;
}
@ -356,7 +390,12 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
is_regulation: isRegulation,
department_ids: sentToAll ? undefined : selectedDepartments,
recipient_ids: sentToAll ? undefined : selectedRecipients,
acknowledgement_required: acknowledgementRequired,
acknowledgement_required: acknowledgementMode !== "none",
acknowledgement_for_all: acknowledgementMode === "all",
acknowledgement_department_ids:
acknowledgementMode === "restricted" ? acknowledgementDepartments : undefined,
acknowledgement_recipient_ids:
acknowledgementMode === "restricted" ? acknowledgementRecipients : undefined,
tag_ids: selectedTags.length > 0 ? selectedTags : undefined,
});
} else {
@ -376,7 +415,12 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
is_regulation: isRegulation,
department_ids: sentToAll ? undefined : selectedDepartments,
recipient_ids: sentToAll ? undefined : selectedRecipients,
acknowledgement_required: acknowledgementRequired,
acknowledgement_required: acknowledgementMode !== "none",
acknowledgement_for_all: acknowledgementMode === "all",
acknowledgement_department_ids:
acknowledgementMode === "restricted" ? acknowledgementDepartments : undefined,
acknowledgement_recipient_ids:
acknowledgementMode === "restricted" ? acknowledgementRecipients : undefined,
tag_ids: selectedTags.length > 0 ? selectedTags : undefined,
});
}
@ -396,10 +440,12 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
setUploadItems([]);
setSelectedFolderId(currentFolderId ?? null);
setSentToAll(true);
setIsRegulation(false);
setAcknowledgementRequired(false);
setIsRegulation(defaultIsRegulation);
setAcknowledgementMode("all");
setSelectedDepartments([]);
setSelectedRecipients([]);
setAcknowledgementDepartments([]);
setAcknowledgementRecipients([]);
setSelectedTags([]);
if (onSuccess) {
@ -717,140 +763,37 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
</div>
)}
{/* Divider */}
<div className="app-divider border-t pt-4">
<h3 className="mb-3 text-sm font-semibold text-[var(--foreground)]">Настройки доступа и уведомлений</h3>
</div>
<div className="space-y-6">
<DocumentAudienceSelector
kind="access"
mode={sentToAll ? "all" : "restricted"}
onModeChange={(mode) => setSentToAll(mode === "all")}
employees={employees}
departments={departments}
selectedEmployeeIds={selectedRecipients}
selectedDepartmentIds={selectedDepartments}
onSelectedEmployeeIdsChange={setSelectedRecipients}
onSelectedDepartmentIdsChange={setSelectedDepartments}
loading={loadingEmployees || loadingDepartments}
disabled={isSubmitting}
/>
{/* Sent to All Toggle */}
<div className="app-surface-muted flex items-start gap-3 rounded-lg p-3">
<input
type="checkbox"
id="sentToAll"
checked={sentToAll}
onChange={(e) => {
setSentToAll(e.target.checked);
if (e.target.checked) {
setSelectedDepartments([]);
setSelectedRecipients([]);
}
}}
className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)]"
/>
<div className="flex-1">
<label htmlFor="sentToAll" 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>
{/* Recipients section - only show when sentToAll is false */}
{!sentToAll && (
<div className="app-surface-muted space-y-4 rounded-lg p-4">
<div className="flex items-center gap-2 text-sm font-medium text-[var(--foreground)]">
<Users size={16} />
<span>Выберите получателей</span>
<div className="app-divider border-t pt-5">
<DocumentAudienceSelector
kind="acknowledgement"
mode={acknowledgementMode}
onModeChange={setAcknowledgementMode}
employees={acknowledgementEmployees}
departments={acknowledgementDepartmentOptions}
selectedEmployeeIds={acknowledgementRecipients}
selectedDepartmentIds={acknowledgementDepartments}
onSelectedEmployeeIdsChange={setAcknowledgementRecipients}
onSelectedDepartmentIdsChange={setAcknowledgementDepartments}
loading={loadingEmployees || loadingDepartments}
disabled={isSubmitting}
/>
</div>
{/* Departments */}
<div>
<label htmlFor="departments" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
<Building2 size={14} className="mr-1 inline" />
Отделы
</label>
<select
id="departments"
multiple
value={selectedDepartments.map(String)}
onChange={(e) => {
const values = Array.from(e.target.selectedOptions, (option) => Number(option.value));
setSelectedDepartments(values);
}}
disabled={loadingDepartments}
className="app-select w-full rounded-lg px-3 py-2 text-sm"
size={5}
>
{loadingDepartments ? (
<option disabled>Загрузка...</option>
) : departments.length === 0 ? (
<option disabled>Нет доступных отделов</option>
) : (
departments.map((dept) => (
<option key={dept.id} value={dept.id}>
{dept.name}
</option>
))
)}
</select>
<p className="app-text-muted mt-1 text-xs">
Удерживайте Ctrl/Cmd для выбора нескольких
</p>
</div>
{/* Recipients */}
<div>
<label htmlFor="recipients" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
<Users size={14} className="mr-1 inline" />
Конкретные сотрудники
</label>
<select
id="recipients"
multiple
value={selectedRecipients.map(String)}
onChange={(e) => {
const values = Array.from(e.target.selectedOptions, (option) => Number(option.value));
setSelectedRecipients(values);
}}
disabled={loadingEmployees}
className="app-select w-full rounded-lg px-3 py-2 text-sm"
size={8}
>
{loadingEmployees ? (
<option disabled>Загрузка...</option>
) : employees.length === 0 ? (
<option disabled>Нет доступных сотрудников</option>
) : (
employees.map((emp) => (
<option key={emp.id} value={emp.id}>
{emp.last_name} {emp.first_name} {emp.email && `(${emp.email})`}
</option>
))
)}
</select>
<p className="app-text-muted mt-1 text-xs">
Удерживайте Ctrl/Cmd для выбора нескольких
</p>
</div>
{selectedDepartments.length === 0 && selectedRecipients.length === 0 && (
<div className="app-feedback-warning flex items-start gap-2 rounded-lg p-3 text-xs">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<span>Выберите хотя бы один отдел или сотрудника</span>
</div>
)}
</div>
)}
{/* Acknowledgement Required */}
<div className="app-surface-muted flex items-start gap-3 rounded-lg p-3">
<input
type="checkbox"
id="acknowledgementRequired"
checked={acknowledgementRequired}
onChange={(e) => setAcknowledgementRequired(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="acknowledgementRequired" className="block cursor-pointer text-sm font-medium text-[var(--foreground)]">
<CheckCircle size={14} className="mr-1 inline" />
Требуется подтверждение ознакомления
</label>
<p className="app-text-muted mt-0.5 text-xs">
Получатели должны будут подтвердить, что ознакомились с документом
</p>
</div>
</div>
@ -870,8 +813,8 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
: uploadItems.length > 1
? `Загрузить ${uploadItems.length} документов`
: uploadItems.length === 1
? "Загрузить документ"
: "Создать документ"}
? isRegulation ? "Загрузить регламент" : "Загрузить документ"
: isRegulation ? "Создать регламент" : "Создать документ"}
</button>
{onCancel && (

View File

@ -35,7 +35,7 @@ export default function CommentItem({
const handleEdit = async () => {
try {
const updated = await apiClient.updateDocumentComment(comment.id, editText);
const updated = await apiClient.updateDocumentComment(comment.document, comment.id, editText);
onCommentUpdated(updated);
setIsEditing(false);
} catch (err) {
@ -49,7 +49,7 @@ export default function CommentItem({
try {
setIsDeleting(true);
await apiClient.deleteDocumentComment(comment.id);
await apiClient.deleteDocumentComment(comment.document, comment.id);
onCommentDeleted(comment.id);
} catch (err) {
console.error('Failed to delete comment:', err);

View File

@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
type ExpandableFeedTextProps = {
text: string;
className?: string;
};
export function ExpandableFeedText({ text, className = "" }: ExpandableFeedTextProps) {
const [expanded, setExpanded] = useState(false);
const isLong = text.length > 320 || text.split("\n").length > 6;
return (
<div>
<p
className={`app-text-wrap whitespace-pre-line text-sm leading-6 text-[var(--foreground)] ${
isLong && !expanded ? "line-clamp-6" : ""
} ${className}`}
>
{text}
</p>
{isLong ? (
<button
type="button"
onClick={() => setExpanded((current) => !current)}
className="app-accent-text mt-1 inline-flex items-center gap-1 py-1 text-xs font-medium hover:text-[var(--accent-primary-strong)]"
aria-expanded={expanded}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
{expanded ? "Скрыть" : "Подробнее"}
</button>
) : null}
</div>
);
}

View File

@ -6,6 +6,7 @@ import type { ReactNode } from "react";
import { Heart, MessageSquare, Paperclip, Pin } from "lucide-react";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { ExpandableFeedText } from "@/components/feed/ExpandableFeedText";
import { resolveMediaUrl } from "@/lib/url";
import type { Post } from "@/types/api";
@ -136,9 +137,7 @@ export function FeedPostCard({
) : null}
{postText ? (
<p className="app-text-wrap whitespace-pre-line text-sm leading-6 text-[var(--foreground)]">
{postText}
</p>
<ExpandableFeedText text={postText} />
) : null}
{resolvedImageSrc ? (

View File

@ -0,0 +1,387 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import {
CheckCircle2,
ChevronRight,
Download,
Eye,
FileText,
FolderOpen,
Link2,
Loader2,
MessageSquare,
Pencil,
ScrollText,
Trash2,
Users,
} from "lucide-react";
import { ExpandableFeedText } from "@/components/feed/ExpandableFeedText";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { canPreviewDocument } from "@/lib/document-preview";
import { userProfileLink } from "@/lib/shared";
import type { Document } from "@/types/api";
type FeedRegulationCardProps = {
currentUserId?: number | null;
document: Document;
isAcknowledging?: boolean;
onAcknowledge: (document: Document) => void;
onDelete: (document: Document) => void;
onEdit: (document: Document) => void;
onLinkTask: (document: Document) => void;
onMove: (document: Document) => void;
onOpen: (document: Document) => void;
onOpenAcknowledgements: (document: Document) => void;
onOpenComments: (document: Document) => void;
onPreview: (document: Document) => void;
};
function formatTimeAgo(value?: string) {
if (!value) return "";
const date = new Date(value);
const diffMs = Date.now() - date.getTime();
if (!Number.isFinite(diffMs) || diffMs < 60_000) return "только что";
const diffHours = Math.floor(diffMs / 3_600_000);
if (diffHours < 24) return `${diffHours} ч. назад`;
return `${Math.floor(diffHours / 24)} дн. назад`;
}
function uploaderName(document: Document) {
const uploader = document.uploaded_by || document.created_by;
const name = `${uploader?.last_name || ""} ${uploader?.first_name || ""}`.trim();
return name || uploader?.email || "Сотрудник";
}
export function FeedRegulationCard({
currentUserId,
document,
isAcknowledging = false,
onAcknowledge,
onDelete,
onEdit,
onLinkTask,
onMove,
onOpen,
onOpenAcknowledgements,
onOpenComments,
onPreview,
}: FeedRegulationCardProps) {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const uploader = document.uploaded_by || document.created_by;
const name = uploaderName(document);
const initials = `${uploader?.last_name?.[0] || ""}${uploader?.first_name?.[0] || ""}`.toUpperCase() || "Р";
const linkedTasks = document.linked_tasks || [];
const uploadedAt = document.uploaded_at || document.created_at;
const acknowledgementRequiredForUser =
document.acknowledgement_required_for_user ?? document.acknowledgement_required;
const acknowledgedCount = document.acknowledged_count || 0;
const acknowledgementTotal = document.acknowledgement_total || 0;
const commentsCount = document.comments_count || 0;
const hasPreview = Boolean(
document.file_url && canPreviewDocument(document.file_name || document.title),
);
useEffect(() => {
if (!menuOpen) return;
const handlePointerDown = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setMenuOpen(false);
};
window.document.addEventListener("mousedown", handlePointerDown);
window.document.addEventListener("keydown", handleEscape);
return () => {
window.document.removeEventListener("mousedown", handlePointerDown);
window.document.removeEventListener("keydown", handleEscape);
};
}, [menuOpen]);
return (
<article
className="app-surface rounded-2xl p-5"
style={{ borderColor: "var(--regulation-border)" }}
>
<header className="mb-3 flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<RequestAvatar
alt={name}
fallback={initials}
size="lg"
src={uploader?.avatar}
/>
<div className="min-w-0">
{uploader ? (
<Link
href={userProfileLink(uploader, currentUserId)}
className="block truncate text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]"
>
{name}
</Link>
) : (
<p className="truncate text-sm font-semibold text-[var(--foreground)]">{name}</p>
)}
<div className="app-text-muted flex flex-wrap items-center gap-1.5 text-xs">
<span>{formatTimeAgo(uploadedAt)}</span>
<span className="app-selected app-accent-text inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium">
<ScrollText size={11} />
Регламент
</span>
</div>
</div>
</div>
<div ref={menuRef} className="relative shrink-0">
<button
type="button"
onClick={() => setMenuOpen((current) => !current)}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Действия с регламентом"
aria-label={`Действия с регламентом ${document.title}`}
aria-expanded={menuOpen}
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${menuOpen ? "rotate-90" : ""}`}
/>
</button>
{menuOpen ? (
<div className="app-menu absolute right-0 top-full z-30 mt-2 w-56 rounded-xl py-1.5" role="menu">
<button
type="button"
onClick={() => {
setMenuOpen(false);
onOpen(document);
}}
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)]"
>
<FileText size={14} />
Детали
</button>
<button
type="button"
onClick={() => {
setMenuOpen(false);
onEdit(document);
}}
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)]"
>
<Pencil size={14} />
Редактировать
</button>
<button
type="button"
onClick={() => {
setMenuOpen(false);
onMove(document);
}}
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)]"
>
<FolderOpen size={14} />
Переместить
</button>
{hasPreview ? (
<button
type="button"
onClick={() => {
setMenuOpen(false);
onPreview(document);
}}
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)]"
>
<Eye size={14} />
Предпросмотр
</button>
) : null}
{document.file_url ? (
<a
href={document.file_url}
download={document.file_name || document.title}
onClick={() => setMenuOpen(false)}
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)]"
>
<Download size={14} />
Скачать
</a>
) : null}
<button
type="button"
onClick={() => {
setMenuOpen(false);
onOpenComments(document);
}}
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)]"
>
<MessageSquare size={14} />
Комментарии ({commentsCount})
</button>
{document.acknowledgement_required ? (
<button
type="button"
onClick={() => {
setMenuOpen(false);
onOpenAcknowledgements(document);
}}
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)]"
>
<Users size={14} />
Ведомость ({acknowledgedCount}/{acknowledgementTotal})
</button>
) : null}
{acknowledgementRequiredForUser && !document.is_acknowledged ? (
<button
type="button"
disabled={isAcknowledging}
onClick={() => {
setMenuOpen(false);
onAcknowledge(document);
}}
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)] disabled:opacity-50"
>
{isAcknowledging ? <Loader2 size={14} className="animate-spin" /> : <CheckCircle2 size={14} />}
Ознакомился(лась)
</button>
) : null}
<button
type="button"
onClick={() => {
setMenuOpen(false);
onLinkTask(document);
}}
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)]"
>
<Link2 size={14} />
Связать с задачей
</button>
<div className="my-1 border-t border-[var(--border-subtle)]" />
<button
type="button"
onClick={() => {
setMenuOpen(false);
onDelete(document);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)]"
>
<Trash2 size={14} />
Удалить
</button>
</div>
) : null}
</div>
</header>
<button
type="button"
onClick={() => onOpen(document)}
className="block w-full text-left"
>
<h3 className="app-text-wrap text-base font-semibold text-[var(--foreground)]">
{document.title}
</h3>
</button>
{document.description ? (
<ExpandableFeedText text={document.description} className="mt-1" />
) : null}
<div className="app-text-muted mt-3 flex flex-wrap items-center gap-2 text-xs">
{document.sent_to_all ? (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1">
<Users size={11} /> Для всей компании
</span>
) : null}
{(document.departments || []).slice(0, 3).map((department) => (
<span key={department.id} className="app-badge rounded-full px-2 py-1">
{department.name}
</span>
))}
{acknowledgementRequiredForUser ? (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 ${document.is_acknowledged ? "app-feedback-success" : "app-feedback-warning"}`}>
<CheckCircle2 size={11} />
{document.is_acknowledged ? "Ознакомлен" : "Требует ознакомления"}
</span>
) : null}
</div>
<footer className="app-text-muted mt-4 flex flex-wrap items-center gap-3 text-sm">
<div className="flex flex-wrap items-center gap-1">
{acknowledgementRequiredForUser && !document.is_acknowledged ? (
<button
type="button"
onClick={() => onAcknowledge(document)}
disabled={isAcknowledging}
className="app-action-ghost inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-60"
title="Ознакомился(лась)"
aria-label="Ознакомился(лась)"
>
{isAcknowledging ? <Loader2 size={16} className="animate-spin" /> : <CheckCircle2 size={16} />}
</button>
) : null}
{document.file_url ? (
<a
href={document.file_url}
download={document.file_name || document.title}
className="app-action-ghost inline-flex h-9 w-9 items-center justify-center rounded-lg"
title="Скачать"
aria-label="Скачать регламент"
>
<Download size={16} />
</a>
) : null}
</div>
{linkedTasks.length > 0 ? (
<div className="flex max-w-full flex-wrap gap-1.5">
{linkedTasks.slice(0, 3).map((task) => (
<TaskLinkPill
key={task.link_id || task.id}
task={task}
maxTitleClassName="max-w-40"
/>
))}
{linkedTasks.length > 3 ? (
<span className="app-badge rounded-full px-2 py-0.5 text-[11px] font-medium">
+{linkedTasks.length - 3}
</span>
) : null}
</div>
) : null}
<div className="ml-auto flex flex-wrap items-center justify-end gap-1">
<button
type="button"
onClick={() => onOpenComments(document)}
className="app-action-ghost inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-2"
title="Комментарии"
aria-label={`Комментарии: ${commentsCount}`}
>
<MessageSquare size={16} className="app-text-muted" />
<span>{commentsCount}</span>
</button>
{document.acknowledgement_required ? (
<button
type="button"
onClick={() => onOpenAcknowledgements(document)}
className="app-action-ghost inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-2"
title="Ведомость ознакомлений"
aria-label={`Ведомость ознакомлений: ${acknowledgedCount} из ${acknowledgementTotal}`}
>
<Users size={16} />
<span>{acknowledgedCount}/{acknowledgementTotal}</span>
</button>
) : null}
</div>
</footer>
</article>
);
}

View File

@ -2,8 +2,9 @@
import { useState } from "react";
import Link from "next/link";
import { AlertTriangle, Check, CheckCircle2, CircleDot, ExternalLink, MessageSquare, Minus, Plus, RotateCcw, Save, SlidersHorizontal, X } from "lucide-react";
import { AlertTriangle, Check, CheckCircle2, CircleDot, Download, ExternalLink, MessageSquare, Minus, Paperclip, Plus, RotateCcw, Save, SlidersHorizontal, Trash2, X } from "lucide-react";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { ProcurementRequestHistory } from "@/components/procurement/ProcurementRequestHistory";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { Modal } from "@/components/ui";
@ -11,7 +12,7 @@ import { cleanLinkRows, linkHref, toLinkRows } from "@/lib/procurementLinks";
import { getExpectedDeliveryDateBadgeClass } from "@/lib/procurementDates";
import { getProcurementUnitLabel } from "@/lib/procurementUnits";
import { formatDate, formatMoney, userProfileLink } from "@/lib/shared";
import type { ProcurementItem, ProcurementItemComment, ProcurementItemExecutionStatus, ProcurementRequest, User } from "@/types/api";
import type { ProcurementItem, ProcurementItemAttachment, ProcurementItemComment, ProcurementItemExecutionStatus, ProcurementRequest, User } from "@/types/api";
interface ProcurementRequestDetailContentProps {
currentUserId?: number | null;
@ -37,6 +38,9 @@ interface ProcurementRequestDetailContentProps {
onItemCommentDraftChange?: (itemId: number, value: string) => void;
onAddItemComment?: (requestId: number, itemId: number) => void | Promise<void>;
onDeleteItemComment?: (requestId: number, itemId: number, commentId: number) => void | Promise<void>;
onDownloadItemAttachment?: (itemId: number, attachment: ProcurementItemAttachment) => void | Promise<void>;
onUploadItemAttachments?: (requestId: number, itemId: number, files: File[]) => void | Promise<unknown>;
onDeleteItemAttachment?: (requestId: number, itemId: number, attachmentId: number) => void | Promise<unknown>;
footer?: React.ReactNode;
}
@ -178,6 +182,9 @@ interface ProcurementItemCardProps {
onCommentDraftChange?: (itemId: number, value: string) => void;
onAddComment?: (requestId: number, itemId: number) => void | Promise<void>;
onDeleteComment?: (requestId: number, itemId: number, commentId: number) => void | Promise<void>;
onDownloadAttachment?: (itemId: number, attachment: ProcurementItemAttachment) => void | Promise<void>;
onUploadAttachments?: (requestId: number, itemId: number, files: File[]) => void | Promise<unknown>;
onDeleteAttachment?: (requestId: number, itemId: number, attachmentId: number) => void | Promise<unknown>;
}
function ProcurementItemCard({
@ -204,6 +211,9 @@ function ProcurementItemCard({
onCommentDraftChange,
onAddComment,
onDeleteComment,
onDownloadAttachment,
onUploadAttachments,
onDeleteAttachment,
}: ProcurementItemCardProps) {
const [draft, setDraft] = useState<ItemProcessingDraft>(() => normalizeItemDraft(item));
const [processingOpen, setProcessingOpen] = useState(false);
@ -214,6 +224,7 @@ function ProcurementItemCard({
const links = Array.isArray(item.links) ? item.links.filter(Boolean) : [];
const expectedDeliveryDates = toDateRows(item.expected_delivery_dates);
const commentsTotal = item.comments_count ?? comments.length;
const attachments = item.attachments || [];
const requestedQuantity = Math.max(0, Math.trunc(toNumber(item.quantity)));
const orderedQuantity = effectiveOrderedQuantity(item, requestedQuantity);
const receivedQuantity = effectiveReceivedQuantity(item, requestedQuantity);
@ -452,6 +463,65 @@ function ProcurementItemCard({
</div>
) : null}
{attachments.length > 0 || (canEditItemProcessing && onUploadAttachments) ? (
<div className="mt-3">
<div className="mb-1 flex items-center justify-between gap-2">
<p className="app-text-muted text-[11px] font-medium">Файлы</p>
{canEditItemProcessing && onUploadAttachments ? (
<label className="app-action-ghost inline-flex cursor-pointer items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium">
<Plus size={11} /> Добавить
<input
type="file"
multiple
className="hidden"
onChange={(event) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) {
void onUploadAttachments(requestId, item.id, files);
}
event.target.value = "";
}}
/>
</label>
) : null}
</div>
{attachments.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{attachments.map((attachment) => (
<span
key={attachment.id}
className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]"
>
<button
type="button"
onClick={() => void onDownloadAttachment?.(item.id, attachment)}
className="inline-flex min-w-0 items-center gap-1"
title={`Скачать ${attachment.file_name}`}
>
<Paperclip size={11} className="shrink-0" />
<span className="max-w-48 truncate">{attachment.file_name}</span>
<Download size={10} className="shrink-0" />
</button>
{canEditItemProcessing && onDeleteAttachment ? (
<button
type="button"
title="Удалить файл"
disabled={busyKey === `item-attachment-delete-${attachment.id}`}
onClick={() => void onDeleteAttachment(requestId, item.id, attachment.id)}
className="app-action-ghost inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full disabled:opacity-60"
>
<Trash2 size={10} />
</button>
) : null}
</span>
))}
</div>
) : (
<p className="app-text-muted text-[11px]">Файлы не прикреплены</p>
)}
</div>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-2">
<div>
<button
@ -926,6 +996,9 @@ export function ProcurementRequestDetailContent({
onItemCommentDraftChange,
onAddItemComment,
onDeleteItemComment,
onDownloadItemAttachment,
onUploadItemAttachments,
onDeleteItemAttachment,
footer,
}: ProcurementRequestDetailContentProps) {
const canEditItemProcessing = Boolean(canProcessItems && onUpdateItem);
@ -969,7 +1042,11 @@ export function ProcurementRequestDetailContent({
<p className="app-text-wrap mt-2 whitespace-pre-line text-sm leading-6 text-[var(--foreground)]">
{request.description || "—"}
</p>
<div className="mt-3 grid gap-2 text-xs sm:grid-cols-3">
<div className="mt-3 grid gap-2 text-xs sm:grid-cols-2 lg:grid-cols-4">
<div className="app-surface-muted rounded-lg px-3 py-2">
<p className="app-text-muted text-[11px] uppercase tracking-wide">Создана</p>
<p className="mt-1 font-medium text-[var(--foreground)]">{formatDate(request.created_at) || "—"}</p>
</div>
<div className="app-surface-muted rounded-lg px-3 py-2">
<p className="app-text-muted text-[11px] uppercase tracking-wide">Отправлена</p>
<p className="mt-1 font-medium text-[var(--foreground)]">{formatDate(request.submitted_at) || "—"}</p>
@ -1040,6 +1117,9 @@ export function ProcurementRequestDetailContent({
onCommentDraftChange={onItemCommentDraftChange}
onAddComment={onAddItemComment}
onDeleteComment={onDeleteItemComment}
onDownloadAttachment={onDownloadItemAttachment}
onUploadAttachments={onUploadItemAttachments}
onDeleteAttachment={onDeleteItemAttachment}
/>
))}
</div>
@ -1102,6 +1182,8 @@ export function ProcurementRequestDetailContent({
</div>
) : null}
<ProcurementRequestHistory request={request} displayUserName={displayUserName} />
{footer}
</div>
);

View File

@ -0,0 +1,181 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ChevronRight, History, Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api";
import { formatDateTime } from "@/lib/shared";
import type { ProcurementRequest, ProcurementRequestActivity, User } from "@/types/api";
interface ProcurementRequestHistoryProps {
request: ProcurementRequest;
displayUserName: (
person?: User | number | null,
fallbackName?: string | null,
fallbackEmail?: string | null,
) => string;
}
const fieldLabels: Record<string, string> = {
title: "название",
description: "описание",
department_id: "отдел-заказчик",
processing_department_id: "отдел-исполнитель",
urgency: "срочность",
actual_cost: "фактическую стоимость",
execution_status: "статус исполнения",
ordered_quantity: "заказанное количество",
received_quantity: "полученное количество",
expected_delivery_dates: "ожидаемые даты",
actual_unit_price: "фактическую цену",
executor_comment: "комментарий исполнителя",
links: "ссылки",
};
function textMetadata(metadata: Record<string, unknown>, key: string): string {
return typeof metadata[key] === "string" ? String(metadata[key]) : "";
}
function getActivityDetail(activity: ProcurementRequestActivity): string {
const metadata = activity.metadata || {};
if (activity.action === "updated" || activity.action === "item_updated") {
const fields = Array.isArray(metadata.fields) ? metadata.fields : [];
const labels = fields.map((field) => fieldLabels[String(field)] || String(field));
const itemName = textMetadata(metadata, "item_name");
const prefix = itemName ? `${itemName}. ` : "";
return labels.length > 0 ? `${prefix}Изменено: ${labels.join(", ")}` : itemName;
}
if (activity.action === "executor_reassigned") {
const previous = textMetadata(metadata, "previous_executor");
const next = textMetadata(metadata, "new_executor");
return previous && next ? `${previous} -> ${next}` : next;
}
if (activity.action === "stage_approved" || activity.action === "stage_rejected") {
const step = textMetadata(metadata, "step_name");
const comment = textMetadata(metadata, "comment");
return [step, comment].filter(Boolean).join(". ");
}
if (activity.action === "attachment_added" || activity.action === "attachment_removed") {
const fileName = textMetadata(metadata, "file_name");
const itemName = textMetadata(metadata, "item_name");
return [itemName, fileName].filter(Boolean).join(": ");
}
if (activity.action === "comment_added" || activity.action === "comment_removed") {
const itemName = textMetadata(metadata, "item_name");
const comment = textMetadata(metadata, "comment_text");
return [itemName, comment].filter(Boolean).join(": ");
}
if (activity.action === "cancelled") {
return textMetadata(metadata, "reason");
}
return "";
}
function ActivityCard({
activity,
displayUserName,
}: {
activity: ProcurementRequestActivity;
displayUserName: ProcurementRequestHistoryProps["displayUserName"];
}) {
const detail = getActivityDetail(activity);
return (
<article className="app-surface-muted rounded-xl border border-[var(--border-subtle)] px-3 py-2.5">
<div className="flex items-start gap-3">
<span className="app-selected mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg">
<History size={14} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<p className="text-sm font-semibold text-[var(--foreground)]">
{activity.action_display || activity.action}
</p>
<span className="app-text-muted text-xs">{formatDateTime(activity.created_at)}</span>
</div>
<p className="app-text-muted mt-0.5 text-xs">
{activity.actor ? displayUserName(activity.actor) : "Система"}
</p>
{detail ? (
<p className="app-text-wrap mt-2 whitespace-pre-wrap text-xs text-[var(--foreground)]">
{detail}
</p>
) : null}
</div>
</div>
</article>
);
}
export function ProcurementRequestHistory({ request, displayUserName }: ProcurementRequestHistoryProps) {
const [open, setOpen] = useState(false);
const [activities, setActivities] = useState<ProcurementRequestActivity[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState("");
const loadActivity = useCallback(async () => {
setLoading(true);
setError("");
try {
const result = await apiClient.getProcurementRequestActivity(request.id);
setActivities(Array.isArray(result) ? result : []);
setLoaded(true);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Не удалось загрузить историю");
} finally {
setLoading(false);
}
}, [request.id]);
useEffect(() => {
if (open) void loadActivity();
}, [open, request.updated_at, loadActivity]);
const toggle = () => {
setOpen((current) => !current);
};
const count = loaded ? activities.length : request.activity_count || 0;
return (
<div className="rounded-xl border border-[var(--border-subtle)]">
<button
type="button"
onClick={toggle}
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left"
aria-expanded={open}
>
<span className="flex min-w-0 items-center gap-2">
<History size={15} className="app-text-muted shrink-0" />
<span className="text-sm font-medium text-[var(--foreground)]">История</span>
{count > 0 ? (
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">{count}</span>
) : null}
</span>
<ChevronRight
size={16}
className={`app-text-muted shrink-0 transition-transform ${open ? "rotate-90" : ""}`}
/>
</button>
{open ? (
<div className="space-y-2 border-t border-[var(--border-subtle)] p-3">
{loading && !loaded ? (
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-4 text-center">
<Loader2 size={18} className="mx-auto animate-spin text-sky-500" />
</div>
) : error ? (
<div className="app-feedback-danger rounded-xl px-3 py-3 text-xs">{error}</div>
) : activities.length > 0 ? (
activities.map((activity) => (
<ActivityCard key={activity.id} activity={activity} displayUserName={displayUserName} />
))
) : (
<div className="app-surface-muted rounded-xl border border-dashed border-[var(--border-subtle)] px-3 py-4 text-center">
<p className="app-text-muted text-xs">Истории действий пока нет</p>
</div>
)}
</div>
) : null}
</div>
);
}

View File

@ -143,52 +143,34 @@ export function RequestListItem({
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-center gap-2">
{authorLink ? (
<Link href={authorLink} className="group flex min-w-0 items-center gap-2">
<RequestAvatar
alt={authorName}
fallback={authorFallback}
size="lg"
src={requestAuthor?.avatar}
/>
<span className="truncate text-sm font-medium text-[var(--foreground)] group-hover:text-[var(--accent-primary-strong)]">
{authorName}
</span>
</Link>
) : (
<div className="flex min-w-0 items-center gap-2">
<RequestAvatar
alt={authorName}
fallback={authorFallback}
size="lg"
/>
<span className="truncate text-sm font-medium text-[var(--foreground)]">{authorName}</span>
</div>
)}
</div>
<button type="button" onClick={() => onOpenDetails(request)} className="block w-full text-left">
<h3
className={`${rowOpen ? "app-text-wrap line-clamp-3" : "truncate"} text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]`}
>
<span className="app-text-muted">{typeLabel}:</span>{" "}
<span className="text-[var(--foreground)]">{title}</span>
</h3>
</button>
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>
Период: {request.date_from ? formatDate(request.date_from) : "—"}
{request.date_to ? ` — ${formatDate(request.date_to)}` : ""}
</span>
</div>
<div className="mb-2 flex min-w-0 flex-wrap items-start gap-2">
<div className="min-w-0 flex-[1_1_9rem]">
{authorLink ? (
<Link href={authorLink} className="group flex min-w-0 max-w-full items-center gap-2">
<RequestAvatar
alt={authorName}
fallback={authorFallback}
size="lg"
src={requestAuthor?.avatar}
/>
<span className="app-text-wrap line-clamp-2 min-w-0 text-sm font-medium text-[var(--foreground)] group-hover:text-[var(--accent-primary-strong)]">
{authorName}
</span>
</Link>
) : (
<div className="flex min-w-0 max-w-full items-center gap-2">
<RequestAvatar
alt={authorName}
fallback={authorFallback}
size="lg"
/>
<span className="app-text-wrap line-clamp-2 min-w-0 text-sm font-medium text-[var(--foreground)]">{authorName}</span>
</div>
)}
</div>
<div className="shrink-0">
<div ref={isMenuOpen ? menuRef : null} className="flex items-center justify-end gap-2">
<div className="ml-auto shrink-0">
<div ref={isMenuOpen ? menuRef : null} className="flex max-w-full items-center justify-end gap-2">
<span className={`app-status-pill ${status.className}`}>
{status.label}
</span>
@ -270,6 +252,22 @@ export function RequestListItem({
</div>
</div>
<button type="button" onClick={() => onOpenDetails(request)} className="block w-full text-left">
<h3
className={`app-text-wrap ${rowOpen ? "line-clamp-4" : "line-clamp-2"} text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]`}
>
<span className="app-text-muted">{typeLabel}:</span>{" "}
<span className="text-[var(--foreground)]">{title}</span>
</h3>
</button>
<div className="app-text-muted app-text-wrap mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>
Период: {request.date_from ? formatDate(request.date_from) : "—"}
{request.date_to ? ` — ${formatDate(request.date_to)}` : ""}
</span>
</div>
{summary ? (
<p
className={`${rowOpen ? "app-text-wrap line-clamp-10" : "app-text-wrap line-clamp-3"} mt-3 text-sm text-[var(--foreground)]`}

View File

@ -1,9 +1,10 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useEffect, useId, useRef, type ReactNode } from "react";
import { X } from "lucide-react";
let openModalCount = 0;
const openModalStack: string[] = [];
export interface ModalProps {
isOpen: boolean;
@ -20,6 +21,8 @@ export interface ModalProps {
noPadding?: boolean;
/** Hide the built-in header (caller manages its own header inside children) */
noHeader?: boolean;
/** Visual level for a modal opened above another modal */
stackLevel?: number;
}
export function Modal({
@ -35,36 +38,41 @@ export function Modal({
className = "",
noPadding = false,
noHeader = false,
stackLevel = 0,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const titleId = useId();
useEffect(() => {
if (!isOpen) return;
openModalCount += 1;
openModalStack.push(titleId);
document.body.style.overflow = "hidden";
return () => {
openModalCount = Math.max(0, openModalCount - 1);
const stackIndex = openModalStack.lastIndexOf(titleId);
if (stackIndex >= 0) openModalStack.splice(stackIndex, 1);
if (openModalCount === 0) {
document.body.style.overflow = "";
}
};
}, [isOpen]);
}, [isOpen, titleId]);
useEffect(() => {
if (!isOpen || !closeOnEsc) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (e.key === "Escape" && openModalStack.at(-1) === titleId) {
onClose();
}
};
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [isOpen, closeOnEsc, onClose]);
}, [isOpen, closeOnEsc, onClose, titleId]);
useEffect(() => {
if (!isOpen || !contentRef.current) return;
@ -81,7 +89,7 @@ export function Modal({
firstElement?.focus();
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.key !== "Tab" || openModalStack.at(-1) !== titleId) return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
@ -98,12 +106,16 @@ export function Modal({
document.addEventListener("keydown", handleTab);
return () => document.removeEventListener("keydown", handleTab);
}, [isOpen]);
}, [isOpen, titleId]);
if (!isOpen) return null;
const handleBackdropClick = (e: React.MouseEvent) => {
if (closeOnClickOutside && e.target === modalRef.current) {
if (
closeOnClickOutside &&
e.target === modalRef.current &&
openModalStack.at(-1) === titleId
) {
onClose();
}
};
@ -126,9 +138,10 @@ export function Modal({
onClick={handleBackdropClick}
data-overlay-root="true"
className="app-overlay fixed inset-0 z-[100] flex items-center justify-center p-2 transition-opacity duration-200 opacity-100 sm:p-4"
style={{ zIndex: 100 + stackLevel }}
role="dialog"
aria-modal="true"
aria-labelledby={title ? "modal-title" : undefined}
aria-labelledby={title ? titleId : undefined}
>
<div
ref={contentRef}
@ -139,7 +152,7 @@ export function Modal({
<div className="mb-3 flex shrink-0 items-center gap-2 px-4 pt-4 sm:mb-4 sm:gap-3 sm:px-6 sm:pt-6">
{title && (
<h3
id="modal-title"
id={titleId}
className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--foreground)] sm:text-base lg:text-lg"
title={title}
>

View File

@ -4,6 +4,7 @@ import React, { createContext, useContext, useEffect, useState, useCallback, use
import apiClient from '@/lib/api';
import wsManager from '@/lib/websocketManager';
import { getVerbCategory } from '@/lib/verbTranslations';
import { useUser } from '@/contexts/UserContext';
const NOTIFICATIONS_SYNC_INTERVAL_MS = 30000;
const UNREAD_NOTIFICATIONS_PAGE_SIZE = 50;
@ -52,6 +53,8 @@ interface NotificationsContextType {
const NotificationsContext = createContext<NotificationsContextType | undefined>(undefined);
export function NotificationsProvider({ children }: { children: React.ReactNode }) {
const { user } = useUser();
const authenticatedUserId = user?.id ?? null;
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
@ -63,6 +66,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
);
const fetchInFlightRef = useRef<Promise<void> | null>(null);
const summaryInFlightRef = useRef<Promise<void> | null>(null);
const knownNotificationIdsRef = useRef<Set<number>>(new Set());
const applyUnreadSummary = useCallback((summary: {
total?: number;
@ -114,6 +118,11 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
});
const notifs = data.notifications || data.results || data;
const notificationsArray = Array.isArray(notifs) ? notifs : [];
notificationsArray.forEach((notification) => {
if (typeof notification.id === 'number') {
knownNotificationIdsRef.current.add(notification.id);
}
});
setNotifications(notificationsArray);
setUnreadCount(data.unread_count ?? notificationsArray.length);
setError(null);
@ -157,13 +166,24 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
// Загрузка начальных данных
useEffect(() => {
if (!authenticatedUserId) {
setNotifications([]);
setUnreadCount(0);
setUnreadCategoryCounts({});
setUnreadProcurementRequestCounts({});
knownNotificationIdsRef.current.clear();
setError(null);
setLoading(false);
return;
}
void syncUnreadNotifications(true);
void syncUnreadSummary();
}, [syncUnreadNotifications, syncUnreadSummary]);
}, [authenticatedUserId, syncUnreadNotifications, syncUnreadSummary]);
// ЕДИНСТВЕННЫЙ WebSocket через singleton manager
useEffect(() => {
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
if (!authenticatedUserId || typeof window === 'undefined' || !localStorage.getItem('access_token')) {
console.log('[NotificationsContext] WebSocket disabled: not in browser or not authenticated');
return;
}
@ -178,12 +198,13 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
const incomingNotification = data.notification;
if (data.type === 'notification' && incomingNotification) {
setNotifications(prev => {
if (prev.some(n => n.id === incomingNotification.id)) {
return prev;
}
return [incomingNotification, ...prev];
});
if (knownNotificationIdsRef.current.has(incomingNotification.id)) {
void syncUnreadSummary();
return;
}
knownNotificationIdsRef.current.add(incomingNotification.id);
setNotifications(prev => [incomingNotification, ...prev]);
const isRead = incomingNotification.is_read ?? !incomingNotification.unread;
if (!isRead) {
@ -205,6 +226,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
}
}
}
void syncUnreadSummary();
return;
}
@ -251,11 +273,11 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
unsubscribeMessages();
unsubscribeStatus();
};
}, [syncUnreadNotifications, syncUnreadSummary]);
}, [authenticatedUserId, syncUnreadNotifications, syncUnreadSummary]);
// Регулярная сверка с API нужна даже при живом WebSocket: часть событий может не дойти по сокету.
useEffect(() => {
if (typeof window === 'undefined' || !localStorage.getItem('access_token')) {
if (!authenticatedUserId || typeof window === 'undefined' || !localStorage.getItem('access_token')) {
return;
}
@ -282,16 +304,16 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [syncUnreadNotifications, syncUnreadSummary]);
}, [authenticatedUserId, syncUnreadNotifications, syncUnreadSummary]);
useEffect(() => {
if (!isWsConnected) {
if (!authenticatedUserId || !isWsConnected) {
return;
}
void syncUnreadSummary();
void syncUnreadNotifications(false);
}, [isWsConnected, syncUnreadNotifications, syncUnreadSummary]);
}, [authenticatedUserId, isWsConnected, syncUnreadNotifications, syncUnreadSummary]);
const markAsRead = useCallback(async (id: number) => {
await apiClient.markNotificationAsRead(id);

View File

@ -12,6 +12,7 @@ import type {
ProcurementApprovalStepSelection,
ProcurementComment,
ProcurementItem,
ProcurementItemAttachment,
ProcurementItemComment,
ProcurementRequest,
ProcurementStatus,
@ -29,6 +30,9 @@ type ItemDraft = {
supplier_info: string;
links: string[];
initial_comment: string;
attachments: ProcurementItemAttachment[];
pending_files: File[];
removed_attachment_ids: number[];
};
const emptyItem: ItemDraft = {
@ -40,6 +44,9 @@ const emptyItem: ItemDraft = {
supplier_info: "",
links: [],
initial_comment: "",
attachments: [],
pending_files: [],
removed_attachment_ids: [],
};
type FormState = {
@ -575,6 +582,9 @@ export function useProcurementPage(user: User | null) {
supplier_info: item.supplier_info || "",
links: toLinkRows(item.links),
initial_comment: "",
attachments: item.attachments || [],
pending_files: [],
removed_attachment_ids: [],
}))
: [{ ...emptyItem }],
});
@ -714,9 +724,10 @@ export function useProcurementPage(user: User | null) {
})),
};
let savedRequest: ProcurementRequest;
if (modalMode === "create") {
await apiClient.createProcurementRequest(payload) as ProcurementRequest;
toast.success("Заявка создана и направлена в отдел.");
const created = await apiClient.createProcurementRequest(payload) as ProcurementRequest;
savedRequest = await apiClient.getProcurementRequest(created.id) as ProcurementRequest;
setCreateOpen(false);
} else if (editingId) {
await apiClient.updateProcurementRequest(editingId, {
@ -726,10 +737,43 @@ export function useProcurementPage(user: User | null) {
processing_department: payload.processing_department,
items: payload.items,
});
toast.success("Заявка обновлена.");
savedRequest = await apiClient.getProcurementRequest(editingId) as ProcurementRequest;
setEditingId(null);
} else {
return;
}
const existingIds = new Set(
validItems
.map((item) => item.id)
.filter((id): id is number => typeof id === "number"),
);
const createdItems = (savedRequest.items || []).filter((item) => !existingIds.has(item.id));
let createdItemIndex = 0;
for (const item of validItems) {
const itemId = item.id ?? createdItems[createdItemIndex++]?.id;
if (!itemId) continue;
if (item.removed_attachment_ids.length > 0) {
await Promise.all(
item.removed_attachment_ids.map((attachmentId) => (
apiClient.deleteProcurementItemAttachment(itemId, attachmentId)
)),
);
}
if (item.pending_files.length > 0) {
await apiClient.uploadProcurementItemAttachments(itemId, item.pending_files);
}
}
await refreshOne(savedRequest.id);
toast.success(
modalMode === "create"
? "Заявка создана и направлена в отдел."
: "Заявка обновлена.",
);
resetForm();
await refreshCurrentView();
if (editingId) {
@ -740,7 +784,7 @@ export function useProcurementPage(user: User | null) {
} finally {
setBusyKey(null);
}
}, [editingId, form, markRequestNotificationsRead, modalMode, refreshCurrentView, resetForm]);
}, [editingId, form, markRequestNotificationsRead, modalMode, refreshCurrentView, refreshOne, resetForm]);
const handleSave = useCallback(() => saveRequest(), [saveRequest]);
@ -1205,6 +1249,64 @@ export function useProcurementPage(user: User | null) {
}));
}, []);
const handleDownloadItemAttachment = useCallback(async (
itemId: number,
attachment: ProcurementItemAttachment,
) => {
try {
const { blob, filename } = await apiClient.downloadProcurementItemAttachment(itemId, attachment);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
} catch (downloadError) {
setActionError(getReadableError(downloadError, "Не удалось скачать файл"));
}
}, []);
const handleUploadItemAttachments = useCallback(async (
requestId: number,
itemId: number,
files: File[],
) => {
if (files.length === 0) return false;
try {
setBusyKey(`item-attachments-${itemId}`);
await apiClient.uploadProcurementItemAttachments(itemId, files);
await refreshOne(requestId);
toast.success(files.length > 1 ? "Файлы добавлены." : "Файл добавлен.");
return true;
} catch (uploadError) {
setActionError(getReadableError(uploadError, "Не удалось добавить файл"));
return false;
} finally {
setBusyKey(null);
}
}, [refreshOne]);
const handleDeleteItemAttachment = useCallback(async (
requestId: number,
itemId: number,
attachmentId: number,
) => {
try {
setBusyKey(`item-attachment-delete-${attachmentId}`);
await apiClient.deleteProcurementItemAttachment(itemId, attachmentId);
await refreshOne(requestId);
toast.success("Файл удалён.");
return true;
} catch (deleteError) {
setActionError(getReadableError(deleteError, "Не удалось удалить файл"));
return false;
} finally {
setBusyKey(null);
}
}, [refreshOne]);
const activeFilterCount = [
statusFilter.length > 0,
urgencyFilter,
@ -1315,6 +1417,9 @@ export function useProcurementPage(user: User | null) {
addItemRow,
removeItemRow,
updateItemRow,
handleDownloadItemAttachment,
handleUploadItemAttachments,
handleDeleteItemAttachment,
activeFilterCount,
isFinal,
resolveUserId,

View File

@ -388,11 +388,6 @@ export function useSettingsPage() {
toast.error("Можно загрузить только изображение");
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error("Максимальный размер файла 5 МБ");
return;
}
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== "string") {

View File

@ -255,11 +255,6 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
const file = event.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
alert("Файл слишком большой. Максимальный размер: 5MB");
return;
}
if (!file.type.startsWith("image/")) {
alert("Можно загружать только изображения");
return;

View File

@ -8,7 +8,7 @@ import type {
} from '@/types/api';
type CalendarScopeParam = { scope?: "all" };
type CalendarQueryParams = { start?: string; end?: string; calendar?: number; scope?: "all" };
type CalendarQueryParams = { start?: string; end?: string; calendar?: number; scope?: "all"; search?: string; page_size?: number };
type CalendarListResponse = PaginatedResponse<Calendar> | Calendar[];
type CalendarEventsResponse = PaginatedResponse<CalendarEvent> | CalendarEvent[];
type CalendarRulesParams = Record<string, unknown>;
@ -21,6 +21,8 @@ export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) {
if (params?.end) qp.append('end', params.end);
if (params?.calendar) qp.append('calendar', params.calendar.toString());
if (params?.scope) qp.append('scope', params.scope);
if (params?.search) qp.append('search', params.search);
if (params?.page_size) qp.append('page_size', params.page_size.toString());
const qs = qp.toString();
return request(`/api/v1/schedule/events/${qs ? '?' + qs : ''}`);
},

View File

@ -46,11 +46,12 @@ 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; is_regulation?: boolean }) => {
getDocuments: (params?: { search?: string; type?: string; status?: string; scope?: 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);
if (params?.status) qp.append('status', params.status);
if (params?.scope) qp.append('scope', params.scope);
if (params?.page) qp.append('page', params.page.toString());
const pageSize = params?.page_size ?? params?.limit;
if (pageSize) qp.append('page_size', pageSize.toString());
@ -60,7 +61,7 @@ export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, re
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; is_regulation?: 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; acknowledgement_for_all?: boolean; acknowledgement_recipient_ids?: number[]; acknowledgement_department_ids?: number[]; 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);
@ -69,13 +70,16 @@ export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, re
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)));
if (data.acknowledgement_for_all !== undefined) fd.append('acknowledgement_for_all', String(data.acknowledgement_for_all));
if (data.recipient_ids !== undefined) fd.append('recipient_ids', JSON.stringify(data.recipient_ids));
if (data.department_ids !== undefined) fd.append('department_ids', JSON.stringify(data.department_ids));
if (data.acknowledgement_recipient_ids !== undefined) fd.append('acknowledgement_recipient_ids', JSON.stringify(data.acknowledgement_recipient_ids));
if (data.acknowledgement_department_ids !== undefined) fd.append('acknowledgement_department_ids', JSON.stringify(data.acknowledgement_department_ids));
data.tag_ids?.forEach(id => fd.append('tag_ids', String(id)));
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; 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[] }) => {
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; acknowledgement_for_all?: boolean; recipient_ids?: number[]; department_ids?: number[]; acknowledgement_recipient_ids?: number[]; acknowledgement_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);
@ -85,8 +89,11 @@ export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, re
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)));
if (data.acknowledgement_for_all !== undefined) fd.append('acknowledgement_for_all', String(data.acknowledgement_for_all));
if (data.department_ids !== undefined) fd.append('department_ids', JSON.stringify(data.department_ids));
if (data.recipient_ids !== undefined) fd.append('recipient_ids', JSON.stringify(data.recipient_ids));
if (data.acknowledgement_department_ids !== undefined) fd.append('acknowledgement_department_ids', JSON.stringify(data.acknowledgement_department_ids));
if (data.acknowledgement_recipient_ids !== undefined) fd.append('acknowledgement_recipient_ids', JSON.stringify(data.acknowledgement_recipient_ids));
data.tag_ids?.forEach(tagId => fd.append('tag_ids', String(tagId)));
return request(`/api/v1/documents/${id}/`, { method: 'PATCH', body: fd });
},
@ -138,11 +145,11 @@ export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, re
getFolderChildren: (id: number) => request(`/api/v1/folders/${id}/children/`),
getFolderDocuments: (id: number) => request(`/api/v1/folders/${id}/documents/`),
// Comments
getDocumentComments: (documentId: number) => request(`/api/v1/document-comments/?document=${documentId}`),
getDocumentComments: (documentId: number) => request(`/api/v1/documents/${documentId}/comments/`),
createDocumentComment: (data: { document: number; text: string; parent?: number }) =>
request('/api/v1/document-comments/', { method: 'POST', body: JSON.stringify({ document_id: data.document, text: data.text, parent_id: data.parent }) }),
updateDocumentComment: (id: number, text: string) => request(`/api/v1/document-comments/${id}/`, { method: 'PATCH', body: JSON.stringify({ text }) }),
deleteDocumentComment: (id: number): Promise<void> => request(`/api/v1/document-comments/${id}/`, { method: 'DELETE' }),
request(`/api/v1/documents/${data.document}/comments/`, { method: 'POST', body: JSON.stringify({ text: data.text, parent_id: data.parent }) }),
updateDocumentComment: (documentId: number, id: number, text: string) => request(`/api/v1/documents/${documentId}/comments/${id}/`, { method: 'PATCH', body: JSON.stringify({ text }) }),
deleteDocumentComment: (documentId: number, id: number): Promise<void> => request(`/api/v1/documents/${documentId}/comments/${id}/`, { method: 'DELETE' }),
getCommentReplies: (commentId: number) => request(`/api/v1/document-comments/${commentId}/replies/`),
// Tags
getDocumentTags: (params?: { page?: number; page_size?: number; limit?: number }) => {

View File

@ -32,10 +32,10 @@ export const apiClient = Object.assign(base, {
...createFeedApi(req, tok),
...createRequestsApi(req, tok),
...createEquipmentApi(req, tok),
...createProcurementApi(req),
...createProcurementApi(req, raw),
...createAttendanceApi(req, tok),
...createGuestsApi(req),
...createTasksApi(req),
...createTasksApi(req, raw),
});
export default apiClient;

View File

@ -1,11 +1,22 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ProcurementApprovalOptions, ProcurementApprovalStepSelection, ProcurementCreateOptions } from '@/types/api';
import { buildQuery, type RequestFn } from './utils';
import type { ProcurementApprovalOptions, ProcurementApprovalStepSelection, ProcurementCreateOptions, ProcurementItemAttachment, ProcurementRequestActivity } from '@/types/api';
import { buildQuery, type RawRequestFn, type RequestFn } from './utils';
export function createProcurementApi(request: RequestFn) {
function parseDownloadFilename(response: Response, fallback: string): string {
const disposition = response.headers.get('content-disposition') || '';
const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/);
const plainMatch = disposition.match(/filename="?([^";]+)"?/);
return encodedMatch?.[1]
? decodeURIComponent(encodedMatch[1])
: plainMatch?.[1] || fallback;
}
export function createProcurementApi(request: RequestFn, requestRaw: RawRequestFn) {
return {
getProcurementRequests: (params?: Record<string, string | number>) => request(`/api/v1/procurement/requests/${buildQuery(params)}`),
getProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/`),
getProcurementRequestActivity: (id: number): Promise<ProcurementRequestActivity[]> =>
request(`/api/v1/procurement/requests/${id}/activity/`),
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) }),
@ -63,6 +74,30 @@ export function createProcurementApi(request: RequestFn) {
request(`/api/v1/procurement/items/${itemId}/comments/`, { method: 'POST', body: JSON.stringify({ text }) }),
deleteProcurementItemComment: (itemId: number, commentId: number): Promise<void> =>
request(`/api/v1/procurement/items/${itemId}/comments/${commentId}/`, { method: 'DELETE' }),
getProcurementItemAttachments: (itemId: number): Promise<ProcurementItemAttachment[]> =>
request(`/api/v1/procurement/items/${itemId}/attachments/`),
uploadProcurementItemAttachments: (itemId: number, files: File[]): Promise<ProcurementItemAttachment[]> => {
const formData = new FormData();
files.forEach((file) => formData.append('files', file));
return request(`/api/v1/procurement/items/${itemId}/attachments/`, {
method: 'POST',
body: formData,
});
},
downloadProcurementItemAttachment: async (
itemId: number,
attachment: ProcurementItemAttachment,
): Promise<{ blob: Blob; filename: string }> => {
const response = await requestRaw(
attachment.download_url || `/api/v1/procurement/items/${itemId}/attachments/${attachment.id}/`,
);
return {
blob: await response.blob(),
filename: parseDownloadFilename(response, attachment.file_name),
};
},
deleteProcurementItemAttachment: (itemId: number, attachmentId: number): Promise<void> =>
request(`/api/v1/procurement/items/${itemId}/attachments/${attachmentId}/`, { method: 'DELETE' }),
// Suppliers
getProcurementSuppliers: (params?: Record<string, string | number>) => request(`/api/v1/procurement/suppliers/${buildQuery(params)}`),
createProcurementSupplier: (data: Record<string, any>) => request('/api/v1/procurement/suppliers/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),

View File

@ -4,6 +4,8 @@ import type {
TaskCard,
TaskColumn,
TaskActivity,
TaskAttachment,
TaskChecklistItem,
TaskComment,
TaskLabel,
TaskLinkedAttendanceRecord,
@ -16,10 +18,20 @@ import type {
TaskLinkedPost,
TaskLinkedProcurementRequest,
TaskLinkedRequest,
TaskExternalLink,
} from "@/types/api";
import { buildQuery, type RequestFn } from "./utils";
import { buildQuery, type RawRequestFn, type RequestFn } from "./utils";
export function createTasksApi(request: RequestFn) {
function parseDownloadFilename(response: Response, fallback: string): string {
const disposition = response.headers.get("content-disposition") || "";
const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/);
const plainMatch = disposition.match(/filename="?([^";]+)"?/);
return encodedMatch?.[1]
? decodeURIComponent(encodedMatch[1])
: plainMatch?.[1] || fallback;
}
export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
return {
getTaskBoards: (params?: Record<string, string | number>) =>
request(`/api/v1/tasks/boards/${buildQuery(params)}`),
@ -69,8 +81,65 @@ export function createTasksApi(request: RequestFn) {
method: "POST",
body: JSON.stringify(data),
}),
completeTask: (id: number): Promise<TaskCard> =>
request(`/api/v1/tasks/${id}/complete/`, {
method: "POST",
}),
claimTask: (id: number): Promise<TaskCard> =>
request(`/api/v1/tasks/${id}/claim/`, {
method: "POST",
}),
getTaskActivity: (id: number): Promise<TaskActivity[]> =>
request(`/api/v1/tasks/${id}/activity/`),
getTaskAttachments: (id: number): Promise<TaskAttachment[]> =>
request(`/api/v1/tasks/${id}/attachments/`),
uploadTaskAttachments: (id: number, files: File[]): Promise<TaskAttachment[]> => {
const formData = new FormData();
files.forEach((file) => formData.append("files", file));
return request(`/api/v1/tasks/${id}/attachments/`, {
method: "POST",
body: formData,
});
},
downloadTaskAttachment: async (
taskId: number,
attachment: TaskAttachment,
): Promise<{ blob: Blob; filename: string }> => {
const response = await requestRaw(
attachment.download_url || `/api/v1/tasks/${taskId}/attachments/${attachment.id}/download/`,
);
return {
blob: await response.blob(),
filename: parseDownloadFilename(response, attachment.file_name),
};
},
deleteTaskAttachment: (taskId: number, attachmentId: number): Promise<void> =>
request(`/api/v1/tasks/${taskId}/attachments/${attachmentId}/`, {
method: "DELETE",
}),
getTaskChecklist: (id: number): Promise<TaskChecklistItem[]> =>
request(`/api/v1/tasks/${id}/checklist/`),
addTaskChecklistItem: (
id: number,
data: { title: string; position?: number; is_completed?: boolean },
): Promise<TaskChecklistItem> =>
request(`/api/v1/tasks/${id}/checklist/`, {
method: "POST",
body: JSON.stringify(data),
}),
updateTaskChecklistItem: (
id: number,
itemId: number,
data: { title?: string; position?: number; is_completed?: boolean },
): Promise<TaskChecklistItem> =>
request(`/api/v1/tasks/${id}/checklist/${itemId}/`, {
method: "PATCH",
body: JSON.stringify(data),
}),
deleteTaskChecklistItem: (id: number, itemId: number): Promise<void> =>
request(`/api/v1/tasks/${id}/checklist/${itemId}/`, {
method: "DELETE",
}),
getTaskComments: (id: number): Promise<TaskComment[]> =>
request(`/api/v1/tasks/${id}/comments/`),
addTaskComment: (id: number, text: string): Promise<TaskComment> =>
@ -78,6 +147,11 @@ export function createTasksApi(request: RequestFn) {
method: "POST",
body: JSON.stringify({ text }),
}),
updateTaskComment: (id: number, commentId: number, text: string): Promise<TaskComment> =>
request(`/api/v1/tasks/${id}/comments/${commentId}/`, {
method: "PATCH",
body: JSON.stringify({ text }),
}),
deleteTaskComment: (id: number, commentId: number): Promise<void> =>
request(`/api/v1/tasks/${id}/comments/${commentId}/`, {
method: "DELETE",
@ -210,6 +284,20 @@ export function createTasksApi(request: RequestFn) {
request(`/api/v1/tasks/${id}/linked-events/${linkId}/`, {
method: "DELETE",
}),
getTaskExternalLinks: (id: number): Promise<TaskExternalLink[]> =>
request(`/api/v1/tasks/${id}/external-links/`),
addTaskExternalLink: (
id: number,
data: { url: string; title?: string },
): Promise<TaskExternalLink> =>
request(`/api/v1/tasks/${id}/external-links/`, {
method: "POST",
body: JSON.stringify(data),
}),
deleteTaskExternalLink: (id: number, linkId: number): Promise<void> =>
request(`/api/v1/tasks/${id}/external-links/${linkId}/`, {
method: "DELETE",
}),
deleteTask: (id: number): Promise<void> =>
request(`/api/v1/tasks/${id}/`, { method: "DELETE" }),
};

View File

@ -0,0 +1,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import { canPreviewDocument, getDocumentFileExtension } from "./document-preview.ts";
test("document preview capabilities stay consistent across document surfaces", () => {
assert.equal(getDocumentFileExtension("policy.final.PDF?download=1"), "pdf");
assert.equal(canPreviewDocument("policy.pdf"), true);
assert.equal(canPreviewDocument("instructions.docx"), true);
assert.equal(canPreviewDocument("README"), true);
assert.equal(canPreviewDocument("archive.zip"), false);
});

View File

@ -0,0 +1,100 @@
const DOCUMENT_PREVIEW_EXTENSIONS = new Set([
"pdf",
"jpg",
"jpeg",
"png",
"gif",
"webp",
"svg",
"bmp",
"ico",
"avif",
"docx",
"xls",
"xlsx",
"xlsm",
"xlsb",
"ods",
"csv",
"tsv",
"txt",
"text",
"md",
"markdown",
"json",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"js",
"jsx",
"ts",
"tsx",
"mjs",
"cjs",
"yml",
"yaml",
"ini",
"conf",
"cfg",
"log",
"sql",
"py",
"java",
"c",
"cpp",
"h",
"hpp",
"cs",
"go",
"rs",
"php",
"rb",
"kt",
"kts",
"swift",
"sh",
"bash",
"zsh",
"ps1",
"bat",
"cmd",
"properties",
"env",
"rtf",
"mp4",
"webm",
"ogg",
"ogv",
"mov",
"m4v",
"mp3",
"wav",
"oga",
"m4a",
"aac",
"flac",
]);
const DOCUMENT_PREVIEW_FILE_NAMES = new Set([
"dockerfile",
"makefile",
"readme",
"license",
".gitignore",
".env",
]);
export function getDocumentFileExtension(fileName?: string | null): string {
const cleanName = (fileName || "").toLowerCase().split("?")[0].split("#")[0];
return cleanName.match(/\.([^.]+)$/)?.[1] || "";
}
export function canPreviewDocument(fileName?: string | null): boolean {
const lowerName = (fileName || "").toLowerCase();
const extension = getDocumentFileExtension(fileName);
return DOCUMENT_PREVIEW_EXTENSIONS.has(extension) || DOCUMENT_PREVIEW_FILE_NAMES.has(lowerName);
}

View File

@ -4,7 +4,6 @@
*/
export interface ImageCompressOptions {
maxSizeMB?: number;
maxWidthOrHeight?: number;
quality?: number;
}
@ -28,7 +27,6 @@ export async function compressImage(
options: ImageCompressOptions = {}
): Promise<Blob> {
const {
maxSizeMB = 10,
maxWidthOrHeight = 2048,
quality = 0.85
} = options;
@ -62,21 +60,7 @@ export async function compressImage(
canvas.toBlob(
(blob) => {
if (blob) {
// Проверяем размер
const sizeMB = blob.size / (1024 * 1024);
if (sizeMB > maxSizeMB) {
// Если еще слишком большой, рекурсивно сжимаем с меньшим quality
const newQuality = quality * 0.8;
if (newQuality < 0.1) {
reject(new Error('Не удалось сжать изображение до требуемого размера'));
} else {
compressImage(file, { ...options, quality: newQuality })
.then(resolve)
.catch(reject);
}
} else {
resolve(blob);
}
resolve(blob);
} else {
reject(new Error('Ошибка создания Blob'));
}

View File

@ -4,9 +4,9 @@
*/
import { performOCR, isOCRSupported } from './ocr';
import { extractPDFText, generatePDFThumbnail, getPDFInfo } from './pdf';
import { extractPDFText, generatePDFThumbnail } from './pdf';
import { extractDOCXText, isDOCXFile } from './docx';
import { compressImage, generateImageThumbnail, isImageFile } from './image';
import { generateImageThumbnail, isImageFile } from './image';
export interface ProcessingResult {
extractedText: string;
@ -24,17 +24,15 @@ export interface ProcessingResult {
}
export interface ProcessingProgress {
stage: 'compressing' | 'ocr' | 'extracting_text' | 'generating_thumbnail' | 'complete';
stage: 'ocr' | 'extracting_text' | 'generating_thumbnail' | 'complete';
progress: number;
message: string;
}
export interface ProcessDocumentOptions {
enableOCR?: boolean;
enableCompression?: boolean;
enableTextExtraction?: boolean;
enableThumbnail?: boolean;
maxImageSizeMB?: number;
onProgress?: (progress: ProcessingProgress) => void;
}
@ -42,7 +40,6 @@ export interface ProcessDocumentOptions {
* Интеллектуально обрабатывает документ перед загрузкой
*
* Автоматически определяет тип файла и выполняет:
* - Сжатие изображений
* - OCR для изображений
* - Извлечение текста из PDF/DOCX
* - Генерацию thumbnails
@ -57,42 +54,18 @@ export async function processDocument(
): Promise<ProcessingResult> {
const {
enableOCR = true,
enableCompression = true,
enableTextExtraction = true,
enableThumbnail = true,
maxImageSizeMB = 10,
onProgress
} = options;
const startTime = Date.now();
let extractedText = '';
let thumbnail: string | undefined;
let processedFile: File | Blob | undefined;
let ocrApplied = false;
let compressed = false;
try {
// 1. Сжатие изображений
if (enableCompression && isImageFile(file)) {
onProgress?.({
stage: 'compressing',
progress: 10,
message: 'Сжатие изображения...'
});
const sizeMB = file.size / (1024 * 1024);
if (sizeMB > maxImageSizeMB) {
processedFile = await compressImage(file, {
maxSizeMB: maxImageSizeMB,
quality: 0.85
});
compressed = true;
}
}
const fileToProcess = processedFile || file;
// 2. Извлечение текста
// 1. Извлечение текста
if (enableTextExtraction) {
onProgress?.({
stage: 'extracting_text',
@ -136,7 +109,7 @@ export async function processDocument(
}
}
// 3. Генерация thumbnail
// 2. Генерация thumbnail
if (enableThumbnail) {
onProgress?.({
stage: 'generating_thumbnail',
@ -162,15 +135,12 @@ export async function processDocument(
return {
extractedText,
thumbnail,
processedFile,
metadata: {
originalSize: file.size,
processedSize: processedFile?.size,
mimeType: file.type,
hasText: extractedText.length > 0,
processingTime,
ocrApplied,
compressed
ocrApplied
}
};
} catch (error) {

View File

@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import { NAV_NOTIFICATION_CATEGORIES } from "./navigation-notifications.ts";
import { getVerbCategory } from "./verbTranslations.ts";
const notificationSamples = [
["feed", "feed_new_post"],
["messages", "chat_new_message"],
["requests", "request_new"],
["guests", "guest_visit_submitted"],
["procurement", "procurement_new_request"],
["tasks", "task_assigned"],
["documents", "document_ready"],
];
test("navigation badges use the same categories as notification verbs", () => {
for (const [navigationKey, verb] of notificationSamples) {
assert.equal(
NAV_NOTIFICATION_CATEGORIES[navigationKey],
getVerbCategory(verb),
`${verb} must update the ${navigationKey} navigation badge`,
);
}
});

View File

@ -0,0 +1,9 @@
export const NAV_NOTIFICATION_CATEGORIES = {
feed: "Новости",
messages: "Сообщения",
requests: "Заявления",
guests: "Гости",
procurement: "Закупки",
tasks: "Доска",
documents: "Документы",
} as const;

View File

@ -222,6 +222,9 @@ export interface TaskCard {
linked_attendance_records_count?: number;
linked_objects_count?: number;
comments_count?: number;
attachments_count?: number;
checklist_total?: number;
checklist_completed?: number;
created_at: string;
updated_at: string;
}
@ -230,6 +233,7 @@ export interface TaskBoard {
id: number;
name: string;
description?: string;
access_scope?: "all" | "private" | "restricted";
created_by?: User;
members?: number[];
member_details?: User[];
@ -451,22 +455,57 @@ export interface TaskLinkedAttendanceRecord {
created_at: string;
}
export interface TaskExternalLink {
id: number;
task: number;
url: string;
title: string;
created_by?: User;
created_at: string;
}
export interface TaskActivity {
id: number;
action: "created" | "updated" | "moved" | "linked" | "unlinked";
action: "created" | "updated" | "claimed" | "moved" | "linked" | "unlinked" | "attachment_added" | "attachment_removed" | "checklist_item_added" | "checklist_item_updated" | "checklist_item_completed" | "checklist_item_reopened" | "checklist_item_removed" | "comment_added" | "comment_edited" | "comment_removed";
action_display?: string;
actor?: User | null;
object_kind?: "post" | "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | "attendance_record" | "";
object_kind?: "post" | "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | "attendance_record" | "external_link" | "checklist_item" | "comment" | "";
object_id?: number | null;
metadata?: Record<string, unknown>;
created_at: string;
}
export interface TaskAttachment {
id: number;
task: number;
file_name: string;
file_size: number;
mime_type?: string;
uploaded_by?: User;
created_at: string;
download_url: string;
}
export interface TaskChecklistItem {
id: number;
task: number;
title: string;
position: number;
is_completed: boolean;
created_by?: User;
completed_by?: User | null;
completed_at?: string | null;
created_at: string;
updated_at: string;
}
export interface TaskComment {
id: number;
task: number;
author: User;
text: string;
is_edited?: boolean;
edited_at?: string | null;
created_at: string;
}
@ -660,7 +699,14 @@ export interface Document {
departments?: { id: number; name: string }[];
acknowledgements?: DocumentAcknowledgement[];
acknowledgement_required?: boolean;
acknowledgement_for_all?: boolean;
acknowledgement_required_for_user?: boolean;
acknowledgement_recipients?: User[];
acknowledgement_departments?: { id: number; name: string }[];
is_acknowledged?: boolean;
acknowledged_count?: number;
acknowledgement_total?: number;
comments_count?: number;
linked_tasks?: MessageLinkedTask[];
}
@ -1008,6 +1054,17 @@ export type ProcurementItemExecutionStatus = 'pending' | 'ordered' | 'rejected'
export type UrgencyLevel = 'low' | 'medium' | 'high' | 'critical';
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
export interface ProcurementItemAttachment {
id: number;
file_name: string;
file_size: number;
mime_type?: string;
uploaded_by: number;
uploaded_by_name?: string;
created_at: string;
download_url?: string;
}
export interface ProcurementItem {
id: number;
request: number;
@ -1027,6 +1084,8 @@ export interface ProcurementItem {
received_quantity?: number | null;
initial_comment?: string;
comments_count?: number;
attachments?: ProcurementItemAttachment[];
attachments_count?: number;
equipment?: number | null;
}
@ -1110,6 +1169,7 @@ export interface ProcurementRequest {
total_received_quantity?: number;
quantity_unit_label?: string;
comments_count?: number;
activity_count?: number;
can_current_user_approve?: boolean;
can_current_user_submit_for_approval?: boolean;
can_current_user_start_work?: boolean;
@ -1122,6 +1182,17 @@ export interface ProcurementRequest {
completed_at?: string | null;
}
export interface ProcurementRequestActivity {
id: number;
action: string;
action_display: string;
actor?: User | null;
object_kind?: string;
object_id?: number | null;
metadata?: Record<string, unknown>;
created_at: string;
}
export interface ProcurementComment {
id: number;
request: number;