feat: improve documents management
This commit is contained in:
parent
e6eee5ddf8
commit
14d9d4a4dc
@ -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
|
||||
|
||||
@ -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", [])
|
||||
|
||||
@ -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):
|
||||
"""Получить дочерние папки данной папки."""
|
||||
|
||||
@ -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
@ -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 && (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@ -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) }),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user