diff --git a/backend/api/v1/documents/permissions.py b/backend/api/v1/documents/permissions.py index 17d3326a..bd6396e0 100644 --- a/backend/api/v1/documents/permissions.py +++ b/backend/api/v1/documents/permissions.py @@ -64,6 +64,7 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms): "archive", "comments", "document_comment", + "set_maximally_hidden", ): return bool(request.user and request.user.is_authenticated) @@ -129,6 +130,7 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms): "archive", "comments", "document_comment", + "set_maximally_hidden", ): return _user_has_document_access(user, obj) diff --git a/backend/api/v1/documents/serializers.py b/backend/api/v1/documents/serializers.py index 6b86b9d1..a7954934 100644 --- a/backend/api/v1/documents/serializers.py +++ b/backend/api/v1/documents/serializers.py @@ -10,7 +10,11 @@ from django.http import QueryDict from django.contrib.auth import get_user_model from django.db.models import Q from django.utils.text import slugify -from documents.models import Document, DocumentAcknowledgement +from documents.models import ( + Document, + DocumentAcknowledgement, + DocumentDisplayState, +) from rest_framework import serializers from ..employees.serializers import EmployeeBriefSerializer @@ -300,6 +304,7 @@ class DocumentReadSerializer(serializers.ModelSerializer): source="file.size", read_only=True, allow_null=True ) is_acknowledged = serializers.SerializerMethodField() + is_maximally_hidden = serializers.SerializerMethodField() acknowledgement_required_for_user = serializers.SerializerMethodField() acknowledged_count = serializers.SerializerMethodField() acknowledgement_total = serializers.SerializerMethodField() @@ -338,6 +343,7 @@ class DocumentReadSerializer(serializers.ModelSerializer): "file_name", "file_size", "is_acknowledged", + "is_maximally_hidden", "linked_tasks", ) @@ -361,6 +367,21 @@ class DocumentReadSerializer(serializers.ModelSerializer): document=obj, user=request.user ).exists() + def get_is_maximally_hidden(self, obj: Document) -> bool: + """Максимально ли скрыта карточка для текущего пользователя.""" + annotated = getattr(obj, "_is_maximally_hidden", None) + if annotated is not None: + return bool(annotated) + + request = self.context.get("request") + if not request or not request.user.is_authenticated: + return False + return DocumentDisplayState.objects.filter( + document=obj, + user=request.user, + is_maximally_hidden=True, + ).exists() + def get_acknowledgement_required_for_user(self, obj: Document) -> bool: """Нужно ли текущему сотруднику подтверждать ознакомление.""" request = self.context.get("request") diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index e37bafa1..6e06fcf1 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -10,9 +10,11 @@ from django.contrib.contenttypes.models import ContentType from django.db import transaction from django.db.models import Count, Exists, OuterRef, QuerySet, Q from django.http import FileResponse +from django.utils import timezone from documents.models import ( Document, DocumentAcknowledgement, + DocumentDisplayState, DocumentTag, ) from documents.audience import ( @@ -155,7 +157,15 @@ class DocumentViewSet(ModelViewSet): subq = DocumentAcknowledgement.objects.filter( document=OuterRef("pk"), user=user ) - qs = qs.annotate(_is_acknowledged=Exists(subq)) + hidden_subq = DocumentDisplayState.objects.filter( + document=OuterRef("pk"), + user=user, + is_maximally_hidden=True, + ) + qs = qs.annotate( + _is_acknowledged=Exists(subq), + _is_maximally_hidden=Exists(hidden_subq), + ) return qs.order_by("-uploaded_at") @@ -524,6 +534,54 @@ class DocumentViewSet(ModelViewSet): ) return Response({"ok": True, "already": not created}) + @action(methods=["post"], detail=True, url_path="set-maximally-hidden") + def set_maximally_hidden(self, request, pk=None): + """Переключить персональное максимальное скрытие карточки регламента.""" + document = self.get_object() + if not document.is_regulation: + return Response( + {"detail": "Максимальное скрытие доступно только для регламентов."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + raw_value = request.data.get("is_maximally_hidden", True) + + if isinstance(raw_value, bool): + is_maximally_hidden = raw_value + elif isinstance(raw_value, str): + normalized = raw_value.strip().lower() + if normalized in {"true", "1", "yes", "on"}: + is_maximally_hidden = True + elif normalized in {"false", "0", "no", "off"}: + is_maximally_hidden = False + else: + return Response( + {"is_maximally_hidden": "Ожидается булево значение."}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return Response( + {"is_maximally_hidden": "Ожидается булево значение."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + display_state, _ = DocumentDisplayState.objects.get_or_create( + document=document, + user=request.user, + defaults={"is_maximally_hidden": is_maximally_hidden}, + ) + if display_state.is_maximally_hidden != is_maximally_hidden: + display_state.is_maximally_hidden = is_maximally_hidden + display_state.updated_at = timezone.now() + display_state.save( + update_fields=["is_maximally_hidden", "updated_at"], + ) + + document._is_maximally_hidden = is_maximally_hidden + self._attach_linked_task_payloads([document]) + self._attach_document_metrics([document]) + return Response(self.get_serializer(document).data) + def _document_comment_payload(self, document, message): """Формат комментария документа для frontend.""" return { diff --git a/backend/documents/migrations/0018_document_display_state.py b/backend/documents/migrations/0018_document_display_state.py new file mode 100644 index 00000000..caaae4e6 --- /dev/null +++ b/backend/documents/migrations/0018_document_display_state.py @@ -0,0 +1,70 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "0017_document_acknowledgement_audience"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DocumentDisplayState", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "is_maximally_hidden", + models.BooleanField( + default=True, + verbose_name="Максимально скрыт", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + verbose_name="Обновлено", + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="display_states", + to="documents.document", + verbose_name="Документ", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="document_display_states", + to=settings.AUTH_USER_MODEL, + verbose_name="Сотрудник", + ), + ), + ], + options={ + "verbose_name": "Настройка отображения документа", + "verbose_name_plural": "Настройки отображения документов", + }, + ), + migrations.AddConstraint( + model_name="documentdisplaystate", + constraint=models.UniqueConstraint( + fields=("document", "user"), + name="unique_document_display_state_user", + ), + ), + ] diff --git a/backend/documents/models.py b/backend/documents/models.py index 6c97af49..25da2d0b 100644 --- a/backend/documents/models.py +++ b/backend/documents/models.py @@ -300,6 +300,44 @@ class DocumentAcknowledgement(models.Model): ) +class DocumentDisplayState(models.Model): + """Персональные настройки отображения документа в списках.""" + + document = models.ForeignKey( + Document, + verbose_name=_("Документ"), + on_delete=models.CASCADE, + related_name="display_states", + ) + user = models.ForeignKey( + User, + verbose_name=_("Сотрудник"), + on_delete=models.CASCADE, + related_name="document_display_states", + ) + is_maximally_hidden = models.BooleanField( + _("Максимально скрыт"), + default=True, + ) + updated_at = models.DateTimeField(_("Обновлено"), auto_now=True) + + class Meta: + verbose_name = _("Настройка отображения документа") + verbose_name_plural = _("Настройки отображения документов") + constraints = [ + models.UniqueConstraint( + fields=("document", "user"), + name="unique_document_display_state_user", + ), + ] + + def __str__(self): + return ( + f"{self.document_id}: {self.user_id} " + f"hidden={self.is_maximally_hidden}" + ) + + # ============================================================================ # ТИПЫ ДОКУМЕНТОВ И МЕТАДАННЫЕ # ============================================================================ diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py index 14d87c9f..6cbdf00a 100644 --- a/backend/tests/api/v1/documents/test_documents_api.py +++ b/backend/tests/api/v1/documents/test_documents_api.py @@ -28,7 +28,12 @@ from django.contrib.auth.models import Permission from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from django.utils import timezone -from documents.models import Document, DocumentAcknowledgement, DocumentTag +from documents.models import ( + Document, + DocumentAcknowledgement, + DocumentDisplayState, + DocumentTag, +) from employees.models import Department, EmployeeDepartment from filer.models import File as FilerFile, Folder from rest_framework import status @@ -176,6 +181,9 @@ def api_urls(): def _ack(pk: int) -> str: return reverse("api:v1:documents-acknowledge", args=[pk]) + def _set_maximally_hidden(pk: int) -> str: + return reverse("api:v1:documents-set-maximally-hidden", args=[pk]) + def _acknowledgements(pk: int) -> str: return reverse("api:v1:documents-acknowledgements", args=[pk]) @@ -199,6 +207,7 @@ def api_urls(): "archive": reverse("api:v1:documents-archive"), "detail": _detail, "ack": _ack, + "set_maximally_hidden": _set_maximally_hidden, "acknowledgements": _acknowledgements, "comments": _comments, "comment_detail": _comment_detail, @@ -1466,6 +1475,57 @@ class TestAcknowledge: ] == 0 +class TestDocumentDisplayState: + """Персональный режим максимального скрытия карточки.""" + + def test_set_maximally_hidden_is_persisted_for_current_user( + self, auth_client, make_user, api_urls + ): + author = make_user("display-state-author@example.com") + viewer = make_user("display-state-viewer@example.com") + other_viewer = make_user("display-state-other-viewer@example.com") + document = make_document( + uploaded_by=author, + sent_to_all=True, + is_regulation=True, + ) + client = auth_client(viewer) + + initial = client.get(api_urls["detail"](document.id)) + assert initial.status_code == status.HTTP_200_OK + assert initial.json()["is_maximally_hidden"] is False + + hidden = client.post( + api_urls["set_maximally_hidden"](document.id), + {"is_maximally_hidden": True}, + format="json", + ) + assert hidden.status_code == status.HTTP_200_OK + assert hidden.json()["is_maximally_hidden"] is True + assert DocumentDisplayState.objects.filter( + document=document, + user=viewer, + is_maximally_hidden=True, + ).exists() + other_viewer_detail = auth_client(other_viewer).get( + api_urls["detail"](document.id) + ) + assert other_viewer_detail.status_code == status.HTTP_200_OK + assert other_viewer_detail.json()["is_maximally_hidden"] is False + assert not DocumentDisplayState.objects.filter( + document=document, + user=other_viewer, + ).exists() + + shown = client.post( + api_urls["set_maximally_hidden"](document.id), + {"is_maximally_hidden": False}, + format="json", + ) + assert shown.status_code == status.HTTP_200_OK + assert shown.json()["is_maximally_hidden"] is False + + class TestAcknowledgementsReport: """Ведомость ознакомлений доступна всем, кому доступен документ.""" diff --git a/frontend/src/app/departments/[id]/page.tsx b/frontend/src/app/departments/[id]/page.tsx index f3a35744..459f8e1c 100644 --- a/frontend/src/app/departments/[id]/page.tsx +++ b/frontend/src/app/departments/[id]/page.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import dynamic from "next/dynamic"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; import { @@ -51,6 +52,13 @@ import { displayUserName, formatDate, loadAllPages, userProfileLink } from "@/li import type { DepartmentMemberLink, DepartmentPermissionChoice, DepartmentRole, Document, User } from "@/types/api"; import { toast } from "sonner"; +const RegulationCreateForm = dynamic( + () => import("@/components/documents/DocumentUploadForm").then((mod) => ({ + default: mod.RegulationCreateForm, + })), + { ssr: false }, +); + type DepartmentMemberModalMode = "add" | "assignRole"; type DepartmentRoleDraft = { id: number | null; @@ -995,6 +1003,9 @@ export default function DepartmentDetailPage() { const [membersExpanded, setMembersExpanded] = useState(false); const [regulationsExpanded, setRegulationsExpanded] = useState(false); const [departmentRegulations, setDepartmentRegulations] = useState([]); + const [departmentRegulationsLoading, setDepartmentRegulationsLoading] = useState(true); + const [regulationsRefreshKey, setRegulationsRefreshKey] = useState(0); + const [createRegulationOpen, setCreateRegulationOpen] = useState(false); const [selectedRegulation, setSelectedRegulation] = useState(null); const [roleMenuOpenForId, setRoleMenuOpenForId] = useState(null); const roleMenuRef = useRef(null); @@ -1023,9 +1034,9 @@ export default function DepartmentDetailPage() { if (!Number.isFinite(departmentId)) return; let cancelled = false; - setRegulationsExpanded(false); const loadDepartmentRegulations = async () => { + setDepartmentRegulationsLoading(true); try { const regulations = await loadAllPages((pageParams) => ( apiClient.getDocuments({ ...pageParams, is_regulation: true }) @@ -1046,6 +1057,8 @@ export default function DepartmentDetailPage() { } catch (error) { console.error("Не удалось загрузить регламенты отдела:", error); if (!cancelled) setDepartmentRegulations([]); + } finally { + if (!cancelled) setDepartmentRegulationsLoading(false); } }; @@ -1053,6 +1066,10 @@ export default function DepartmentDetailPage() { return () => { cancelled = true; }; + }, [departmentId, regulationsRefreshKey]); + + useEffect(() => { + setRegulationsExpanded(false); }, [departmentId]); const refreshSelectedRegulation = async () => { @@ -1425,62 +1442,93 @@ export default function DepartmentDetailPage() { - {departmentRegulations.length > 0 ? ( -
-
-
-
-

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

- {departmentRegulations.length} -
-

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

+
+
+
+

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

+ + {departmentRegulationsLoading ? "…" : departmentRegulations.length} +
+

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

+
+
+ {departmentRegulations.length > 0 ? ( + + ) : null}
+
- {regulationsExpanded ? ( -
- {departmentRegulations.map((document) => ( - - ))} -
- ) : null} -
- ) : null} + + + + ))} +
+ ) : null} +
+ + setCreateRegulationOpen(false)} + title="Создать регламент отдела" + size="xl" + > + setCreateRegulationOpen(false)} + onSuccess={() => { + setCreateRegulationOpen(false); + setRegulationsExpanded(true); + setRegulationsRefreshKey((current) => current + 1); + }} + /> + ); } diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index e839ee5d..9da60be5 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -12,6 +12,7 @@ import { FileText, Plus, Eye, + EyeOff, X, FolderOpen, Tags, @@ -204,6 +205,7 @@ function DocumentsPageContent() { const [editingFolder, setEditingFolder] = useState(null); const [downloadingFolderId, setDownloadingFolderId] = useState(null); const [acknowledgingDocumentId, setAcknowledgingDocumentId] = useState(null); + const [maximallyHidingDocumentId, setMaximallyHidingDocumentId] = useState(null); const [selectionMode, setSelectionMode] = useState(false); // Bulk selection @@ -766,6 +768,34 @@ function DocumentsPageContent() { } }; + const handleToggleDocumentMaximallyHidden = async ( + documentItem: Document, + isMaximallyHidden: boolean, + ) => { + setMaximallyHidingDocumentId(documentItem.id); + setDocumentMenuOpenId(null); + + try { + const updatedDocument = await apiClient.setDocumentMaximallyHidden( + documentItem.id, + isMaximallyHidden, + ) as Document; + setDocuments((currentDocuments) => currentDocuments.map((document) => ( + document.id === documentItem.id + ? { + ...document, + is_maximally_hidden: updatedDocument.is_maximally_hidden, + } + : document + ))); + } catch (error) { + console.error("Не удалось изменить режим отображения регламента:", error); + toast.error("Не удалось изменить режим отображения регламента"); + } finally { + setMaximallyHidingDocumentId(null); + } + }; + const downloadFolderArchive = async (folder: FolderNode) => { setDownloadingFolderId(folder.id); @@ -1549,7 +1579,7 @@ function DocumentsPageContent() { })} {/* Document Cards */} - {filteredDocuments.map((doc) => { + {filteredDocuments.map((doc, documentIndex) => { const authorName = doc.created_by ? `${doc.created_by.last_name || ''} ${doc.created_by.first_name || ''}`.trim() : null; @@ -1562,8 +1592,71 @@ function DocumentsPageContent() { const departmentsCount = doc.departments?.length || 0; const hasPreview = Boolean(doc.file_url && canPreviewDocument(doc.file_name || doc.title)); const isAcknowledging = acknowledgingDocumentId === doc.id; + const isChangingMaximumHiding = maximallyHidingDocumentId === doc.id; const acknowledgementRequiredForUser = doc.acknowledgement_required_for_user ?? doc.acknowledgement_required; + const isMaximallyHidden = ( + activeSection === "regulations" + && Boolean(doc.is_maximally_hidden) + ); + + if (isMaximallyHidden) { + return ( +
+
+ {selectionMode ? ( + toggleDocumentSelectionFromCheckbox(doc.id)} + className="h-4 w-4 shrink-0 rounded accent-[var(--accent-primary)]" + aria-label={`Выбрать регламент ${doc.title}`} + /> + ) : null} + + {documentIndex + 1} + + + +
+
+ ); + } // DEBUG: Проверка данных документа if (!authorName || !createdDate) { @@ -1602,16 +1695,25 @@ function DocumentsPageContent() {
- - {doc.is_regulation ? ( - - ) : ( - - )} - {doc.is_regulation ? "Регламент" : "Документ"} - + {activeSection === "regulations" ? ( + + {documentIndex + 1} + + ) : ( + + {doc.is_regulation ? ( + + ) : ( + + )} + {doc.is_regulation ? "Регламент" : "Документ"} + + )} {doc.tags?.slice(0, 3).map((tag) => ( ) : null} -
+
+ {activeSection === "regulations" ? ( + + ) : null} {hasPreview ? ( ) : null} {document.file_url ? ( diff --git a/frontend/src/lib/api/documents.ts b/frontend/src/lib/api/documents.ts index 297b9bf8..0bd14b09 100644 --- a/frontend/src/lib/api/documents.ts +++ b/frontend/src/lib/api/documents.ts @@ -112,6 +112,12 @@ export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, re ); }, acknowledgeDocument: (id: number) => request(`/api/v1/documents/${id}/acknowledge/`, { method: 'POST', body: JSON.stringify({}) }), + setDocumentMaximallyHidden: (id: number, isMaximallyHidden: boolean) => + request(`/api/v1/documents/${id}/set-maximally-hidden/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_maximally_hidden: isMaximallyHidden }), + }), getDocumentAcknowledgements: (id: number, search?: string) => { const qp = new URLSearchParams(); if (search) qp.append('search', search); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index d5c6920c..e1116eb4 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -704,6 +704,7 @@ export interface Document { acknowledgement_recipients?: User[]; acknowledgement_departments?: { id: number; name: string }[]; is_acknowledged?: boolean; + is_maximally_hidden?: boolean; acknowledged_count?: number; acknowledgement_total?: number; comments_count?: number;