diff --git a/backend/api/v1/documents/permissions.py b/backend/api/v1/documents/permissions.py index b6f6f024..7af6aff5 100644 --- a/backend/api/v1/documents/permissions.py +++ b/backend/api/v1/documents/permissions.py @@ -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 diff --git a/backend/api/v1/documents/serializers.py b/backend/api/v1/documents/serializers.py index 6ad5fc84..2ac20f59 100644 --- a/backend/api/v1/documents/serializers.py +++ b/backend/api/v1/documents/serializers.py @@ -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", []) diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index f45150f9..618a1634 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -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): """Получить дочерние папки данной папки.""" diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py index e855c6ca..63094291 100644 --- a/backend/tests/api/v1/documents/test_documents_api.py +++ b/backend/tests/api/v1/documents/test_documents_api.py @@ -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: diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index 7afe1bf9..48e5bbd9 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -2,7 +2,7 @@ import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; -import { Suspense, useEffect, useMemo, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import dynamic from "next/dynamic"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { Document } from "@/types/api"; @@ -16,12 +16,16 @@ import { Tags, Filter, CheckCircle, + CheckSquare, AlertCircle, Calendar, User, Users, Download, ChevronDown, + ChevronRight, + Pencil, + Trash2, } from "lucide-react"; import { DocumentAcknowledgementsReport } from "@/components/documents/DocumentAcknowledgementsReport"; import { DocumentMetadataEditor } from "@/components/documents/DocumentMetadataEditor"; @@ -65,6 +69,42 @@ function formatFileSize(value?: number): string { return `${(value / (1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 ? 0 : 1)} МБ`; } +type DocumentTagOption = { id: number; name: string }; +type PaginatedResponse = { results: T[]; next?: string | null }; +const DOCUMENTS_PAGE_SIZE = 10000; + +function isPaginatedResponse(value: unknown): value is PaginatedResponse { + if (typeof value !== "object" || value === null) return false; + return Array.isArray((value as { results?: unknown }).results); +} + +async function loadAllPaginated( + fetchPage: (page: number) => Promise +): Promise { + const items: T[] = []; + let page = 1; + + while (true) { + const response = await fetchPage(page); + + if (Array.isArray(response)) { + return response as T[]; + } + + if (!isPaginatedResponse(response)) { + return items; + } + + items.push(...response.results); + + if (!response.next || response.results.length === 0) { + return items; + } + + page += 1; + } +} + export default function DocumentsPage() { return ( }> @@ -94,7 +134,7 @@ function DocumentsPageContent() { const [selectedFolderId, setSelectedFolderId] = useState(null); const [search, setSearch] = useState(""); const [selectedTags, setSelectedTags] = useState([]); - const [availableTags, setAvailableTags] = useState>([]); + const [availableTags, setAvailableTags] = useState([]); const [dateFrom, setDateFrom] = useState(""); const [dateTo, setDateTo] = useState(""); const [sortBy, setSortBy] = useState<"date" | "title">("date"); @@ -113,14 +153,22 @@ function DocumentsPageContent() { } | null>(null); const [showMetadataEditor, setShowMetadataEditor] = useState(false); const [selectedDocument, setSelectedDocument] = useState(null); + const [metadataDocument, setMetadataDocument] = useState(null); const [previewFile, setPreviewFile] = useState<{ url: string; name: string } | null>(null); const [pdfViewerFile, setPdfViewerFile] = useState<{ url: string; name: string } | null>(null); // Folder dropdown const [showFolderDropdown, setShowFolderDropdown] = useState(false); + const [folderMenuOpenId, setFolderMenuOpenId] = useState(null); + const [documentMenuOpenId, setDocumentMenuOpenId] = useState(null); + const folderMenuRef = useRef(null); + const documentMenuRef = useRef(null); + const [createFolderParentId, setCreateFolderParentId] = useState(null); + const [editingFolder, setEditingFolder] = useState(null); + const [downloadingFolderId, setDownloadingFolderId] = useState(null); + const [selectionMode, setSelectionMode] = useState(false); // Bulk selection - const selection = useDocumentSelection(documents); const linkedDocumentId = Number(searchParams.get("document") || ""); const clearDocumentParam = () => { @@ -135,30 +183,38 @@ function DocumentsPageContent() { clearDocumentParam(); }; - const loadDocuments = async () => { + const loadDocuments = useCallback(async () => { try { setLoading(true); setError(null); - const params: any = {}; + const params: { folder_id?: number } = {}; if (selectedFolderId !== null) { params.folder_id = selectedFolderId; } console.log("📁 Загрузка документов с параметрами:", params); - const response = await apiClient.getDocuments(params); - console.log("📄 Получено документов:", response.results?.length || 0); - setDocuments(response.results || response || []); + const loadedDocuments = await loadAllPaginated((page) => + apiClient.getDocuments({ + ...params, + page, + page_size: DOCUMENTS_PAGE_SIZE, + }) + ); + console.log("📄 Получено документов:", loadedDocuments.length); + setDocuments(loadedDocuments); } catch (err) { console.error("Ошибка загрузки документов:", err); setError("Не удалось загрузить документы"); } finally { setLoading(false); } - }; + }, [selectedFolderId]); const loadFolders = async () => { try { - const response = await apiClient.getFolders({ root: true }); - setFolders(response.results || response || []); + const loadedFolders = await loadAllPaginated((page) => + apiClient.getFolders({ page, page_size: DOCUMENTS_PAGE_SIZE }) + ); + setFolders(loadedFolders); } catch (err) { console.error("Ошибка загрузки папок:", err); } @@ -166,8 +222,10 @@ function DocumentsPageContent() { const loadTags = async () => { try { - const response = await apiClient.getDocumentTags(); - setAvailableTags(response.results || response || []); + const loadedTags = await loadAllPaginated((page) => + apiClient.getDocumentTags({ page, page_size: DOCUMENTS_PAGE_SIZE }) + ); + setAvailableTags(loadedTags); } catch (err) { console.error("Ошибка загрузки тегов:", err); } @@ -175,13 +233,39 @@ function DocumentsPageContent() { useEffect(() => { loadDocuments(); - }, [selectedFolderId]); + }, [loadDocuments]); useEffect(() => { loadFolders(); loadTags(); }, []); + useEffect(() => { + if (folderMenuOpenId === null) return; + + const handlePointerDown = (event: MouseEvent) => { + if (folderMenuRef.current && !folderMenuRef.current.contains(event.target as Node)) { + setFolderMenuOpenId(null); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, [folderMenuOpenId]); + + useEffect(() => { + if (documentMenuOpenId === null) return; + + const handlePointerDown = (event: MouseEvent) => { + if (documentMenuRef.current && !documentMenuRef.current.contains(event.target as Node)) { + setDocumentMenuOpenId(null); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, [documentMenuOpenId]); + useEffect(() => { if (!linkedDocumentId || selectedDocument?.id === linkedDocumentId) return; @@ -206,7 +290,7 @@ function DocumentsPageContent() { const q = search.trim().toLowerCase(); // Filter documents - let filtered = documents.filter((doc) => { + const filtered = documents.filter((doc) => { // Text search if (q) { const title = doc.title.toLowerCase(); @@ -221,7 +305,7 @@ function DocumentsPageContent() { // Tag filter if (selectedTags.length > 0) { - const docTags = (doc.tags || []).map((t: any) => t.id); + const docTags = (doc.tags || []).map((tag) => tag.id); const hasTag = selectedTags.some(tagId => docTags.includes(tagId)); if (!hasTag) return false; } @@ -262,31 +346,288 @@ function DocumentsPageContent() { return filtered; }, [documents, search, selectedTags, dateFrom, dateTo, sortBy, sortOrder]); + const selection = useDocumentSelection(filteredDocuments); + // Find selected folder and build breadcrumb path const selectedFolder = useMemo(() => { if (!selectedFolderId) return null; - - const findFolder = (foldersArray: FolderNode[]): FolderNode | null => { - for (const folder of foldersArray) { - if (folder.id === selectedFolderId) return folder; - if (folder.children) { - const found = findFolder(folder.children); - if (found) return found; - } - } - return null; - }; - - return findFolder(folders); + + return folders.find((folder) => folder.id === selectedFolderId) || null; }, [selectedFolderId, folders]); const breadcrumbs = useMemo(() => { if (!selectedFolder) return []; - return selectedFolder.path.split(' / '); - }, [selectedFolder]); + + const foldersById = new Map(folders.map((folder) => [folder.id, folder])); + const chain: FolderNode[] = []; + const visitedIds = new Set(); + let current: FolderNode | null = selectedFolder; + + while (current && !visitedIds.has(current.id)) { + chain.push(current); + visitedIds.add(current.id); + current = current.parent_id !== null + ? foldersById.get(current.parent_id) || null + : null; + } + + return chain.reverse(); + }, [selectedFolder, folders]); + + const childFolderCounts = useMemo(() => { + const counts = new Map(); + + folders.forEach((folder) => { + if (folder.parent_id !== null) { + counts.set(folder.parent_id, (counts.get(folder.parent_id) || 0) + 1); + } + }); + + return counts; + }, [folders]); + + const visibleFolders = useMemo(() => { + const q = search.trim().toLowerCase(); + + return folders + .filter((folder) => folder.parent_id === selectedFolderId) + .filter((folder) => { + if (!q) return true; + return ( + folder.name.toLowerCase().includes(q) || + folder.path.toLowerCase().includes(q) + ); + }) + .sort((a, b) => a.name.localeCompare(b.name, 'ru')); + }, [folders, search, selectedFolderId]); + + const createFolderParent = useMemo(() => { + if (createFolderParentId === null) return null; + return folders.find((folder) => folder.id === createFolderParentId) || null; + }, [createFolderParentId, folders]); + + const bulkFolderOptions = useMemo( + () => [ + { id: "__root__", name: "Без папки" }, + ...folders.map((folder) => ({ + id: String(folder.id), + name: folder.path || folder.name, + })), + ], + [folders] + ); + + const bulkTagOptions = useMemo( + () => availableTags.map((tag) => ({ + id: String(tag.id), + name: tag.name, + })), + [availableTags] + ); const activeFilterCount = selectedTags.length + (dateFrom ? 1 : 0) + (dateTo ? 1 : 0); + const openCreateFolderModal = (parentId: number | null) => { + setCreateFolderParentId(parentId); + setShowCreateFolder(true); + }; + + const closeCreateFolderModal = () => { + setShowCreateFolder(false); + setCreateFolderParentId(null); + }; + + const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + }; + + const handleBulkMoveDocuments = async (folderId: string, documentIds: number[]) => { + const targetFolderId = folderId === "__root__" ? null : Number(folderId); + if (folderId !== "__root__" && Number.isNaN(targetFolderId)) return; + + for (const documentId of documentIds) { + await apiClient.updateDocument(documentId, { folder: targetFolderId }); + } + loadDocuments(); + loadFolders(); + }; + + const handleBulkAddTags = async (tagIds: string[], documentIds: number[]) => { + const parsedTagIds = tagIds + .map((tagId) => Number(tagId)) + .filter((tagId) => !Number.isNaN(tagId)); + + if (parsedTagIds.length === 0) return; + + for (const documentId of documentIds) { + const documentItem = documents.find((doc) => doc.id === documentId); + const currentTagIds = (documentItem?.tags || []).map((tag) => tag.id); + const nextTagIds = Array.from(new Set([...currentTagIds, ...parsedTagIds])); + + await apiClient.updateDocument(documentId, { tag_ids: nextTagIds }); + } + loadDocuments(); + }; + + const handleBulkDownloadDocuments = async (documentIds: number[]) => { + const { blob, filename } = await apiClient.downloadDocumentsArchive(documentIds); + downloadBlob(blob, filename || "documents.zip"); + }; + + const handleBulkDeleteDocuments = async (documentIds: number[]) => { + for (const documentId of documentIds) { + await apiClient.deleteDocument(documentId); + } + + if (selectedDocument && documentIds.includes(selectedDocument.id)) { + closeSelectedDocument(); + } else if (linkedDocumentId && documentIds.includes(linkedDocumentId)) { + clearDocumentParam(); + } + + loadDocuments(); + loadFolders(); + }; + + const disableSelectionMode = () => { + selection.clearSelection(); + setSelectionMode(false); + }; + + const selectDocumentFromMenu = (documentId: number) => { + if (!selection.isSelected(documentId)) { + selection.toggleDocument(documentId); + } + setSelectionMode(true); + setDocumentMenuOpenId(null); + }; + + const unselectDocumentFromMenu = (documentId: number) => { + if (selection.isSelected(documentId)) { + selection.toggleDocument(documentId); + } + if (selection.selectedIds.length <= 1) { + setSelectionMode(false); + } + setDocumentMenuOpenId(null); + }; + + const toggleDocumentSelectionFromCheckbox = (documentId: number) => { + const isLastSelectedDocument = selection.isSelected(documentId) && selection.selectedIds.length <= 1; + selection.toggleDocument(documentId); + + if (isLastSelectedDocument) { + setSelectionMode(false); + } else { + setSelectionMode(true); + } + }; + + const openDocumentDetailsFromMenu = (documentItem: Document) => { + setSelectedDocument(documentItem); + setDocumentMenuOpenId(null); + }; + + const openDocumentMetadataFromMenu = (documentItem: Document) => { + setMetadataDocument(documentItem); + setShowMetadataEditor(true); + setDocumentMenuOpenId(null); + }; + + const openDocumentReportFromMenu = (documentItem: Document) => { + setShowAcknowledgementsReport({ + documentId: documentItem.id, + documentTitle: documentItem.title, + }); + setDocumentMenuOpenId(null); + }; + + const openDocumentPdfFromMenu = (documentItem: Document) => { + if (!documentItem.file_url) return; + setPdfViewerFile({ + url: documentItem.file_url, + name: documentItem.file_name || documentItem.title, + }); + setDocumentMenuOpenId(null); + }; + + const deleteDocumentFromMenu = async (documentItem: Document) => { + setDocumentMenuOpenId(null); + + if (!window.confirm(`Удалить документ "${documentItem.title}"?`)) return; + + try { + await apiClient.deleteDocument(documentItem.id); + + if (selectedDocument?.id === documentItem.id) { + closeSelectedDocument(); + } else if (linkedDocumentId === documentItem.id) { + clearDocumentParam(); + } + + if (selection.isSelected(documentItem.id)) { + selection.toggleDocument(documentItem.id); + if (selection.selectedIds.length <= 1) { + setSelectionMode(false); + } + } + + loadDocuments(); + loadFolders(); + } catch (err) { + console.error("Ошибка удаления документа:", err); + alert("Не удалось удалить документ"); + } + }; + + const downloadFolderArchive = async (folder: FolderNode) => { + setDownloadingFolderId(folder.id); + + try { + const { blob, filename } = await apiClient.downloadFolderArchive(folder.id); + downloadBlob(blob, filename || `${folder.name}.zip`); + } catch (err) { + console.error("Ошибка выгрузки архива папки:", err); + alert("Не удалось выгрузить архив папки"); + } finally { + setDownloadingFolderId(null); + } + }; + + const deleteFolder = async (folder: FolderNode) => { + const nestedFolderCount = childFolderCounts.get(folder.id) || 0; + const directDocumentCount = folder.document_count || 0; + const details = [ + directDocumentCount > 0 ? `${directDocumentCount} док.` : null, + nestedFolderCount > 0 ? `${nestedFolderCount} подпап.` : null, + ].filter(Boolean).join(", "); + const message = details + ? `Удалить папку "${folder.name}" (${details})? Будут удалены все подпапки, документы внутри них и связанные документы.` + : `Удалить папку "${folder.name}"? Будут удалены все подпапки, документы внутри них и связанные документы.`; + + if (!window.confirm(message)) return; + + try { + await apiClient.deleteFolder(folder.id); + setFolderMenuOpenId(null); + if (selectedFolderId === folder.id) { + setSelectedFolderId(folder.parent_id); + } + loadFolders(); + loadDocuments(); + } catch (err) { + console.error("Ошибка удаления папки:", err); + alert("Не удалось удалить папку"); + } + }; + return (
@@ -377,7 +718,7 @@ function DocumentsPageContent() {
- {breadcrumbs.map((crumb, index) => ( - - / - - {crumb} + {breadcrumbs.map((crumb, index) => { + const isLast = index === breadcrumbs.length - 1; + + return ( + + / + {isLast ? ( + + {crumb.name} + + ) : ( + + )} - - ))} + ); + })}
)} @@ -629,27 +986,136 @@ function DocumentsPageContent() { {/* Documents List */}
- {filteredDocuments.length === 0 ? ( + {visibleFolders.length === 0 && filteredDocuments.length === 0 ? (

Документы не найдены

) : ( <> - {/* Select All */} - {filteredDocuments.length > 0 && ( -
- - - Выбрать все ({filteredDocuments.length}) - -
- )} +
+

+ {visibleFolders.length > 0 ? `${visibleFolders.length} пап.` : ""} + {visibleFolders.length > 0 && filteredDocuments.length > 0 ? " · " : ""} + {filteredDocuments.length > 0 ? `${filteredDocuments.length} док.` : ""} +

+
+ + {/* Folder Cards */} + {visibleFolders.map((folder) => { + const nestedFolderCount = childFolderCounts.get(folder.id) || 0; + const directDocumentCount = folder.document_count || 0; + const isFolderMenuOpen = folderMenuOpenId === folder.id; + const isDownloadingArchive = downloadingFolderId === folder.id; + + return ( +
+ +
+ + + {isFolderMenuOpen ? ( +
+ + + + + +
+ ) : null} +
+
+ ); + })} {/* Document Cards */} {filteredDocuments.map((doc) => { @@ -657,7 +1123,9 @@ function DocumentsPageContent() { ? `${doc.created_by.last_name || ''} ${doc.created_by.first_name || ''}`.trim() : null; const createdDate = formatDate(doc.created_at); - const isSelected = selection.isSelected(doc.id); + const isDocumentSelected = selection.isSelected(doc.id); + const isSelected = selectionMode && isDocumentSelected; + const isDocumentMenuOpen = documentMenuOpenId === doc.id; const fileSize = formatFileSize(doc.file_size); const recipientsCount = doc.recipients?.length || 0; const departmentsCount = doc.departments?.length || 0; @@ -678,7 +1146,7 @@ function DocumentsPageContent() { return (
- selection.toggleDocument(doc.id)} - className="mt-1 h-4 w-4 rounded accent-[var(--accent-primary)]" - /> + {selectionMode && ( + toggleDocumentSelectionFromCheckbox(doc.id)} + className="mt-1 h-4 w-4 shrink-0 rounded accent-[var(--accent-primary)]" + aria-label={`Выбрать документ ${doc.title}`} + /> + )}
@@ -763,23 +1234,156 @@ function DocumentsPageContent() {
-
- {doc.acknowledgement_required ? ( - doc.is_acknowledged ? ( - - Ознакомлен - +
+
+ {doc.acknowledgement_required ? ( + doc.is_acknowledged ? ( + + Ознакомлен + + ) : ( + + + Требует ознакомления + + ) ) : ( - - - Требует ознакомления + + Документ - ) - ) : ( - - Документ - - )} + )} +
+ +
+ + + {isDocumentMenuOpen ? ( +
+ + + + + + + {hasPreview && ( + + )} + + {doc.file_url && ( + setDocumentMenuOpenId(null)} + 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)]" + > + + Скачать + + )} + + {doc.acknowledgement_required && ( + + )} + + {doc.acknowledgement_required && !doc.is_acknowledged && ( + + )} + +
+ + {isDocumentSelected ? ( + + ) : ( + + )} + + +
+ ) : null} +
@@ -810,73 +1414,6 @@ function DocumentsPageContent() { {doc.description}

)} - -
- - - {hasPreview && ( - - )} - - {doc.file_url && ( - - - Скачать - - )} - - {doc.acknowledgement_required && ( - - )} - - {doc.acknowledgement_required && !doc.is_acknowledged && ( - - )} -
@@ -904,6 +1441,7 @@ function DocumentsPageContent() { onSuccess={() => { setShowUploadForm(false); loadDocuments(); + loadFolders(); }} onCancel={() => setShowUploadForm(false)} /> @@ -916,12 +1454,16 @@ function DocumentsPageContent() { onClose={closeSelectedDocument} onUpdate={() => { loadDocuments(); + loadFolders(); if (selectedDocument) { apiClient.getDocument(selectedDocument.id).then(setSelectedDocument); } }} onEditMetadata={() => { - setShowMetadataEditor(true); + if (selectedDocument) { + setMetadataDocument(selectedDocument); + setShowMetadataEditor(true); + } }} onViewReport={() => { if (selectedDocument) { @@ -958,7 +1500,7 @@ function DocumentsPageContent() { {/* Create Folder Modal */} setShowCreateFolder(false)} + onClose={closeCreateFolderModal} title="Создать папку" size="sm" > @@ -976,9 +1518,9 @@ function DocumentsPageContent() { try { await apiClient.createFolder({ name: name.trim(), - parent: selectedFolderId, + parent: createFolderParentId, }); - setShowCreateFolder(false); + closeCreateFolderModal(); loadFolders(); } catch (err) { console.error('Ошибка создания папки:', err); @@ -1002,11 +1544,11 @@ function DocumentsPageContent() { />
- {selectedFolderId && ( + {createFolderParent && (

- Будет создана в выбранной папке + Будет создана в папке «{createFolderParent.name}»

)} @@ -1014,7 +1556,7 @@ function DocumentsPageContent() {
+ +
+ + + setShowTagManagement(false)} onTagsUpdated={loadTags} /> + {showAcknowledgementsReport && ( + setShowAcknowledgementsReport(null)} + /> + )} + {/* Document Metadata Editor */} - {selectedDocument && ( + {metadataDocument && ( setShowMetadataEditor(false)} - document={selectedDocument} + onClose={() => { + setShowMetadataEditor(false); + setMetadataDocument(null); + }} + document={metadataDocument} onUpdate={() => { loadDocuments(); - // Refresh selected document - apiClient.getDocument(selectedDocument.id).then(setSelectedDocument); + loadFolders(); + if (selectedDocument?.id === metadataDocument.id) { + apiClient.getDocument(metadataDocument.id).then(setSelectedDocument); + } }} /> )} diff --git a/frontend/src/components/documents/DocumentUploadForm.tsx b/frontend/src/components/documents/DocumentUploadForm.tsx index 18dc2e2e..5f569819 100644 --- a/frontend/src/components/documents/DocumentUploadForm.tsx +++ b/frontend/src/components/documents/DocumentUploadForm.tsx @@ -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(); + + 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 = { compressing: "Сжатие изображения...", @@ -48,9 +128,7 @@ const STAGE_MESSAGES: Record = { export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: DocumentUploadFormProps) { const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); - const [file, setFile] = useState(null); - const [processedFile, setProcessedFile] = useState(null); - const [extractedText, setExtractedText] = useState(""); + const [uploadItems, setUploadItems] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const [processingProgress, setProcessingProgress] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -63,16 +141,24 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc const [selectedRecipients, setSelectedRecipients] = useState([]); // Metadata fields + const [selectedFolderId, setSelectedFolderId] = useState(currentFolderId ?? null); const [selectedTags, setSelectedTags] = useState([]); // Data for selects const [departments, setDepartments] = useState([]); const [employees, setEmployees] = useState([]); - const [documentTags, setDocumentTags] = useState([]); + const [folders, setFolders] = useState([]); + const [documentTags, setDocumentTags] = useState([]); 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
)} - {/* Current folder info */} - {currentFolderId && ( -
-
- - Документ будет сохранён в выбранной папке -
-
- )} - {/* File Upload */}
- {!file ? ( -
- - -

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

-

- PDF, Word, Excel, изображения, текстовые файлы -

-
- ) : ( -
-
- -
-

{file.name}

-

- {(file.size / 1024 / 1024).toFixed(2)} МБ -

-
- +
+ + +

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

+

+ Можно выбрать несколько файлов любых форматов +

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

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

+ ) : null + )} +
+ )} + {/* Индикатор обработки */} {isProcessing && processingProgress && (
@@ -322,36 +497,37 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc />

- {processingProgress.progress}% + {processingProgress.message} · {processingProgress.progress}%

)} - - {/* Сообщение о сжатии */} - {processedFile && !isProcessing && ( -
- ✓ Изображение сжато: {(file.size / 1024 / 1024).toFixed(2)} МБ → {(processedFile.size / 1024 / 1024).toFixed(2)} МБ -
- )}
)}
{/* Title */} -
- - setTitle(e.target.value)} - placeholder="Введите название документа" - required - className="app-input w-full rounded-lg px-3 py-2 text-sm" - /> -
+ {uploadItems.length <= 1 ? ( +
+ + setTitle(e.target.value)} + placeholder="По умолчанию — название файла" + className="app-input w-full rounded-lg px-3 py-2 text-sm" + /> +

+ Поле необязательное. Если оставить пустым, будет использовано имя файла без расширения. +

+
+ ) : ( +
+ Для каждого документа название будет взято из имени соответствующего файла. +
+ )} {/* Description */}
@@ -376,42 +552,92 @@ export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: Doc
- {/* Tags */} + {/* Folder */}
-
+ + {/* Tags */} +
+
+ Теги +
+
+ {loadingDocumentTags ? ( + Загрузка тегов... + ) : documentTags.length === 0 ? ( + Нет доступных тегов + ) : ( + documentTags.map((tag) => ( + + )) + )} +
+

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

{/* Извлеченный текст */} - {extractedText && ( + {uploadItems.length === 1 && uploadItems[0].extractedText && (