feat: improve documents management

This commit is contained in:
lizerk 2026-06-01 16:47:22 +03:00
parent e6eee5ddf8
commit 14d9d4a4dc
12 changed files with 2037 additions and 457 deletions

View File

@ -44,6 +44,7 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms):
if request.method in SAFE_METHODS or action in (
"acknowledge",
"download",
"archive",
):
return bool(request.user and request.user.is_authenticated)
@ -103,6 +104,7 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms):
if request.method in SAFE_METHODS or action in (
"acknowledge",
"download",
"archive",
):
if getattr(obj, "sent_to_all", False):
return True

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import json
import logging
import os
from typing import Any, Dict, Sequence, Iterable
from drf_spectacular.utils import extend_schema_field
@ -313,6 +314,13 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
- tag_ids (теги)
"""
title = serializers.CharField(
required=False,
allow_blank=True,
max_length=255,
help_text="Название документа. Если пусто, будет взято из имени файла.",
)
recipient_ids = RecipientIDsField(
write_only=True,
required=False,
@ -369,9 +377,24 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
serializers.ValidationError: Если sent_to_all=false
и нет recipient_ids или department_ids.
"""
if self.instance is None and not attrs.get("file"):
file_obj = attrs.get("file")
if self.instance is None and not file_obj:
raise serializers.ValidationError({"file": "Обязательное поле."})
if not str(attrs.get("title") or "").strip():
if file_obj:
file_name = (
getattr(file_obj, "original_filename", "")
or getattr(file_obj, "name", "")
)
default_title = os.path.splitext(os.path.basename(file_name))[
0
].strip()
attrs["title"] = default_title or file_name or "Документ"
elif self.instance is not None:
attrs.pop("title", None)
sent_to_all = attrs.get("sent_to_all", True)
recipient_ids = attrs.get("recipient_ids", [])
department_ids = attrs.get("department_ids", [])

View File

