Merge branch 'develop'

This commit is contained in:
lizerk 2026-07-22 22:34:51 +03:00
commit 4be732f6b9
33 changed files with 2209 additions and 563 deletions

View File

@ -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}'
# ===== Константы =====

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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" />;
}

View File

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

View File

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

View File

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

View File

@ -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", () => {

View File

@ -6,4 +6,5 @@ export const NAV_NOTIFICATION_CATEGORIES = {
procurement: "Закупки",
tasks: "Доска",
documents: "Документы",
regulations: "Регламенты",
} as const;

View File

@ -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+)\/?(?:[#?].*)?$/);

View 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" },
}),
"Вся компания",
);
});

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

View File

@ -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: 'Приглашение на событие',