From b9829a613e5fa342a11afb75cefc31ccd221faee Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 22 Jul 2026 22:34:26 +0300 Subject: [PATCH] feat(documents): refine regulations workflow and notifications --- backend/documents/notifications/config.py | 58 +- backend/documents/notifications/handlers.py | 155 ++-- backend/notifications/api/serializers.py | 10 + backend/notifications/cache.py | 49 ++ .../0012_split_regulation_notifications.py | 211 +++++ .../api/v1/documents/test_documents_api.py | 54 ++ .../notifications/test_notifications_api.py | 31 +- frontend/design-qa.md | 51 ++ frontend/next-env.d.ts | 2 +- frontend/next.config.ts | 20 + frontend/src/app/departments/[id]/page.tsx | 165 +++- frontend/src/app/documents/page.tsx | 737 +++++++++++++----- frontend/src/app/globals.css | 26 + frontend/src/app/notifications/page.tsx | 15 +- frontend/src/app/page.tsx | 39 +- frontend/src/components/AppShell.tsx | 2 +- .../src/components/NotificationCenter.tsx | 13 + .../documents/DocumentAcknowledgement.tsx | 53 +- .../documents/DocumentAudienceSelector.tsx | 8 +- .../documents/DocumentDetailModal.tsx | 51 +- .../documents/DocumentFilePanel.tsx | 148 ++++ .../documents/DocumentMetadataEditor.tsx | 86 +- .../documents/DocumentTagQuickCreate.tsx | 43 +- .../documents/DocumentUploadForm.tsx | 563 ++++++++----- frontend/src/components/documents/index.ts | 3 +- .../src/contexts/NotificationsContext.tsx | 46 ++ frontend/src/lib/api/notifications.ts | 6 + .../src/lib/navigation-notifications.test.js | 1 + frontend/src/lib/navigation-notifications.ts | 1 + frontend/src/lib/notifications/actionUrl.ts | 30 +- .../lib/notifications/regulationScope.test.js | 47 ++ .../src/lib/notifications/regulationScope.ts | 34 + frontend/src/lib/verbTranslations.ts | 14 + 33 files changed, 2209 insertions(+), 563 deletions(-) create mode 100644 backend/notifications/migrations/0012_split_regulation_notifications.py create mode 100644 frontend/design-qa.md create mode 100644 frontend/src/components/documents/DocumentFilePanel.tsx create mode 100644 frontend/src/lib/notifications/regulationScope.test.js create mode 100644 frontend/src/lib/notifications/regulationScope.ts diff --git a/backend/documents/notifications/config.py b/backend/documents/notifications/config.py index 15e8de71..d58da2af 100644 --- a/backend/documents/notifications/config.py +++ b/backend/documents/notifications/config.py @@ -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}' # ===== Константы ===== diff --git a/backend/documents/notifications/handlers.py b/backend/documents/notifications/handlers.py index dbce0346..181cd3fb 100644 --- a/backend/documents/notifications/handlers.py +++ b/backend/documents/notifications/handlers.py @@ -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), }, ) diff --git a/backend/notifications/api/serializers.py b/backend/notifications/api/serializers.py index c14a8e81..d0a71aa0 100644 --- a/backend/notifications/api/serializers.py +++ b/backend/notifications/api/serializers.py @@ -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): diff --git a/backend/notifications/cache.py b/backend/notifications/cache.py index e77d4b85..620c788e 100644 --- a/backend/notifications/cache.py +++ b/backend/notifications/cache.py @@ -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"], } diff --git a/backend/notifications/migrations/0012_split_regulation_notifications.py b/backend/notifications/migrations/0012_split_regulation_notifications.py new file mode 100644 index 00000000..1ced4644 --- /dev/null +++ b/backend/notifications/migrations/0012_split_regulation_notifications.py @@ -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, + ), + ] diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py index e5729e23..14d87c9f 100644 --- a/backend/tests/api/v1/documents/test_documents_api.py +++ b/backend/tests/api/v1/documents/test_documents_api.py @@ -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 ): diff --git a/backend/tests/api/v1/notifications/test_notifications_api.py b/backend/tests/api/v1/notifications/test_notifications_api.py index f8926a91..59f711d0 100644 --- a/backend/tests/api/v1/notifications/test_notifications_api.py +++ b/backend/tests/api/v1/notifications/test_notifications_api.py @@ -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 diff --git a/frontend/design-qa.md b/frontend/design-qa.md new file mode 100644 index 00000000..dd853b85 --- /dev/null +++ b/frontend/design-qa.md @@ -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 diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/frontend/next.config.ts b/frontend/next.config.ts index d6048774..03de4fea 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -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; diff --git a/frontend/src/app/departments/[id]/page.tsx b/frontend/src/app/departments/[id]/page.tsx index da4a8fe0..f3a35744 100644 --- a/frontend/src/app/departments/[id]/page.tsx +++ b/frontend/src/app/departments/[id]/page.tsx @@ -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 ( + + Ознакомление не требуется + + ); + } + + if (document.is_acknowledged) { + return ( + + + Ознакомлен + + ); + } + + return ( + + + Требует ознакомления + + ); +} + 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([]); + const [selectedRegulation, setSelectedRegulation] = useState(null); const [roleMenuOpenForId, setRoleMenuOpenForId] = useState(null); const roleMenuRef = useRef(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((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() { + {departmentRegulations.length > 0 ? ( +
+
+
+
+