@ -1,6 +1,10 @@
from __future__ import annotations
import io
import posixpath
import zipfile
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Count, Exists, OuterRef, QuerySet, Q
@ -188,6 +192,108 @@ class DocumentViewSet(ModelViewSet):
kwargs["partial"] = True
return self.update(request, *args, **kwargs)
def _safe_archive_part(self, value: object, fallback: str) -> str:
"""Очищает часть ZIP-пути без потери кириллицы."""
raw = str(value or fallback)
cleaned = "".join(
"_" if char in {"/", "\\"} or ord(char) < 32 else char
for char in raw
).strip(" .")
return cleaned or fallback
def _unique_archive_path(
self, archive_path: str, used_paths: set[str]
) -> str:
"""Делает путь файла уникальным внутри архива."""
if archive_path not in used_paths:
used_paths.add(archive_path)
return archive_path
directory = posixpath.dirname(archive_path)
filename = posixpath.basename(archive_path)
stem, ext = posixpath.splitext(filename)
counter = 2
while True:
candidate_name = f"{stem} ({counter}){ext}"
candidate = (
posixpath.join(directory, candidate_name)
if directory
else candidate_name
)
if candidate not in used_paths:
used_paths.add(candidate)
return candidate
counter += 1
@action(detail=False, methods=["post"], url_path="archive")
def archive(self, request):
"""Выгрузить выбранные документы одним ZIP-архивом."""
raw_ids = request.data.get("document_ids", [])
if not isinstance(raw_ids, list):
return Response(
{"detail": "document_ids должен быть списком."},
status=status.HTTP_400_BAD_REQUEST,
)
document_ids: list[int] = []
for raw_id in raw_ids:
try:
document_ids.append(int(raw_id))
except (TypeError, ValueError):
continue
if not document_ids:
return Response(
{"detail": "Передайте хотя бы один документ."},
status=status.HTTP_400_BAD_REQUEST,
)
documents = (
self.get_queryset()
.filter(id__in=document_ids)
.select_related("file", "folder")
.order_by("title", "id")
)
archive_buffer = io.BytesIO()
used_file_paths: set[str] = set()
with zipfile.ZipFile(
archive_buffer, mode="w", compression=zipfile.ZIP_DEFLATED
) as zip_file:
for document in documents:
if not document.file:
continue
file_name = (
getattr(document.file, "original_filename", "")
or getattr(document.file, "name", "")
or document.title
or f"document-{document.id}"
)
safe_file_name = self._safe_archive_part(
file_name, f"document-{document.id}"
)
archive_path = self._unique_archive_path(
safe_file_name, used_file_paths
)
file_obj = document.file.file
try:
file_obj.open("rb")
zip_file.writestr(archive_path, file_obj.read())
finally:
file_obj.close()
archive_buffer.seek(0)
return FileResponse(
archive_buffer,
as_attachment=True,
filename="documents.zip",
content_type="application/zip",
)
@action(methods=["post"], detail=True)
def acknowledge(self, request, pk=None):
"""Отметить ознакомление текущего пользователя с документом.
@ -707,6 +813,180 @@ class FolderViewSet(ModelViewSet):
result = FolderSerializer(folder).data
return Response(result, status=status.HTTP_201_CREATED)
def _collect_descendant_folder_ids(self, folder: Folder) -> set[int]:
"""Возвращает ID папки и всех её подпапок."""
folder_ids = {folder.id}
current_level = [folder.id]
while current_level:
child_ids = list(
Folder.objects.filter(parent_id__in=current_level)
.values_list("id", flat=True)
)
folder_ids.update(child_ids)
current_level = child_ids
return folder_ids
def _collect_document_ids_for_delete(
self, folder_ids: set[int]
) -> set[int]:
"""Возвращает документы в папках и прямо связанные с ними."""
document_ids = set(
Document.objects.filter(folder_id__in=folder_ids)
.values_list("id", flat=True)
)
if not document_ids:
return document_ids
related_ids = set(
Document.objects.filter(related_documents__id__in=document_ids)
.values_list("id", flat=True)
)
document_ids.update(related_ids)
return document_ids
def _safe_archive_part(self, value: object, fallback: str) -> str:
"""Очищает часть ZIP-пути без потери кириллицы."""
raw = str(value or fallback)
cleaned = "".join(
"_" if char in {"/", "\\"} or ord(char) < 32 else char
for char in raw
).strip(" .")
return cleaned or fallback
def _unique_archive_path(
self, archive_path: str, used_paths: set[str]
) -> str:
"""Делает путь файла уникальным внутри архива."""
if archive_path not in used_paths:
used_paths.add(archive_path)
return archive_path
directory = posixpath.dirname(archive_path)
filename = posixpath.basename(archive_path)
stem, ext = posixpath.splitext(filename)
counter = 2
while True:
candidate_name = f"{stem} ({counter}){ext}"
candidate = (
posixpath.join(directory, candidate_name)
if directory
else candidate_name
)
if candidate not in used_paths:
used_paths.add(candidate)
return candidate
counter += 1
def _folder_archive_paths(
self, folder_ids: set[int]
) -> dict[int, str]:
"""Возвращает ZIP-пути для папок внутри удаляемого дерева."""
folders = {
folder.id: folder
for folder in Folder.objects.filter(id__in=folder_ids)
.select_related("parent")
}
archive_paths: dict[int, str] = {}
def build_path(folder: Folder) -> str:
if folder.id in archive_paths:
return archive_paths[folder.id]
folder_name = self._safe_archive_part(
folder.name, f"folder-{folder.id}"
)
if folder.parent_id in folders:
archive_path = posixpath.join(
build_path(folders[folder.parent_id]), folder_name
)
else:
archive_path = folder_name
archive_paths[folder.id] = archive_path
return archive_path
for folder in folders.values():
build_path(folder)
return archive_paths
@action(detail=True, methods=["get"], url_path="archive")
def archive(self, request, pk=None):
"""Выгрузить папку со всеми подпапками и документами в ZIP."""
folder = self.get_object()
folder_ids = self._collect_descendant_folder_ids(folder)
folder_paths = self._folder_archive_paths(folder_ids)
archive_buffer = io.BytesIO()
used_file_paths: set[str] = set()
documents = (
Document.objects.filter(folder_id__in=folder_ids)
.select_related("file", "folder")
.order_by("folder_id", "title", "id")
)
with zipfile.ZipFile(
archive_buffer, mode="w", compression=zipfile.ZIP_DEFLATED
) as zip_file:
for folder_id, archive_path in sorted(
folder_paths.items(), key=lambda item: item[1]
):
zip_file.writestr(f"{archive_path}/", b"")
for document in documents:
if not document.file:
continue
file_name = (
getattr(document.file, "original_filename", "")
or getattr(document.file, "name", "")
or document.title
or f"document-{document.id}"
)
safe_file_name = self._safe_archive_part(
file_name, f"document-{document.id}"
)
folder_path = folder_paths.get(document.folder_id, "")
archive_path = self._unique_archive_path(
posixpath.join(folder_path, safe_file_name),
used_file_paths,
)
file_obj = document.file.file
try:
file_obj.open("rb")
zip_file.writestr(archive_path, file_obj.read())
finally:
file_obj.close()
archive_buffer.seek(0)
archive_name = f"{self._safe_archive_part(folder.name, 'folder')}.zip"
return FileResponse(
archive_buffer,
as_attachment=True,
filename=archive_name,
content_type="application/zip",
)
@transaction.atomic
def destroy(self, request, *args, **kwargs):
"""Удаляет папку вместе с подпапками и связанными документами."""
folder = self.get_object()
folder_ids = self._collect_descendant_folder_ids(folder)
document_ids = self._collect_document_ids_for_delete(folder_ids)
if document_ids:
Document.objects.filter(id__in=document_ids).delete()
Folder.objects.filter(id__in=folder_ids).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get"])
def children(self, request, pk=None):
"""Получить дочерние папки данной папки."""

View File

@ -16,6 +16,8 @@ from __future__ import annotations
"""
import tempfile
import io
import zipfile
from typing import Iterable
import pytest
@ -26,7 +28,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from django.utils import timezone
from documents.models import Document, DocumentAcknowledgement
from filer.models import File as FilerFile
from filer.models import File as FilerFile, Folder
from rest_framework import status
from rest_framework.test import APIClient
from tests.conftest import _unique_phone
@ -156,10 +158,19 @@ def api_urls():
def _ack(pk: int) -> str:
return reverse("api:v1:documents-acknowledge", args=[pk])
def _folder_detail(pk: int) -> str:
return reverse("api:v1:folders-detail", args=[pk])
def _folder_archive(pk: int) -> str:
return reverse("api:v1:folders-archive", args=[pk])
return {
"list": reverse("api:v1:documents-list"),
"archive": reverse("api:v1:documents-archive"),
"detail": _detail,
"ack": _ack,
"folder_detail": _folder_detail,
"folder_archive": _folder_archive,
}
@pytest.fixture(autouse=True)
@ -374,6 +385,29 @@ class TestCreate:
assert doc.title == "Title"
assert doc.sent_to_all is True
def test_create_allows_arbitrary_file_format(
self, auth_client, make_user, api_urls
):
"""API принимает файл с любым расширением и MIME-типом."""
uploader = make_user("any-format-uploader@example.com", staff=True)
client = auth_client(uploader)
arbitrary_file = SimpleUploadedFile(
name="archive.custom-format",
content=b"custom payload",
content_type="application/octet-stream",
)
response = client.post(
api_urls["list"],
{"file": arbitrary_file, "sent_to_all": True},
format="multipart",
)
assert response.status_code in (200, 201), response.content
body = response.json()
assert body["title"] == "archive"
assert body["file_name"] == "archive.custom-format"
def test_create_sent_to_all_false_with_recipients_variants(
self, auth_client, make_user, api_urls
):
@ -427,9 +461,14 @@ class TestCreate:
client = auth_client(uploader)
url = api_urls["list"]
# нет title
r = client.post(url, {"file": _file(), "sent_to_all": True}, format="multipart")
assert r.status_code == 400
# title можно не передавать: он берется из имени файла
r = client.post(
url,
{"file": _file(name="derived-title.pdf"), "sent_to_all": True},
format="multipart",
)
assert r.status_code in (200, 201), r.content
assert r.json()["title"] == "derived-title"
# нет file
r = client.post(url, {"title": "T", "sent_to_all": True}, format="multipart")
@ -702,6 +741,157 @@ class TestDelete:
# каскад: записей нет
assert not DocumentAcknowledgement.objects.filter(document=d).exists()
def test_delete_folder_removes_nested_folders_documents_and_related(
self, auth_client, make_user, api_urls
):
"""DELETE папки удаляет её дерево, документы внутри и связанные документы."""
owner = make_user("folder-owner@example.com")
client = auth_client(owner)
root = Folder.objects.create(name="Root", owner=owner)
child = Folder.objects.create(name="Child", parent=root, owner=owner)
other = Folder.objects.create(name="Other", owner=owner)
root_doc = make_document(title="Root doc", uploaded_by=owner)
root_doc.folder = root
root_doc.save(update_fields=["folder"])
child_doc = make_document(title="Child doc", uploaded_by=owner)
child_doc.folder = child
child_doc.save(update_fields=["folder"])
related_doc = make_document(title="Related doc", uploaded_by=owner)
related_doc.folder = other
related_doc.save(update_fields=["folder"])
root_doc.related_documents.add(related_doc)
unrelated_doc = make_document(title="Unrelated doc", uploaded_by=owner)
unrelated_doc.folder = other
unrelated_doc.save(update_fields=["folder"])
response = client.delete(api_urls["folder_detail"](root.pk))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Folder.objects.filter(pk__in=[root.pk, child.pk]).exists()
assert Folder.objects.filter(pk=other.pk).exists()
assert not Document.objects.filter(
pk__in=[root_doc.pk, child_doc.pk, related_doc.pk]
).exists()
assert Document.objects.filter(pk=unrelated_doc.pk).exists()
# -------------------- E2. Папки --------------------
class TestFolders:
"""E2. Операции с папками документов."""
def test_documents_archive_contains_selected_documents(
self, auth_client, make_user, api_urls
):
"""POST archive отдаёт ZIP только с выбранными документами."""
owner = make_user("documents-archive-owner@example.com")
client = auth_client(owner)
first_file = _filer_file(
name="first.txt", content=b"first payload", owner=owner
)
second_file = _filer_file(
name="second.txt", content=b"second payload", owner=owner
)
other_file = _filer_file(
name="other.txt", content=b"other payload", owner=owner
)
first = Document.objects.create(
title="First doc",
file=first_file,
uploaded_by=owner,
sent_to_all=True,
)
second = Document.objects.create(
title="Second doc",
file=second_file,
uploaded_by=owner,
sent_to_all=True,
)
Document.objects.create(
title="Other doc",
file=other_file,
uploaded_by=owner,
sent_to_all=True,
)
response = client.post(
api_urls["archive"],
{"document_ids": [first.pk, second.pk]},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert response["Content-Type"] == "application/zip"
archive_bytes = b"".join(response.streaming_content)
archive = zipfile.ZipFile(io.BytesIO(archive_bytes))
assert archive.read("first.txt") == b"first payload"
assert archive.read("second.txt") == b"second payload"
assert "other.txt" not in archive.namelist()
def test_folder_archive_contains_nested_documents(
self, auth_client, make_user, api_urls
):
"""GET archive отдаёт ZIP с папкой, подпапками и документами."""
owner = make_user("folder-archive-owner@example.com")
client = auth_client(owner)
root = Folder.objects.create(name="Root", owner=owner)
child = Folder.objects.create(name="Child", parent=root, owner=owner)
other = Folder.objects.create(name="Other", owner=owner)
root_file = _filer_file(
name="root.txt", content=b"root payload", owner=owner
)
child_file = _filer_file(
name="child.txt", content=b"child payload", owner=owner
)
other_file = _filer_file(
name="other.txt", content=b"other payload", owner=owner
)
Document.objects.create(
title="Root doc",
file=root_file,
folder=root,
uploaded_by=owner,
sent_to_all=True,
)
Document.objects.create(
title="Child doc",
file=child_file,
folder=child,
uploaded_by=owner,
sent_to_all=True,
)
Document.objects.create(
title="Other doc",
file=other_file,
folder=other,
uploaded_by=owner,
sent_to_all=True,
)
response = client.get(api_urls["folder_archive"](root.pk))
assert response.status_code == status.HTTP_200_OK
assert response["Content-Type"] == "application/zip"
archive_bytes = b"".join(response.streaming_content)
archive = zipfile.ZipFile(io.BytesIO(archive_bytes))
assert "Root/" in archive.namelist()
assert "Root/Child/" in archive.namelist()
assert archive.read("Root/root.txt") == b"root payload"
assert archive.read("Root/Child/child.txt") == b"child payload"
assert "Other/other.txt" not in archive.namelist()
# -------------------- F. Экшен acknowledge --------------------
class TestAcknowledge:

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useMemo } from "react";
import { useDropzone } from "react-dropzone";
import {
Upload,
@ -30,12 +30,92 @@ interface User {
email?: string;
}
interface DocumentTagOption {
id: number;
name: string;
color?: string;
}
interface FolderOption {
id: number;
name: string;
parent_id: number | null;
path?: string;
}
interface FolderOptionRow {
folder: FolderOption;
level: number;
}
interface DocumentUploadFormProps {
onSuccess?: () => void;
onCancel?: () => void;
currentFolderId?: number | null;
}
interface UploadFileItem {
id: string;
file: File;
processedFile?: File | Blob;
extractedText: string;
processingError?: string;
}
function getDefaultDocumentTitle(fileName: string): string {
return fileName.replace(/\.[^/.]+$/, "").trim() || fileName;
}
function createUploadItem(file: File): UploadFileItem {
return {
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2)}`,
file,
extractedText: "",
};
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(2)} МБ`;
}
function getFileForUpload(item: UploadFileItem): File | Blob {
if (!item.processedFile) return item.file;
if (item.processedFile instanceof File) return item.processedFile;
return new File([item.processedFile], item.file.name, {
type: item.processedFile.type || item.file.type,
lastModified: item.file.lastModified,
});
}
function buildFolderOptionRows(folders: FolderOption[]): FolderOptionRow[] {
const childrenByParent = new Map<number | null, FolderOption[]>();
folders.forEach((folder) => {
const parentId = folder.parent_id ?? null;
const children = childrenByParent.get(parentId) || [];
children.push(folder);
childrenByParent.set(parentId, children);
});
childrenByParent.forEach((children) => {
children.sort((a, b) => a.name.localeCompare(b.name, "ru"));
});
const rows: FolderOptionRow[] = [];
const appendChildren = (parentId: number | null, level: number) => {
(childrenByParent.get(parentId) || []).forEach((folder) => {
rows.push({ folder, level });
appendChildren(folder.id, level + 1);
});
};
appendChildren(null, 0);
return rows;
}
// Сообщения для различных этапов обработки
const STAGE_MESSAGES: Record<string, string> = {
compressing: "Сжатие изображения...",
@ -48,9 +128,7 @@ const STAGE_MESSAGES: Record<string, string> = {
export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: DocumentUploadFormProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [file, setFile] = useState<File | null>(null);
const [processedFile, setProcessedFile] = useState<File | Blob | null>(null);
const [extractedText, setExtractedText] = useState("");
const [uploadItems, setUploadItems] = useState<UploadFileItem[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [processingProgress, setProcessingProgress] = useState<ProcessingProgress | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
@ -63,16 +141,24 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
const [selectedRecipients, setSelectedRecipients] = useState<number[]>([]);
// Metadata fields
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(currentFolderId ?? null);
const [selectedTags, setSelectedTags] = useState<number[]>([]);
// Data for selects
const [departments, setDepartments] = useState<Department[]>([]);
const [employees, setEmployees] = useState<User[]>([]);
const [documentTags, setDocumentTags] = useState<any[]>([]);
const [folders, setFolders] = useState<FolderOption[]>([]);
const [documentTags, setDocumentTags] = useState<DocumentTagOption[]>([]);
const [loadingDepartments, setLoadingDepartments] = useState(false);
const [loadingEmployees, setLoadingEmployees] = useState(false);
const [loadingFolders, setLoadingFolders] = useState(false);
const [loadingDocumentTags, setLoadingDocumentTags] = useState(false);
const folderOptionRows = useMemo(() => buildFolderOptionRows(folders), [folders]);
useEffect(() => {
setSelectedFolderId(currentFolderId ?? null);
}, [currentFolderId]);
// Load departments and employees
useEffect(() => {
@ -96,6 +182,17 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
} finally {
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);
}
try {
setLoadingDocumentTags(true);
@ -112,79 +209,126 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
}, []);
const onDrop = useCallback(async (acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
const selectedFile = acceptedFiles[0];
setFile(selectedFile);
setError(null);
// Автоматически заполняем название, если оно пустое
if (!title && selectedFile.name) {
setTitle(selectedFile.name.replace(/\.[^/.]+$/, "")); // Убираем расширение
}
if (acceptedFiles.length === 0) return;
// Проверяем, требуется ли обработка файла
if (needsProcessing(selectedFile)) {
setIsProcessing(true);
setProcessingProgress({
stage: "compressing",
progress: 0,
message: "Начало обработки..."
});
const newItems = acceptedFiles.map(createUploadItem);
const totalAfterDrop = uploadItems.length + newItems.length;
setUploadItems((prev) => [...prev, ...newItems]);
setError(null);
if (totalAfterDrop === 1 && !title.trim()) {
setTitle(getDefaultDocumentTitle(newItems[0].file.name));
} else if (totalAfterDrop > 1) {
setTitle("");
}
if (!newItems.some((item) => needsProcessing(item.file))) return;
setIsProcessing(true);
setProcessingProgress({
stage: "compressing",
progress: 0,
message: "Начало обработки...",
});
let hasProcessingErrors = false;
try {
for (const [index, item] of newItems.entries()) {
if (!needsProcessing(item.file)) continue;
try {
const result = await processDocument(selectedFile, {
const result = await processDocument(item.file, {
enableOCR: true,
enableCompression: true,
enableTextExtraction: true,
enableThumbnail: true,
onProgress: (progress) => {
setProcessingProgress(progress);
setProcessingProgress({
...progress,
progress: Math.round(progress.progress),
message:
newItems.length > 1
? `Файл ${index + 1} из ${newItems.length}: ${progress.message}`
: progress.message,
});
},
});
// Сохраняем извлеченный текст
if (result.extractedText) {
setExtractedText(result.extractedText);
}
// Если файл был обработан (сжат), используем обработанную версию
if (result.processedFile) {
setProcessedFile(result.processedFile);
}
toast.success("Документ обработан успешно");
setUploadItems((prev) =>
prev.map((current) =>
current.id === item.id
? {
...current,
processedFile: result.processedFile,
extractedText: result.extractedText || current.extractedText,
processingError: undefined,
}
: current
)
);
} catch (err) {
hasProcessingErrors = true;
console.error("Ошибка обработки документа:", err);
setError(err instanceof Error ? err.message : "Не удалось обработать документ");
toast.error("Ошибка обработки документа");
} finally {
setIsProcessing(false);
setProcessingProgress(null);
setUploadItems((prev) =>
prev.map((current) =>
current.id === item.id
? {
...current,
processingError:
err instanceof Error ? err.message : "Не удалось обработать документ",
}
: current
)
);
}
}
if (hasProcessingErrors) {
setError("Некоторые файлы не удалось обработать. Они будут загружены в исходном виде.");
toast("Некоторые файлы не удалось обработать");
} else {
toast.success(newItems.length > 1 ? "Файлы обработаны успешно" : "Документ обработан успешно");
}
} finally {
setIsProcessing(false);
setProcessingProgress(null);
}
}, [title]);
}, [title, uploadItems.length]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: false,
accept: {
'application/pdf': ['.pdf'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'application/vnd.ms-excel': ['.xls'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'image/*': ['.png', '.jpg', '.jpeg', '.gif'],
'text/*': ['.txt', '.csv'],
},
multiple: true,
disabled: isProcessing || isSubmitting,
});
const removeUploadItem = (id: string) => {
const next = uploadItems.filter((item) => item.id !== id);
setUploadItems(next);
if (next.length === 0) {
setTitle("");
} else if (next.length === 1 && !title.trim()) {
setTitle(getDefaultDocumentTitle(next[0].file.name));
}
};
const updateSingleExtractedText = (value: string) => {
setUploadItems((prev) =>
prev.map((item, index) =>
index === 0 ? { ...item, extractedText: value } : item
)
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!title || !file) {
setError("Заполните все обязательные поля");
if (uploadItems.length === 0) {
setError("Выберите хотя бы один файл");
return;
}
@ -197,30 +341,37 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
setIsSubmitting(true);
try {
// Используем обработанный файл, если он есть, иначе оригинальный
const fileToUpload = processedFile || file;
for (const item of uploadItems) {
const documentTitle =
uploadItems.length === 1
? title.trim() || getDefaultDocumentTitle(item.file.name)
: getDefaultDocumentTitle(item.file.name);
await apiClient.createDocument({
title,
description,
file: fileToUpload,
extracted_text: extractedText || undefined,
folder_id: currentFolderId || undefined,
sent_to_all: sentToAll,
department_ids: sentToAll ? undefined : selectedDepartments,
recipient_ids: sentToAll ? undefined : selectedRecipients,
acknowledgement_required: acknowledgementRequired,
tag_ids: selectedTags.length > 0 ? selectedTags : undefined,
});
await apiClient.createDocument({
title: documentTitle,
description,
file: getFileForUpload(item),
extracted_text: item.extractedText || undefined,
folder_id: selectedFolderId ?? undefined,
sent_to_all: sentToAll,
department_ids: sentToAll ? undefined : selectedDepartments,
recipient_ids: sentToAll ? undefined : selectedRecipients,
acknowledgement_required: acknowledgementRequired,
tag_ids: selectedTags.length > 0 ? selectedTags : undefined,
});
}
toast.success("Документ успешно загружен");
toast.success(
uploadItems.length > 1
? `${uploadItems.length} документов успешно загружено`
: "Документ успешно загружен"
);
// Сброс формы
setTitle("");
setDescription("");
setFile(null);
setProcessedFile(null);
setExtractedText("");
setUploadItems([]);
setSelectedFolderId(currentFolderId ?? null);
setSentToAll(true);
setAcknowledgementRequired(false);
setSelectedDepartments([]);
@ -248,64 +399,88 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
</div>
)}
{/* Current folder info */}
{currentFolderId && (
<div className="app-selected app-accent-text rounded-lg p-3 text-sm">
<div className="flex items-center gap-2">
<FolderOpen size={16} />
<span>Документ будет сохранён в выбранной папке</span>
</div>
</div>
)}
{/* File Upload */}
<div>
<label className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
Файл <span className="text-red-500">*</span>
Файлы <span className="text-red-500">*</span>
</label>
{!file ? (
<div
{...getRootProps()}
className={`cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition ${
isDragActive
? "border-[var(--accent-primary)] bg-[color:color-mix(in_srgb,var(--accent-primary)_10%,var(--surface-secondary))]"
: "border-[var(--border-strong)] bg-[var(--surface-secondary)] hover:border-[var(--accent-primary)] hover:bg-[color:color-mix(in_srgb,var(--accent-primary)_8%,var(--surface-secondary))]"
}`}
>
<input {...getInputProps()} />
<Upload size={32} className="app-text-muted mx-auto mb-3" />
<p className="text-sm font-medium text-[var(--foreground)]">
{isDragActive ? "Отпустите файл здесь" : "Перетащите файл или нажмите для выбора"}
</p>
<p className="app-text-muted mt-1 text-xs">
PDF, Word, Excel, изображения, текстовые файлы
</p>
</div>
) : (
<div className="space-y-3">
<div className="app-surface flex items-center gap-3 rounded-lg p-3">
<FileText size={20} className="app-accent-text shrink-0" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-[var(--foreground)]">{file.name}</p>
<p className="app-text-muted text-xs">
{(file.size / 1024 / 1024).toFixed(2)} МБ
</p>
</div>
<button
type="button"
onClick={() => {
setFile(null);
setProcessedFile(null);
setExtractedText("");
}}
disabled={isProcessing}
className="app-action-ghost app-text-muted shrink-0 rounded-full p-1 hover:text-[var(--foreground)] disabled:cursor-not-allowed disabled:opacity-50"
>
<X size={16} />
</button>
<div
{...getRootProps()}
className={`cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition ${
isDragActive
? "border-[var(--accent-primary)] bg-[color:color-mix(in_srgb,var(--accent-primary)_10%,var(--surface-secondary))]"
: "border-[var(--border-strong)] bg-[var(--surface-secondary)] hover:border-[var(--accent-primary)] hover:bg-[color:color-mix(in_srgb,var(--accent-primary)_8%,var(--surface-secondary))]"
} ${isProcessing || isSubmitting ? "cursor-not-allowed opacity-60" : ""}`}
>
<input {...getInputProps()} />
<Upload size={32} className="app-text-muted mx-auto mb-3" />
<p className="text-sm font-medium text-[var(--foreground)]">
{isDragActive
? "Отпустите файлы здесь"
: uploadItems.length > 0
? "Добавьте ещё файлы или нажмите для выбора"
: "Перетащите файлы или нажмите для выбора"}
</p>
<p className="app-text-muted mt-1 text-xs">
Можно выбрать несколько файлов любых форматов
</p>
</div>
{uploadItems.length > 0 && (
<div className="mt-3 space-y-3">
<div className="app-text-muted text-xs">
Выбрано файлов: {uploadItems.length}
</div>
<div className="flex flex-wrap gap-2">
{uploadItems.map((item) => (
<span
key={item.id}
className={`inline-flex max-w-full items-center gap-2 rounded-full px-2.5 py-1.5 ${
item.processingError ? "app-feedback-danger" : "app-badge"
}`}
title={item.processingError || item.file.name}
>
<FileText size={16} className="app-accent-text shrink-0" />
<span className="min-w-0">
<span className="block max-w-[14rem] truncate text-sm font-medium text-[var(--foreground)]">
{item.file.name}
</span>
<span className="app-text-muted block truncate text-xs">
{formatFileSize(item.file.size)}
{item.extractedText ? " · текст извлечён" : ""}
{item.processedFile
? ` · сжато до ${formatFileSize(item.processedFile.size)}`
: ""}
{item.processingError ? " · ошибка обработки" : ""}
</span>
</span>
<button
type="button"
onClick={() => removeUploadItem(item.id)}
disabled={isProcessing}
className="app-action-ghost app-text-muted -mr-1 shrink-0 rounded-full p-1 hover:text-[var(--foreground)] disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Убрать файл ${item.file.name}`}
>
<X size={14} />
</button>
</span>
))}
</div>
{uploadItems.some((item) => item.processingError) && (
<div className="space-y-1">
{uploadItems.map((item) =>
item.processingError ? (
<p key={item.id} className="text-xs text-red-500">
{item.file.name}: {item.processingError}
</p>
) : null
)}
</div>
)}
{/* Индикатор обработки */}
{isProcessing && processingProgress && (
<div className="app-selected rounded-lg p-3">
@ -322,36 +497,37 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
/>
</div>
<p className="app-accent-text mt-1 text-xs">
{processingProgress.progress}%
{processingProgress.message} · {processingProgress.progress}%
</p>
</div>
)}
{/* Сообщение о сжатии */}
{processedFile && !isProcessing && (
<div className="app-feedback-success rounded-lg p-2 text-xs">
✓ Изображение сжато: {(file.size / 1024 / 1024).toFixed(2)} МБ → {(processedFile.size / 1024 / 1024).toFixed(2)} МБ
</div>
)}
</div>
)}
</div>
{/* Title */}
<div>
<label htmlFor="title" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
Название <span className="text-red-500">*</span>
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Введите название документа"
required
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</div>
{uploadItems.length <= 1 ? (
<div>
<label htmlFor="title" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
Название
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="По умолчанию — название файла"
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
<p className="app-text-muted mt-1 text-xs">
Поле необязательное. Если оставить пустым, будет использовано имя файла без расширения.
</p>
</div>
) : (
<div className="app-selected app-accent-text rounded-lg p-3 text-sm">
Для каждого документа название будет взято из имени соответствующего файла.
</div>
)}
{/* Description */}
<div>
@ -376,42 +552,92 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
</h3>
</div>
{/* Tags */}
{/* Folder */}
<div>
<label htmlFor="tags" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
Теги
<label htmlFor="folder" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
<FolderOpen size={14} className="mr-1 inline" />
Папка
</label>
<select
id="tags"
multiple
value={selectedTags.map(String)}
onChange={(e) => {
const values = Array.from(e.target.selectedOptions, (option) => Number(option.value));
setSelectedTags(values);
}}
disabled={loadingDocumentTags}
id="folder"
value={selectedFolderId ?? ""}
onChange={(e) => setSelectedFolderId(e.target.value ? Number(e.target.value) : null)}
disabled={loadingFolders}
className="app-select w-full rounded-lg px-3 py-2 text-sm"
size={4}
>
{loadingDocumentTags ? (
<option value="">Без папки</option>
{loadingFolders ? (
<option disabled>Загрузка...</option>
) : documentTags.length === 0 ? (
<option disabled>Нет доступных тегов</option>
) : folderOptionRows.length === 0 ? (
<option disabled>Нет доступных папок</option>
) : (
documentTags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
folderOptionRows.map(({ folder, level }) => (
<option key={folder.id} value={folder.id}>
{`${"— ".repeat(level)}${folder.name}`}
</option>
))
)}
</select>
<p className="app-text-muted mt-1 text-xs">
Удерживайте Ctrl/Cmd для выбора нескольких тегов
Выбранная папка будет применена ко всем загружаемым документам.
</p>
</div>
{/* Tags */}
<div>
<div id="document-upload-tags-label" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
Теги
</div>
<div
role="group"
aria-labelledby="document-upload-tags-label"
className="app-surface-muted flex min-h-12 flex-wrap items-center gap-2 rounded-lg p-2"
>
{loadingDocumentTags ? (
<span className="app-text-muted px-1 text-sm">Загрузка тегов...</span>
) : documentTags.length === 0 ? (
<span className="app-text-muted px-1 text-sm">Нет доступных тегов</span>
) : (
documentTags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() =>
setSelectedTags((prev) =>
prev.includes(tag.id)
? prev.filter((id) => id !== tag.id)
: [...prev, tag.id]
)
}
className={`inline-flex max-w-full items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
selectedTags.includes(tag.id)
? "app-badge app-badge-accent"
: "app-badge hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]"
}`}
aria-pressed={selectedTags.includes(tag.id)}
>
{tag.color && (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: tag.color }}
/>
)}
<span className="truncate">{tag.name}</span>
</button>
))
)}
</div>
<p className="app-text-muted mt-1 text-xs">
{selectedTags.length > 0 ? (
<>Выбрано: {selectedTags.length}</>
) : (
<>Нажмите на тег, чтобы добавить его к документу</>
)}
</p>
</div>
{/* Извлеченный текст */}
{extractedText && (
{uploadItems.length === 1 && uploadItems[0].extractedText && (
<div>
<label htmlFor="extractedText" className="mb-1.5 block text-sm font-medium text-[var(--foreground)]">
Извлеченный текст
@ -421,18 +647,24 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
</label>
<textarea
id="extractedText"
value={extractedText}
onChange={(e) => setExtractedText(e.target.value)}
value={uploadItems[0].extractedText}
onChange={(e) => updateSingleExtractedText(e.target.value)}
placeholder="Текст, извлеченный из документа"
rows={5}
className="app-input w-full rounded-lg px-3 py-2 text-sm font-mono"
/>
<p className="app-text-muted mt-1 text-xs">
Символов: {extractedText.length}
Символов: {uploadItems[0].extractedText.length}
</p>
</div>
)}
{uploadItems.length > 1 && uploadItems.some((item) => item.extractedText) && (
<div className="app-selected app-accent-text rounded-lg p-3 text-sm">
Текст извлечён для {uploadItems.filter((item) => item.extractedText).length} файлов и будет сохранён для поиска.
</div>
)}
{/* Divider */}
<div className="app-divider border-t pt-4">
<h3 className="mb-3 text-sm font-semibold text-[var(--foreground)]">Настройки доступа и уведомлений</h3>
@ -571,13 +803,19 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
</div>
{/* Buttons */}
<div className="flex gap-3">
<div className="app-divider sticky bottom-0 z-10 -mx-4 flex gap-3 border-t bg-[var(--surface-elevated)] px-4 py-3 sm:-mx-6 sm:px-6">
<button
type="submit"
disabled={isSubmitting || isProcessing || !title || !file}
disabled={isSubmitting || isProcessing || uploadItems.length === 0}
className="app-action-primary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? "Загрузка..." : isProcessing ? "Обработка..." : "Загрузить документ"}
{isSubmitting
? "Загрузка..."
: isProcessing
? "Обработка..."
: uploadItems.length > 1
? `Загрузить ${uploadItems.length} документов`
: "Загрузить документ"}
</button>
{onCancel && (

View File

@ -1,19 +1,18 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import {
ChevronDown,
CheckSquare,
Square,
Download,
Folder,
Tag,
RefreshCw,
Trash2,
X,
Loader2,
Undo2,
CheckCircle2,
} from "lucide-react";
import { Document as ApiDocument } from "@/types/api";
export interface Document {
id: number;
@ -42,10 +41,10 @@ export interface BatchOperationResult {
interface BulkActionsToolbarProps {
selectedIds: number[];
documents: Document[];
onMove?: (folderId: string, documentIds: number[]) => Promise<void>;
onAddTags?: (tagIds: string[], documentIds: number[]) => Promise<void>;
onChangeStatus?: (status: string, documentIds: number[]) => Promise<void>;
onDownload?: (documentIds: number[]) => Promise<void>;
onDelete?: (documentIds: number[]) => Promise<void>;
onClearSelection: () => void;
availableFolders?: Array<{ id: string; name: string }>;
@ -55,10 +54,10 @@ interface BulkActionsToolbarProps {
export function BulkActionsToolbar({
selectedIds,
documents,
onMove,
onAddTags,
onChangeStatus,
onDownload,
onDelete,
onClearSelection,
availableFolders = [],
@ -70,34 +69,36 @@ export function BulkActionsToolbar({
const [progress, setProgress] = useState(0);
const [history, setHistory] = useState<BatchOperationResult[]>([]);
const [showUndoNotification, setShowUndoNotification] = useState(false);
const [openActionMenu, setOpenActionMenu] = useState<"move" | "tag" | "status" | null>(null);
const actionMenuRef = useRef<HTMLDivElement | null>(null);
// Выбранные документы
const selectedDocuments = useMemo(
() => documents.filter((doc) => selectedIds.includes(doc.id)),
[documents, selectedIds]
);
useEffect(() => {
if (!openActionMenu) return;
const handlePointerDown = (event: MouseEvent) => {
if (actionMenuRef.current && !actionMenuRef.current.contains(event.target as Node)) {
setOpenActionMenu(null);
}
};
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, [openActionMenu]);
// Выполнение batch операции с прогрессом
const executeBatchOperation = useCallback(
async (
action: string,
operation: (id: number) => Promise<void>,
operation: () => Promise<void>,
onSuccess?: () => void
) => {
setIsProcessing(true);
setCurrentAction(action);
setProgress(0);
const total = selectedIds.length;
let completed = 0;
setProgress(15);
try {
// Выполняем операцию для каждого документа
for (const id of selectedIds) {
await operation(id);
completed++;
setProgress(Math.round((completed / total) * 100));
}
await operation();
setProgress(100);
// Успех
if (onSuccess) onSuccess();
@ -112,7 +113,7 @@ export function BulkActionsToolbar({
setProgress(0);
}
},
[selectedIds]
[]
);
// Переместить в папку
@ -214,6 +215,15 @@ export function BulkActionsToolbar({
[selectedIds, onChangeStatus, executeBatchOperation, onClearSelection]
);
// Скачать выбранные
const handleDownload = useCallback(async () => {
if (!onDownload) return;
await executeBatchOperation("Подготовка архива", async () => {
await onDownload(selectedIds);
});
}, [selectedIds, onDownload, executeBatchOperation]);
// Удалить
const handleDelete = useCallback(async () => {
if (!onDelete) return;
@ -264,101 +274,175 @@ export function BulkActionsToolbar({
return (
<>
{/* Toolbar */}
<div className="app-selected sticky top-0 z-10 rounded-lg p-3 shadow-[var(--shadow-card)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="app-accent-text flex items-center gap-2 text-sm font-medium">
<CheckSquare size={16} />
<span>
Выбрано: <span className="font-bold">{selectedIds.length}</span>
</span>
</div>
<div className="app-selected sticky top-0 z-10 rounded-xl px-3 py-2.5 shadow-[var(--shadow-card)]">
<div className="flex flex-wrap items-center gap-2">
<div className="app-accent-text flex h-8 shrink-0 items-center gap-2 rounded-full bg-[color:var(--accent-soft)] px-3 text-sm font-semibold">
<CheckSquare size={16} className="shrink-0" />
<span>
Выбрано: <span className="font-bold">{selectedIds.length}</span>
</span>
</div>
{/* Действия */}
<div className="flex items-center gap-2">
{/* Переместить */}
{onMove && availableFolders.length > 0 && (
<select
onChange={(e) => handleMove(e.target.value)}
value=""
disabled={isProcessing}
className="app-select rounded px-3 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Переместить в...</option>
{availableFolders.map((folder) => (
<option key={folder.id} value={folder.id}>
📁 {folder.name}
</option>
))}
</select>
)}
{/* Добавить теги */}
{onAddTags && availableTags.length > 0 && (
<select
onChange={(e) => {
const value = e.target.value;
if (value) {
handleAddTags([value]);
e.target.value = "";
}
}}
value=""
disabled={isProcessing}
className="app-select rounded px-3 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Добавить тег...</option>
{availableTags.map((tag) => (
<option key={tag.id} value={tag.id}>
🏷️ {tag.name}
</option>
))}
</select>
)}
{/* Изменить статус */}
{onChangeStatus && availableStatuses.length > 0 && (
<select
onChange={(e) => handleChangeStatus(e.target.value)}
value=""
disabled={isProcessing}
className="app-select rounded px-3 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Изменить статус...</option>
{availableStatuses.map((status) => (
<option key={status.id} value={status.id}>
{status.name}
</option>
))}
</select>
)}
{/* Удалить */}
{onDelete && (
{/* Действия */}
<div ref={actionMenuRef} className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{/* Переместить */}
{onMove && availableFolders.length > 0 && (
<div className="relative">
<button
onClick={handleDelete}
type="button"
onClick={() => setOpenActionMenu((prev) => (prev === "move" ? null : "move"))}
disabled={isProcessing}
className="app-action-danger flex items-center gap-1 rounded px-3 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-50"
title="Удалить выбранные"
className="app-action-secondary flex h-8 items-center gap-1.5 rounded-full px-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
title="Переместить выбранные"
aria-expanded={openActionMenu === "move"}
aria-haspopup="menu"
>
<Trash2 size={14} />
Удалить
<Folder size={14} />
Переместить
<ChevronDown
size={14}
className={`transition-transform ${openActionMenu === "move" ? "rotate-180" : ""}`}
/>
</button>
)}
</div>
{openActionMenu === "move" && (
<div className="app-menu absolute left-0 top-full z-30 mt-2 max-h-64 w-64 overflow-y-auto rounded-xl py-1.5">
{availableFolders.map((folder) => (
<button
key={folder.id}
type="button"
onClick={() => {
setOpenActionMenu(null);
void handleMove(folder.id);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Folder size={14} className="shrink-0" />
<span className="truncate">{folder.name}</span>
</button>
))}
</div>
)}
</div>
)}
{/* Добавить теги */}
{onAddTags && availableTags.length > 0 && (
<div className="relative">
<button
type="button"
onClick={() => setOpenActionMenu((prev) => (prev === "tag" ? null : "tag"))}
disabled={isProcessing}
className="app-action-secondary flex h-8 items-center gap-1.5 rounded-full px-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
title="Добавить тег к выбранным"
aria-expanded={openActionMenu === "tag"}
aria-haspopup="menu"
>
<Tag size={14} />
Тег
<ChevronDown
size={14}
className={`transition-transform ${openActionMenu === "tag" ? "rotate-180" : ""}`}
/>
</button>
{openActionMenu === "tag" && (
<div className="app-menu absolute left-0 top-full z-30 mt-2 max-h-64 w-56 overflow-y-auto rounded-xl py-1.5">
{availableTags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => {
setOpenActionMenu(null);
void handleAddTags([tag.id]);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Tag size={14} className="shrink-0" />
<span className="truncate">{tag.name}</span>
</button>
))}
</div>
)}
</div>
)}
{/* Изменить статус */}
{onChangeStatus && availableStatuses.length > 0 && (
<div className="relative">
<button
type="button"
onClick={() => setOpenActionMenu((prev) => (prev === "status" ? null : "status"))}
disabled={isProcessing}
className="app-action-secondary flex h-8 items-center gap-1.5 rounded-full px-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
title="Изменить статус выбранных"
aria-expanded={openActionMenu === "status"}
aria-haspopup="menu"
>
Статус
<ChevronDown
size={14}
className={`transition-transform ${openActionMenu === "status" ? "rotate-180" : ""}`}
/>
</button>
{openActionMenu === "status" && (
<div className="app-menu absolute left-0 top-full z-30 mt-2 max-h-64 w-56 overflow-y-auto rounded-xl py-1.5">
{availableStatuses.map((status) => (
<button
key={status.id}
type="button"
onClick={() => {
setOpenActionMenu(null);
void handleChangeStatus(status.id);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<span className="truncate">{status.name}</span>
</button>
))}
</div>
)}
</div>
)}
{/* Скачать */}
{onDownload && (
<button
onClick={handleDownload}
disabled={isProcessing}
className="app-action-secondary flex h-8 shrink-0 items-center gap-1.5 rounded-full px-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
title="Скачать выбранные архивом"
>
<Download size={14} />
Скачать
</button>
)}
{/* Удалить */}
{onDelete && (
<button
onClick={handleDelete}
disabled={isProcessing}
className="app-action-danger flex h-8 shrink-0 items-center gap-1.5 rounded-full px-3 text-sm disabled:cursor-not-allowed disabled:opacity-50"
title="Удалить выбранные"
>
<Trash2 size={14} />
Удалить
</button>
)}
</div>
{/* Очистить выбор */}
<button
onClick={onClearSelection}
disabled={isProcessing}
className="app-text-muted flex items-center gap-1 text-sm hover:text-[var(--foreground)] disabled:cursor-not-allowed disabled:opacity-50"
className="app-text-muted ml-auto flex h-8 shrink-0 items-center gap-1 rounded-full px-2.5 text-sm hover:bg-[var(--surface-secondary)] hover:text-[var(--foreground)] disabled:cursor-not-allowed disabled:opacity-50"
>
<X size={14} />
Отменить
</button>
</div>
{/* Прогресс */}
{isProcessing && (
<div className="mt-3">

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useCallback } from "react";
import { ChevronRight, ChevronDown, Folder, FolderOpen, Plus, Edit2, Trash2 } from "lucide-react";
export interface FolderNode {
@ -29,7 +29,7 @@ export function FolderTree({
onEditFolder,
onDeleteFolder,
}: FolderTreeProps) {
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const [collapsedIds, setCollapsedIds] = useState<Set<number>>(new Set());
const [hoveredId, setHoveredId] = useState<number | null>(null);
// Построение дерева из плоского списка
@ -61,7 +61,7 @@ export function FolderTree({
const tree = buildTree(folders);
const toggleExpand = (folderId: number) => {
setExpandedIds((prev) => {
setCollapsedIds((prev) => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
@ -73,9 +73,9 @@ export function FolderTree({
};
const renderNode = (node: FolderNode, level: number = 0) => {
const isExpanded = expandedIds.has(node.id);
const isSelected = selectedFolderId === node.id;
const hasChildren = node.children && node.children.length > 0;
const isExpanded = Boolean(hasChildren && !collapsedIds.has(node.id));
const isHovered = hoveredId === node.id;
return (

View File

@ -84,7 +84,7 @@ export class ApiClientBase {
return data.access;
}
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
private buildHeaders(options: RequestInit): Record<string, string> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
@ -98,6 +98,20 @@ export class ApiClientBase {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
private async parseErrorDetails(response: Response): Promise<string> {
try {
const data = await response.json();
return data.detail || JSON.stringify(data);
} catch {
return response.statusText;
}
}
async requestRaw(endpoint: string, options: RequestInit = {}): Promise<Response> {
const headers = this.buildHeaders(options);
const response = await fetch(endpoint, { ...options, headers });
if (response.status === 401 && this.getRefreshToken()) {
@ -112,8 +126,60 @@ export class ApiClientBase {
const retryResponse = await fetch(endpoint, { ...options, headers });
if (!retryResponse.ok) {
let errorDetails = '';
try { const d = await retryResponse.json(); errorDetails = d.detail || JSON.stringify(d); } catch { errorDetails = retryResponse.statusText; }
const errorDetails = await this.parseErrorDetails(retryResponse);
throw new Error(`API Error: ${retryResponse.status} ${errorDetails}`);
}
return retryResponse;
} catch (refreshError) {
this.isRefreshing = false;
this.refreshSubscribers = [];
throw refreshError;
}
}
return new Promise((resolve, reject) => {
this.addRefreshSubscriber((newToken: string) => {
headers['Authorization'] = `Bearer ${newToken}`;
fetch(endpoint, { ...options, headers })
.then(async (res) => {
if (!res.ok) {
const errorDetails = await this.parseErrorDetails(res);
reject(new Error(`API Error: ${res.status} ${errorDetails}`));
return;
}
resolve(res);
})
.catch(reject);
});
});
}
if (!response.ok) {
const errorDetails = await this.parseErrorDetails(response);
throw new Error(`API Error: ${response.status} ${errorDetails}`);
}
return response;
}
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const headers = this.buildHeaders(options);
const response = await fetch(endpoint, { ...options, headers });
if (response.status === 401 && this.getRefreshToken()) {
if (!this.isRefreshing) {
this.isRefreshing = true;
try {
const newToken = await this.refreshAccessToken();
this.isRefreshing = false;
this.onRefreshed(newToken);
headers['Authorization'] = `Bearer ${newToken}`;
const retryResponse = await fetch(endpoint, { ...options, headers });
if (!retryResponse.ok) {
const errorDetails = await this.parseErrorDetails(retryResponse);
throw new Error(`API Error: ${retryResponse.status} ${errorDetails}`);
}
if (options.method === 'DELETE' && retryResponse.status === 204) return undefined as T;
@ -143,8 +209,7 @@ export class ApiClientBase {
}
if (!response.ok) {
let errorDetails = '';
try { const d = await response.json(); errorDetails = d.detail || JSON.stringify(d); } catch { errorDetails = response.statusText; }
const errorDetails = await this.parseErrorDetails(response);
throw new Error(`API Error: ${response.status} ${errorDetails}`);
}

View File

@ -1,22 +1,67 @@
import type { RequestFn } from './utils';
import type { GetTokenFn, RawRequestFn, RequestFn } from './utils';
function parseDownloadFilename(response: Response, fallback: string): string {
const disposition = response.headers.get('content-disposition') || '';
const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/);
const plainMatch = disposition.match(/filename="?([^"]+)"?/);
return encodedMatch?.[1]
? decodeURIComponent(encodedMatch[1])
: plainMatch?.[1] || fallback;
}
async function fetchDownload(
endpoint: string,
options: RequestInit,
fallbackFilename: string,
requestRaw?: RawRequestFn,
getToken?: GetTokenFn
): Promise<{ blob: Blob; filename: string }> {
let response: Response;
if (requestRaw) {
response = await requestRaw(endpoint, options);
} else {
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
const token = getToken?.();
if (token) headers.Authorization = `Bearer ${token}`;
response = await fetch(endpoint, { ...options, headers });
if (!response.ok) {
let errorDetails = response.statusText;
try {
const data = await response.json();
errorDetails = data.detail || JSON.stringify(data);
} catch {}
throw new Error(`API Error: ${response.status} ${errorDetails}`);
}
}
export function createDocumentsApi(request: RequestFn) {
return {
getDocuments: (params?: { search?: string; type?: string; status?: string; page?: number; limit?: number; folder_id?: number }) => {
blob: await response.blob(),
filename: parseDownloadFilename(response, fallbackFilename),
};
}
export function createDocumentsApi(request: RequestFn, getToken?: GetTokenFn, requestRaw?: RawRequestFn) {
return {
getDocuments: (params?: { search?: string; type?: string; status?: string; page?: number; page_size?: number; limit?: number; folder_id?: number }) => {
const qp = new URLSearchParams();
if (params?.search) qp.append('search', params.search);
if (params?.type) qp.append('type', params.type);
if (params?.status) qp.append('status', params.status);
if (params?.page) qp.append('page', params.page.toString());
if (params?.limit) qp.append('limit', params.limit.toString());
const pageSize = params?.page_size ?? params?.limit;
if (pageSize) qp.append('page_size', pageSize.toString());
if (params?.folder_id !== undefined) qp.append('folder_id', params.folder_id.toString());
const qs = qp.toString();
return request(`/api/v1/documents/${qs ? '?' + qs : ''}`);
},
getDocument: (id: number) => request(`/api/v1/documents/${id}/`),
createDocument: (data: { title: string; description?: string; file: File | Blob; extracted_text?: string; sent_to_all?: boolean; recipient_ids?: number[]; department_ids?: number[]; folder_id?: number | null; acknowledgement_required?: boolean; tag_ids?: number[] }) => {
createDocument: (data: { title?: string; description?: string; file: File | Blob; extracted_text?: string; sent_to_all?: boolean; recipient_ids?: number[]; department_ids?: number[]; folder_id?: number | null; acknowledgement_required?: boolean; tag_ids?: number[] }) => {
const fd = new FormData();
fd.append('title', data.title);
if (data.title?.trim()) fd.append('title', data.title.trim());
if (data.description) fd.append('description', data.description);
if (data.extracted_text) fd.append('extracted_text', data.extracted_text);
if (data.folder_id) fd.append('folder', String(data.folder_id));
@ -38,6 +83,19 @@ export function createDocumentsApi(request: RequestFn) {
return request(`/api/v1/documents/${id}/`, { method: 'PATCH', body: fd });
},
deleteDocument: (id: number): Promise<void> => request(`/api/v1/documents/${id}/`, { method: 'DELETE' }),
downloadDocumentsArchive: async (ids: number[]): Promise<{ blob: Blob; filename: string }> => {
return fetchDownload(
'/api/v1/documents/archive/',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document_ids: ids }),
},
'documents.zip',
requestRaw,
getToken
);
},
acknowledgeDocument: (id: number) => request(`/api/v1/documents/${id}/acknowledge/`, { method: 'POST', body: JSON.stringify({}) }),
getDocumentAcknowledgements: (id: number, search?: string) => {
const qp = new URLSearchParams();
@ -46,10 +104,13 @@ export function createDocumentsApi(request: RequestFn) {
return request(`/api/v1/documents/${id}/acknowledgements/${qs ? '?' + qs : ''}`);
},
// Folders
getFolders: (params?: { parent_id?: number; root?: boolean }) => {
getFolders: (params?: { parent_id?: number; root?: boolean; page?: number; page_size?: number; limit?: number }) => {
const qp = new URLSearchParams();
if (params?.parent_id !== undefined) qp.append('parent_id', params.parent_id.toString());
if (params?.root) qp.append('root', 'true');
if (params?.page) qp.append('page', params.page.toString());
const pageSize = params?.page_size ?? params?.limit;
if (pageSize) qp.append('page_size', pageSize.toString());
const qs = qp.toString();
return request(`/api/v1/folders/${qs ? '?' + qs : ''}`);
},
@ -57,6 +118,15 @@ export function createDocumentsApi(request: RequestFn) {
createFolder: (data: { name: string; parent?: number | null }) => request('/api/v1/folders/', { method: 'POST', body: JSON.stringify(data) }),
updateFolder: (id: number, data: { name?: string; parent?: number | null }) => request(`/api/v1/folders/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteFolder: (id: number): Promise<void> => request(`/api/v1/folders/${id}/`, { method: 'DELETE' }),
downloadFolderArchive: async (id: number): Promise<{ blob: Blob; filename: string }> => {
return fetchDownload(
`/api/v1/folders/${id}/archive/`,
{},
`folder-${id}.zip`,
requestRaw,
getToken
);
},
getFolderChildren: (id: number) => request(`/api/v1/folders/${id}/children/`),
getFolderDocuments: (id: number) => request(`/api/v1/folders/${id}/documents/`),
// Comments
@ -67,7 +137,14 @@ export function createDocumentsApi(request: RequestFn) {
deleteDocumentComment: (id: number): Promise<void> => request(`/api/v1/document-comments/${id}/`, { method: 'DELETE' }),
getCommentReplies: (commentId: number) => request(`/api/v1/document-comments/${commentId}/replies/`),
// Tags
getDocumentTags: () => request('/api/v1/document-tags/'),
getDocumentTags: (params?: { page?: number; page_size?: number; limit?: number }) => {
const qp = new URLSearchParams();
if (params?.page) qp.append('page', params.page.toString());
const pageSize = params?.page_size ?? params?.limit;
if (pageSize) qp.append('page_size', pageSize.toString());
const qs = qp.toString();
return request(`/api/v1/document-tags/${qs ? '?' + qs : ''}`);
},
getDocumentTag: (id: number) => request(`/api/v1/document-tags/${id}/`),
createDocumentTag: (data: { name: string; color?: string }) => request('/api/v1/document-tags/', { method: 'POST', body: JSON.stringify(data) }),
updateDocumentTag: (id: number, data: { name?: string; color?: string }) => request(`/api/v1/document-tags/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }),

View File

@ -18,11 +18,12 @@ import { createAttendanceApi } from './attendance';
const base = new ApiClientBase();
const req = base.request.bind(base);
const tok = base.getToken.bind(base);
const raw = base.requestRaw.bind(base);
export const apiClient = Object.assign(base, {
...createAuthApi(req),
...createEmployeesApi(req),
...createDocumentsApi(req),
...createDocumentsApi(req, tok, raw),
...createMessagesApi(req, tok),
...createNotificationsApi(req),
...createCalendarApi(req, tok),

View File

@ -13,4 +13,5 @@ export function buildQuery(params?: Record<string, string | number | boolean | u
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type RequestFn = (endpoint: string, options?: RequestInit) => Promise<any>;
export type RawRequestFn = (endpoint: string, options?: RequestInit) => Promise<Response>;
export type GetTokenFn = () => string | null;