feat(documents): refine regulations workflow and notifications
This commit is contained in:
parent
8f59f2d1ed
commit
b9829a613e
@ -17,6 +17,22 @@ class NotificationVerbs:
|
||||
DOCUMENT_SIGNED_ALL = 'document_signed_all'
|
||||
DOCUMENT_COMMENT = 'document_comment'
|
||||
DOCUMENT_COMMENT_REPLY = 'document_comment_reply'
|
||||
REGULATION_READY = 'regulation_ready'
|
||||
REGULATION_SIGNED_ALL = 'regulation_signed_all'
|
||||
REGULATION_COMMENT = 'regulation_comment'
|
||||
REGULATION_COMMENT_REPLY = 'regulation_comment_reply'
|
||||
|
||||
@classmethod
|
||||
def ready(cls, is_regulation: bool) -> str:
|
||||
return cls.REGULATION_READY if is_regulation else cls.DOCUMENT_READY
|
||||
|
||||
@classmethod
|
||||
def signed_all(cls, is_regulation: bool) -> str:
|
||||
return (
|
||||
cls.REGULATION_SIGNED_ALL
|
||||
if is_regulation
|
||||
else cls.DOCUMENT_SIGNED_ALL
|
||||
)
|
||||
|
||||
|
||||
class MessageTemplates:
|
||||
@ -27,29 +43,41 @@ class MessageTemplates:
|
||||
uploader_name: str,
|
||||
document_title: str,
|
||||
acknowledgement_required: bool = True,
|
||||
is_regulation: bool = False,
|
||||
) -> str:
|
||||
"""Шаблон для нового документа."""
|
||||
message = f'{uploader_name} загрузил документ "{document_title}".'
|
||||
resource = 'регламент' if is_regulation else 'документ'
|
||||
action = 'опубликовал' if is_regulation else 'загрузил'
|
||||
message = f'{uploader_name} {action} {resource} "{document_title}".'
|
||||
if acknowledgement_required:
|
||||
return f"{message} Требуется ознакомление."
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def document_ready_title(acknowledgement_required: bool = True) -> str:
|
||||
def document_ready_title(
|
||||
acknowledgement_required: bool = True,
|
||||
is_regulation: bool = False,
|
||||
) -> str:
|
||||
"""Заголовок для нового документа."""
|
||||
resource = 'регламент' if is_regulation else 'документ'
|
||||
if not acknowledgement_required:
|
||||
return 'Новый документ'
|
||||
return 'Новый документ на ознакомление'
|
||||
return f'Новый {resource}'
|
||||
return f'Новый {resource} на ознакомление'
|
||||
|
||||
@staticmethod
|
||||
def all_acknowledged(document_title: str) -> str:
|
||||
def all_acknowledged(
|
||||
document_title: str,
|
||||
is_regulation: bool = False,
|
||||
) -> str:
|
||||
"""Шаблон для завершения ознакомления всеми."""
|
||||
return f'Все сотрудники ознакомились с документом "{document_title}"'
|
||||
resource = 'регламентом' if is_regulation else 'документом'
|
||||
return f'Все сотрудники ознакомились с {resource} "{document_title}"'
|
||||
|
||||
@staticmethod
|
||||
def all_acknowledged_title() -> str:
|
||||
def all_acknowledged_title(is_regulation: bool = False) -> str:
|
||||
"""Заголовок для завершения ознакомления."""
|
||||
return 'Все ознакомились с документом'
|
||||
resource = 'регламентом' if is_regulation else 'документом'
|
||||
return f'Все ознакомились с {resource}'
|
||||
|
||||
@staticmethod
|
||||
def comment(author_name: str, document_title: str) -> str:
|
||||
@ -84,13 +112,13 @@ class ActionURLs:
|
||||
DOCUMENTS = '/documents'
|
||||
|
||||
@staticmethod
|
||||
def document_detail(document_id: int) -> str:
|
||||
"""
|
||||
Возвращает URL-адрес документа.
|
||||
|
||||
TODO: реализовать прямые ссылки на конкретные документы
|
||||
"""
|
||||
return f'/documents?document={document_id}'
|
||||
def document_detail(
|
||||
document_id: int,
|
||||
is_regulation: bool = False,
|
||||
) -> str:
|
||||
"""Возвращает прямую ссылку на документ или регламент."""
|
||||
section = 'regulations' if is_regulation else 'folders'
|
||||
return f'/documents?section={section}&document={document_id}'
|
||||
|
||||
|
||||
# ===== Константы =====
|
||||
|
||||
@ -27,7 +27,47 @@ logger = logging.getLogger(__name__)
|
||||
Employee = get_user_model()
|
||||
|
||||
|
||||
def _document_ready_payload(document, uploader_name, recipient=None):
|
||||
def _regulation_notification_context(document):
|
||||
"""Возвращает отделы и область действия регламента для уведомления."""
|
||||
if not document.is_regulation:
|
||||
return {}
|
||||
|
||||
if (
|
||||
document.acknowledgement_required
|
||||
and not document.acknowledgement_for_all
|
||||
):
|
||||
departments = document.acknowledgement_departments.all()
|
||||
elif not document.sent_to_all:
|
||||
departments = document.departments.all()
|
||||
else:
|
||||
departments = document.departments.none()
|
||||
|
||||
department_rows = list(
|
||||
departments.order_by("name", "id").values("id", "name")
|
||||
)
|
||||
if department_rows:
|
||||
scope = "department"
|
||||
elif document.sent_to_all:
|
||||
scope = "company"
|
||||
else:
|
||||
scope = "personal"
|
||||
|
||||
return {
|
||||
"is_regulation": True,
|
||||
"regulation_scope": scope,
|
||||
"regulation_department_ids": [row["id"] for row in department_rows],
|
||||
"regulation_department_names": [
|
||||
row["name"] for row in department_rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _document_ready_payload(
|
||||
document,
|
||||
uploader_name,
|
||||
recipient=None,
|
||||
regulation_context=None,
|
||||
):
|
||||
"""Собирает единый текст и данные уведомления о новом документе."""
|
||||
if recipient is None:
|
||||
acknowledgement_required = bool(
|
||||
@ -45,10 +85,12 @@ def _document_ready_payload(document, uploader_name, recipient=None):
|
||||
uploader_name,
|
||||
document.title,
|
||||
acknowledgement_required=acknowledgement_required,
|
||||
is_regulation=document.is_regulation,
|
||||
),
|
||||
"data": {
|
||||
"title": MessageTemplates.document_ready_title(
|
||||
acknowledgement_required=acknowledgement_required,
|
||||
is_regulation=document.is_regulation,
|
||||
),
|
||||
"document_id": document.id,
|
||||
"uploaded_by_id": (
|
||||
@ -56,10 +98,42 @@ def _document_ready_payload(document, uploader_name, recipient=None):
|
||||
),
|
||||
"sent_to_all": document.sent_to_all,
|
||||
"acknowledgement_required": acknowledgement_required,
|
||||
"is_regulation": document.is_regulation,
|
||||
**(
|
||||
regulation_context
|
||||
if regulation_context is not None
|
||||
else _regulation_notification_context(document)
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _send_document_ready_notification(
|
||||
document,
|
||||
recipient,
|
||||
uploader_name,
|
||||
regulation_context=None,
|
||||
):
|
||||
payload = _document_ready_payload(
|
||||
document,
|
||||
uploader_name,
|
||||
recipient,
|
||||
regulation_context,
|
||||
)
|
||||
notify.send(
|
||||
sender=document.uploaded_by,
|
||||
recipient=recipient,
|
||||
verb=NotificationVerbs.ready(document.is_regulation),
|
||||
action_object=document,
|
||||
description=payload["description"],
|
||||
action_url=ActionURLs.document_detail(
|
||||
document.id,
|
||||
is_regulation=document.is_regulation,
|
||||
),
|
||||
data=payload["data"],
|
||||
)
|
||||
|
||||
|
||||
def notify_document_ready(document, recipient):
|
||||
"""
|
||||
Отправляет уведомление конкретному получателю о новом документе.
|
||||
@ -74,17 +148,7 @@ def notify_document_ready(document, recipient):
|
||||
)
|
||||
|
||||
uploader_name = get_uploader_name(document.uploaded_by)
|
||||
payload = _document_ready_payload(document, uploader_name, recipient)
|
||||
|
||||
notify.send(
|
||||
sender=document.uploaded_by,
|
||||
recipient=recipient,
|
||||
verb=NotificationVerbs.DOCUMENT_READY,
|
||||
action_object=document,
|
||||
description=payload["description"],
|
||||
action_url=ActionURLs.DOCUMENTS,
|
||||
data=payload["data"],
|
||||
)
|
||||
_send_document_ready_notification(document, recipient, uploader_name)
|
||||
|
||||
|
||||
def notify_all_employees(document):
|
||||
@ -111,19 +175,14 @@ def notify_all_employees(document):
|
||||
# Создаём уведомления
|
||||
created_count = 0
|
||||
uploader_name = get_uploader_name(document.uploaded_by)
|
||||
regulation_context = _regulation_notification_context(document)
|
||||
for employee in active_employees:
|
||||
try:
|
||||
payload = _document_ready_payload(
|
||||
document, uploader_name, employee
|
||||
)
|
||||
notify.send(
|
||||
sender=document.uploaded_by,
|
||||
recipient=employee,
|
||||
verb=NotificationVerbs.DOCUMENT_READY,
|
||||
action_object=document,
|
||||
description=payload["description"],
|
||||
action_url=ActionURLs.DOCUMENTS,
|
||||
data=payload["data"],
|
||||
_send_document_ready_notification(
|
||||
document,
|
||||
employee,
|
||||
uploader_name,
|
||||
regulation_context,
|
||||
)
|
||||
created_count += 1
|
||||
except Exception as e:
|
||||
@ -180,19 +239,16 @@ def notify_specific_users(document, user_ids):
|
||||
# Уведомляем получателей
|
||||
created_count = 0
|
||||
uploader_name = get_uploader_name(document.uploaded_by)
|
||||
regulation_context = _regulation_notification_context(document)
|
||||
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,
|
||||
recipient=user,
|
||||
verb=NotificationVerbs.DOCUMENT_READY,
|
||||
action_object=document,
|
||||
description=payload["description"],
|
||||
action_url=ActionURLs.DOCUMENTS,
|
||||
data=payload["data"],
|
||||
_send_document_ready_notification(
|
||||
document,
|
||||
user,
|
||||
uploader_name,
|
||||
regulation_context,
|
||||
)
|
||||
created_count += 1
|
||||
except Employee.DoesNotExist:
|
||||
@ -264,19 +320,14 @@ def notify_department_employees(document, department_ids):
|
||||
# Создаём уведомления
|
||||
created_count = 0
|
||||
uploader_name = get_uploader_name(document.uploaded_by)
|
||||
regulation_context = _regulation_notification_context(document)
|
||||
for employee in all_employees:
|
||||
try:
|
||||
payload = _document_ready_payload(
|
||||
document, uploader_name, employee
|
||||
)
|
||||
notify.send(
|
||||
sender=document.uploaded_by,
|
||||
recipient=employee,
|
||||
verb=NotificationVerbs.DOCUMENT_READY,
|
||||
action_object=document,
|
||||
description=payload["description"],
|
||||
action_url=ActionURLs.DOCUMENTS,
|
||||
data=payload["data"],
|
||||
_send_document_ready_notification(
|
||||
document,
|
||||
employee,
|
||||
uploader_name,
|
||||
regulation_context,
|
||||
)
|
||||
created_count += 1
|
||||
except Exception as e:
|
||||
@ -329,14 +380,24 @@ def notify_all_acknowledged(document, total_recipients, acknowledged_count):
|
||||
notify.send(
|
||||
sender=None,
|
||||
recipient=document.uploaded_by,
|
||||
verb=NotificationVerbs.DOCUMENT_SIGNED_ALL,
|
||||
verb=NotificationVerbs.signed_all(document.is_regulation),
|
||||
action_object=document,
|
||||
description=MessageTemplates.all_acknowledged(document.title),
|
||||
action_url=ActionURLs.DOCUMENTS,
|
||||
description=MessageTemplates.all_acknowledged(
|
||||
document.title,
|
||||
is_regulation=document.is_regulation,
|
||||
),
|
||||
action_url=ActionURLs.document_detail(
|
||||
document.id,
|
||||
is_regulation=document.is_regulation,
|
||||
),
|
||||
data={
|
||||
"title": MessageTemplates.all_acknowledged_title(),
|
||||
"title": MessageTemplates.all_acknowledged_title(
|
||||
is_regulation=document.is_regulation,
|
||||
),
|
||||
"document_id": document.id,
|
||||
"total_recipients": total_recipients,
|
||||
"acknowledged_count": acknowledged_count,
|
||||
"is_regulation": document.is_regulation,
|
||||
**_regulation_notification_context(document),
|
||||
},
|
||||
)
|
||||
|
||||
@ -51,10 +51,20 @@ class UnreadSummaryProcurementRequestSerializer(serializers.Serializer):
|
||||
unread = serializers.IntegerField()
|
||||
|
||||
|
||||
class UnreadSummaryRegulationDepartmentSerializer(serializers.Serializer):
|
||||
department_id = serializers.IntegerField()
|
||||
unread = serializers.IntegerField()
|
||||
|
||||
|
||||
class UnreadSummaryResponseSerializer(serializers.Serializer):
|
||||
total = serializers.IntegerField()
|
||||
verbs = UnreadSummaryVerbSerializer(many=True)
|
||||
procurement_requests = UnreadSummaryProcurementRequestSerializer(many=True)
|
||||
regulation_departments = UnreadSummaryRegulationDepartmentSerializer(
|
||||
many=True,
|
||||
)
|
||||
regulation_company_unread = serializers.IntegerField()
|
||||
regulation_personal_unread = serializers.IntegerField()
|
||||
|
||||
|
||||
class StatusResponseSerializer(serializers.Serializer):
|
||||
|
||||
@ -72,6 +72,51 @@ def _build_procurement_request_counts(user_id):
|
||||
]
|
||||
|
||||
|
||||
def _build_regulation_counts(user_id):
|
||||
"""Группирует непрочитанные уведомления о регламентах по отделам."""
|
||||
from .models import Notification
|
||||
|
||||
department_counts = {}
|
||||
company_unread = 0
|
||||
personal_unread = 0
|
||||
notifications = Notification.objects.filter(
|
||||
recipient_id=user_id,
|
||||
unread=True,
|
||||
deleted=False,
|
||||
verb__startswith="regulation_",
|
||||
).only("data")
|
||||
|
||||
for notification in notifications:
|
||||
data = notification.data if isinstance(notification.data, dict) else {}
|
||||
raw_department_ids = data.get("regulation_department_ids")
|
||||
department_ids = set()
|
||||
if isinstance(raw_department_ids, list):
|
||||
for value in raw_department_ids:
|
||||
department_id = _coerce_positive_int(value)
|
||||
if department_id is not None:
|
||||
department_ids.add(department_id)
|
||||
|
||||
for department_id in department_ids:
|
||||
department_counts[department_id] = (
|
||||
department_counts.get(department_id, 0) + 1
|
||||
)
|
||||
|
||||
if not department_ids:
|
||||
if data.get("regulation_scope") == "company":
|
||||
company_unread += 1
|
||||
else:
|
||||
personal_unread += 1
|
||||
|
||||
return {
|
||||
"departments": [
|
||||
{"department_id": department_id, "unread": unread}
|
||||
for department_id, unread in sorted(department_counts.items())
|
||||
],
|
||||
"company_unread": company_unread,
|
||||
"personal_unread": personal_unread,
|
||||
}
|
||||
|
||||
|
||||
def build_unread_summary(user_id):
|
||||
from .models import Notification
|
||||
|
||||
@ -89,10 +134,14 @@ def build_unread_summary(user_id):
|
||||
{"verb": row["verb"], "unread": row["unread"]}
|
||||
for row in rows
|
||||
]
|
||||
regulation_counts = _build_regulation_counts(user_id)
|
||||
return {
|
||||
"total": sum(item["unread"] for item in verbs),
|
||||
"verbs": verbs,
|
||||
"procurement_requests": _build_procurement_request_counts(user_id),
|
||||
"regulation_departments": regulation_counts["departments"],
|
||||
"regulation_company_unread": regulation_counts["company_unread"],
|
||||
"regulation_personal_unread": regulation_counts["personal_unread"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,211 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
DOCUMENT_TO_REGULATION_VERBS = {
|
||||
"document_ready": "regulation_ready",
|
||||
"document_signed_all": "regulation_signed_all",
|
||||
"document_comment": "regulation_comment",
|
||||
"document_comment_reply": "regulation_comment_reply",
|
||||
}
|
||||
|
||||
|
||||
def _regulation_metadata(document):
|
||||
if (
|
||||
document.acknowledgement_required
|
||||
and not document.acknowledgement_for_all
|
||||
):
|
||||
departments = document.acknowledgement_departments.all()
|
||||
elif not document.sent_to_all:
|
||||
departments = document.departments.all()
|
||||
else:
|
||||
departments = document.departments.none()
|
||||
|
||||
department_rows = list(
|
||||
departments.order_by("name", "id").values("id", "name")
|
||||
)
|
||||
if department_rows:
|
||||
scope = "department"
|
||||
elif document.sent_to_all:
|
||||
scope = "company"
|
||||
else:
|
||||
scope = "personal"
|
||||
|
||||
return {
|
||||
"is_regulation": True,
|
||||
"regulation_scope": scope,
|
||||
"regulation_department_ids": [row["id"] for row in department_rows],
|
||||
"regulation_department_names": [
|
||||
row["name"] for row in department_rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def split_existing_regulation_notifications(apps, schema_editor):
|
||||
Document = apps.get_model("documents", "Document")
|
||||
Notification = apps.get_model("notifications", "Notification")
|
||||
UserChannelPreferences = apps.get_model(
|
||||
"notifications",
|
||||
"UserChannelPreferences",
|
||||
)
|
||||
|
||||
regulations = {
|
||||
str(document.pk): (
|
||||
document.pk,
|
||||
_regulation_metadata(document),
|
||||
)
|
||||
for document in Document.objects.filter(is_regulation=True).iterator()
|
||||
}
|
||||
pending = []
|
||||
notifications = Notification.objects.filter(
|
||||
verb__in=DOCUMENT_TO_REGULATION_VERBS,
|
||||
).only(
|
||||
"id",
|
||||
"verb",
|
||||
"data",
|
||||
"description",
|
||||
"action_url",
|
||||
"action_object_object_id",
|
||||
)
|
||||
|
||||
for notification in notifications.iterator():
|
||||
data = notification.data if isinstance(notification.data, dict) else {}
|
||||
document_id = data.get("document_id")
|
||||
regulation = regulations.get(str(document_id))
|
||||
if regulation is None:
|
||||
regulation = regulations.get(
|
||||
str(notification.action_object_object_id)
|
||||
)
|
||||
if regulation is None:
|
||||
continue
|
||||
|
||||
resolved_document_id, metadata = regulation
|
||||
notification.verb = DOCUMENT_TO_REGULATION_VERBS[notification.verb]
|
||||
title = data.get("title")
|
||||
if isinstance(title, str):
|
||||
title = title.replace("Документ", "Регламент").replace(
|
||||
"документ",
|
||||
"регламент",
|
||||
)
|
||||
notification.data = {
|
||||
**data,
|
||||
**({"title": title} if isinstance(title, str) else {}),
|
||||
**metadata,
|
||||
}
|
||||
if notification.description:
|
||||
notification.description = (
|
||||
notification.description
|
||||
.replace("загрузил документ", "опубликовал регламент")
|
||||
.replace("Документ", "Регламент")
|
||||
.replace("документом", "регламентом")
|
||||
.replace("документ", "регламент")
|
||||
)
|
||||
notification.action_url = (
|
||||
"/documents?section=regulations"
|
||||
f"&document={resolved_document_id}"
|
||||
)
|
||||
pending.append(notification)
|
||||
|
||||
if pending:
|
||||
Notification.objects.bulk_update(
|
||||
pending,
|
||||
["verb", "data", "description", "action_url"],
|
||||
batch_size=500,
|
||||
)
|
||||
|
||||
preferences_to_update = []
|
||||
for preferences in UserChannelPreferences.objects.only(
|
||||
"id",
|
||||
"disabled_verbs",
|
||||
).iterator():
|
||||
disabled_verbs = list(preferences.disabled_verbs or [])
|
||||
changed = False
|
||||
for document_verb, regulation_verb in (
|
||||
DOCUMENT_TO_REGULATION_VERBS.items()
|
||||
):
|
||||
if (
|
||||
document_verb in disabled_verbs
|
||||
and regulation_verb not in disabled_verbs
|
||||
):
|
||||
disabled_verbs.append(regulation_verb)
|
||||
changed = True
|
||||
if changed:
|
||||
preferences.disabled_verbs = disabled_verbs
|
||||
preferences_to_update.append(preferences)
|
||||
|
||||
if preferences_to_update:
|
||||
UserChannelPreferences.objects.bulk_update(
|
||||
preferences_to_update,
|
||||
["disabled_verbs"],
|
||||
batch_size=500,
|
||||
)
|
||||
|
||||
|
||||
def merge_regulation_notifications_back(apps, schema_editor):
|
||||
Notification = apps.get_model("notifications", "Notification")
|
||||
UserChannelPreferences = apps.get_model(
|
||||
"notifications",
|
||||
"UserChannelPreferences",
|
||||
)
|
||||
reverse_verbs = {
|
||||
regulation_verb: document_verb
|
||||
for document_verb, regulation_verb in (
|
||||
DOCUMENT_TO_REGULATION_VERBS.items()
|
||||
)
|
||||
}
|
||||
pending = []
|
||||
for notification in Notification.objects.filter(
|
||||
verb__in=reverse_verbs,
|
||||
).only("id", "verb", "data", "action_url").iterator():
|
||||
data = notification.data if isinstance(notification.data, dict) else {}
|
||||
for key in (
|
||||
"is_regulation",
|
||||
"regulation_scope",
|
||||
"regulation_department_ids",
|
||||
"regulation_department_names",
|
||||
):
|
||||
data.pop(key, None)
|
||||
notification.verb = reverse_verbs[notification.verb]
|
||||
notification.data = data
|
||||
notification.action_url = "/documents"
|
||||
pending.append(notification)
|
||||
|
||||
if pending:
|
||||
Notification.objects.bulk_update(
|
||||
pending,
|
||||
["verb", "data", "action_url"],
|
||||
batch_size=500,
|
||||
)
|
||||
|
||||
preferences_to_update = []
|
||||
for preferences in UserChannelPreferences.objects.only(
|
||||
"id",
|
||||
"disabled_verbs",
|
||||
).iterator():
|
||||
disabled_verbs = list(preferences.disabled_verbs or [])
|
||||
filtered_verbs = [
|
||||
verb for verb in disabled_verbs if verb not in reverse_verbs
|
||||
]
|
||||
if filtered_verbs != disabled_verbs:
|
||||
preferences.disabled_verbs = filtered_verbs
|
||||
preferences_to_update.append(preferences)
|
||||
|
||||
if preferences_to_update:
|
||||
UserChannelPreferences.objects.bulk_update(
|
||||
preferences_to_update,
|
||||
["disabled_verbs"],
|
||||
batch_size=500,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0017_document_acknowledgement_audience"),
|
||||
("notifications", "0011_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
split_existing_regulation_notifications,
|
||||
merge_regulation_notifications_back,
|
||||
),
|
||||
]
|
||||
@ -111,6 +111,8 @@ def make_document(
|
||||
description: str = "desc",
|
||||
sent_to_all: bool = True,
|
||||
acknowledgement_required: bool = False,
|
||||
acknowledgement_for_all: bool = True,
|
||||
is_regulation: bool = False,
|
||||
recipients: Iterable[User] | None = None,
|
||||
) -> Document:
|
||||
"""Создаёт Document напрямую (минует API).
|
||||
@ -121,6 +123,8 @@ def make_document(
|
||||
description (str): Описание.
|
||||
sent_to_all (bool): Признак рассылки всем.
|
||||
acknowledgement_required (bool): Требуется ли ознакомление.
|
||||
acknowledgement_for_all (bool): Ознакомление требуется всей аудитории.
|
||||
is_regulation (bool): Документ относится к регламентам.
|
||||
recipients (Iterable[User] | None): Конкретные получатели при sent_to_all=false.
|
||||
|
||||
Returns:
|
||||
@ -136,6 +140,8 @@ def make_document(
|
||||
uploaded_at=timezone.now(),
|
||||
sent_to_all=sent_to_all,
|
||||
acknowledgement_required=acknowledgement_required,
|
||||
acknowledgement_for_all=acknowledgement_for_all,
|
||||
is_regulation=is_regulation,
|
||||
file=filer_file,
|
||||
)
|
||||
if not sent_to_all and recipients:
|
||||
@ -1108,6 +1114,54 @@ class TestDocumentNotifications:
|
||||
assert payload["data"]["title"] == "Новый документ"
|
||||
assert payload["data"]["acknowledgement_required"] is False
|
||||
|
||||
def test_regulation_notification_has_own_category_and_department_scope(
|
||||
self, monkeypatch, make_user, department_factory, link_factory
|
||||
):
|
||||
"""Регламент не смешивается с документами и содержит отдел ознакомления."""
|
||||
from documents.notifications.handlers import notify_document_ready
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"documents.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
author = make_user("regulation-author@example.com")
|
||||
viewer = make_user("regulation-viewer@example.com")
|
||||
department = department_factory(name="Бухгалтерия")
|
||||
link_factory(viewer, department)
|
||||
doc = make_document(
|
||||
title="Порядок закрытия месяца",
|
||||
uploaded_by=author,
|
||||
sent_to_all=False,
|
||||
acknowledgement_required=True,
|
||||
acknowledgement_for_all=False,
|
||||
is_regulation=True,
|
||||
recipients=[viewer],
|
||||
)
|
||||
doc.acknowledgement_departments.add(department)
|
||||
calls.clear()
|
||||
|
||||
notify_document_ready(doc, viewer)
|
||||
|
||||
assert len(calls) == 1
|
||||
payload = calls[0]
|
||||
assert payload["verb"] == "regulation_ready"
|
||||
assert payload["action_url"] == (
|
||||
f"/documents?section=regulations&document={doc.id}"
|
||||
)
|
||||
assert payload["data"]["title"] == "Новый регламент на ознакомление"
|
||||
assert payload["data"]["is_regulation"] is True
|
||||
assert payload["data"]["regulation_scope"] == "department"
|
||||
assert payload["data"]["regulation_department_ids"] == [department.id]
|
||||
assert payload["data"]["regulation_department_names"] == [
|
||||
"Бухгалтерия"
|
||||
]
|
||||
assert "опубликовал регламент" in payload["description"]
|
||||
|
||||
def test_optional_document_acknowledgement_does_not_notify_uploader(
|
||||
self, monkeypatch, make_user
|
||||
):
|
||||
|
||||
@ -35,6 +35,27 @@ class TestNotificationUnreadSummary:
|
||||
verb="chat_new_message",
|
||||
description="Сообщение",
|
||||
)
|
||||
Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="regulation_ready",
|
||||
description="Регламент Бухгалтерии",
|
||||
data={
|
||||
"regulation_scope": "department",
|
||||
"regulation_department_ids": [7, "7", 8],
|
||||
},
|
||||
)
|
||||
Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="regulation_ready",
|
||||
description="Регламент компании",
|
||||
data={"regulation_scope": "company"},
|
||||
)
|
||||
Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="regulation_signed_all",
|
||||
description="Личный регламент",
|
||||
data={"regulation_scope": "personal"},
|
||||
)
|
||||
Notification.objects.create(
|
||||
recipient=user,
|
||||
verb="document_uploaded",
|
||||
@ -53,18 +74,26 @@ class TestNotificationUnreadSummary:
|
||||
response = api_client.get("/api/v1/notifications/summary/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["total"] == 3
|
||||
assert response.data["total"] == 6
|
||||
assert {
|
||||
item["verb"]: item["unread"]
|
||||
for item in response.data["verbs"]
|
||||
} == {
|
||||
"procurement_department_request": 2,
|
||||
"chat_new_message": 1,
|
||||
"regulation_ready": 2,
|
||||
"regulation_signed_all": 1,
|
||||
}
|
||||
assert response.data["procurement_requests"] == [
|
||||
{"request_id": 10, "unread": 1},
|
||||
{"request_id": 12, "unread": 1},
|
||||
]
|
||||
assert response.data["regulation_departments"] == [
|
||||
{"department_id": 7, "unread": 1},
|
||||
{"department_id": 8, "unread": 1},
|
||||
]
|
||||
assert response.data["regulation_company_unread"] == 1
|
||||
assert response.data["regulation_personal_unread"] == 1
|
||||
|
||||
def test_unread_summary_cache_does_not_stay_stale_after_mark_as_read(
|
||||
self, api_client, user_factory
|
||||
|
||||
51
frontend/design-qa.md
Normal file
51
frontend/design-qa.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Design QA — mobile document cards
|
||||
|
||||
- Source visual truth: screenshot attached by the user in the conversation; no local source path is available.
|
||||
- Implementation screenshot: unavailable because the in-app browser is not exposed in this session.
|
||||
- Viewport: source approximately 356 × 469 px; implementation viewport not captured.
|
||||
- Source pixel dimensions: approximately 356 × 469 px.
|
||||
- Implementation pixel dimensions: not available.
|
||||
- CSS size and density normalization: not available.
|
||||
- State: narrow document list with a regulation requiring acknowledgement and a regular document.
|
||||
|
||||
## Full-view comparison evidence
|
||||
|
||||
Blocked. The source screenshot is visible in the conversation, but a browser-rendered implementation screenshot could not be captured for a same-viewport comparison.
|
||||
|
||||
## Focused region comparison evidence
|
||||
|
||||
Blocked for the same reason. Static source inspection confirms that the card now uses stable rows for type, acknowledgement state, folder, title, metadata, and actions, but static inspection is not a visual comparison.
|
||||
|
||||
## Findings and fixes implemented
|
||||
|
||||
- P1: the fixed-width status/menu column squeezed the title and metadata. Fixed by removing that competing column and anchoring the overflow menu independently.
|
||||
- P1: primary and secondary actions wrapped unpredictably. Fixed by placing the acknowledgement action on its own responsive row and keeping utility actions in a non-wrapping group.
|
||||
- P2: folder and type badges competed in one wrapping row. Fixed by giving the folder path its own truncating row.
|
||||
- P2: the chevron implied navigation while opening a menu. Fixed by using an overflow-menu icon.
|
||||
- P2: document type could be duplicated. Fixed by rendering type once and reserving the status badge for acknowledgement state.
|
||||
- P2: title and author text truncated or wrapped unpredictably. Fixed with a two-line title and stable mobile metadata rows.
|
||||
- P2: compact icon targets and weak icon-only labels. Fixed with 44 px targets and explicit accessible labels.
|
||||
- P2: the actions popup lacked complete menu semantics and Escape handling. Fixed with menu roles, relationships, and Escape dismissal.
|
||||
|
||||
## Required fidelity surfaces
|
||||
|
||||
- Fonts and typography: existing product typography preserved; title wrapping changed to two lines on narrow cards.
|
||||
- Spacing and layout rhythm: source-level structure corrected; browser measurement blocked.
|
||||
- Colors and visual tokens: existing application tokens preserved.
|
||||
- Image quality and asset fidelity: no raster assets are used by this component.
|
||||
- Copy and content: acknowledgement action changed to “Подтвердить ознакомление”; document type and acknowledgement state are no longer duplicated.
|
||||
|
||||
## Comparison history
|
||||
|
||||
- Initial source evidence showed squeezed metadata, inconsistent badge placement, and orphaned action buttons.
|
||||
- Source fixes were implemented and passed ESLint, TypeScript, and the production Next.js build.
|
||||
- A user-provided implementation screenshot then exposed a container-width regression: the viewport-level `sm` breakpoint put the acknowledgement button and five utility actions on one line inside a narrow sidebar column. The action area was changed to remain stacked according to its actual card width.
|
||||
- A second user-provided screenshot showed that the safe stacked layout made the acknowledgement action unnecessarily full-width in a wide card. The card now establishes an inline-size container and switches to a compact horizontal action row only when the card itself is at least 32rem wide.
|
||||
- A third screenshot showed that five 44px utility targets still exceeded the content width of an approximately 15rem card. The redundant “Details” icon is now hidden below a 20rem card width; the title and overflow menu continue to provide the same action, while four full-size utility targets remain visible.
|
||||
- Post-fix browser evidence could not be captured in this session.
|
||||
|
||||
## Remaining blocker
|
||||
|
||||
A same-viewport browser capture and interaction check are still required to verify the final visual result.
|
||||
|
||||
final result: blocked
|
||||
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@ -25,6 +25,26 @@ const nextConfig: NextConfig = {
|
||||
|
||||
// Полностью отключаем Next.js Dev Tools UI (кружок "N")
|
||||
devIndicators: false,
|
||||
|
||||
webpack(config, { dev, isServer, webpack }) {
|
||||
// Next.js 16.1.6 bundles a webpack version where eval-based source maps
|
||||
// break pdfjs-dist at runtime with "Object.defineProperty called on non-object".
|
||||
// Next restores config.devtool after this hook, so replace its eval plugin
|
||||
// with the regular external source-map plugin for the browser compilation.
|
||||
if (dev && !isServer) {
|
||||
config.plugins = config.plugins.filter(
|
||||
(plugin: { constructor?: { name?: string } } | null | undefined) =>
|
||||
plugin?.constructor?.name !== "EvalSourceMapDevToolPlugin",
|
||||
);
|
||||
config.plugins.push(
|
||||
new webpack.SourceMapDevToolPlugin({
|
||||
filename: "[file].map",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@ -4,10 +4,12 @@ import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
BadgePlus,
|
||||
CalendarDays,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Crown,
|
||||
@ -16,6 +18,7 @@ import {
|
||||
Minus,
|
||||
PencilLine,
|
||||
Plus,
|
||||
ScrollText,
|
||||
Search,
|
||||
Trash2,
|
||||
UserRoundCog,
|
||||
@ -25,6 +28,7 @@ import {
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { DepartmentFeedSection } from "@/components/departments/DepartmentFeedSection";
|
||||
import { DocumentDetailModal } from "@/components/documents/DocumentDetailModal";
|
||||
import {
|
||||
DEPARTMENT_MEMBERS_EMPTY_STATE_CLASSNAME,
|
||||
getDepartmentMembersListClassName,
|
||||
@ -42,8 +46,9 @@ import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useDepartmentPage } from "@/hooks/useDepartmentPage";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { displayUserName, userProfileLink } from "@/lib/shared";
|
||||
import type { DepartmentMemberLink, DepartmentPermissionChoice, DepartmentRole, User } from "@/types/api";
|
||||
import { regulationMatchesAcknowledgementSource } from "@/lib/feed-regulation-filters";
|
||||
import { displayUserName, formatDate, loadAllPages, userProfileLink } from "@/lib/shared";
|
||||
import type { DepartmentMemberLink, DepartmentPermissionChoice, DepartmentRole, Document, User } from "@/types/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type DepartmentMemberModalMode = "add" | "assignRole";
|
||||
@ -80,6 +85,35 @@ function MetaChip({
|
||||
);
|
||||
}
|
||||
|
||||
function RegulationAcknowledgementStatus({ document }: { document: Document }) {
|
||||
const acknowledgementRequiredForUser =
|
||||
document.acknowledgement_required_for_user ?? document.acknowledgement_required;
|
||||
|
||||
if (!acknowledgementRequiredForUser) {
|
||||
return (
|
||||
<span className="app-badge inline-flex items-center rounded-full px-2 py-1 font-medium">
|
||||
Ознакомление не требуется
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (document.is_acknowledged) {
|
||||
return (
|
||||
<span className="app-feedback-success inline-flex items-center gap-1 rounded-full px-2 py-1 font-medium">
|
||||
<CheckCircle2 size={11} />
|
||||
Ознакомлен
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2 py-1 font-medium">
|
||||
<AlertCircle size={11} />
|
||||
Требует ознакомления
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DepartmentMemberRow({
|
||||
canAssignRoles,
|
||||
canChangeHead,
|
||||
@ -959,6 +993,9 @@ export default function DepartmentDetailPage() {
|
||||
const h = useDepartmentPage(departmentId);
|
||||
const [managementMode, setManagementMode] = useState(false);
|
||||
const [membersExpanded, setMembersExpanded] = useState(false);
|
||||
const [regulationsExpanded, setRegulationsExpanded] = useState(false);
|
||||
const [departmentRegulations, setDepartmentRegulations] = useState<Document[]>([]);
|
||||
const [selectedRegulation, setSelectedRegulation] = useState<Document | null>(null);
|
||||
const [roleMenuOpenForId, setRoleMenuOpenForId] = useState<number | null>(null);
|
||||
const roleMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [departmentMenuOpen, setDepartmentMenuOpen] = useState(false);
|
||||
@ -982,6 +1019,58 @@ export default function DepartmentDetailPage() {
|
||||
}
|
||||
}, [isManagementMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(departmentId)) return;
|
||||
|
||||
let cancelled = false;
|
||||
setRegulationsExpanded(false);
|
||||
|
||||
const loadDepartmentRegulations = async () => {
|
||||
try {
|
||||
const regulations = await loadAllPages<Document>((pageParams) => (
|
||||
apiClient.getDocuments({ ...pageParams, is_regulation: true })
|
||||
));
|
||||
if (cancelled) return;
|
||||
|
||||
const departmentSource = `department:${departmentId}` as const;
|
||||
setDepartmentRegulations(
|
||||
regulations
|
||||
.filter((document) => (
|
||||
regulationMatchesAcknowledgementSource(document, departmentSource)
|
||||
))
|
||||
.sort((left, right) => (
|
||||
new Date(right.uploaded_at || right.created_at).getTime()
|
||||
- new Date(left.uploaded_at || left.created_at).getTime()
|
||||
)),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Не удалось загрузить регламенты отдела:", error);
|
||||
if (!cancelled) setDepartmentRegulations([]);
|
||||
}
|
||||
};
|
||||
|
||||
void loadDepartmentRegulations();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [departmentId]);
|
||||
|
||||
const refreshSelectedRegulation = async () => {
|
||||
if (!selectedRegulation) return;
|
||||
|
||||
const selectedId = selectedRegulation.id;
|
||||
try {
|
||||
const updatedDocument = await apiClient.getDocument(selectedId);
|
||||
setSelectedRegulation((current) => current?.id === selectedId ? updatedDocument : current);
|
||||
setDepartmentRegulations((current) => current.map((document) => (
|
||||
document.id === selectedId ? updatedDocument : document
|
||||
)));
|
||||
} catch (error) {
|
||||
console.error("Не удалось обновить регламент:", error);
|
||||
toast.error("Не удалось обновить регламент");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (roleMenuOpenForId === null) return;
|
||||
|
||||
@ -1336,6 +1425,63 @@ export default function DepartmentDetailPage() {
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{departmentRegulations.length > 0 ? (
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<div className={regulationsExpanded ? "mb-4 flex items-center justify-between gap-3" : "flex items-center justify-between gap-3"}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
|
||||
Регламенты отдела
|
||||
</p>
|
||||
<MetaChip tone="accent">{departmentRegulations.length}</MetaChip>
|
||||
</div>
|
||||
<p className="app-text-muted mt-1 text-sm">
|
||||
Документы, требующие ознакомления сотрудников отдела.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRegulationsExpanded((current) => !current)}
|
||||
className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
|
||||
aria-label={regulationsExpanded ? "Свернуть список регламентов" : "Развернуть список регламентов"}
|
||||
aria-expanded={regulationsExpanded}
|
||||
>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={`transition-transform duration-200 ${regulationsExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{regulationsExpanded ? (
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)]">
|
||||
{departmentRegulations.map((document) => (
|
||||
<button
|
||||
key={document.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedRegulation(document)}
|
||||
className="flex w-full min-w-0 items-center gap-3 border-b border-[var(--border-subtle)] px-3 py-3 text-left transition last:border-b-0 hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<span className="app-selected app-accent-text flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<ScrollText size={17} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">
|
||||
{document.title}
|
||||
</span>
|
||||
<span className="app-text-muted mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<RegulationAcknowledgementStatus document={document} />
|
||||
<span>{formatDate(document.uploaded_at || document.created_at)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight size={16} className="app-text-muted shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<DepartmentFeedSection
|
||||
canCreatePosts={
|
||||
h.userPerms.can_publish_posts || h.userPerms.can_manage_feed
|
||||
@ -1413,6 +1559,21 @@ export default function DepartmentDetailPage() {
|
||||
onSave={h.saveRole}
|
||||
roleDraft={h.roleDraft}
|
||||
/>
|
||||
|
||||
<DocumentDetailModal
|
||||
document={selectedRegulation}
|
||||
isOpen={selectedRegulation !== null}
|
||||
onClose={() => setSelectedRegulation(null)}
|
||||
onUpdate={() => void refreshSelectedRegulation()}
|
||||
onNavigateToRelated={(documentId) => {
|
||||
void apiClient.getDocument(documentId)
|
||||
.then(setSelectedRegulation)
|
||||
.catch((error) => {
|
||||
console.error("Не удалось открыть связанный документ:", error);
|
||||
toast.error("Не удалось открыть связанный документ");
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -528,6 +528,32 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
border-color: color-mix(in srgb, #10b981 24%, var(--border-strong));
|
||||
}
|
||||
|
||||
.document-card {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.document-card-detail-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@container (min-width: 20rem) {
|
||||
.document-card-detail-action {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 32rem) {
|
||||
.document-card-actions {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.document-card-acknowledge {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.app-action-reject {
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-elevated);
|
||||
|
||||
@ -8,6 +8,7 @@ import { formatDistanceToNow } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale/ru';
|
||||
import { getVerbCategory, getVerbName } from '@/lib/verbTranslations';
|
||||
import { resolveNotificationActionUrl } from '@/lib/notifications/actionUrl';
|
||||
import { getRegulationNotificationScopeLabel } from '@/lib/notifications/regulationScope';
|
||||
import type { NotificationItem } from '@/contexts/NotificationsContext';
|
||||
|
||||
type NotificationReadFilter = 'all' | 'unread' | 'read';
|
||||
@ -375,7 +376,10 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredNotifications.map((notification) => (
|
||||
{filteredNotifications.map((notification) => {
|
||||
const regulationScope = getRegulationNotificationScopeLabel(notification);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={notification.id}
|
||||
className={`group overflow-hidden rounded-xl border transition ${
|
||||
@ -421,6 +425,12 @@ export default function NotificationsPage() {
|
||||
{notification.short_message || notification.message}
|
||||
</p>
|
||||
|
||||
{regulationScope ? (
|
||||
<p className="app-accent-text mb-2 text-xs font-medium">
|
||||
{regulationScope}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="app-text-muted flex items-center gap-3 text-xs">
|
||||
{notification.category && (
|
||||
<span className="app-badge px-2 py-0.5 font-medium">
|
||||
@ -458,7 +468,8 @@ export default function NotificationsPage() {
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@ -18,6 +18,7 @@ import TaskLinkPill from "@/components/tasks/TaskLinkPill";
|
||||
import { useNotifications } from "@/contexts/NotificationsContext";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { getDocumentFileExtension } from "@/lib/document-preview";
|
||||
import { NAV_NOTIFICATION_CATEGORIES } from "@/lib/navigation-notifications";
|
||||
import {
|
||||
getRegulationAcknowledgementDepartments,
|
||||
isCompanyAcknowledgementRegulation,
|
||||
@ -41,8 +42,8 @@ const DocumentPreview = dynamic(
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const DocumentUploadForm = dynamic(
|
||||
() => import("@/components/documents/DocumentUploadForm").then((mod) => ({ default: mod.DocumentUploadForm })),
|
||||
const RegulationCreateForm = dynamic(
|
||||
() => import("@/components/documents/DocumentUploadForm").then((mod) => ({ default: mod.RegulationCreateForm })),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
@ -88,7 +89,12 @@ function HomePageFallback() {
|
||||
|
||||
function HomePageContent() {
|
||||
const { user } = useUser();
|
||||
const { notifications } = useNotifications();
|
||||
const {
|
||||
notifications,
|
||||
unreadCategoryCounts,
|
||||
unreadRegulationDepartmentCounts,
|
||||
unreadCompanyRegulationCount,
|
||||
} = useNotifications();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@ -410,9 +416,15 @@ function HomePageContent() {
|
||||
const postSourceUnreadCounts = useMemo(() => {
|
||||
const departmentUnread = new Map<number, number>();
|
||||
let companyUnread = 0;
|
||||
let regulationsUnread = 0;
|
||||
let companyRegulationsUnread = 0;
|
||||
const departmentRegulationsUnread = new Map<number, number>();
|
||||
let regulationsUnread = (
|
||||
unreadCategoryCounts[NAV_NOTIFICATION_CATEGORIES.regulations] || 0
|
||||
);
|
||||
let companyRegulationsUnread = unreadCompanyRegulationCount;
|
||||
const departmentRegulationsUnread = new Map<number, number>(
|
||||
Object.entries(unreadRegulationDepartmentCounts).map(([id, unread]) => (
|
||||
[Number(id), unread]
|
||||
)),
|
||||
);
|
||||
const regulationsById = new Map(regulations.map((document) => [document.id, document]));
|
||||
|
||||
for (const notification of notifications) {
|
||||
@ -421,6 +433,8 @@ function HomePageContent() {
|
||||
? rawData as Record<string, unknown>
|
||||
: null;
|
||||
|
||||
// Обратная совместимость до применения миграции, которая переводит
|
||||
// старые уведомления регламентов из document_ready в regulation_ready.
|
||||
if ((notification.verb || "") === "document_ready") {
|
||||
const rawDocumentId = data?.document_id;
|
||||
const documentId = typeof rawDocumentId === "number"
|
||||
@ -442,6 +456,8 @@ function HomePageContent() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((notification.verb || "").startsWith("regulation_")) continue;
|
||||
|
||||
if ((notification.verb || "") !== "feed_new_post") continue;
|
||||
|
||||
const postType = typeof data?.post_type === "string" ? data.post_type : "";
|
||||
@ -476,7 +492,13 @@ function HomePageContent() {
|
||||
companyRegulations: companyRegulationsUnread,
|
||||
departments: departmentEntries,
|
||||
};
|
||||
}, [notifications, regulations]);
|
||||
}, [
|
||||
notifications,
|
||||
regulations,
|
||||
unreadCategoryCounts,
|
||||
unreadCompanyRegulationCount,
|
||||
unreadRegulationDepartmentCounts,
|
||||
]);
|
||||
|
||||
const postSourceOptions = useMemo(() => {
|
||||
const unreadByDepartment = new Map(
|
||||
@ -1165,8 +1187,7 @@ function HomePageContent() {
|
||||
title="Создать регламент"
|
||||
size="xl"
|
||||
>
|
||||
<DocumentUploadForm
|
||||
defaultIsRegulation
|
||||
<RegulationCreateForm
|
||||
onCancel={() => setCreateRegulationOpen(false)}
|
||||
onSuccess={() => {
|
||||
setCreateRegulationOpen(false);
|
||||
|
||||
@ -63,7 +63,7 @@ const navItems = [
|
||||
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: NAV_NOTIFICATION_CATEGORIES.procurement, autoReadOnNavigate: false },
|
||||
{ href: "/tasks", label: "Доска", icon: Kanban, category: NAV_NOTIFICATION_CATEGORIES.tasks },
|
||||
{ href: "/documents?section=folders", label: "Документы", icon: FileText, category: NAV_NOTIFICATION_CATEGORIES.documents, documentSection: "folders" as const },
|
||||
{ href: "/documents?section=regulations", label: "Регламенты", icon: ScrollText, documentSection: "regulations" as const },
|
||||
{ href: "/documents?section=regulations", label: "Регламенты", icon: ScrollText, category: NAV_NOTIFICATION_CATEGORIES.regulations, autoReadOnNavigate: false, documentSection: "regulations" as const },
|
||||
{ href: "/finances", label: "Финансы", icon: Wallet },
|
||||
];
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import { formatDistanceToNow } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale/ru';
|
||||
import { getVerbName } from '@/lib/verbTranslations';
|
||||
import { resolveNotificationActionUrl } from '@/lib/notifications/actionUrl';
|
||||
import { getRegulationNotificationScopeLabel } from '@/lib/notifications/regulationScope';
|
||||
import Link from 'next/link';
|
||||
import type { NotificationItem } from '@/contexts/NotificationsContext';
|
||||
|
||||
@ -167,6 +168,7 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
|
||||
const timestamp = getNotificationTimestamp(notification);
|
||||
const title = getNotificationTitle(notification);
|
||||
const message = getNotificationMessage(notification);
|
||||
const regulationScope = getRegulationNotificationScopeLabel(notification);
|
||||
|
||||
return (
|
||||
<li
|
||||
@ -189,6 +191,11 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen
|
||||
<p className="app-text-muted mb-1 line-clamp-2 text-xs">
|
||||
{message}
|
||||
</p>
|
||||
{regulationScope ? (
|
||||
<p className="app-accent-text mb-1 truncate text-[10px] font-medium" title={regulationScope}>
|
||||
{regulationScope}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="app-text-muted text-[10px]">
|
||||
{timestamp ? formatDistanceToNow(new Date(timestamp), {
|
||||
addSuffix: true,
|
||||
@ -295,6 +302,7 @@ export function NotificationPanel({ onClose }: { onClose?: () => void }) {
|
||||
const timestamp = getNotificationTimestamp(notification);
|
||||
const title = getNotificationTitle(notification);
|
||||
const message = getNotificationMessage(notification);
|
||||
const regulationScope = getRegulationNotificationScopeLabel(notification);
|
||||
|
||||
return (
|
||||
<li
|
||||
@ -311,6 +319,11 @@ export function NotificationPanel({ onClose }: { onClose?: () => void }) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="mb-0.5 truncate text-sm font-medium text-[var(--foreground)]">{title}</h4>
|
||||
<p className="app-text-muted mb-1 line-clamp-2 text-xs">{message}</p>
|
||||
{regulationScope ? (
|
||||
<p className="app-accent-text mb-1 truncate text-[10px] font-medium" title={regulationScope}>
|
||||
{regulationScope}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="app-text-muted text-[10px]">
|
||||
{timestamp ? formatDistanceToNow(new Date(timestamp), { addSuffix: true, locale: ru }) : 'Только что'}
|
||||
</p>
|
||||
|
||||
@ -9,14 +9,21 @@ import type { Document } from "@/types/api";
|
||||
interface DocumentAcknowledgementProps {
|
||||
document: Document;
|
||||
onAcknowledge?: () => void;
|
||||
showAction?: boolean;
|
||||
}
|
||||
|
||||
export function DocumentAcknowledgement({
|
||||
interface DocumentAcknowledgeButtonProps {
|
||||
document: Document;
|
||||
onAcknowledge?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DocumentAcknowledgeButton({
|
||||
document,
|
||||
onAcknowledge,
|
||||
}: DocumentAcknowledgementProps) {
|
||||
className = "",
|
||||
}: DocumentAcknowledgeButtonProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showAcknowledgements, setShowAcknowledgements] = useState(false);
|
||||
|
||||
const handleAcknowledge = async () => {
|
||||
setIsSubmitting(true);
|
||||
@ -34,6 +41,32 @@ export function DocumentAcknowledgement({
|
||||
}
|
||||
};
|
||||
|
||||
const isAcknowledged = document.is_acknowledged || false;
|
||||
const acknowledgementRequiredForUser =
|
||||
document.acknowledgement_required_for_user ?? document.acknowledgement_required;
|
||||
|
||||
if (!acknowledgementRequiredForUser || isAcknowledged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleAcknowledge}
|
||||
disabled={isSubmitting}
|
||||
className={`app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
||||
>
|
||||
<CheckCircle size={17} className="shrink-0" />
|
||||
{isSubmitting ? "Подтверждение..." : "Ознакомился(лась)"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentAcknowledgement({
|
||||
document,
|
||||
onAcknowledge,
|
||||
showAction = true,
|
||||
}: DocumentAcknowledgementProps) {
|
||||
const [showAcknowledgements, setShowAcknowledgements] = useState(false);
|
||||
const acknowledgements = document.acknowledgements || [];
|
||||
const isAcknowledged = document.is_acknowledged || false;
|
||||
const acknowledgementRequiredForUser =
|
||||
@ -61,13 +94,13 @@ export function DocumentAcknowledgement({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAcknowledge}
|
||||
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 ? "Подтверждение..." : "Ознакомился(лась)"}
|
||||
</button>
|
||||
{showAction && (
|
||||
<DocumentAcknowledgeButton
|
||||
document={document}
|
||||
onAcknowledge={onAcknowledge}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-feedback-success flex items-center gap-2 rounded-lg px-3 py-2 text-sm">
|
||||
|
||||
@ -10,6 +10,7 @@ export type DocumentAudienceMode = "all" | "none" | "restricted";
|
||||
|
||||
type DocumentAudienceSelectorProps = {
|
||||
kind: "access" | "acknowledgement";
|
||||
resource?: "document" | "regulation";
|
||||
mode: DocumentAudienceMode;
|
||||
onModeChange: (mode: DocumentAudienceMode) => void;
|
||||
employees: User[];
|
||||
@ -39,6 +40,7 @@ function normalizeSearch(value: string) {
|
||||
|
||||
export function DocumentAudienceSelector({
|
||||
kind,
|
||||
resource = "document",
|
||||
mode,
|
||||
onModeChange,
|
||||
employees,
|
||||
@ -53,6 +55,8 @@ export function DocumentAudienceSelector({
|
||||
const [employeeSearch, setEmployeeSearch] = useState("");
|
||||
const [departmentSearch, setDepartmentSearch] = useState("");
|
||||
const isAcknowledgement = kind === "acknowledgement";
|
||||
const resourceDative = resource === "regulation" ? "регламенту" : "документу";
|
||||
const resourceAccusative = resource === "regulation" ? "регламент" : "документ";
|
||||
|
||||
const options: Array<{ value: DocumentAudienceMode; label: string }> = isAcknowledgement
|
||||
? [
|
||||
@ -99,12 +103,12 @@ export function DocumentAudienceSelector({
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground)]">
|
||||
{isAcknowledgement ? "Требование ознакомления" : "Доступ к документу"}
|
||||
{isAcknowledgement ? "Требование ознакомления" : `Доступ к ${resourceDative}`}
|
||||
</h3>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{isAcknowledgement
|
||||
? "Выберите сотрудников, которые должны подтвердить ознакомление."
|
||||
: "Выберите сотрудников, которые смогут открыть документ."}
|
||||
: `Выберите сотрудников, которые смогут открыть ${resourceAccusative}.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ import {
|
||||
Link2,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
import { DocumentAcknowledgement } from "./DocumentAcknowledgement";
|
||||
import { DocumentAcknowledgeButton, DocumentAcknowledgement } from "./DocumentAcknowledgement";
|
||||
import { DocumentComments } from "./DocumentComments";
|
||||
import { DocumentRelated } from "./DocumentRelated";
|
||||
import { DocumentTaskLinks } from "./DocumentTaskLinks";
|
||||
@ -78,6 +78,10 @@ export function DocumentDetailModal({
|
||||
const documentId = document?.id ?? null;
|
||||
const documentHasFile = Boolean(document?.file_url);
|
||||
const documentRequiresAcknowledgement = Boolean(document?.acknowledgement_required);
|
||||
const acknowledgementRequiredForUser = Boolean(
|
||||
document?.acknowledgement_required_for_user ?? document?.acknowledgement_required,
|
||||
);
|
||||
const showPersistentAcknowledgeAction = acknowledgementRequiredForUser && !document?.is_acknowledged;
|
||||
|
||||
if (!document) return null;
|
||||
|
||||
@ -114,6 +118,8 @@ export function DocumentDetailModal({
|
||||
? activeTabState.value
|
||||
: defaultTab;
|
||||
const currentActiveTab = tabs.some((tab) => tab.id === activeTab) ? activeTab : defaultTab;
|
||||
const desktopActiveTab: Exclude<TabType, "preview"> =
|
||||
currentActiveTab === "preview" ? "info" : currentActiveTab;
|
||||
const isFullscreen = fullscreenState.documentId === documentId ? fullscreenState.value : false;
|
||||
const detailTabs = tabs.filter((tab) => tab.id !== "preview");
|
||||
const setDocumentActiveTab = (value: TabType) => {
|
||||
@ -149,9 +155,10 @@ export function DocumentDetailModal({
|
||||
onClose={onClose}
|
||||
size="full"
|
||||
showCloseButton
|
||||
className="flex flex-col"
|
||||
noPadding
|
||||
className="relative flex h-[95vh] flex-col sm:h-[90vh]"
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mx-4 flex h-full min-h-0 flex-col sm:mx-6">
|
||||
{/* Header with Title and Actions */}
|
||||
<div className="app-divider app-header shrink-0 border-b px-4 py-4 sm:px-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
@ -257,20 +264,20 @@ export function DocumentDetailModal({
|
||||
<div className="hidden h-full lg:flex">
|
||||
{/* Preview Panel (Left) */}
|
||||
{documentHasFile && (
|
||||
<div className={`${isFullscreen ? "w-full" : "w-3/5"} shrink-0 border-r border-[var(--border-subtle)] transition-all`}>
|
||||
<div className={`${isFullscreen ? "w-full" : "w-3/5"} min-h-0 shrink-0 overflow-hidden border-r border-[var(--border-subtle)] transition-all`}>
|
||||
{renderPreview()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Panel (Right) */}
|
||||
{!(documentHasFile && isFullscreen) && (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Desktop Tabs */}
|
||||
<div className="app-divider app-surface-muted shrink-0 border-b px-6">
|
||||
<nav className="app-tabs-scroll flex gap-1 overflow-x-auto overflow-y-hidden pb-1">
|
||||
{detailTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = currentActiveTab === tab.id;
|
||||
const isActive = desktopActiveTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
@ -291,7 +298,7 @@ export function DocumentDetailModal({
|
||||
|
||||
{/* Desktop Tab Content */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-[var(--surface-secondary)]">
|
||||
{currentActiveTab === "info" && (
|
||||
{desktopActiveTab === "info" && (
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
{/* Description */}
|
||||
@ -461,7 +468,7 @@ export function DocumentDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentActiveTab === "activity" && (
|
||||
{desktopActiveTab === "activity" && (
|
||||
<div className="p-6">
|
||||
<div className="app-surface rounded-xl p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-[var(--foreground)]">История активности</h3>
|
||||
@ -495,7 +502,7 @@ export function DocumentDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentActiveTab === "acknowledgements" && (
|
||||
{desktopActiveTab === "acknowledgements" && (
|
||||
<div className="p-6">
|
||||
<div className="app-surface rounded-xl p-6">
|
||||
{document.acknowledgement_required ? (
|
||||
@ -511,7 +518,11 @@ export function DocumentDetailModal({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<DocumentAcknowledgement document={document} onAcknowledge={() => onUpdate?.()} />
|
||||
<DocumentAcknowledgement
|
||||
document={document}
|
||||
onAcknowledge={() => onUpdate?.()}
|
||||
showAction={false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12 text-center">
|
||||
@ -525,7 +536,7 @@ export function DocumentDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentActiveTab === "comments" && (
|
||||
{desktopActiveTab === "comments" && (
|
||||
<div className="p-6">
|
||||
<div className="app-surface rounded-xl p-6">
|
||||
<DocumentComments
|
||||
@ -536,7 +547,7 @@ export function DocumentDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentActiveTab === "related" && (
|
||||
{desktopActiveTab === "related" && (
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="app-surface rounded-xl p-6">
|
||||
@ -782,7 +793,11 @@ export function DocumentDetailModal({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<DocumentAcknowledgement document={document} onAcknowledge={() => onUpdate?.()} />
|
||||
<DocumentAcknowledgement
|
||||
document={document}
|
||||
onAcknowledge={() => onUpdate?.()}
|
||||
showAction={false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12 text-center">
|
||||
@ -824,6 +839,16 @@ export function DocumentDetailModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPersistentAcknowledgeAction && (
|
||||
<div className="pointer-events-none absolute inset-x-4 bottom-4 z-30 flex justify-end sm:inset-x-6 sm:bottom-6">
|
||||
<DocumentAcknowledgeButton
|
||||
document={document}
|
||||
onAcknowledge={() => onUpdate?.()}
|
||||
className="pointer-events-auto w-full shadow-[0_12px_32px_rgba(0,0,0,0.35)] sm:w-auto sm:min-w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
148
frontend/src/components/documents/DocumentFilePanel.tsx
Normal file
148
frontend/src/components/documents/DocumentFilePanel.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { ChevronRight, FileText, Loader2, Paperclip, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
interface DocumentFilePanelProps {
|
||||
title: string;
|
||||
count: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
children?: ReactNode;
|
||||
multiple?: boolean;
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
addLabel?: string;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
interface DocumentFileRowProps {
|
||||
name: string;
|
||||
meta?: string;
|
||||
pending?: boolean;
|
||||
error?: string;
|
||||
onRemove?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DocumentFilePanel({
|
||||
title,
|
||||
count,
|
||||
open,
|
||||
onOpenChange,
|
||||
onFilesSelected,
|
||||
children,
|
||||
multiple = false,
|
||||
disabled = false,
|
||||
busy = false,
|
||||
addLabel = multiple ? "Добавить файлы" : "Добавить файл",
|
||||
emptyText = multiple ? "Файлов пока нет" : "Файл пока не выбран",
|
||||
}: DocumentFilePanelProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--border-subtle)]">
|
||||
<div className="flex items-center px-1 py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(!open)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-1.5 text-left"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Paperclip size={15} className="app-text-muted shrink-0" />
|
||||
<span className="truncate text-sm font-medium text-[var(--foreground)]">{title}</span>
|
||||
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">{count}</span>
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
className="sr-only"
|
||||
disabled={disabled || busy}
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.currentTarget.files || []);
|
||||
event.currentTarget.value = "";
|
||||
if (files.length === 0) return;
|
||||
onOpenChange(true);
|
||||
onFilesSelected(files);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled || busy}
|
||||
className="app-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-lg disabled:opacity-50"
|
||||
title={addLabel}
|
||||
aria-label={addLabel}
|
||||
>
|
||||
{busy ? <Loader2 size={15} className="animate-spin" /> : <Plus size={15} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(!open)}
|
||||
className="app-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
|
||||
title={open ? "Скрыть файлы" : "Показать файлы"}
|
||||
aria-label={open ? "Скрыть файлы" : "Показать файлы"}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className={`transition-transform ${open ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="space-y-3 border-t border-[var(--border-subtle)] p-3">
|
||||
{children ?? (
|
||||
<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">{emptyText}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentFileRow({
|
||||
name,
|
||||
meta,
|
||||
pending = false,
|
||||
error,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
}: DocumentFileRowProps) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 border-b border-[var(--border-subtle)] px-3 py-2.5 last:border-b-0">
|
||||
<span className="app-selected flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<FileText size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-[var(--foreground)]">{name}</span>
|
||||
{meta || error ? (
|
||||
<span className={`mt-0.5 block truncate text-[11px] ${error ? "text-red-500" : "app-text-muted"}`}>
|
||||
{error || meta}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{pending ? (
|
||||
<span className="app-badge shrink-0 rounded-full px-2 py-0.5 text-[10px]">новое</span>
|
||||
) : null}
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
disabled={disabled}
|
||||
className="app-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-lg disabled:opacity-50"
|
||||
title="Убрать"
|
||||
aria-label={`Убрать ${name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -11,8 +11,6 @@ import {
|
||||
Loader2,
|
||||
ScrollText,
|
||||
Tag as TagIcon,
|
||||
Upload,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type { Department, Document, User } from '@/types/api';
|
||||
import { DocumentTagQuickCreate, type QuickDocumentTag } from './DocumentTagQuickCreate';
|
||||
@ -20,6 +18,7 @@ import {
|
||||
DocumentAudienceSelector,
|
||||
type DocumentAudienceMode,
|
||||
} from './DocumentAudienceSelector';
|
||||
import { DocumentFilePanel, DocumentFileRow } from './DocumentFilePanel';
|
||||
|
||||
type DocumentTagOption = NonNullable<Document["tags"]>[number];
|
||||
|
||||
@ -115,6 +114,7 @@ export function DocumentMetadataEditor({
|
||||
const [description, setDescription] = useState(document.description || '');
|
||||
const [extractedText, setExtractedText] = useState(document.extracted_text || '');
|
||||
const [replacementFile, setReplacementFile] = useState<File | null>(null);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [selectedTags, setSelectedTags] = useState<number[]>([]);
|
||||
const [selectedFolder, setSelectedFolder] = useState<number | null>(document.folder?.id || null);
|
||||
const [isRegulation, setIsRegulation] = useState(Boolean(document.is_regulation));
|
||||
@ -141,6 +141,7 @@ export function DocumentMetadataEditor({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const folderOptionRows = useMemo(() => buildFolderOptionRows(folders), [folders]);
|
||||
const currentFileName = document.file_name || document.file?.split('/').pop() || '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@ -149,6 +150,7 @@ export function DocumentMetadataEditor({
|
||||
setDescription(document.description || '');
|
||||
setExtractedText(document.extracted_text || '');
|
||||
setReplacementFile(null);
|
||||
setFilesOpen(false);
|
||||
setSelectedTags((document.tags || []).map((tag) => tag.id));
|
||||
setSelectedFolder(document.folder?.id || null);
|
||||
setIsRegulation(Boolean(document.is_regulation));
|
||||
@ -423,56 +425,43 @@ export function DocumentMetadataEditor({
|
||||
<div className="app-surface-muted rounded-lg p-3 text-sm">
|
||||
<p className="font-medium text-[var(--foreground)]">ID: {document.id}</p>
|
||||
<div className="app-text-muted mt-2 space-y-1 text-xs">
|
||||
<p>Текущий файл: {document.file_name || 'не прикреплён'}</p>
|
||||
{document.file_size ? <p>Размер: {formatFileSize(document.file_size)}</p> : null}
|
||||
{document.folder_path ? <p>Папка: {document.folder_path}</p> : null}
|
||||
<p>{document.is_regulation ? 'Регламент' : 'Документ'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--foreground)]">
|
||||
<Upload className="h-4 w-4" />
|
||||
Файл документа
|
||||
</div>
|
||||
|
||||
<div className="app-surface-muted rounded-lg p-3">
|
||||
<label
|
||||
htmlFor="documentEditFile"
|
||||
className="app-action-secondary inline-flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Выбрать новый файл
|
||||
</label>
|
||||
<input
|
||||
id="documentEditFile"
|
||||
type="file"
|
||||
onChange={(event) => setReplacementFile(event.target.files?.[0] || null)}
|
||||
disabled={isSaving}
|
||||
className="sr-only"
|
||||
/>
|
||||
<p className="app-text-muted mt-2 text-xs">
|
||||
Если файл не выбрать, текущий файл останется без изменений.
|
||||
</p>
|
||||
|
||||
{replacementFile && (
|
||||
<div className="app-selected app-accent-text mt-3 flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm">
|
||||
<span className="min-w-0 truncate">
|
||||
Новый файл: {replacementFile.name} ({formatFileSize(replacementFile.size)})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplacementFile(null)}
|
||||
<DocumentFilePanel
|
||||
title={isRegulation ? 'Файл регламента' : 'Файл документа'}
|
||||
count={replacementFile || currentFileName ? 1 : 0}
|
||||
open={filesOpen}
|
||||
onOpenChange={setFilesOpen}
|
||||
onFilesSelected={(files) => setReplacementFile(files[0] || null)}
|
||||
disabled={isSaving}
|
||||
addLabel={currentFileName ? 'Заменить файл' : 'Добавить файл'}
|
||||
emptyText={isRegulation ? 'Файл регламента пока не прикреплён' : 'Файл пока не прикреплён'}
|
||||
>
|
||||
{replacementFile ? (
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--border-subtle)]">
|
||||
<DocumentFileRow
|
||||
name={replacementFile.name}
|
||||
meta={formatFileSize(replacementFile.size)}
|
||||
pending
|
||||
onRemove={() => setReplacementFile(null)}
|
||||
disabled={isSaving}
|
||||
className="app-action-secondary inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
|
||||
title="Убрать выбранный файл"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : currentFileName ? (
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--border-subtle)]">
|
||||
<DocumentFileRow
|
||||
name={currentFileName}
|
||||
meta={`${formatFileSize(document.file_size) || 'Размер не указан'} · текущий файл`}
|
||||
/>
|
||||
</div>
|
||||
) : undefined}
|
||||
</DocumentFilePanel>
|
||||
|
||||
<div>
|
||||
<label htmlFor="documentEditExtractedText" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
@ -498,16 +487,17 @@ export function DocumentMetadataEditor({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="documentEditFolder" className="mb-1.5 flex items-center gap-2 text-sm font-medium text-[var(--foreground)]">
|
||||
<Folder className="h-4 w-4" />
|
||||
Папка
|
||||
</label>
|
||||
<div id="document-edit-folder-label" className="mb-2 flex items-center gap-2">
|
||||
<Folder className="app-text-muted h-[15px] w-[15px]" />
|
||||
<span className="app-text-muted text-xs font-medium">Папка</span>
|
||||
</div>
|
||||
<select
|
||||
id="documentEditFolder"
|
||||
value={selectedFolder || ''}
|
||||
onChange={(event) => setSelectedFolder(event.target.value ? Number(event.target.value) : null)}
|
||||
disabled={loadingReferences || isSaving}
|
||||
className="app-select w-full rounded-lg px-3 py-2 text-sm"
|
||||
className="app-select w-full rounded-xl px-3 py-2 text-sm"
|
||||
aria-labelledby="document-edit-folder-label"
|
||||
>
|
||||
<option value="">Без папки (корень)</option>
|
||||
{folderOptionRows.map(({ folder, level }) => (
|
||||
|
||||
@ -17,6 +17,7 @@ interface DocumentTagQuickCreateProps {
|
||||
disabled?: boolean;
|
||||
onCreated: (tag: QuickDocumentTag) => void;
|
||||
className?: string;
|
||||
layout?: 'disclosure' | 'inline';
|
||||
}
|
||||
|
||||
const TAG_COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#64748B'];
|
||||
@ -26,6 +27,7 @@ export function DocumentTagQuickCreate({
|
||||
disabled = false,
|
||||
onCreated,
|
||||
className = '',
|
||||
layout = 'disclosure',
|
||||
}: DocumentTagQuickCreateProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
@ -62,7 +64,11 @@ export function DocumentTagQuickCreate({
|
||||
color,
|
||||
});
|
||||
onCreated(created);
|
||||
resetForm();
|
||||
setName('');
|
||||
if (layout === 'disclosure') {
|
||||
setColor(TAG_COLORS[0]);
|
||||
setIsOpen(false);
|
||||
}
|
||||
toast.success('Тег создан');
|
||||
} catch (error) {
|
||||
console.error('Error creating document tag:', error);
|
||||
@ -72,6 +78,41 @@ export function DocumentTagQuickCreate({
|
||||
}
|
||||
};
|
||||
|
||||
if (layout === 'inline') {
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={`grid gap-2 md:grid-cols-[auto_1fr_auto] ${className}`}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(event) => setColor(event.target.value)}
|
||||
disabled={disabled || isCreating}
|
||||
className="h-10 w-14 cursor-pointer rounded-lg border border-[var(--border-subtle)] bg-transparent p-1 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Цвет тега"
|
||||
/>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
disabled={disabled || isCreating}
|
||||
maxLength={100}
|
||||
className="app-input min-w-0 rounded-xl px-3 py-2 text-sm"
|
||||
placeholder="Новый тег"
|
||||
aria-label="Название нового тега"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || isCreating || !name.trim()}
|
||||
className="app-action-secondary inline-flex items-center justify-center gap-1.5 rounded-xl px-3 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Добавить
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<div className={className}>
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import {
|
||||
Upload,
|
||||
X,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
Plus,
|
||||
ScrollText,
|
||||
Tag as TagIcon,
|
||||
} from "lucide-react";
|
||||
@ -21,6 +18,7 @@ import {
|
||||
DocumentAudienceSelector,
|
||||
type DocumentAudienceMode,
|
||||
} from "./DocumentAudienceSelector";
|
||||
import { DocumentFilePanel, DocumentFileRow } from "./DocumentFilePanel";
|
||||
|
||||
interface DocumentTagOption {
|
||||
id: number;
|
||||
@ -43,8 +41,9 @@ interface FolderOptionRow {
|
||||
interface DocumentUploadFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
onFolderCreated?: () => void;
|
||||
currentFolderId?: number | null;
|
||||
defaultIsRegulation?: boolean;
|
||||
mode?: "document" | "regulation";
|
||||
}
|
||||
|
||||
interface UploadFileItem {
|
||||
@ -121,12 +120,16 @@ const STAGE_MESSAGES: Record<string, string> = {
|
||||
export function DocumentUploadForm({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
onFolderCreated,
|
||||
currentFolderId,
|
||||
defaultIsRegulation = false,
|
||||
mode = "document",
|
||||
}: DocumentUploadFormProps) {
|
||||
const isDedicatedRegulation = mode === "regulation";
|
||||
const [isRegulation, setIsRegulation] = useState(isDedicatedRegulation);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [uploadItems, setUploadItems] = useState<UploadFileItem[]>([]);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [processingProgress, setProcessingProgress] = useState<ProcessingProgress | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@ -141,9 +144,13 @@ export function DocumentUploadForm({
|
||||
const [acknowledgementRecipients, setAcknowledgementRecipients] = useState<number[]>([]);
|
||||
|
||||
// Metadata fields
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(currentFolderId ?? null);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(
|
||||
isDedicatedRegulation ? null : currentFolderId ?? null,
|
||||
);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||
const [folderCreateError, setFolderCreateError] = useState<string | null>(null);
|
||||
const [selectedTags, setSelectedTags] = useState<number[]>([]);
|
||||
const [isRegulation, setIsRegulation] = useState(defaultIsRegulation);
|
||||
|
||||
// Data for selects
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
@ -156,10 +163,14 @@ export function DocumentUploadForm({
|
||||
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||
const [loadingDocumentTags, setLoadingDocumentTags] = useState(false);
|
||||
const folderOptionRows = useMemo(() => buildFolderOptionRows(folders), [folders]);
|
||||
const selectedFolder = useMemo(
|
||||
() => folders.find((folder) => folder.id === selectedFolderId) || null,
|
||||
[folders, selectedFolderId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedFolderId(currentFolderId ?? null);
|
||||
}, [currentFolderId]);
|
||||
setSelectedFolderId(isDedicatedRegulation ? null : currentFolderId ?? null);
|
||||
}, [currentFolderId, isDedicatedRegulation]);
|
||||
|
||||
// Load departments and employees
|
||||
useEffect(() => {
|
||||
@ -188,15 +199,17 @@ export function DocumentUploadForm({
|
||||
setLoadingEmployees(false);
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingFolders(true);
|
||||
const foldersResponse = await apiClient.getFolders({});
|
||||
const foldersData = foldersResponse.results || foldersResponse;
|
||||
setFolders(Array.isArray(foldersData) ? foldersData : []);
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки папок:", err);
|
||||
} finally {
|
||||
setLoadingFolders(false);
|
||||
if (!isDedicatedRegulation) {
|
||||
try {
|
||||
setLoadingFolders(true);
|
||||
const foldersResponse = await apiClient.getFolders({ limit: 1000 });
|
||||
const foldersData = foldersResponse.results || foldersResponse;
|
||||
setFolders(Array.isArray(foldersData) ? foldersData : []);
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки папок:", err);
|
||||
} finally {
|
||||
setLoadingFolders(false);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@ -211,7 +224,7 @@ export function DocumentUploadForm({
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
}, [isDedicatedRegulation]);
|
||||
|
||||
const acknowledgementEmployees = useMemo(() => {
|
||||
if (sentToAll) return employees;
|
||||
@ -240,10 +253,11 @@ export function DocumentUploadForm({
|
||||
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length === 0) return;
|
||||
|
||||
const newItems = acceptedFiles.map(createUploadItem);
|
||||
const totalAfterDrop = uploadItems.length + newItems.length;
|
||||
const newItems = (isDedicatedRegulation ? acceptedFiles.slice(0, 1) : acceptedFiles).map(createUploadItem);
|
||||
const totalAfterDrop = isDedicatedRegulation ? newItems.length : uploadItems.length + newItems.length;
|
||||
|
||||
setUploadItems((prev) => [...prev, ...newItems]);
|
||||
setUploadItems((prev) => isDedicatedRegulation ? newItems : [...prev, ...newItems]);
|
||||
setFilesOpen(true);
|
||||
setError(null);
|
||||
|
||||
if (totalAfterDrop === 1 && !title.trim()) {
|
||||
@ -317,19 +331,19 @@ export function DocumentUploadForm({
|
||||
setError("Некоторые файлы не удалось обработать. Они будут загружены в исходном виде.");
|
||||
toast("Некоторые файлы не удалось обработать");
|
||||
} else {
|
||||
toast.success(newItems.length > 1 ? "Файлы обработаны успешно" : "Документ обработан успешно");
|
||||
toast.success(
|
||||
newItems.length > 1
|
||||
? "Файлы обработаны успешно"
|
||||
: isRegulation
|
||||
? "Регламент обработан успешно"
|
||||
: "Документ обработан успешно",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setProcessingProgress(null);
|
||||
}
|
||||
}, [title, uploadItems.length]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
multiple: true,
|
||||
disabled: isProcessing || isSubmitting,
|
||||
});
|
||||
}, [isDedicatedRegulation, isRegulation, title, uploadItems.length]);
|
||||
|
||||
const removeUploadItem = (id: string) => {
|
||||
const next = uploadItems.filter((item) => item.id !== id);
|
||||
@ -359,13 +373,76 @@ export function DocumentUploadForm({
|
||||
setSelectedTags((prev) => (prev.includes(tag.id) ? prev : [...prev, tag.id]));
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
const normalizedName = newFolderName.trim();
|
||||
setFolderCreateError(null);
|
||||
|
||||
if (!normalizedName) {
|
||||
setFolderCreateError("Введите название папки");
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicateExists = folders.some((folder) => (
|
||||
folder.parent_id === selectedFolderId
|
||||
&& folder.name.trim().toLocaleLowerCase("ru") === normalizedName.toLocaleLowerCase("ru")
|
||||
));
|
||||
if (duplicateExists) {
|
||||
setFolderCreateError("В этой папке уже есть папка с таким названием");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingFolder(true);
|
||||
try {
|
||||
const created = await apiClient.createFolder({
|
||||
name: normalizedName,
|
||||
parent: selectedFolderId,
|
||||
});
|
||||
const parentId = created.parent_id
|
||||
?? (typeof created.parent === "number" ? created.parent : created.parent?.id)
|
||||
?? selectedFolderId
|
||||
?? null;
|
||||
const createdFolder: FolderOption = {
|
||||
id: created.id,
|
||||
name: created.name || normalizedName,
|
||||
parent_id: parentId,
|
||||
path: created.path || [selectedFolder?.path || selectedFolder?.name, normalizedName]
|
||||
.filter(Boolean)
|
||||
.join(" / "),
|
||||
};
|
||||
|
||||
setFolders((current) => (
|
||||
current.some((folder) => folder.id === createdFolder.id)
|
||||
? current
|
||||
: [...current, createdFolder]
|
||||
));
|
||||
setSelectedFolderId(createdFolder.id);
|
||||
setNewFolderName("");
|
||||
onFolderCreated?.();
|
||||
toast.success("Папка создана");
|
||||
} catch (error) {
|
||||
console.error("Ошибка создания папки:", error);
|
||||
setFolderCreateError(error instanceof Error ? error.message : "Не удалось создать папку");
|
||||
} finally {
|
||||
setIsCreatingFolder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (isDedicatedRegulation && !title.trim()) {
|
||||
setError("Укажите название регламента");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate recipients if sent_to_all is false
|
||||
if (!sentToAll && selectedDepartments.length === 0 && selectedRecipients.length === 0) {
|
||||
setError("Выберите хотя бы один отдел или сотрудника с доступом к документу");
|
||||
setError(
|
||||
`Выберите хотя бы один отдел или сотрудника с доступом к ${
|
||||
isRegulation ? "регламенту" : "документу"
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -385,7 +462,7 @@ export function DocumentUploadForm({
|
||||
await apiClient.createDocument({
|
||||
title: title.trim() || undefined,
|
||||
description,
|
||||
folder_id: selectedFolderId ?? undefined,
|
||||
folder_id: isDedicatedRegulation ? undefined : selectedFolderId ?? undefined,
|
||||
sent_to_all: sentToAll,
|
||||
is_regulation: isRegulation,
|
||||
department_ids: sentToAll ? undefined : selectedDepartments,
|
||||
@ -410,7 +487,7 @@ export function DocumentUploadForm({
|
||||
description,
|
||||
file: getFileForUpload(item),
|
||||
extracted_text: item.extractedText || undefined,
|
||||
folder_id: selectedFolderId ?? undefined,
|
||||
folder_id: isDedicatedRegulation ? undefined : selectedFolderId ?? undefined,
|
||||
sent_to_all: sentToAll,
|
||||
is_regulation: isRegulation,
|
||||
department_ids: sentToAll ? undefined : selectedDepartments,
|
||||
@ -427,20 +504,29 @@ export function DocumentUploadForm({
|
||||
}
|
||||
|
||||
toast.success(
|
||||
uploadItems.length === 0
|
||||
? "Документ успешно создан"
|
||||
: uploadItems.length > 1
|
||||
? `${uploadItems.length} документов успешно загружено`
|
||||
: "Документ успешно загружен"
|
||||
isRegulation
|
||||
? isDedicatedRegulation || uploadItems.length === 0
|
||||
? "Регламент успешно создан"
|
||||
: uploadItems.length > 1
|
||||
? `${uploadItems.length} регламентов успешно загружено`
|
||||
: "Регламент успешно загружен"
|
||||
: uploadItems.length === 0
|
||||
? "Документ успешно создан"
|
||||
: uploadItems.length > 1
|
||||
? `${uploadItems.length} документов успешно загружено`
|
||||
: "Документ успешно загружен",
|
||||
);
|
||||
|
||||
// Сброс формы
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setUploadItems([]);
|
||||
setSelectedFolderId(currentFolderId ?? null);
|
||||
setFilesOpen(false);
|
||||
setSelectedFolderId(isDedicatedRegulation ? null : currentFolderId ?? null);
|
||||
setNewFolderName("");
|
||||
setFolderCreateError(null);
|
||||
setSentToAll(true);
|
||||
setIsRegulation(defaultIsRegulation);
|
||||
setIsRegulation(isDedicatedRegulation);
|
||||
setAcknowledgementMode("all");
|
||||
setSelectedDepartments([]);
|
||||
setSelectedRecipients([]);
|
||||
@ -452,9 +538,13 @@ export function DocumentUploadForm({
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки документа:", err);
|
||||
setError(err instanceof Error ? err.message : "Не удалось загрузить документ");
|
||||
toast.error("Ошибка загрузки документа");
|
||||
console.error(`Ошибка создания ${isRegulation ? "регламента" : "документа"}:`, err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `Не удалось создать ${isRegulation ? "регламент" : "документ"}`,
|
||||
);
|
||||
toast.error(`Ошибка создания ${isRegulation ? "регламента" : "документа"}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@ -469,90 +559,60 @@ export function DocumentUploadForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
Файлы
|
||||
</label>
|
||||
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition ${
|
||||
isDragActive
|
||||
? "border-[var(--accent-primary)] bg-[color:color-mix(in_srgb,var(--accent-primary)_10%,var(--surface-secondary))]"
|
||||
: "border-[var(--border-strong)] bg-[var(--surface-secondary)] hover:border-[var(--accent-primary)] hover:bg-[color:color-mix(in_srgb,var(--accent-primary)_8%,var(--surface-secondary))]"
|
||||
} ${isProcessing || isSubmitting ? "cursor-not-allowed opacity-60" : ""}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload size={32} className="app-text-muted mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">
|
||||
{isDragActive
|
||||
? "Отпустите файлы здесь"
|
||||
: uploadItems.length > 0
|
||||
? "Добавьте ещё файлы или нажмите для выбора"
|
||||
: "Перетащите файлы или нажмите для выбора"}
|
||||
</p>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Можно выбрать несколько файлов любых форматов или создать документ без файла
|
||||
</p>
|
||||
{isDedicatedRegulation && (
|
||||
<div className="app-selected flex items-start gap-3 rounded-xl p-4">
|
||||
<span className="app-badge-accent flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<ScrollText size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Основные сведения</p>
|
||||
<p className="app-text-muted mt-1 text-xs leading-relaxed">
|
||||
Добавьте регламент, настройте доступ и укажите, кто должен подтвердить ознакомление.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadItems.length > 0 && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="app-text-muted text-xs">
|
||||
Выбрано файлов: {uploadItems.length}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{uploadItems.map((item) => (
|
||||
<span
|
||||
key={item.id}
|
||||
className={`inline-flex max-w-full items-center gap-2 rounded-full px-2.5 py-1.5 ${
|
||||
item.processingError ? "app-feedback-danger" : "app-badge"
|
||||
}`}
|
||||
title={item.processingError || item.file.name}
|
||||
>
|
||||
<FileText size={16} className="app-accent-text shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block max-w-[14rem] truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{item.file.name}
|
||||
</span>
|
||||
<span className="app-text-muted block truncate text-xs">
|
||||
{formatFileSize(item.file.size)}
|
||||
{item.extractedText ? " · текст извлечён" : ""}
|
||||
{item.processedFile
|
||||
? ` · сжато до ${formatFileSize(item.processedFile.size)}`
|
||||
: ""}
|
||||
{item.processingError ? " · ошибка обработки" : ""}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeUploadItem(item.id)}
|
||||
{/* File Upload */}
|
||||
<DocumentFilePanel
|
||||
title={isDedicatedRegulation ? "Файл регламента" : "Файлы"}
|
||||
count={uploadItems.length}
|
||||
open={filesOpen}
|
||||
onOpenChange={setFilesOpen}
|
||||
onFilesSelected={(files) => void onDrop(files)}
|
||||
multiple={!isDedicatedRegulation}
|
||||
disabled={isSubmitting}
|
||||
busy={isProcessing}
|
||||
addLabel={
|
||||
isDedicatedRegulation && uploadItems.length > 0 ? "Заменить файл регламента" : undefined
|
||||
}
|
||||
emptyText={isDedicatedRegulation ? "Файл регламента пока не выбран" : "Файлов пока нет"}
|
||||
>
|
||||
{uploadItems.length > 0 || (isProcessing && processingProgress) ? (
|
||||
<div className="space-y-3">
|
||||
{uploadItems.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--border-subtle)]">
|
||||
{uploadItems.map((item) => (
|
||||
<DocumentFileRow
|
||||
key={item.id}
|
||||
name={item.file.name}
|
||||
meta={[
|
||||
formatFileSize(item.file.size),
|
||||
item.extractedText ? "текст извлечён" : null,
|
||||
item.processedFile
|
||||
? `сжато до ${formatFileSize(item.processedFile.size)}`
|
||||
: null,
|
||||
].filter(Boolean).join(" · ")}
|
||||
error={item.processingError}
|
||||
pending
|
||||
onRemove={() => removeUploadItem(item.id)}
|
||||
disabled={isProcessing}
|
||||
className="app-action-ghost app-text-muted -mr-1 shrink-0 rounded-full p-1 hover:text-[var(--foreground)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`Убрать файл ${item.file.name}`}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{uploadItems.some((item) => item.processingError) && (
|
||||
<div className="space-y-1">
|
||||
{uploadItems.map((item) =>
|
||||
item.processingError ? (
|
||||
<p key={item.id} className="text-xs text-red-500">
|
||||
{item.file.name}: {item.processingError}
|
||||
</p>
|
||||
) : null
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Индикатор обработки */}
|
||||
{isProcessing && processingProgress && (
|
||||
{isProcessing && processingProgress ? (
|
||||
<div className="app-selected rounded-lg p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Loader2 size={16} className="app-accent-text animate-spin" />
|
||||
@ -561,7 +621,7 @@ export function DocumentUploadForm({
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[color:color-mix(in_srgb,var(--accent-primary)_18%,var(--surface-primary))]">
|
||||
<div
|
||||
<div
|
||||
className="h-full bg-[var(--accent-primary)] transition-all duration-300"
|
||||
style={{ width: `${processingProgress.progress}%` }}
|
||||
/>
|
||||
@ -570,29 +630,39 @@ export function DocumentUploadForm({
|
||||
{processingProgress.message} · {processingProgress.progress}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : undefined}
|
||||
</DocumentFilePanel>
|
||||
|
||||
{/* Title */}
|
||||
{uploadItems.length <= 1 ? (
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
Название
|
||||
{isDedicatedRegulation && <span className="ml-1 text-red-500">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={uploadItems.length === 0 ? "Название документа" : "По умолчанию — название файла"}
|
||||
placeholder={
|
||||
isDedicatedRegulation
|
||||
? "Например, Регламент работы с обращениями"
|
||||
: uploadItems.length === 0
|
||||
? "Название документа"
|
||||
: "По умолчанию — название файла"
|
||||
}
|
||||
required={isDedicatedRegulation}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{uploadItems.length === 0
|
||||
? 'Поле необязательное. Если оставить пустым, будет использовано название "Документ".'
|
||||
: "Поле необязательное. Если оставить пустым, будет использовано имя файла без расширения."}
|
||||
{isDedicatedRegulation
|
||||
? "Название будет отображаться в списке регламентов и результатах поиска."
|
||||
: uploadItems.length === 0
|
||||
? 'Поле необязательное. Если оставить пустым, будет использовано название "Документ".'
|
||||
: "Поле необязательное. Если оставить пустым, будет использовано имя файла без расширения."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -610,7 +680,11 @@ export function DocumentUploadForm({
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Краткое описание документа"
|
||||
placeholder={
|
||||
isDedicatedRegulation
|
||||
? "Кратко опишите назначение и область применения регламента"
|
||||
: "Краткое описание документа"
|
||||
}
|
||||
rows={3}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
@ -620,61 +694,104 @@ export function DocumentUploadForm({
|
||||
<div className="app-divider border-t pt-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-[var(--foreground)]">
|
||||
<TagIcon size={16} />
|
||||
Категоризация
|
||||
{isDedicatedRegulation ? "Оформление" : "Категоризация"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Folder */}
|
||||
<div>
|
||||
<label htmlFor="folder" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
|
||||
<FolderOpen size={14} className="mr-1 inline" />
|
||||
Папка
|
||||
</label>
|
||||
<select
|
||||
id="folder"
|
||||
value={selectedFolderId ?? ""}
|
||||
onChange={(e) => setSelectedFolderId(e.target.value ? Number(e.target.value) : null)}
|
||||
disabled={loadingFolders}
|
||||
className="app-select w-full rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Без папки</option>
|
||||
{loadingFolders ? (
|
||||
<option disabled>Загрузка...</option>
|
||||
) : folderOptionRows.length === 0 ? (
|
||||
<option disabled>Нет доступных папок</option>
|
||||
{!isDedicatedRegulation && (
|
||||
<div>
|
||||
<div id="document-upload-folder-label" className="mb-2 flex items-center gap-2">
|
||||
<FolderOpen size={15} className="app-text-muted" />
|
||||
<span className="app-text-muted text-xs font-medium">Папка</span>
|
||||
</div>
|
||||
<select
|
||||
id="folder"
|
||||
value={selectedFolderId ?? ""}
|
||||
onChange={(event) => {
|
||||
setSelectedFolderId(event.target.value ? Number(event.target.value) : null);
|
||||
setFolderCreateError(null);
|
||||
}}
|
||||
disabled={loadingFolders || isSubmitting || isCreatingFolder}
|
||||
className="app-select w-full rounded-xl px-3 py-2 text-sm"
|
||||
aria-labelledby="document-upload-folder-label"
|
||||
>
|
||||
<option value="">Без папки</option>
|
||||
{loadingFolders ? (
|
||||
<option disabled>Загрузка...</option>
|
||||
) : folderOptionRows.length === 0 ? (
|
||||
<option disabled>Нет доступных папок</option>
|
||||
) : (
|
||||
folderOptionRows.map(({ folder, level }) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{`${"— ".repeat(level)}${folder.name}`}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<input
|
||||
value={newFolderName}
|
||||
onChange={(event) => {
|
||||
setNewFolderName(event.target.value);
|
||||
setFolderCreateError(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
if (!isCreatingFolder && newFolderName.trim()) {
|
||||
void handleCreateFolder();
|
||||
}
|
||||
}}
|
||||
disabled={loadingFolders || isSubmitting || isCreatingFolder}
|
||||
maxLength={255}
|
||||
className="app-input min-w-0 rounded-xl px-3 py-2 text-sm"
|
||||
placeholder="Новая папка"
|
||||
aria-label="Название новой папки"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateFolder()}
|
||||
disabled={loadingFolders || isSubmitting || isCreatingFolder || !newFolderName.trim()}
|
||||
className="app-action-secondary inline-flex items-center justify-center gap-1.5 rounded-xl px-3 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isCreatingFolder ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={15} />
|
||||
)}
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{folderCreateError ? (
|
||||
<p className="mt-1 text-xs text-red-500">{folderCreateError}</p>
|
||||
) : (
|
||||
folderOptionRows.map(({ folder, level }) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{`${"— ".repeat(level)}${folder.name}`}
|
||||
</option>
|
||||
))
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{selectedFolder
|
||||
? `Новая папка будет создана внутри «${selectedFolder.name}».`
|
||||
: "Новая папка будет создана в корне."}
|
||||
</p>
|
||||
)}
|
||||
</select>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
Выбранная папка будет применена ко всем загружаемым документам.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<div id="document-upload-tags-label" className="mb-1.5 text-sm font-medium text-[var(--foreground)]">
|
||||
Теги
|
||||
<div id="document-upload-tags-label" className="mb-2 flex items-center gap-2">
|
||||
<TagIcon size={15} className="app-text-muted" />
|
||||
<span className="app-text-muted text-xs font-medium">Теги</span>
|
||||
</div>
|
||||
<DocumentTagQuickCreate
|
||||
existingTags={documentTags}
|
||||
disabled={loadingDocumentTags || isSubmitting}
|
||||
onCreated={handleCreatedTag}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="document-upload-tags-label"
|
||||
className="app-surface-muted flex min-h-12 flex-wrap items-center gap-2 rounded-lg p-2"
|
||||
className="flex min-h-7 flex-wrap items-center gap-2"
|
||||
>
|
||||
{loadingDocumentTags ? (
|
||||
<span className="app-text-muted px-1 text-sm">Загрузка тегов...</span>
|
||||
<span className="app-text-muted text-xs">Загрузка тегов...</span>
|
||||
) : documentTags.length === 0 ? (
|
||||
<span className="app-text-muted px-1 text-sm">Нет доступных тегов</span>
|
||||
<span className="app-text-muted text-xs">Тегов пока нет</span>
|
||||
) : (
|
||||
documentTags.map((tag) => (
|
||||
<button
|
||||
@ -687,52 +804,56 @@ export function DocumentUploadForm({
|
||||
: [...prev, tag.id]
|
||||
)
|
||||
}
|
||||
className={`inline-flex max-w-full items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
|
||||
className={`inline-flex max-w-full items-center rounded-full border px-3 py-1 text-xs font-medium transition ${
|
||||
selectedTags.includes(tag.id)
|
||||
? "app-badge app-badge-accent"
|
||||
: "app-badge hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]"
|
||||
? "border-transparent text-white"
|
||||
: "border-[var(--border-subtle)] text-[var(--muted-foreground)] hover:border-[var(--border-strong)]"
|
||||
}`}
|
||||
style={
|
||||
selectedTags.includes(tag.id)
|
||||
? { backgroundColor: tag.color || "#38bdf8" }
|
||||
: undefined
|
||||
}
|
||||
aria-pressed={selectedTags.includes(tag.id)}
|
||||
>
|
||||
{tag.color && (
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{tag.name}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{selectedTags.length > 0 ? (
|
||||
<>Выбрано: {selectedTags.length}</>
|
||||
) : (
|
||||
<>Нажмите на тег, чтобы добавить его к документу</>
|
||||
)}
|
||||
</p>
|
||||
<DocumentTagQuickCreate
|
||||
existingTags={documentTags}
|
||||
disabled={loadingDocumentTags || isSubmitting}
|
||||
onCreated={handleCreatedTag}
|
||||
layout="inline"
|
||||
className="mt-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Regulation flag */}
|
||||
<div className="app-surface-muted flex items-start gap-3 rounded-lg p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isRegulation"
|
||||
checked={isRegulation}
|
||||
onChange={(e) => setIsRegulation(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)]"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="isRegulation" className="block cursor-pointer text-sm font-medium text-[var(--foreground)]">
|
||||
<ScrollText size={14} className="mr-1 inline" />
|
||||
Регламент
|
||||
</label>
|
||||
<p className="app-text-muted mt-0.5 text-xs">
|
||||
Документ будет отображаться в отдельном разделе регламентов.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isDedicatedRegulation ? (
|
||||
<label
|
||||
htmlFor="documentIsRegulation"
|
||||
className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl border border-[var(--border-subtle)] p-3"
|
||||
>
|
||||
<input
|
||||
id="documentIsRegulation"
|
||||
type="checkbox"
|
||||
checked={isRegulation}
|
||||
onChange={(event) => setIsRegulation(event.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 rounded border-[var(--border-strong)] accent-[var(--accent-primary)] disabled:opacity-50"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-[var(--foreground)]">
|
||||
<ScrollText size={14} />
|
||||
Регламент
|
||||
</span>
|
||||
<span className="app-text-muted mt-1 block text-xs">
|
||||
Документ будет отображаться в отдельном разделе регламентов.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{/* Извлеченный текст */}
|
||||
{uploadItems.length === 1 && uploadItems[0].extractedText && (
|
||||
@ -747,7 +868,7 @@ export function DocumentUploadForm({
|
||||
id="extractedText"
|
||||
value={uploadItems[0].extractedText}
|
||||
onChange={(e) => updateSingleExtractedText(e.target.value)}
|
||||
placeholder="Текст, извлеченный из документа"
|
||||
placeholder={`Текст, извлеченный из ${isRegulation ? "регламента" : "документа"}`}
|
||||
rows={5}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm font-mono"
|
||||
/>
|
||||
@ -767,6 +888,7 @@ export function DocumentUploadForm({
|
||||
<div className="space-y-6">
|
||||
<DocumentAudienceSelector
|
||||
kind="access"
|
||||
resource={isRegulation ? "regulation" : "document"}
|
||||
mode={sentToAll ? "all" : "restricted"}
|
||||
onModeChange={(mode) => setSentToAll(mode === "all")}
|
||||
employees={employees}
|
||||
@ -782,6 +904,7 @@ export function DocumentUploadForm({
|
||||
<div className="app-divider border-t pt-5">
|
||||
<DocumentAudienceSelector
|
||||
kind="acknowledgement"
|
||||
resource={isRegulation ? "regulation" : "document"}
|
||||
mode={acknowledgementMode}
|
||||
onModeChange={setAcknowledgementMode}
|
||||
employees={acknowledgementEmployees}
|
||||
@ -805,16 +928,24 @@ export function DocumentUploadForm({
|
||||
className="app-action-primary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting
|
||||
? uploadItems.length === 0
|
||||
? isDedicatedRegulation || uploadItems.length === 0
|
||||
? "Создание..."
|
||||
: "Загрузка..."
|
||||
: isProcessing
|
||||
? "Обработка..."
|
||||
: uploadItems.length > 1
|
||||
? `Загрузить ${uploadItems.length} документов`
|
||||
: uploadItems.length === 1
|
||||
? isRegulation ? "Загрузить регламент" : "Загрузить документ"
|
||||
: isRegulation ? "Создать регламент" : "Создать документ"}
|
||||
: isDedicatedRegulation
|
||||
? "Создать регламент"
|
||||
: isRegulation
|
||||
? uploadItems.length > 1
|
||||
? `Загрузить ${uploadItems.length} регламентов`
|
||||
: uploadItems.length === 1
|
||||
? "Загрузить регламент"
|
||||
: "Создать регламент"
|
||||
: uploadItems.length > 1
|
||||
? `Загрузить ${uploadItems.length} документов`
|
||||
: uploadItems.length === 1
|
||||
? "Загрузить документ"
|
||||
: "Создать документ"}
|
||||
</button>
|
||||
|
||||
{onCancel && (
|
||||
@ -831,3 +962,9 @@ export function DocumentUploadForm({
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegulationCreateForm(
|
||||
props: Omit<DocumentUploadFormProps, "currentFolderId" | "mode">,
|
||||
) {
|
||||
return <DocumentUploadForm {...props} mode="regulation" />;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export { DocumentUploadForm } from "./DocumentUploadForm";
|
||||
export { DocumentUploadForm, RegulationCreateForm } from "./DocumentUploadForm";
|
||||
export { DocumentFilePanel, DocumentFileRow } from "./DocumentFilePanel";
|
||||
export { DocumentPreview } from "./DocumentPreview";
|
||||
export { DocumentAcknowledgement } from "./DocumentAcknowledgement";
|
||||
export { DocumentAcknowledgementsReport } from "./DocumentAcknowledgementsReport";
|
||||
|
||||
@ -40,6 +40,9 @@ interface NotificationsContextType {
|
||||
unreadCount: number;
|
||||
unreadCategoryCounts: Record<string, number>;
|
||||
unreadProcurementRequestCounts: Record<number, number>;
|
||||
unreadRegulationDepartmentCounts: Record<number, number>;
|
||||
unreadCompanyRegulationCount: number;
|
||||
unreadPersonalRegulationCount: number;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refreshUnreadSummary: () => Promise<void>;
|
||||
@ -61,6 +64,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [unreadCategoryCounts, setUnreadCategoryCounts] = useState<Record<string, number>>({});
|
||||
const [unreadProcurementRequestCounts, setUnreadProcurementRequestCounts] = useState<Record<number, number>>({});
|
||||
const [unreadRegulationDepartmentCounts, setUnreadRegulationDepartmentCounts] = useState<Record<number, number>>({});
|
||||
const [unreadCompanyRegulationCount, setUnreadCompanyRegulationCount] = useState(0);
|
||||
const [unreadPersonalRegulationCount, setUnreadPersonalRegulationCount] = useState(0);
|
||||
const [isWsConnected, setIsWsConnected] = useState(
|
||||
typeof window !== 'undefined' && wsManager.getState() === WebSocket.OPEN
|
||||
);
|
||||
@ -72,6 +78,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
total?: number;
|
||||
verbs?: Array<{ verb?: string; unread?: number }>;
|
||||
procurement_requests?: Array<{ request_id?: number; unread?: number }>;
|
||||
regulation_departments?: Array<{ department_id?: number; unread?: number }>;
|
||||
regulation_company_unread?: number;
|
||||
regulation_personal_unread?: number;
|
||||
}) => {
|
||||
const nextCategoryCounts: Record<string, number> = {};
|
||||
const nextProcurementRequestCounts: Record<number, number> = {};
|
||||
@ -79,6 +88,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
const procurementRequests = Array.isArray(summary.procurement_requests)
|
||||
? summary.procurement_requests
|
||||
: [];
|
||||
const regulationDepartments = Array.isArray(summary.regulation_departments)
|
||||
? summary.regulation_departments
|
||||
: [];
|
||||
|
||||
verbs.forEach((item) => {
|
||||
if (!item.verb || typeof item.unread !== 'number' || item.unread <= 0) {
|
||||
@ -96,8 +108,28 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
nextProcurementRequestCounts[requestId] = item.unread;
|
||||
});
|
||||
|
||||
const nextRegulationDepartmentCounts: Record<number, number> = {};
|
||||
regulationDepartments.forEach((item) => {
|
||||
const departmentId = Number(item.department_id);
|
||||
if (!Number.isFinite(departmentId) || departmentId <= 0 || typeof item.unread !== 'number' || item.unread <= 0) {
|
||||
return;
|
||||
}
|
||||
nextRegulationDepartmentCounts[departmentId] = item.unread;
|
||||
});
|
||||
|
||||
setUnreadCategoryCounts(nextCategoryCounts);
|
||||
setUnreadProcurementRequestCounts(nextProcurementRequestCounts);
|
||||
setUnreadRegulationDepartmentCounts(nextRegulationDepartmentCounts);
|
||||
setUnreadCompanyRegulationCount(
|
||||
typeof summary.regulation_company_unread === 'number'
|
||||
? summary.regulation_company_unread
|
||||
: 0,
|
||||
);
|
||||
setUnreadPersonalRegulationCount(
|
||||
typeof summary.regulation_personal_unread === 'number'
|
||||
? summary.regulation_personal_unread
|
||||
: 0,
|
||||
);
|
||||
setUnreadCount(typeof summary.total === 'number' ? summary.total : verbs.reduce((sum, item) => sum + (item.unread || 0), 0));
|
||||
}, []);
|
||||
|
||||
@ -171,6 +203,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
setUnreadCount(0);
|
||||
setUnreadCategoryCounts({});
|
||||
setUnreadProcurementRequestCounts({});
|
||||
setUnreadRegulationDepartmentCounts({});
|
||||
setUnreadCompanyRegulationCount(0);
|
||||
setUnreadPersonalRegulationCount(0);
|
||||
knownNotificationIdsRef.current.clear();
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
@ -346,6 +381,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
setUnreadCount(0);
|
||||
setUnreadCategoryCounts({});
|
||||
setUnreadProcurementRequestCounts({});
|
||||
setUnreadRegulationDepartmentCounts({});
|
||||
setUnreadCompanyRegulationCount(0);
|
||||
setUnreadPersonalRegulationCount(0);
|
||||
void syncUnreadSummary();
|
||||
}, [syncUnreadSummary]);
|
||||
|
||||
@ -369,6 +407,11 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
if (category === 'Закупки') {
|
||||
setUnreadProcurementRequestCounts({});
|
||||
}
|
||||
if (category === 'Регламенты') {
|
||||
setUnreadRegulationDepartmentCounts({});
|
||||
setUnreadCompanyRegulationCount(0);
|
||||
setUnreadPersonalRegulationCount(0);
|
||||
}
|
||||
void syncUnreadSummary();
|
||||
}, [syncUnreadSummary]);
|
||||
|
||||
@ -389,6 +432,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
|
||||
unreadCount,
|
||||
unreadCategoryCounts,
|
||||
unreadProcurementRequestCounts,
|
||||
unreadRegulationDepartmentCounts,
|
||||
unreadCompanyRegulationCount,
|
||||
unreadPersonalRegulationCount,
|
||||
loading,
|
||||
error,
|
||||
refreshUnreadSummary: syncUnreadSummary,
|
||||
|
||||
@ -11,6 +11,12 @@ export type UnreadNotificationsSummary = {
|
||||
request_id: number;
|
||||
unread: number;
|
||||
}>;
|
||||
regulation_departments?: Array<{
|
||||
department_id: number;
|
||||
unread: number;
|
||||
}>;
|
||||
regulation_company_unread?: number;
|
||||
regulation_personal_unread?: number;
|
||||
};
|
||||
|
||||
export function createNotificationsApi(request: RequestFn) {
|
||||
|
||||
@ -12,6 +12,7 @@ const notificationSamples = [
|
||||
["procurement", "procurement_new_request"],
|
||||
["tasks", "task_assigned"],
|
||||
["documents", "document_ready"],
|
||||
["regulations", "regulation_ready"],
|
||||
];
|
||||
|
||||
test("navigation badges use the same categories as notification verbs", () => {
|
||||
|
||||
@ -6,4 +6,5 @@ export const NAV_NOTIFICATION_CATEGORIES = {
|
||||
procurement: "Закупки",
|
||||
tasks: "Доска",
|
||||
documents: "Документы",
|
||||
regulations: "Регламенты",
|
||||
} as const;
|
||||
|
||||
@ -18,7 +18,10 @@ function parseNumericId(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildUrl(pathname: string, params: Record<string, number | null>): string {
|
||||
function buildUrl(
|
||||
pathname: string,
|
||||
params: Record<string, string | number | null>,
|
||||
): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
@ -63,6 +66,8 @@ export function resolveNotificationActionUrl(
|
||||
notification.category || "",
|
||||
objectType,
|
||||
].join(" ").toLowerCase();
|
||||
const isRegulation = data?.is_regulation === true
|
||||
|| (notification.verb || "").startsWith("regulation_");
|
||||
|
||||
if (
|
||||
visitId !== null && (
|
||||
@ -92,7 +97,10 @@ export function resolveNotificationActionUrl(
|
||||
|| objectType === "document"
|
||||
)
|
||||
) {
|
||||
return buildUrl("/documents", { document: documentId });
|
||||
return buildUrl("/documents", {
|
||||
section: isRegulation ? "regulations" : "folders",
|
||||
document: documentId,
|
||||
});
|
||||
}
|
||||
|
||||
if (eventId !== null && categoryHint.includes("event")) {
|
||||
@ -125,7 +133,10 @@ export function resolveNotificationActionUrl(
|
||||
return buildUrl("/", { post: objectId });
|
||||
}
|
||||
if (objectType === "document") {
|
||||
return buildUrl("/documents", { document: objectId });
|
||||
return buildUrl("/documents", {
|
||||
section: isRegulation ? "regulations" : "folders",
|
||||
document: objectId,
|
||||
});
|
||||
}
|
||||
if (objectType === "request") {
|
||||
return buildUrl("/requests", { request: objectId });
|
||||
@ -162,7 +173,13 @@ export function resolveNotificationActionUrl(
|
||||
const rawLinkedDocumentId = parseNumericId(
|
||||
url.searchParams.get("document") ?? url.searchParams.get("id"),
|
||||
);
|
||||
return buildUrl("/documents", { document: rawLinkedDocumentId });
|
||||
const rawSection = url.searchParams.get("section");
|
||||
return buildUrl("/documents", {
|
||||
section: rawSection === "regulations" || isRegulation
|
||||
? "regulations"
|
||||
: "folders",
|
||||
document: rawLinkedDocumentId,
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/calendar") {
|
||||
@ -199,7 +216,10 @@ export function resolveNotificationActionUrl(
|
||||
|
||||
const documentMatch = rawActionUrl.match(/^\/documents\/(\d+)\/?(?:[#?].*)?$/);
|
||||
if (documentMatch) {
|
||||
return buildUrl("/documents", { document: Number(documentMatch[1]) });
|
||||
return buildUrl("/documents", {
|
||||
section: isRegulation ? "regulations" : "folders",
|
||||
document: Number(documentMatch[1]),
|
||||
});
|
||||
}
|
||||
|
||||
const calendarMatch = rawActionUrl.match(/^\/calendar\/events\/(\d+)\/?(?:[#?].*)?$/);
|
||||
|
||||
47
frontend/src/lib/notifications/regulationScope.test.js
Normal file
47
frontend/src/lib/notifications/regulationScope.test.js
Normal file
@ -0,0 +1,47 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveNotificationActionUrl } from "./actionUrl.ts";
|
||||
import { getRegulationNotificationScopeLabel } from "./regulationScope.ts";
|
||||
|
||||
test("regulation notifications open the regulations section", () => {
|
||||
assert.equal(
|
||||
resolveNotificationActionUrl({
|
||||
verb: "regulation_ready",
|
||||
data: { document_id: 42, is_regulation: true },
|
||||
}),
|
||||
"/documents?section=regulations&document=42",
|
||||
);
|
||||
});
|
||||
|
||||
test("ordinary document notifications stay in the documents section", () => {
|
||||
assert.equal(
|
||||
resolveNotificationActionUrl({
|
||||
verb: "document_ready",
|
||||
data: { document_id: 42 },
|
||||
}),
|
||||
"/documents?section=folders&document=42",
|
||||
);
|
||||
});
|
||||
|
||||
test("regulation notification shows its acknowledgement departments", () => {
|
||||
assert.equal(
|
||||
getRegulationNotificationScopeLabel({
|
||||
verb: "regulation_ready",
|
||||
data: {
|
||||
regulation_department_names: ["Бухгалтерия", "Разработка"],
|
||||
},
|
||||
}),
|
||||
"Отделы: Бухгалтерия, Разработка",
|
||||
);
|
||||
});
|
||||
|
||||
test("company regulation notification shows company scope", () => {
|
||||
assert.equal(
|
||||
getRegulationNotificationScopeLabel({
|
||||
verb: "regulation_ready",
|
||||
data: { regulation_scope: "company" },
|
||||
}),
|
||||
"Вся компания",
|
||||
);
|
||||
});
|
||||
34
frontend/src/lib/notifications/regulationScope.ts
Normal file
34
frontend/src/lib/notifications/regulationScope.ts
Normal file
@ -0,0 +1,34 @@
|
||||
type RegulationScopeNotification = {
|
||||
verb?: string | null;
|
||||
data?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function getRegulationNotificationScopeLabel(
|
||||
notification: RegulationScopeNotification,
|
||||
): string | null {
|
||||
const data = notification.data;
|
||||
const isRegulation = data?.is_regulation === true
|
||||
|| (notification.verb || "").startsWith("regulation_");
|
||||
|
||||
if (!isRegulation) return null;
|
||||
|
||||
const departmentNames = Array.isArray(data?.regulation_department_names)
|
||||
? Array.from(new Set(
|
||||
data.regulation_department_names
|
||||
.filter((name): name is string => typeof name === "string")
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean),
|
||||
))
|
||||
: [];
|
||||
|
||||
if (departmentNames.length === 1) {
|
||||
return `Отдел: ${departmentNames[0]}`;
|
||||
}
|
||||
if (departmentNames.length > 1) {
|
||||
return `Отделы: ${departmentNames.join(", ")}`;
|
||||
}
|
||||
|
||||
if (data?.regulation_scope === "company") return "Вся компания";
|
||||
if (data?.regulation_scope === "personal") return "Личное ознакомление";
|
||||
return null;
|
||||
}
|
||||
@ -52,6 +52,13 @@ export const VERB_CATEGORIES: Record<string, string> = {
|
||||
document_comment: 'Документы',
|
||||
document_comment_reply: 'Документы',
|
||||
document_related: 'Документы',
|
||||
|
||||
// Регламенты
|
||||
regulation_ready: 'Регламенты',
|
||||
regulation_signed_all: 'Регламенты',
|
||||
regulation_reminder: 'Регламенты',
|
||||
regulation_comment: 'Регламенты',
|
||||
regulation_comment_reply: 'Регламенты',
|
||||
|
||||
// Календарь
|
||||
calendar_invitation: 'Календарь',
|
||||
@ -170,6 +177,13 @@ export const VERB_NAMES: Record<string, string> = {
|
||||
document_comment: 'Комментарий к документу',
|
||||
document_comment_reply: 'Ответ на комментарий к документу',
|
||||
document_related: 'Связанный документ',
|
||||
|
||||
// Регламенты
|
||||
regulation_ready: 'Регламент на ознакомление',
|
||||
regulation_signed_all: 'Все ознакомились с регламентом',
|
||||
regulation_reminder: 'Напоминание об ознакомлении с регламентом',
|
||||
regulation_comment: 'Комментарий к регламенту',
|
||||
regulation_comment_reply: 'Ответ на комментарий к регламенту',
|
||||
|
||||
// Календарь
|
||||
calendar_invitation: 'Приглашение на событие',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user