+ Регламенты отдела +

+ {departmentRegulations.length} +
+

+ Документы, требующие ознакомления сотрудников отдела. +

+
+ +
+ + {regulationsExpanded ? ( +
+ {departmentRegulations.map((document) => ( + + ))} +
+ ) : null} +
+ ) : null} + + + setSelectedRegulation(null)} + onUpdate={() => void refreshSelectedRegulation()} + onNavigateToRelated={(documentId) => { + void apiClient.getDocument(documentId) + .then(setSelectedRegulation) + .catch((error) => { + console.error("Не удалось открыть связанный документ:", error); + toast.error("Не удалось открыть связанный документ"); + }); + }} + /> ); } diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index 2ad16fc2..e839ee5d 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -5,7 +5,7 @@ import { apiClient } from "@/lib/api"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import dynamic from "next/dynamic"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import type { Document } from "@/types/api"; +import type { Department, Document } from "@/types/api"; import { toast } from "sonner"; import { Search, @@ -25,6 +25,7 @@ import { Download, ChevronDown, ChevronRight, + MoreHorizontal, Pencil, Trash2, ScrollText, @@ -41,6 +42,12 @@ import { TagManagementModal } from "@/components/documents/tags"; import TaskLinkPill from "@/components/tasks/TaskLinkPill"; import { Modal } from "@/components/ui"; import { canPreviewDocument, getDocumentFileExtension } from "@/lib/document-preview"; +import { useNotifications } from "@/contexts/NotificationsContext"; +import { NAV_NOTIFICATION_CATEGORIES } from "@/lib/navigation-notifications"; +import { + regulationMatchesAcknowledgementSource, + type RegulationSourceFilter, +} from "@/lib/feed-regulation-filters"; // Динамический импорт компонентов с PDF обработкой (избегаем SSR ошибок с DOMMatrix) const DocumentUploadForm = dynamic( @@ -48,6 +55,11 @@ const DocumentUploadForm = dynamic( { ssr: false } ); +const RegulationCreateForm = dynamic( + () => import("@/components/documents/DocumentUploadForm").then(mod => ({ default: mod.RegulationCreateForm })), + { ssr: false } +); + const EnhancedPDFViewer = dynamic( () => import("@/components/documents/viewer").then(mod => ({ default: mod.EnhancedPDFViewer })), { ssr: false } @@ -140,11 +152,19 @@ function DocumentsPageContent() { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const { + unreadCategoryCounts, + unreadRegulationDepartmentCounts, + unreadCompanyRegulationCount, + unreadPersonalRegulationCount, + } = useNotifications(); const requestedSection = documentSectionFromParam(searchParams.get("section")); // State const [documents, setDocuments] = useState([]); const [folders, setFolders] = useState([]); + const [departments, setDepartments] = useState([]); const [selectedFolderId, setSelectedFolderId] = useState(null); + const [regulationSource, setRegulationSource] = useState("all"); const [activeSection, setActiveSection] = useState(requestedSection); const [search, setSearch] = useState(""); const [selectedTags, setSelectedTags] = useState([]); @@ -174,9 +194,11 @@ function DocumentsPageContent() { // Folder dropdown const [showFolderDropdown, setShowFolderDropdown] = useState(false); + const [showDepartmentDropdown, setShowDepartmentDropdown] = useState(false); const [folderMenuOpenId, setFolderMenuOpenId] = useState(null); const [documentMenuOpenId, setDocumentMenuOpenId] = useState(null); const folderMenuRef = useRef(null); + const departmentMenuRef = useRef(null); const documentMenuRef = useRef(null); const [createFolderParentId, setCreateFolderParentId] = useState(null); const [editingFolder, setEditingFolder] = useState(null); @@ -267,6 +289,19 @@ function DocumentsPageContent() { } }; + const loadDepartments = async () => { + try { + const loadedDepartments = await loadAllPaginated((page) => + apiClient.getDepartments({ page, page_size: DOCUMENTS_PAGE_SIZE }) + ); + setDepartments( + loadedDepartments.sort((left, right) => left.name.localeCompare(right.name, "ru")) + ); + } catch (err) { + console.error("Ошибка загрузки отделов:", err); + } + }; + useEffect(() => { loadDocuments(); }, [loadDocuments]); @@ -274,6 +309,7 @@ function DocumentsPageContent() { useEffect(() => { loadFolders(); loadTags(); + loadDepartments(); }, []); useEffect(() => { @@ -289,6 +325,19 @@ function DocumentsPageContent() { return () => document.removeEventListener("mousedown", handlePointerDown); }, [folderMenuOpenId]); + useEffect(() => { + if (!showDepartmentDropdown) return; + + const handlePointerDown = (event: MouseEvent) => { + if (departmentMenuRef.current && !departmentMenuRef.current.contains(event.target as Node)) { + setShowDepartmentDropdown(false); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, [showDepartmentDropdown]); + useEffect(() => { if (documentMenuOpenId === null) return; @@ -298,8 +347,18 @@ function DocumentsPageContent() { } }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setDocumentMenuOpenId(null); + } + }; + document.addEventListener("mousedown", handlePointerDown); - return () => document.removeEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; }, [documentMenuOpenId]); useEffect(() => { @@ -327,6 +386,13 @@ function DocumentsPageContent() { // Filter documents const filtered = documents.filter((doc) => { + if ( + activeSection === "regulations" + && !regulationMatchesAcknowledgementSource(doc, regulationSource) + ) { + return false; + } + // Text search if (q) { const title = doc.title.toLowerCase(); @@ -380,7 +446,7 @@ function DocumentsPageContent() { }); return filtered; - }, [documents, search, selectedTags, dateFrom, dateTo, sortBy, sortOrder]); + }, [activeSection, documents, search, selectedTags, dateFrom, dateTo, regulationSource, sortBy, sortOrder]); const selection = useDocumentSelection(filteredDocuments); @@ -391,6 +457,47 @@ function DocumentsPageContent() { return folders.find((folder) => folder.id === selectedFolderId) || null; }, [activeSection, selectedFolderId, folders]); + const selectedRegulationDepartment = useMemo(() => { + if (!regulationSource.startsWith("department:")) return null; + const departmentId = Number(regulationSource.split(":")[1]); + return departments.find((department) => department.id === departmentId) || null; + }, [departments, regulationSource]); + + const regulationDepartments = useMemo(() => ( + departments + .map((department) => ({ + department, + unreadCount: unreadRegulationDepartmentCounts[department.id] || 0, + regulationCount: documents.filter((document) => ( + regulationMatchesAcknowledgementSource( + document, + `department:${department.id}` as RegulationSourceFilter, + ) + )).length, + })) + .filter(({ regulationCount }) => regulationCount > 0) + ), [departments, documents, unreadRegulationDepartmentCounts]); + + const unreadRegulationCount = ( + unreadCategoryCounts[NAV_NOTIFICATION_CATEGORIES.regulations] || 0 + ); + + const companyRegulationCount = useMemo(() => ( + documents.filter((document) => ( + regulationMatchesAcknowledgementSource(document, "company") + )).length + ), [documents]); + + const visibleRegulationDepartments = activeSection === "regulations" && regulationSource === "all" + ? regulationDepartments + : []; + + const regulationSourceLabel = regulationSource === "all" + ? "Все отделы" + : regulationSource === "company" + ? "Вся компания" + : selectedRegulationDepartment?.name || "Отдел"; + const breadcrumbs = useMemo(() => { if (!selectedFolder) return []; @@ -706,12 +813,15 @@ function DocumentsPageContent() { {/* Top Bar */}
-

Документы

+

+ {activeSection === "regulations" ? "Регламенты" : "Документы"} +

@@ -724,7 +834,9 @@ function DocumentsPageContent() { setSearch(e.target.value)} - placeholder="Поиск по документам" + placeholder={ + activeSection === "regulations" ? "Поиск по регламентам" : "Поиск по документам" + } className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" />
@@ -747,105 +859,238 @@ function DocumentsPageContent() { -
-
-
- - {activeSection === "folders" && selectedFolderId ? ( - ) : null} - -
- {showFolderDropdown && ( -
-
+ {activeSection === "folders" && selectedFolderId ? ( -
-
- { - console.log("🗂️ Выбрана папка:", id); + type="button" + onClick={(e) => { + e.stopPropagation(); + setSelectedFolderId(null); selectDocumentSection("folders"); - setSelectedFolderId(id); - setShowFolderDropdown(false); }} + className="flex h-7 w-6 shrink-0 items-center justify-center transition hover:bg-sky-500" + title="Сбросить фильтр" + aria-label="Сбросить фильтр папки" + > + + + ) : null} +
+
- )} + {showFolderDropdown && ( +
+
+ +
+
+ { + console.log("🗂️ Выбрана папка:", id); + selectDocumentSection("folders"); + setSelectedFolderId(id); + setShowFolderDropdown(false); + }} + /> +
+
+ )} +
- - + )} + + {activeSection === "regulations" && ( +
+
+
+ + {regulationSource !== "all" ? ( + + ) : null} + +
+ + {showDepartmentDropdown ? ( +
+

+ Требование ознакомления +

+ {([ + { + key: "all" as const, + label: "Все отделы", + unread: unreadRegulationCount, + }, + ...(companyRegulationCount > 0 + ? [{ + key: "company" as const, + label: "Вся компания", + unread: unreadCompanyRegulationCount, + }] + : []), + ] as Array<{ + key: "all" | "company"; + label: string; + unread?: number; + }>).map((option) => ( + + ))} + +
+
+ {regulationDepartments.length > 0 ? regulationDepartments.map(({ department, unreadCount }) => { + const source = `department:${department.id}` as RegulationSourceFilter; + const selected = regulationSource === source; + + return ( + + ); + }) : ( +

+ Отделов с регламентами нет +

+ )} +
+
+ ) : null} +
+
+ )} {/* Active Filters Tags */} {(selectedTags.length > 0 || dateFrom || dateTo) && ( @@ -1096,7 +1341,9 @@ function DocumentsPageContent() { {/* Documents List */}
- {visibleFolders.length === 0 && filteredDocuments.length === 0 ? ( + {visibleFolders.length === 0 + && visibleRegulationDepartments.length === 0 + && filteredDocuments.length === 0 ? (
{activeSection === "regulations" ? ( @@ -1112,7 +1359,23 @@ function DocumentsPageContent() {

{activeSection === "regulations" - ? `${filteredDocuments.length} регл.` + ? ( + <> + {visibleRegulationDepartments.length > 0 + ? `${visibleRegulationDepartments.length} отд.` + : ""} + {visibleRegulationDepartments.length > 0 && filteredDocuments.length > 0 + ? " · " + : ""} + {filteredDocuments.length > 0 ? `${filteredDocuments.length} регл.` : ""} + {unreadRegulationCount > 0 + ? ` · ${unreadRegulationCount} нов.` + : ""} + {unreadPersonalRegulationCount > 0 + ? ` (${unreadPersonalRegulationCount} лично)` + : ""} + + ) : ( <> {visibleFolders.length > 0 ? `${visibleFolders.length} пап.` : ""} @@ -1239,6 +1502,52 @@ function DocumentsPageContent() { ); })} + {/* Regulation Department Cards */} + {visibleRegulationDepartments.map(({ department, regulationCount, unreadCount }) => { + const source = `department:${department.id}` as RegulationSourceFilter; + + return ( +

+ +
+ ); + })} + {/* Document Cards */} {filteredDocuments.map((doc) => { const authorName = doc.created_by @@ -1271,7 +1580,7 @@ function DocumentsPageContent() { return (
-
-
-
- {doc.is_regulation && ( - +
+
+
+ + {doc.is_regulation ? ( - Регламент - - )} - {doc.folder_path && ( - - - {doc.folder_path} - - )} + ) : ( + + )} + {doc.is_regulation ? "Регламент" : "Документ"} + {doc.tags?.slice(0, 3).map((tag) => ( - - {tag.name} + + {tag.name} ))} {doc.tags && doc.tags.length > 3 && ( @@ -1330,69 +1637,77 @@ function DocumentsPageContent() { )}
+ {doc.folder_path && ( +
+ + + + {doc.folder_path} + + +
+ )} + -
+
{authorName && ( - - - {authorName} + + + {authorName} )} {createdDate && ( - - + + {createdDate} )} {fileSize && ( - - + + {fileSize} )}
-
-
- {acknowledgementRequiredForUser ? ( - doc.is_acknowledged ? ( - +
+ {acknowledgementRequiredForUser ? ( +
+ {doc.is_acknowledged ? ( + + Ознакомлен ) : ( - + Требует ознакомления - ) - ) : ( - - {doc.is_regulation ? "Регламент" : "Документ"} - - )} -
+ )} +
+ ) : null}
{isDocumentMenuOpen ? ( -
+ - + {showAction && ( + + )}
) : (
diff --git a/frontend/src/components/documents/DocumentAudienceSelector.tsx b/frontend/src/components/documents/DocumentAudienceSelector.tsx index 275d48e8..7dd1625c 100644 --- a/frontend/src/components/documents/DocumentAudienceSelector.tsx +++ b/frontend/src/components/documents/DocumentAudienceSelector.tsx @@ -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({

- {isAcknowledgement ? "Требование ознакомления" : "Доступ к документу"} + {isAcknowledgement ? "Требование ознакомления" : `Доступ к ${resourceDative}`}

{isAcknowledgement ? "Выберите сотрудников, которые должны подтвердить ознакомление." - : "Выберите сотрудников, которые смогут открыть документ."} + : `Выберите сотрудников, которые смогут открыть ${resourceAccusative}.`}

diff --git a/frontend/src/components/documents/DocumentDetailModal.tsx b/frontend/src/components/documents/DocumentDetailModal.tsx index b1322375..d8c8f05c 100644 --- a/frontend/src/components/documents/DocumentDetailModal.tsx +++ b/frontend/src/components/documents/DocumentDetailModal.tsx @@ -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 = + 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]" > -
+
{/* Header with Title and Actions */}
@@ -257,20 +264,20 @@ export function DocumentDetailModal({
{/* Preview Panel (Left) */} {documentHasFile && ( -
+
{renderPreview()}
)} {/* Info Panel (Right) */} {!(documentHasFile && isFullscreen) && ( -
+
{/* Desktop Tabs */}
)} - onUpdate?.()} /> + onUpdate?.()} + showAction={false} + /> ) : (
@@ -525,7 +536,7 @@ export function DocumentDetailModal({
)} - {currentActiveTab === "comments" && ( + {desktopActiveTab === "comments" && (
)} - {currentActiveTab === "related" && ( + {desktopActiveTab === "related" && (
@@ -782,7 +793,11 @@ export function DocumentDetailModal({
)} - onUpdate?.()} /> + onUpdate?.()} + showAction={false} + /> ) : (
@@ -824,6 +839,16 @@ export function DocumentDetailModal({ )}
+ + {showPersistentAcknowledgeAction && ( +
+ onUpdate?.()} + className="pointer-events-auto w-full shadow-[0_12px_32px_rgba(0,0,0,0.35)] sm:w-auto sm:min-w-[220px]" + /> +
+ )}
); diff --git a/frontend/src/components/documents/DocumentFilePanel.tsx b/frontend/src/components/documents/DocumentFilePanel.tsx new file mode 100644 index 00000000..498810ab --- /dev/null +++ b/frontend/src/components/documents/DocumentFilePanel.tsx @@ -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(null); + + return ( +
+
+ + + { + const files = Array.from(event.currentTarget.files || []); + event.currentTarget.value = ""; + if (files.length === 0) return; + onOpenChange(true); + onFilesSelected(files); + }} + /> + + +
+ + {open ? ( +
+ {children ?? ( +
+

{emptyText}

+
+ )} +
+ ) : null} +
+ ); +} + +export function DocumentFileRow({ + name, + meta, + pending = false, + error, + onRemove, + disabled = false, +}: DocumentFileRowProps) { + return ( +
+ + + + + {name} + {meta || error ? ( + + {error || meta} + + ) : null} + + {pending ? ( + новое + ) : null} + {onRemove ? ( + + ) : null} +
+ ); +} diff --git a/frontend/src/components/documents/DocumentMetadataEditor.tsx b/frontend/src/components/documents/DocumentMetadataEditor.tsx index fa232dfe..08ed6a5b 100644 --- a/frontend/src/components/documents/DocumentMetadataEditor.tsx +++ b/frontend/src/components/documents/DocumentMetadataEditor.tsx @@ -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[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(null); + const [filesOpen, setFilesOpen] = useState(false); const [selectedTags, setSelectedTags] = useState([]); const [selectedFolder, setSelectedFolder] = useState(document.folder?.id || null); const [isRegulation, setIsRegulation] = useState(Boolean(document.is_regulation)); @@ -141,6 +141,7 @@ export function DocumentMetadataEditor({ const [error, setError] = useState(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({

ID: {document.id}

-

Текущий файл: {document.file_name || 'не прикреплён'}

- {document.file_size ?

Размер: {formatFileSize(document.file_size)}

: null} {document.folder_path ?

Папка: {document.folder_path}

: null} +

{document.is_regulation ? 'Регламент' : 'Документ'}

-
- - Файл документа -
- -
- - setReplacementFile(event.target.files?.[0] || null)} - disabled={isSaving} - className="sr-only" - /> -

- Если файл не выбрать, текущий файл останется без изменений. -

- - {replacementFile && ( -
- - Новый файл: {replacementFile.name} ({formatFileSize(replacementFile.size)}) - - + />
- )} -
+ ) : currentFileName ? ( +
+ +
+ ) : undefined} +
- +
+ + Папка +
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="Цвет тега" + /> + 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="Название нового тега" + /> + + + ); + } + if (!isOpen) { return (
diff --git a/frontend/src/components/documents/DocumentUploadForm.tsx b/frontend/src/components/documents/DocumentUploadForm.tsx index 4452f2ce..807058c7 100644 --- a/frontend/src/components/documents/DocumentUploadForm.tsx +++ b/frontend/src/components/documents/DocumentUploadForm.tsx @@ -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 = { 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([]); + const [filesOpen, setFilesOpen] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [processingProgress, setProcessingProgress] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -141,9 +144,13 @@ export function DocumentUploadForm({ const [acknowledgementRecipients, setAcknowledgementRecipients] = useState([]); // Metadata fields - const [selectedFolderId, setSelectedFolderId] = useState(currentFolderId ?? null); + const [selectedFolderId, setSelectedFolderId] = useState( + isDedicatedRegulation ? null : currentFolderId ?? null, + ); + const [newFolderName, setNewFolderName] = useState(""); + const [isCreatingFolder, setIsCreatingFolder] = useState(false); + const [folderCreateError, setFolderCreateError] = useState(null); const [selectedTags, setSelectedTags] = useState([]); - const [isRegulation, setIsRegulation] = useState(defaultIsRegulation); // Data for selects const [departments, setDepartments] = useState([]); @@ -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({
)} - {/* File Upload */} -
- - -
- - -

- {isDragActive - ? "Отпустите файлы здесь" - : uploadItems.length > 0 - ? "Добавьте ещё файлы или нажмите для выбора" - : "Перетащите файлы или нажмите для выбора"} -

-

- Можно выбрать несколько файлов любых форматов или создать документ без файла -

+ {isDedicatedRegulation && ( +
+ + + +
+

Основные сведения

+

+ Добавьте регламент, настройте доступ и укажите, кто должен подтвердить ознакомление. +

+
+ )} - {uploadItems.length > 0 && ( -
-
- Выбрано файлов: {uploadItems.length} -
- -
- {uploadItems.map((item) => ( - - - - - {item.file.name} - - - {formatFileSize(item.file.size)} - {item.extractedText ? " · текст извлечён" : ""} - {item.processedFile - ? ` · сжато до ${formatFileSize(item.processedFile.size)}` - : ""} - {item.processingError ? " · ошибка обработки" : ""} - - - - - ))} -
- - {uploadItems.some((item) => item.processingError) && ( -
- {uploadItems.map((item) => - item.processingError ? ( -

- {item.file.name}: {item.processingError} -

- ) : null - )} + /> + ))}
- )} + ) : null} - {/* Индикатор обработки */} - {isProcessing && processingProgress && ( + {isProcessing && processingProgress ? (
@@ -561,7 +621,7 @@ export function DocumentUploadForm({
-
@@ -570,29 +630,39 @@ export function DocumentUploadForm({ {processingProgress.message} · {processingProgress.progress}%

- )} + ) : null}
- )} -
+ ) : undefined} + {/* Title */} {uploadItems.length <= 1 ? (
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" />

- {uploadItems.length === 0 - ? 'Поле необязательное. Если оставить пустым, будет использовано название "Документ".' - : "Поле необязательное. Если оставить пустым, будет использовано имя файла без расширения."} + {isDedicatedRegulation + ? "Название будет отображаться в списке регламентов и результатах поиска." + : uploadItems.length === 0 + ? 'Поле необязательное. Если оставить пустым, будет использовано название "Документ".' + : "Поле необязательное. Если оставить пустым, будет использовано имя файла без расширения."}

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

- Категоризация + {isDedicatedRegulation ? "Оформление" : "Категоризация"}

{/* Folder */} -
- - { + 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" + > + + {loadingFolders ? ( + + ) : folderOptionRows.length === 0 ? ( + + ) : ( + folderOptionRows.map(({ folder, level }) => ( + + )) + )} + + +
+ { + 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="Название новой папки" + /> + +
+ + {folderCreateError ? ( +

{folderCreateError}

) : ( - folderOptionRows.map(({ folder, level }) => ( - - )) +

+ {selectedFolder + ? `Новая папка будет создана внутри «${selectedFolder.name}».` + : "Новая папка будет создана в корне."} +

)} - -

- Выбранная папка будет применена ко всем загружаемым документам. -

-
+
+ )} {/* Tags */}
-
- Теги +
+ + Теги
-
{loadingDocumentTags ? ( - Загрузка тегов... + Загрузка тегов... ) : documentTags.length === 0 ? ( - Нет доступных тегов + Тегов пока нет ) : ( documentTags.map((tag) => ( )) )}
-

- {selectedTags.length > 0 ? ( - <>Выбрано: {selectedTags.length} - ) : ( - <>Нажмите на тег, чтобы добавить его к документу - )} -

+
- {/* Regulation flag */} -
- setIsRegulation(e.target.checked)} - className="mt-0.5 h-4 w-4 rounded border-[var(--border-strong)] text-[var(--accent-primary)]" - /> -
- -

- Документ будет отображаться в отдельном разделе регламентов. -

-
-
+ {!isDedicatedRegulation ? ( + + ) : 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({
setSentToAll(mode === "all")} employees={employees} @@ -782,6 +904,7 @@ export function DocumentUploadForm({
{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 + ? "Загрузить документ" + : "Создать документ"} {onCancel && ( @@ -831,3 +962,9 @@ export function DocumentUploadForm({ ); } + +export function RegulationCreateForm( + props: Omit, +) { + return ; +} diff --git a/frontend/src/components/documents/index.ts b/frontend/src/components/documents/index.ts index 82390b47..d5c5c7ee 100644 --- a/frontend/src/components/documents/index.ts +++ b/frontend/src/components/documents/index.ts @@ -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"; diff --git a/frontend/src/contexts/NotificationsContext.tsx b/frontend/src/contexts/NotificationsContext.tsx index 5fb3cfbc..7002abf4 100644 --- a/frontend/src/contexts/NotificationsContext.tsx +++ b/frontend/src/contexts/NotificationsContext.tsx @@ -40,6 +40,9 @@ interface NotificationsContextType { unreadCount: number; unreadCategoryCounts: Record; unreadProcurementRequestCounts: Record; + unreadRegulationDepartmentCounts: Record; + unreadCompanyRegulationCount: number; + unreadPersonalRegulationCount: number; loading: boolean; error: Error | null; refreshUnreadSummary: () => Promise; @@ -61,6 +64,9 @@ export function NotificationsProvider({ children }: { children: React.ReactNode const [unreadCount, setUnreadCount] = useState(0); const [unreadCategoryCounts, setUnreadCategoryCounts] = useState>({}); const [unreadProcurementRequestCounts, setUnreadProcurementRequestCounts] = useState>({}); + const [unreadRegulationDepartmentCounts, setUnreadRegulationDepartmentCounts] = useState>({}); + 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 = {}; const nextProcurementRequestCounts: Record = {}; @@ -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 = {}; + 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, diff --git a/frontend/src/lib/api/notifications.ts b/frontend/src/lib/api/notifications.ts index 4b3116f1..36e32000 100644 --- a/frontend/src/lib/api/notifications.ts +++ b/frontend/src/lib/api/notifications.ts @@ -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) { diff --git a/frontend/src/lib/navigation-notifications.test.js b/frontend/src/lib/navigation-notifications.test.js index 551adcb8..69cbe3e8 100644 --- a/frontend/src/lib/navigation-notifications.test.js +++ b/frontend/src/lib/navigation-notifications.test.js @@ -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", () => { diff --git a/frontend/src/lib/navigation-notifications.ts b/frontend/src/lib/navigation-notifications.ts index dcc6a9f1..2e3ca6ae 100644 --- a/frontend/src/lib/navigation-notifications.ts +++ b/frontend/src/lib/navigation-notifications.ts @@ -6,4 +6,5 @@ export const NAV_NOTIFICATION_CATEGORIES = { procurement: "Закупки", tasks: "Доска", documents: "Документы", + regulations: "Регламенты", } as const; diff --git a/frontend/src/lib/notifications/actionUrl.ts b/frontend/src/lib/notifications/actionUrl.ts index 8a3a2fdd..20ecb32a 100644 --- a/frontend/src/lib/notifications/actionUrl.ts +++ b/frontend/src/lib/notifications/actionUrl.ts @@ -18,7 +18,10 @@ function parseNumericId(value: unknown): number | null { return null; } -function buildUrl(pathname: string, params: Record): string { +function buildUrl( + pathname: string, + params: Record, +): 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+)\/?(?:[#?].*)?$/); diff --git a/frontend/src/lib/notifications/regulationScope.test.js b/frontend/src/lib/notifications/regulationScope.test.js new file mode 100644 index 00000000..0a494eba --- /dev/null +++ b/frontend/src/lib/notifications/regulationScope.test.js @@ -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" }, + }), + "Вся компания", + ); +}); diff --git a/frontend/src/lib/notifications/regulationScope.ts b/frontend/src/lib/notifications/regulationScope.ts new file mode 100644 index 00000000..a94dd6c1 --- /dev/null +++ b/frontend/src/lib/notifications/regulationScope.ts @@ -0,0 +1,34 @@ +type RegulationScopeNotification = { + verb?: string | null; + data?: Record | 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; +} diff --git a/frontend/src/lib/verbTranslations.ts b/frontend/src/lib/verbTranslations.ts index 1a68046f..3acca8c2 100644 --- a/frontend/src/lib/verbTranslations.ts +++ b/frontend/src/lib/verbTranslations.ts @@ -52,6 +52,13 @@ export const VERB_CATEGORIES: Record = { 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 = { document_comment: 'Комментарий к документу', document_comment_reply: 'Ответ на комментарий к документу', document_related: 'Связанный документ', + + // Регламенты + regulation_ready: 'Регламент на ознакомление', + regulation_signed_all: 'Все ознакомились с регламентом', + regulation_reminder: 'Напоминание об ознакомлении с регламентом', + regulation_comment: 'Комментарий к регламенту', + regulation_comment_reply: 'Ответ на комментарий к регламенту', // Календарь calendar_invitation: 'Приглашение на событие',