Merge branch 'develop'
This commit is contained in:
commit
a88a7a43e6
@ -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)
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 {
|
||||
|
||||
70
backend/documents/migrations/0018_document_display_state.py
Normal file
70
backend/documents/migrations/0018_document_display_state.py
Normal file
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ТИПЫ ДОКУМЕНТОВ И МЕТАДАННЫЕ
|
||||
# ============================================================================
|
||||
|
||||
@ -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:
|
||||
"""Ведомость ознакомлений доступна всем, кому доступен документ."""
|
||||
|
||||
|
||||
@ -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<Document[]>([]);
|
||||
const [departmentRegulationsLoading, setDepartmentRegulationsLoading] = useState(true);
|
||||
const [regulationsRefreshKey, setRegulationsRefreshKey] = useState(0);
|
||||
const [createRegulationOpen, setCreateRegulationOpen] = useState(false);
|
||||
const [selectedRegulation, setSelectedRegulation] = useState<Document | null>(null);
|
||||
const [roleMenuOpenForId, setRoleMenuOpenForId] = useState<number | null>(null);
|
||||
const roleMenuRef = useRef<HTMLDivElement | null>(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<Document>((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() {
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{departmentRegulations.length > 0 ? (
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<div className={regulationsExpanded ? "mb-4 flex items-center justify-between gap-3" : "flex items-center justify-between gap-3"}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
|
||||
Регламенты отдела
|
||||
</p>
|
||||
<MetaChip tone="accent">{departmentRegulations.length}</MetaChip>
|
||||
</div>
|
||||
<p className="app-text-muted mt-1 text-sm">
|
||||
Документы, требующие ознакомления сотрудников отдела.
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<div className={regulationsExpanded || !departmentRegulations.length ? "mb-4 flex items-center justify-between gap-3" : "flex items-center justify-between gap-3"}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
|
||||
Регламенты отдела
|
||||
</p>
|
||||
<MetaChip tone="accent">
|
||||
{departmentRegulationsLoading ? "…" : departmentRegulations.length}
|
||||
</MetaChip>
|
||||
</div>
|
||||
<p className="app-text-muted mt-1 text-sm">
|
||||
Документы, требующие ознакомления сотрудников отдела.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRegulationsExpanded((current) => !current)}
|
||||
className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
|
||||
aria-label={regulationsExpanded ? "Свернуть список регламентов" : "Развернуть список регламентов"}
|
||||
aria-expanded={regulationsExpanded}
|
||||
onClick={() => setCreateRegulationOpen(true)}
|
||||
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={`transition-transform duration-200 ${regulationsExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
<Plus size={14} />
|
||||
<span className="hidden sm:inline">Создать регламент</span>
|
||||
<span className="sm:hidden">Создать</span>
|
||||
</button>
|
||||
{departmentRegulations.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRegulationsExpanded((current) => !current)}
|
||||
className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
|
||||
aria-label={regulationsExpanded ? "Свернуть список регламентов" : "Развернуть список регламентов"}
|
||||
aria-expanded={regulationsExpanded}
|
||||
>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={`transition-transform duration-200 ${regulationsExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{regulationsExpanded ? (
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)]">
|
||||
{departmentRegulations.map((document) => (
|
||||
<button
|
||||
key={document.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedRegulation(document)}
|
||||
className="flex w-full min-w-0 items-center gap-3 border-b border-[var(--border-subtle)] px-3 py-3 text-left transition last:border-b-0 hover:bg-[var(--surface-secondary)]"
|
||||
{departmentRegulationsLoading ? (
|
||||
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
|
||||
<div className="mx-auto h-6 w-6 animate-spin rounded-full border-2 border-[var(--border-subtle)] border-t-[var(--accent-primary)]" />
|
||||
<p className="app-text-muted mt-3 text-sm">Загружаем регламенты...</p>
|
||||
</div>
|
||||
) : departmentRegulations.length === 0 ? (
|
||||
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
|
||||
<ScrollText size={24} className="app-text-muted mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">
|
||||
Регламентов пока нет
|
||||
</p>
|
||||
<p className="app-text-muted mt-2 text-sm">
|
||||
Создайте первый регламент для сотрудников этого отдела.
|
||||
</p>
|
||||
</div>
|
||||
) : regulationsExpanded ? (
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)]">
|
||||
{departmentRegulations.map((document, index) => (
|
||||
<button
|
||||
key={document.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedRegulation(document)}
|
||||
className="flex w-full min-w-0 items-center gap-3 border-b border-[var(--border-subtle)] px-3 py-3 text-left transition last:border-b-0 hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<span
|
||||
className="app-selected app-accent-text flex h-9 min-w-9 shrink-0 items-center justify-center rounded-lg px-1 text-sm font-semibold tabular-nums"
|
||||
aria-label={`Регламент ${index + 1} из ${departmentRegulations.length}`}
|
||||
>
|
||||
<span className="app-selected app-accent-text flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<ScrollText size={17} />
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">
|
||||
{document.title}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">
|
||||
{document.title}
|
||||
</span>
|
||||
<span className="app-text-muted mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<RegulationAcknowledgementStatus document={document} />
|
||||
<span>{formatDate(document.uploaded_at || document.created_at)}</span>
|
||||
</span>
|
||||
<span className="app-text-muted mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<RegulationAcknowledgementStatus document={document} />
|
||||
<span>{formatDate(document.uploaded_at || document.created_at)}</span>
|
||||
</span>
|
||||
<ChevronRight size={16} className="app-text-muted shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</span>
|
||||
<ChevronRight size={16} className="app-text-muted shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<DepartmentFeedSection
|
||||
canCreatePosts={
|
||||
@ -1574,6 +1622,23 @@ export default function DepartmentDetailPage() {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={createRegulationOpen}
|
||||
onClose={() => setCreateRegulationOpen(false)}
|
||||
title="Создать регламент отдела"
|
||||
size="xl"
|
||||
>
|
||||
<RegulationCreateForm
|
||||
initialDepartmentId={h.department?.id ?? departmentId}
|
||||
onCancel={() => setCreateRegulationOpen(false)}
|
||||
onSuccess={() => {
|
||||
setCreateRegulationOpen(false);
|
||||
setRegulationsExpanded(true);
|
||||
setRegulationsRefreshKey((current) => current + 1);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
FileText,
|
||||
Plus,
|
||||
Eye,
|
||||
EyeOff,
|
||||
X,
|
||||
FolderOpen,
|
||||
Tags,
|
||||
@ -204,6 +205,7 @@ function DocumentsPageContent() {
|
||||
const [editingFolder, setEditingFolder] = useState<FolderNode | null>(null);
|
||||
const [downloadingFolderId, setDownloadingFolderId] = useState<number | null>(null);
|
||||
const [acknowledgingDocumentId, setAcknowledgingDocumentId] = useState<number | null>(null);
|
||||
const [maximallyHidingDocumentId, setMaximallyHidingDocumentId] = useState<number | null>(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 (
|
||||
<article
|
||||
key={doc.id}
|
||||
className="app-surface-muted overflow-hidden rounded-xl transition hover:border-[var(--border-strong)]"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3 px-4 py-2.5">
|
||||
{selectionMode ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDocumentSelected}
|
||||
onChange={() => toggleDocumentSelectionFromCheckbox(doc.id)}
|
||||
className="h-4 w-4 shrink-0 rounded accent-[var(--accent-primary)]"
|
||||
aria-label={`Выбрать регламент ${doc.title}`}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className="app-selected app-accent-text inline-flex h-8 min-w-8 shrink-0 items-center justify-center rounded-full px-2 text-xs font-semibold tabular-nums"
|
||||
aria-label={`Регламент ${documentIndex + 1} из ${filteredDocuments.length}`}
|
||||
>
|
||||
{documentIndex + 1}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedDocument(doc)}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">
|
||||
{doc.title}
|
||||
</span>
|
||||
<span className="app-text-muted mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
{createdDate ? <span>{createdDate}</span> : null}
|
||||
{acknowledgementRequiredForUser ? (
|
||||
<span className={`${doc.is_acknowledged ? "app-feedback-success" : "app-feedback-warning"} inline-flex rounded-full px-2 py-0.5`}>
|
||||
{doc.is_acknowledged ? "Ознакомлен" : "Требует ознакомления"}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleToggleDocumentMaximallyHidden(doc, false)}
|
||||
disabled={isChangingMaximumHiding}
|
||||
className="app-selected app-accent-text inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-60"
|
||||
title="Показать регламент полностью"
|
||||
aria-label={`Показать регламент ${doc.title} полностью`}
|
||||
>
|
||||
{isChangingMaximumHiding ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<Eye size={15} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// DEBUG: Проверка данных документа
|
||||
if (!authorName || !createdDate) {
|
||||
@ -1602,16 +1695,25 @@ function DocumentsPageContent() {
|
||||
<div className="relative flex min-w-0 flex-col">
|
||||
<div className="contents">
|
||||
<div className="order-1 flex min-w-0 flex-wrap items-center gap-1.5 pr-14">
|
||||
<span
|
||||
className={`${doc.is_regulation ? "app-selected app-accent-text" : "app-badge"} inline-flex max-w-full items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium`}
|
||||
>
|
||||
{doc.is_regulation ? (
|
||||
<ScrollText size={12} className="shrink-0" />
|
||||
) : (
|
||||
<FileText size={12} className="shrink-0" />
|
||||
)}
|
||||
{doc.is_regulation ? "Регламент" : "Документ"}
|
||||
</span>
|
||||
{activeSection === "regulations" ? (
|
||||
<span
|
||||
className="app-selected app-accent-text inline-flex h-7 min-w-7 items-center justify-center rounded-full px-2 text-xs font-semibold tabular-nums"
|
||||
aria-label={`Регламент ${documentIndex + 1} из ${filteredDocuments.length}`}
|
||||
>
|
||||
{documentIndex + 1}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`${doc.is_regulation ? "app-selected app-accent-text" : "app-badge"} inline-flex max-w-full items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium`}
|
||||
>
|
||||
{doc.is_regulation ? (
|
||||
<ScrollText size={12} className="shrink-0" />
|
||||
) : (
|
||||
<FileText size={12} className="shrink-0" />
|
||||
)}
|
||||
{doc.is_regulation ? "Регламент" : "Документ"}
|
||||
</span>
|
||||
)}
|
||||
{doc.tags?.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
@ -1920,7 +2022,23 @@ function DocumentsPageContent() {
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-nowrap items-center justify-end gap-1.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
|
||||
{activeSection === "regulations" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleToggleDocumentMaximallyHidden(doc, true)}
|
||||
disabled={isChangingMaximumHiding}
|
||||
title="Максимально скрыть"
|
||||
aria-label={`Максимально скрыть регламент ${doc.title}`}
|
||||
className="app-action-secondary inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isChangingMaximumHiding ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<EyeOff size={16} />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{hasPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
@ -2023,6 +2141,7 @@ function DocumentsPageContent() {
|
||||
size="xl"
|
||||
>
|
||||
<RegulationCreateForm
|
||||
initialDepartmentId={selectedRegulationDepartment?.id ?? null}
|
||||
onSuccess={() => {
|
||||
setShowUploadForm(false);
|
||||
loadDocuments();
|
||||
|
||||
@ -43,6 +43,7 @@ interface DocumentUploadFormProps {
|
||||
onCancel?: () => void;
|
||||
onFolderCreated?: () => void;
|
||||
currentFolderId?: number | null;
|
||||
initialDepartmentId?: number | null;
|
||||
mode?: "document" | "regulation";
|
||||
}
|
||||
|
||||
@ -122,9 +123,11 @@ export function DocumentUploadForm({
|
||||
onCancel,
|
||||
onFolderCreated,
|
||||
currentFolderId,
|
||||
initialDepartmentId,
|
||||
mode = "document",
|
||||
}: DocumentUploadFormProps) {
|
||||
const isDedicatedRegulation = mode === "regulation";
|
||||
const initialDepartmentIds = initialDepartmentId ? [initialDepartmentId] : [];
|
||||
const [isRegulation, setIsRegulation] = useState(isDedicatedRegulation);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
@ -137,10 +140,14 @@ export function DocumentUploadForm({
|
||||
|
||||
// New fields for extended functionality
|
||||
const [sentToAll, setSentToAll] = useState(true);
|
||||
const [acknowledgementMode, setAcknowledgementMode] = useState<DocumentAudienceMode>("all");
|
||||
const [acknowledgementMode, setAcknowledgementMode] = useState<DocumentAudienceMode>(
|
||||
initialDepartmentIds.length > 0 ? "restricted" : "all",
|
||||
);
|
||||
const [selectedDepartments, setSelectedDepartments] = useState<number[]>([]);
|
||||
const [selectedRecipients, setSelectedRecipients] = useState<number[]>([]);
|
||||
const [acknowledgementDepartments, setAcknowledgementDepartments] = useState<number[]>([]);
|
||||
const [acknowledgementDepartments, setAcknowledgementDepartments] = useState<number[]>(
|
||||
initialDepartmentIds,
|
||||
);
|
||||
const [acknowledgementRecipients, setAcknowledgementRecipients] = useState<number[]>([]);
|
||||
|
||||
// Metadata fields
|
||||
@ -527,10 +534,10 @@ export function DocumentUploadForm({
|
||||
setFolderCreateError(null);
|
||||
setSentToAll(true);
|
||||
setIsRegulation(isDedicatedRegulation);
|
||||
setAcknowledgementMode("all");
|
||||
setAcknowledgementMode(initialDepartmentIds.length > 0 ? "restricted" : "all");
|
||||
setSelectedDepartments([]);
|
||||
setSelectedRecipients([]);
|
||||
setAcknowledgementDepartments([]);
|
||||
setAcknowledgementDepartments(initialDepartmentIds);
|
||||
setAcknowledgementRecipients([]);
|
||||
setSelectedTags([]);
|
||||
|
||||
|
||||
@ -320,11 +320,12 @@ export function FeedRegulationCard({
|
||||
type="button"
|
||||
onClick={() => onAcknowledge(document)}
|
||||
disabled={isAcknowledging}
|
||||
className="app-action-ghost inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="app-action-ghost inline-flex h-9 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-60"
|
||||
title="Ознакомился(лась)"
|
||||
aria-label="Ознакомился(лась)"
|
||||
>
|
||||
{isAcknowledging ? <Loader2 size={16} className="animate-spin" /> : <CheckCircle2 size={16} />}
|
||||
<span>{isAcknowledging ? "Подтверждение..." : "Ознакомился(лась)"}</span>
|
||||
</button>
|
||||
) : null}
|
||||
{document.file_url ? (
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user