feat: Implement folder-document integration and acknowledgements UI
Backend changes: - Add acknowledgement_required field to Document model (migration 0009) - Add FolderViewSet with CRUD endpoints for folder management - Add folder_id filtering support in DocumentViewSet.get_queryset() - Register folders endpoint in API router Frontend changes: - Add DocumentAcknowledgementsReport component for viewing acknowledgement status - Add folder_id parameter to getDocuments API method - Add currentFolderId prop to DocumentUploadForm - Update document creation to save to selected folder - Add folder filtering in documents list view - Fix PDF worker loading (use local worker instead of CDN) - Lock pdfjs-dist version to 5.4.296 to prevent conflicts - Add postinstall script to copy PDF worker to public/ Features: - Hierarchical folder structure with parent-child relationships - Filter documents by selected folder - Upload documents to specific folders - View acknowledgement statistics with search and export - Local PDF worker for reliable document preview
This commit is contained in:
parent
8e872b3593
commit
f4b5b75c01
@ -32,13 +32,22 @@ class FilerFileField(serializers.Field):
|
||||
value: filer.File instance или None
|
||||
|
||||
Returns:
|
||||
str: URL файла или None
|
||||
str: Полный URL файла или None
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, FilerFile):
|
||||
return value.url if value.url else None
|
||||
if not value.url:
|
||||
return None
|
||||
|
||||
# Получаем request из контекста для построения абсолютного URL
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(value.url)
|
||||
|
||||
# Fallback: возвращаем относительный URL
|
||||
return value.url
|
||||
|
||||
return None
|
||||
|
||||
@ -86,16 +95,18 @@ class FilerFileField(serializers.Field):
|
||||
|
||||
try:
|
||||
# Создаем filer.File объект
|
||||
# is_public=True делает файл доступным без авторизации
|
||||
filer_file = FilerFile.objects.create(
|
||||
file=data,
|
||||
original_filename=data.name,
|
||||
name=data.name,
|
||||
owner=owner
|
||||
owner=owner,
|
||||
is_public=True # Делаем файл публичным для доступа с frontend
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[FilerFileField] Created filer.File id={filer_file.id} "
|
||||
f"name={data.name} owner={owner.id if owner else None}"
|
||||
f"name={data.name} owner={owner.id if owner else None} is_public=True"
|
||||
)
|
||||
|
||||
return filer_file
|
||||
|
||||
@ -214,6 +214,7 @@ class DocumentReadSerializer(serializers.ModelSerializer):
|
||||
"modified_by",
|
||||
"modified_at",
|
||||
"sent_to_all",
|
||||
"acknowledgement_required", # Требуется ли ознакомление
|
||||
"departments",
|
||||
"recipients",
|
||||
"file_url",
|
||||
|
||||
@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Exists, OuterRef, QuerySet, Q
|
||||
from documents.models import Document, DocumentAcknowledgement
|
||||
from filer.models import Folder
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
@ -16,7 +17,7 @@ from rest_framework.viewsets import ModelViewSet
|
||||
from ..employees.serializers import EmployeeBriefSerializer
|
||||
from ..permissions import AdminOrActionOrModelPerms
|
||||
from .permissions import DocumentReadOrModelPerms
|
||||
from .serializers import DocumentReadSerializer, DocumentWriteSerializer
|
||||
from .serializers import DocumentReadSerializer, DocumentWriteSerializer, FolderSerializer
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@ -50,6 +51,8 @@ class DocumentViewSet(ModelViewSet):
|
||||
|
||||
Поддерживает параметр ?scope=mine для фильтрации документов,
|
||||
доступных текущему пользователю.
|
||||
|
||||
Поддерживает параметр ?folder_id для фильтрации по папке.
|
||||
|
||||
Returns:
|
||||
QuerySet[Document]: оптимизированный для списка без N+1.
|
||||
@ -62,6 +65,14 @@ class DocumentViewSet(ModelViewSet):
|
||||
scope = ""
|
||||
if request:
|
||||
scope = request.query_params.get("scope", "").lower()
|
||||
|
||||
# Фильтрация по папке
|
||||
folder_id = request.query_params.get("folder_id")
|
||||
if folder_id is not None:
|
||||
try:
|
||||
qs = qs.filter(folder_id=int(folder_id))
|
||||
except (ValueError, TypeError):
|
||||
pass # Игнорируем некорректное значение
|
||||
|
||||
# Если scope=mine - показываем только доступные пользователю
|
||||
if scope == "mine" and user and user.is_authenticated:
|
||||
@ -320,3 +331,93 @@ class DocumentViewSet(ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
class FolderViewSet(ModelViewSet):
|
||||
"""CRUD для папок документов (filer.Folder).
|
||||
|
||||
Поддерживает иерархическую структуру папок с parent/children связями.
|
||||
"""
|
||||
|
||||
queryset = Folder.objects.all()
|
||||
serializer_class = FolderSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Возвращает папки, опционально фильтрованные по parent_id.
|
||||
|
||||
Параметры:
|
||||
?parent_id=<id> - показать только дочерние папки указанного parent
|
||||
?root=true - показать только корневые папки (parent=null)
|
||||
"""
|
||||
qs = super().get_queryset()
|
||||
request = getattr(self, "request", None)
|
||||
|
||||
if not request:
|
||||
return qs
|
||||
|
||||
parent_id = request.query_params.get('parent_id')
|
||||
root = request.query_params.get('root', '').lower() == 'true'
|
||||
|
||||
if root:
|
||||
qs = qs.filter(parent__isnull=True)
|
||||
elif parent_id:
|
||||
qs = qs.filter(parent_id=parent_id)
|
||||
|
||||
return qs.order_by('name')
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Создать папку.
|
||||
|
||||
Body:
|
||||
{
|
||||
"name": "Название папки",
|
||||
"parent": <id> | null // опционально
|
||||
}
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Создаем папку
|
||||
name = serializer.validated_data['name']
|
||||
parent_id = request.data.get('parent')
|
||||
|
||||
parent = None
|
||||
if parent_id:
|
||||
try:
|
||||
parent = Folder.objects.get(pk=parent_id)
|
||||
except Folder.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Родительская папка не найдена'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
folder = Folder.objects.create(
|
||||
name=name,
|
||||
parent=parent,
|
||||
owner=request.user
|
||||
)
|
||||
|
||||
result = FolderSerializer(folder).data
|
||||
return Response(result, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def children(self, request, pk=None):
|
||||
"""Получить дочерние папки данной папки."""
|
||||
folder = self.get_object()
|
||||
children = folder.children.all().order_by('name')
|
||||
serializer = self.get_serializer(children, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def documents(self, request, pk=None):
|
||||
"""Получить документы в данной папке."""
|
||||
folder = self.get_object()
|
||||
documents = Document.objects.filter(folder=folder)
|
||||
serializer = DocumentReadSerializer(
|
||||
documents,
|
||||
many=True,
|
||||
context={'request': request}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ from .schedule.views import (
|
||||
|
||||
# Communications ViewSets
|
||||
from .communications.views import ChatViewSet, MessageViewSet, PollViewSet
|
||||
from .documents.views import DocumentViewSet
|
||||
from .documents.views import DocumentViewSet, FolderViewSet
|
||||
from .employees.views import (
|
||||
DepartmentRoleViewSet,
|
||||
DepartmentViewSet,
|
||||
@ -41,6 +41,7 @@ router.register(r"schedule/occurrences", ScheduleOccurrenceViewSet, basename="sc
|
||||
router.register(r"schedule/relations", ScheduleEventRelationViewSet, basename="schedule-relations")
|
||||
|
||||
router.register(r"documents", DocumentViewSet, basename="documents")
|
||||
router.register(r"folders", FolderViewSet, basename="folders")
|
||||
|
||||
router.register(r"requests", RequestViewSet, basename="request")
|
||||
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.4 on 2026-02-28 07:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('documents', '0008_add_extended_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='acknowledgement_required',
|
||||
field=models.BooleanField(default=False, help_text='Если включено — сотрудники должны подтвердить ознакомление', verbose_name='Требуется ознакомление'),
|
||||
),
|
||||
]
|
||||
@ -128,6 +128,14 @@ class Document(models.Model):
|
||||
default=True,
|
||||
help_text=_('Если включено — уведомление получат все активные сотрудники')
|
||||
)
|
||||
|
||||
# Требование ознакомления
|
||||
acknowledgement_required = models.BooleanField(
|
||||
_('Требуется ознакомление'),
|
||||
default=False,
|
||||
help_text=_('Если включено — сотрудники должны подтвердить ознакомление')
|
||||
)
|
||||
|
||||
departments = models.ManyToManyField(
|
||||
'employees.Department',
|
||||
verbose_name=_('Отделы-получатели'),
|
||||
|
||||
@ -25,6 +25,7 @@ ALLOWED_HOSTS = _split_env_list(
|
||||
INSTALLED_APPS = [
|
||||
"daphne",
|
||||
"channels",
|
||||
"corsheaders", # django-cors-headers для CORS
|
||||
"django.contrib.admin",
|
||||
"django_bootstrap5",
|
||||
"django.contrib.auth",
|
||||
@ -67,6 +68,7 @@ INSTALLED_APPS = [
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware", # CORS middleware должен быть перед CommonMiddleware
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
@ -569,3 +571,10 @@ THUMBNAIL_ALIASES = {
|
||||
# django-reversion настройки
|
||||
REVERSION_SAVE_EMPTY_REVISIONS = False # Не сохранять пустые версии
|
||||
|
||||
# CORS Configuration for Next.js frontend
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
]
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
|
||||
@ -42,5 +42,7 @@ urlpatterns = [
|
||||
path("api/", include(("api.urls", "api"), namespace="api")),
|
||||
# Service Worker должен быть в корне для правильного scope
|
||||
path("sw.js", serve_service_worker, name="sw"),
|
||||
# django-filer URLs для обработки приватных файлов
|
||||
path("", include("filer.server.urls")),
|
||||
path("", include("feed.urls_front", namespace="feed")),
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
143
frontend/package-lock.json
generated
143
frontend/package-lock.json
generated
@ -13,13 +13,14 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/xlsx": "^0.0.35",
|
||||
"date-fns": "^4.1.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"lucide-react": "^0.574.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"next": "16.1.6",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.4.624",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"react": "19.2.3",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-dom": "19.2.3",
|
||||
@ -30,7 +31,8 @@
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tesseract.js": "^7.0.0"
|
||||
"tesseract.js": "^7.0.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@ -2456,6 +2458,12 @@
|
||||
"integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/xlsx": {
|
||||
"version": "0.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/xlsx/-/xlsx-0.0.35.tgz",
|
||||
"integrity": "sha512-s0x3DYHZzOkxtjqOk/Nv1ezGzpbN7I8WX+lzlV/nFfTDOv7x4d8ZwGHcnaiB8UCx89omPsftQhS5II3jeWePxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
|
||||
@ -3005,6 +3013,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@ -3437,6 +3454,19 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@ -3479,6 +3509,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@ -3521,6 +3560,18 @@
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
@ -4748,6 +4799,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@ -6354,13 +6414,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-readable-to-web-readable-stream": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
||||
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@ -6630,16 +6683,15 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.624",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.624.tgz",
|
||||
"integrity": "sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg==",
|
||||
"version": "5.4.296",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.16.0 || >=22.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.88",
|
||||
"node-readable-to-web-readable-stream": "^0.4.2"
|
||||
"@napi-rs/canvas": "^0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
@ -6901,18 +6953,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-pdf/node_modules/pdfjs-dist": {
|
||||
"version": "5.4.296",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.16.0 || >=22.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
@ -7489,6 +7529,18 @@
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
@ -8270,6 +8322,24 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@ -8279,6 +8349,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
"dev:turbo": "next dev --turbo",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chatscope/chat-ui-kit-react": "^2.1.1",
|
||||
@ -15,13 +16,14 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/xlsx": "^0.0.35",
|
||||
"date-fns": "^4.1.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"lucide-react": "^0.574.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"next": "16.1.6",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.4.624",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"react": "19.2.3",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-dom": "19.2.3",
|
||||
@ -32,7 +34,8 @@
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tesseract.js": "^7.0.0"
|
||||
"tesseract.js": "^7.0.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
28
frontend/public/pdf.worker.min.mjs
Normal file
28
frontend/public/pdf.worker.min.mjs
Normal file
File diff suppressed because one or more lines are too long
@ -3,6 +3,7 @@
|
||||
import { AppShell } from "../../components/AppShell";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { Document, DocumentStatus } from "@/types/api";
|
||||
import {
|
||||
Search,
|
||||
@ -16,18 +17,34 @@ import {
|
||||
LayoutGrid,
|
||||
CheckSquare,
|
||||
SlidersHorizontal,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { DocumentUploadForm } from "@/components/documents/DocumentUploadForm";
|
||||
import { DocumentStatusBadge } from "@/components/documents/DocumentStatusBadge";
|
||||
import { DocumentWorkflowButtons } from "@/components/documents/DocumentWorkflowButtons";
|
||||
import { DocumentPreview } from "@/components/documents/DocumentPreview";
|
||||
import { DocumentAcknowledgement } from "@/components/documents/DocumentAcknowledgement";
|
||||
import { DocumentAcknowledgementsReport } from "@/components/documents/DocumentAcknowledgementsReport";
|
||||
import { FolderTree, type FolderNode } from "@/components/documents/folders";
|
||||
import { EnhancedPDFViewer } from "@/components/documents/viewer";
|
||||
import { AdvancedSearch } from "@/components/documents/search";
|
||||
import { BulkActionsToolbar, useDocumentSelection } from "@/components/documents/batch";
|
||||
import { DocumentsDashboard } from "@/components/documents/dashboard";
|
||||
|
||||
// Динамический импорт компонентов с PDF обработкой (избегаем SSR ошибок с DOMMatrix)
|
||||
const DocumentUploadForm = dynamic(
|
||||
() => import("@/components/documents/DocumentUploadForm").then(mod => ({ default: mod.DocumentUploadForm })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const EnhancedPDFViewer = dynamic(
|
||||
() => import("@/components/documents/viewer").then(mod => ({ default: mod.EnhancedPDFViewer })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const DocumentPreview = dynamic(
|
||||
() => import("@/components/documents/DocumentPreview").then(mod => ({ default: mod.DocumentPreview })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
@ -55,12 +72,17 @@ export default function DocumentsPage() {
|
||||
// Modals
|
||||
const [showUploadForm, setShowUploadForm] = useState(false);
|
||||
const [showAdvancedSearch, setShowAdvancedSearch] = useState(false);
|
||||
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
||||
const [showAcknowledgementsReport, setShowAcknowledgementsReport] = useState<{
|
||||
documentId: number;
|
||||
documentTitle: string;
|
||||
} | null>(null);
|
||||
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
|
||||
const [previewFile, setPreviewFile] = useState<{ url: string; name: string } | null>(null);
|
||||
const [pdfViewerFile, setPdfViewerFile] = useState<{ url: string; name: string } | null>(null);
|
||||
|
||||
// Sidebar
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
// Folder dropdown
|
||||
const [showFolderDropdown, setShowFolderDropdown] = useState(false);
|
||||
|
||||
// Bulk selection
|
||||
const selection = useDocumentSelection(documents);
|
||||
@ -76,7 +98,9 @@ export default function DocumentsPage() {
|
||||
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 || []);
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки документов:", err);
|
||||
@ -88,10 +112,8 @@ export default function DocumentsPage() {
|
||||
|
||||
const loadFolders = async () => {
|
||||
try {
|
||||
// TODO: Implement folders API endpoint
|
||||
// const response = await apiClient.getFolders();
|
||||
// setFolders(response.results || []);
|
||||
setFolders([]);
|
||||
const response = await apiClient.getFolders({ root: true });
|
||||
setFolders(response.results || response || []);
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки папок:", err);
|
||||
}
|
||||
@ -153,35 +175,20 @@ export default function DocumentsPage() {
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex h-full gap-4">
|
||||
{/* Sidebar */}
|
||||
{showSidebar && (
|
||||
<aside className="w-64 shrink-0 space-y-4">
|
||||
{/* Folders Section */}
|
||||
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||
<FolderOpen size={16} />
|
||||
Папки
|
||||
</h2>
|
||||
</div>
|
||||
<FolderTree
|
||||
folders={folders}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelectFolder={setSelectedFolderId}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<h2 className="mb-3 text-sm font-semibold text-gray-900">Быстрое</h2>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-4">
|
||||
{/* Top Bar */}
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
{/* Left: Tabs + Folder Dropdown */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* View Mode Tabs */}
|
||||
<div className="flex gap-1 rounded-lg bg-gray-100 p-1">
|
||||
<button
|
||||
onClick={() => setViewMode("documents")}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
|
||||
className={`flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition ${
|
||||
viewMode === "documents"
|
||||
? "bg-sky-100 text-sky-900"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<FileText size={16} />
|
||||
@ -189,22 +196,73 @@ export default function DocumentsPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("dashboard")}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
|
||||
className={`flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition ${
|
||||
viewMode === "dashboard"
|
||||
? "bg-sky-100 text-sky-900"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard size={16} />
|
||||
Дашборд
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Folder Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowFolderDropdown(!showFolderDropdown)}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
{selectedFolderId ? "Папка выбрана" : "Все папки"}
|
||||
</button>
|
||||
{showFolderDropdown && (
|
||||
<div className="absolute left-0 top-full z-10 mt-2 w-72 rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||
<div className="max-h-96 overflow-y-auto p-2">
|
||||
<FolderTree
|
||||
folders={folders}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelectFolder={(id) => {
|
||||
console.log("🗂️ Выбрана папка:", id);
|
||||
setSelectedFolderId(id);
|
||||
setShowFolderDropdown(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setShowAdvancedSearch(!showAdvancedSearch)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
{showAdvancedSearch ? "Простой поиск" : "Расширенный"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateFolder(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
Создать папку
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowUploadForm(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-700"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Загрузить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="min-w-0 flex-1 space-y-4">
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-100">
|
||||
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
|
||||
@ -218,7 +276,7 @@ export default function DocumentsPage() {
|
||||
<>
|
||||
{/* Dashboard View */}
|
||||
{viewMode === "dashboard" && (
|
||||
<section className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
||||
<DocumentsDashboard
|
||||
stats={dashboardStats}
|
||||
recentDocuments={filteredDocuments.slice(0, 5).map((doc) => ({
|
||||
@ -240,47 +298,15 @@ export default function DocumentsPage() {
|
||||
uploaded_at: doc.created_at,
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents View */}
|
||||
{viewMode === "documents" && (
|
||||
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
className="rounded-lg p-2 text-gray-600 hover:bg-gray-100"
|
||||
title={showSidebar ? "Скрыть боковую панель" : "Показать боковую панель"}
|
||||
>
|
||||
<LayoutGrid size={20} />
|
||||
</button>
|
||||
<h1 className="text-xl font-semibold text-gray-900">
|
||||
{selectedFolderId ? "Документы в папке" : "Все документы"}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowAdvancedSearch(!showAdvancedSearch)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
{showAdvancedSearch ? "Простой поиск" : "Расширенный"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowUploadForm(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-700"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Загрузить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Search */}
|
||||
{showAdvancedSearch ? (
|
||||
<div className="mb-4">
|
||||
<>
|
||||
{/* Search & Filters */}
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
{showAdvancedSearch ? (
|
||||
<AdvancedSearch
|
||||
onSearch={(filters) => {
|
||||
setSearch(filters.query);
|
||||
@ -308,10 +334,9 @@ export default function DocumentsPage() {
|
||||
{ id: "rejected", name: "Отклонено" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Simple Filters */
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
size={16}
|
||||
@ -342,37 +367,25 @@ export default function DocumentsPage() {
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Toolbar */}
|
||||
{selection.selectedIds.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<BulkActionsToolbar
|
||||
selectedIds={selection.selectedIds}
|
||||
documents={filteredDocuments.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
status: d.status,
|
||||
}))}
|
||||
onClearSelection={selection.clearSelection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Bulk Actions Toolbar */}
|
||||
{selection.selectedIds.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<BulkActionsToolbar
|
||||
selectedIds={selection.selectedIds}
|
||||
documents={filteredDocuments.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
status: d.status,
|
||||
}))}
|
||||
onClearSelection={selection.clearSelection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Bulk Actions Toolbar */}
|
||||
{selection.selectedIds.length > 0 && (
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<BulkActionsToolbar
|
||||
selectedIds={selection.selectedIds}
|
||||
documents={filteredDocuments.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
status: d.status,
|
||||
}))}
|
||||
onClearSelection={selection.clearSelection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents List */}
|
||||
{/* Documents List */}
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="space-y-3">
|
||||
{filteredDocuments.length === 0 ? (
|
||||
<div className="rounded-xl bg-gray-50 p-8 text-center">
|
||||
@ -430,6 +443,20 @@ export default function DocumentsPage() {
|
||||
status={doc.status}
|
||||
statusCode={doc.status_code}
|
||||
/>
|
||||
{/* Acknowledgement Badge */}
|
||||
{doc.acknowledgement_required && (
|
||||
doc.is_acknowledged ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20">
|
||||
<CheckCircle size={12} />
|
||||
Ознакомлен
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700 ring-1 ring-amber-600/20">
|
||||
<AlertCircle size={12} />
|
||||
Требуется ознакомление
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mb-3 text-sm text-gray-700">
|
||||
@ -452,7 +479,7 @@ export default function DocumentsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{doc.file_url && (
|
||||
<>
|
||||
{doc.file_name?.toLowerCase().endsWith(".pdf") ? (
|
||||
@ -484,6 +511,31 @@ export default function DocumentsPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Acknowledge Button - for documents requiring acknowledgement */}
|
||||
{doc.acknowledgement_required && !doc.is_acknowledged && (
|
||||
<button
|
||||
onClick={() => setSelectedDocument(doc)}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-800 ring-1 ring-amber-300 hover:bg-amber-200"
|
||||
>
|
||||
<CheckCircle size={12} />
|
||||
Ознакомиться
|
||||
</button>
|
||||
)}
|
||||
{/* Acknowledgements Report Button - for authors/admins */}
|
||||
{doc.acknowledgement_required && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setShowAcknowledgementsReport({
|
||||
documentId: doc.id,
|
||||
documentTitle: doc.title,
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-1 text-xs text-blue-700 ring-1 ring-blue-200 hover:bg-blue-100"
|
||||
title="Посмотреть кто ознакомился"
|
||||
>
|
||||
Ведомость
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSelectedDocument(doc)}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-gray-50 px-2.5 py-1 text-xs text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100"
|
||||
@ -498,15 +550,16 @@ export default function DocumentsPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadForm && (
|
||||
{/* Upload Modal */}
|
||||
{showUploadForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
@ -519,6 +572,7 @@ export default function DocumentsPage() {
|
||||
</button>
|
||||
</div>
|
||||
<DocumentUploadForm
|
||||
currentFolderId={selectedFolderId}
|
||||
onSuccess={() => {
|
||||
setShowUploadForm(false);
|
||||
loadDocuments();
|
||||
@ -593,9 +647,23 @@ export default function DocumentsPage() {
|
||||
{/* Acknowledgement */}
|
||||
{selectedDocument.acknowledgement_required && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium text-gray-700">
|
||||
Подтверждение прочтения
|
||||
</h3>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-700">
|
||||
Подтверждение прочтения
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAcknowledgementsReport({
|
||||
documentId: selectedDocument.id,
|
||||
documentTitle: selectedDocument.title,
|
||||
});
|
||||
setSelectedDocument(null);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 ring-1 ring-blue-200 hover:bg-blue-100"
|
||||
>
|
||||
Посмотреть ведомость
|
||||
</button>
|
||||
</div>
|
||||
<DocumentAcknowledgement
|
||||
document={selectedDocument}
|
||||
onAcknowledge={() => {
|
||||
@ -628,6 +696,98 @@ export default function DocumentsPage() {
|
||||
onClose={() => setPdfViewerFile(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Folder Modal */}
|
||||
{showCreateFolder && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Создать папку</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateFolder(false)}
|
||||
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const name = formData.get('name') as string;
|
||||
|
||||
if (!name.trim()) {
|
||||
alert('Введите название папки');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.createFolder({
|
||||
name: name.trim(),
|
||||
parent: selectedFolderId,
|
||||
});
|
||||
setShowCreateFolder(false);
|
||||
loadFolders();
|
||||
} catch (err) {
|
||||
console.error('Ошибка создания папки:', err);
|
||||
alert('Не удалось создать папку');
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="folderName" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Название папки
|
||||
</label>
|
||||
<input
|
||||
id="folderName"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
placeholder="Введите название..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedFolderId && (
|
||||
<div className="rounded-lg bg-sky-50 p-3">
|
||||
<p className="text-xs text-sky-700">
|
||||
<FolderOpen className="mr-1 inline" size={14} />
|
||||
Будет создана в выбранной папке
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateFolder(false)}
|
||||
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-700"
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Acknowledgements Report Modal */}
|
||||
{showAcknowledgementsReport && (
|
||||
<DocumentAcknowledgementsReport
|
||||
documentId={showAcknowledgementsReport.documentId}
|
||||
documentTitle={showAcknowledgementsReport.documentTitle}
|
||||
onClose={() => setShowAcknowledgementsReport(null)}
|
||||
/>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Search, CheckCircle, AlertCircle, Users, Download } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Employee {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
patronymic?: string;
|
||||
email: string;
|
||||
position?: {
|
||||
name: string;
|
||||
};
|
||||
department?: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AcknowledgementsData {
|
||||
acknowledged: Employee[];
|
||||
unacknowledged: Employee[];
|
||||
counts: {
|
||||
acknowledged: number;
|
||||
unacknowledged: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DocumentAcknowledgementsReportProps {
|
||||
documentId: number;
|
||||
documentTitle: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DocumentAcknowledgementsReport({
|
||||
documentId,
|
||||
documentTitle,
|
||||
onClose,
|
||||
}: DocumentAcknowledgementsReportProps) {
|
||||
const [data, setData] = useState<AcknowledgementsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"all" | "acknowledged" | "unacknowledged">("all");
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [documentId, search]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await apiClient.getDocumentAcknowledgements(documentId, search || undefined);
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки ведомости:", err);
|
||||
toast.error("Не удалось загрузить ведомость");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (!data) return;
|
||||
|
||||
const rows: string[][] = [
|
||||
["ФИО", "Email", "Должность", "Отдел", "Статус"],
|
||||
];
|
||||
|
||||
// Добавляем ознакомившихся
|
||||
data.acknowledged.forEach((emp) => {
|
||||
rows.push([
|
||||
`${emp.last_name} ${emp.first_name} ${emp.patronymic || ""}`.trim(),
|
||||
emp.email,
|
||||
emp.position?.name || "-",
|
||||
emp.department?.name || "-",
|
||||
"Ознакомлен",
|
||||
]);
|
||||
});
|
||||
|
||||
// Добавляем не ознакомившихся
|
||||
data.unacknowledged.forEach((emp) => {
|
||||
rows.push([
|
||||
`${emp.last_name} ${emp.first_name} ${emp.patronymic || ""}`.trim(),
|
||||
emp.email,
|
||||
emp.position?.name || "-",
|
||||
emp.department?.name || "-",
|
||||
"Не ознакомлен",
|
||||
]);
|
||||
});
|
||||
|
||||
const csvContent = rows.map((row) => row.map((cell) => `"${cell}"`).join(",")).join("\n");
|
||||
const blob = new Blob(["\ufeff" + csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `ознакомления_${documentTitle.replace(/[^a-zA-Zа-яА-Я0-9]/g, "_")}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const getDisplayList = (): Employee[] => {
|
||||
if (!data) return [];
|
||||
|
||||
switch (activeTab) {
|
||||
case "acknowledged":
|
||||
return data.acknowledged;
|
||||
case "unacknowledged":
|
||||
return data.unacknowledged;
|
||||
default:
|
||||
return [...data.acknowledged, ...data.unacknowledged];
|
||||
}
|
||||
};
|
||||
|
||||
const displayList = getDisplayList();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="flex h-[90vh] w-full max-w-4xl flex-col rounded-2xl bg-white shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between border-b border-gray-200 p-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Ведомость ознакомлений</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">{documentTitle}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{data && (
|
||||
<div className="grid grid-cols-3 gap-4 border-b border-gray-200 p-6">
|
||||
<div className="rounded-xl bg-blue-50 p-4">
|
||||
<div className="flex items-center gap-2 text-blue-700">
|
||||
<Users size={20} />
|
||||
<span className="text-sm font-medium">Всего</span>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold text-blue-900">{data.counts.total}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-green-50 p-4">
|
||||
<div className="flex items-center gap-2 text-green-700">
|
||||
<CheckCircle size={20} />
|
||||
<span className="text-sm font-medium">Ознакомлены</span>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold text-green-900">{data.counts.acknowledged}</p>
|
||||
<p className="text-xs text-green-600">
|
||||
{data.counts.total > 0 ? Math.round((data.counts.acknowledged / data.counts.total) * 100) : 0}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-amber-50 p-4">
|
||||
<div className="flex items-center gap-2 text-amber-700">
|
||||
<AlertCircle size={20} />
|
||||
<span className="text-sm font-medium">Не ознакомлены</span>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold text-amber-900">{data.counts.unacknowledged}</p>
|
||||
<p className="text-xs text-amber-600">
|
||||
{data.counts.total > 0 ? Math.round((data.counts.unacknowledged / data.counts.total) * 100) : 0}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs & Search */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по ФИО или email..."
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={exportToCSV}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
<Download size={16} />
|
||||
Экспорт CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab("all")}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
|
||||
activeTab === "all"
|
||||
? "bg-sky-100 text-sky-700"
|
||||
: "text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
Все ({data?.counts.total || 0})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("acknowledged")}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
|
||||
activeTab === "acknowledged"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
Ознакомлены ({data?.counts.acknowledged || 0})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("unacknowledged")}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
|
||||
activeTab === "unacknowledged"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
Не ознакомлены ({data?.counts.unacknowledged || 0})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
|
||||
</div>
|
||||
) : displayList.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-sm text-gray-500">Сотрудников не найдено</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{displayList.map((emp) => {
|
||||
const isAcknowledged = data!.acknowledged.some((a) => a.id === emp.id);
|
||||
return (
|
||||
<div
|
||||
key={emp.id}
|
||||
className={`flex items-center justify-between rounded-lg border p-3 ${
|
||||
isAcknowledged
|
||||
? "border-green-200 bg-green-50"
|
||||
: "border-amber-200 bg-amber-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAcknowledged ? (
|
||||
<CheckCircle size={20} className="shrink-0 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle size={20} className="shrink-0 text-amber-600" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{emp.last_name} {emp.first_name} {emp.patronymic || ""}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-gray-600">
|
||||
<span>{emp.email}</span>
|
||||
{emp.position && <span>• {emp.position.name}</span>}
|
||||
{emp.department && <span>• {emp.department.name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
isAcknowledged ? "text-green-700" : "text-amber-700"
|
||||
}`}
|
||||
>
|
||||
{isAcknowledged ? "Ознакомлен" : "Требуется"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Document as PDFDocument, Page, pdfjs } from "react-pdf";
|
||||
import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Download, X, FileText, Table } from "lucide-react";
|
||||
import "react-pdf/dist/Page/AnnotationLayer.css";
|
||||
import "react-pdf/dist/Page/TextLayer.css";
|
||||
|
||||
@ -15,13 +15,141 @@ interface DocumentPreviewProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface ExcelSheet {
|
||||
name: string;
|
||||
data: any[][];
|
||||
}
|
||||
|
||||
function getFileType(fileName: string): 'pdf' | 'image' | 'word' | 'excel' | 'text' | 'unknown' {
|
||||
const ext = fileName.toLowerCase().split('.').pop() || '';
|
||||
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'image';
|
||||
if (['doc', 'docx'].includes(ext)) return 'word';
|
||||
if (['xls', 'xlsx'].includes(ext)) return 'excel';
|
||||
if (['txt', 'csv', 'json', 'xml', 'html', 'css', 'js', 'ts', 'md'].includes(ext)) return 'text';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function DocumentPreview({ fileUrl, fileName, onClose }: DocumentPreviewProps) {
|
||||
const [numPages, setNumPages] = useState<number>(0);
|
||||
const [pageNumber, setPageNumber] = useState<number>(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [docxContent, setDocxContent] = useState<string>('');
|
||||
const [excelSheets, setExcelSheets] = useState<ExcelSheet[]>([]);
|
||||
const [activeSheet, setActiveSheet] = useState<number>(0);
|
||||
const [textContent, setTextContent] = useState<string>('');
|
||||
|
||||
const isPDF = fileName.toLowerCase().endsWith('.pdf');
|
||||
const fileType = getFileType(fileName);
|
||||
|
||||
// Конвертируем DOCX в HTML на клиенте
|
||||
useEffect(() => {
|
||||
if (fileType === 'word' && fileName.toLowerCase().endsWith('.docx')) {
|
||||
setLoading(true);
|
||||
|
||||
fetch(fileUrl, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`,
|
||||
},
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.arrayBuffer();
|
||||
})
|
||||
.then(async (arrayBuffer) => {
|
||||
try {
|
||||
const mammoth = (await import('mammoth')).default;
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer });
|
||||
setDocxContent(result.value);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Ошибка конвертации DOCX:', err);
|
||||
setError('Не удалось загрузить документ');
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка загрузки файла:', err);
|
||||
setError('Не удалось загрузить файл');
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [fileUrl, fileName, fileType]);
|
||||
|
||||
// Парсим Excel файлы локально
|
||||
useEffect(() => {
|
||||
if (fileType === 'excel') {
|
||||
setLoading(true);
|
||||
|
||||
fetch(fileUrl, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`,
|
||||
},
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.arrayBuffer();
|
||||
})
|
||||
.then(async (arrayBuffer) => {
|
||||
try {
|
||||
const XLSX = (await import('xlsx')).default;
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
|
||||
const sheets: ExcelSheet[] = workbook.SheetNames.map(sheetName => {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
|
||||
return {
|
||||
name: sheetName,
|
||||
data
|
||||
};
|
||||
});
|
||||
|
||||
setExcelSheets(sheets);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Ошибка парсинга Excel:', err);
|
||||
setError('Не удалось загрузить таблицу');
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка загрузки файла:', err);
|
||||
setError('Не удалось загрузить файл');
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [fileUrl, fileName, fileType]);
|
||||
|
||||
// Загружаем текстовые файлы
|
||||
useEffect(() => {
|
||||
if (fileType === 'text') {
|
||||
setLoading(true);
|
||||
|
||||
fetch(fileUrl, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}`,
|
||||
},
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.text();
|
||||
})
|
||||
.then(text => {
|
||||
setTextContent(text);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка загрузки текстового файла:', err);
|
||||
setError('Не удалось загрузить файл');
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [fileUrl, fileName, fileType]);
|
||||
|
||||
function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
|
||||
setNumPages(numPages);
|
||||
@ -68,7 +196,7 @@ export function DocumentPreview({ fileUrl, fileName, onClose }: DocumentPreviewP
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{isPDF ? (
|
||||
{fileType === 'pdf' ? (
|
||||
<div className="flex flex-col items-center">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@ -96,9 +224,159 @@ export function DocumentPreview({ fileUrl, fileName, onClose }: DocumentPreviewP
|
||||
/>
|
||||
</PDFDocument>
|
||||
</div>
|
||||
) : fileType === 'image' ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={fileName}
|
||||
className="max-h-full max-w-full rounded-lg shadow-lg"
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={() => setError('Не удалось загрузить изображение')}
|
||||
/>
|
||||
</div>
|
||||
) : fileType === 'word' ? (
|
||||
<div className="h-full">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
|
||||
<span className="ml-3 text-sm text-gray-600">Загрузка документа...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={fileUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-red-700"
|
||||
>
|
||||
<Download size={14} />
|
||||
Скачать файл
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && docxContent && (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-8 shadow-sm">
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: docxContent }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : fileType === 'excel' ? (
|
||||
<div className="h-full">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
|
||||
<span className="ml-3 text-sm text-gray-600">Загрузка таблицы...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={fileUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-red-700"
|
||||
>
|
||||
<Download size={14} />
|
||||
Скачать файл
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && excelSheets.length > 0 && (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sheet Tabs */}
|
||||
{excelSheets.length > 1 && (
|
||||
<div className="mb-4 flex gap-2 overflow-x-auto border-b border-gray-200 pb-2">
|
||||
{excelSheets.map((sheet, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveSheet(index)}
|
||||
className={`flex items-center gap-2 whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition ${
|
||||
activeSheet === index
|
||||
? 'bg-sky-100 text-sky-900'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Table size={16} />
|
||||
{sheet.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Excel Table */}
|
||||
<div className="flex-1 overflow-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<tbody>
|
||||
{excelSheets[activeSheet]?.data.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-b border-gray-200 hover:bg-gray-50">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td
|
||||
key={cellIndex}
|
||||
className="border-r border-gray-200 px-3 py-2 text-gray-900 last:border-r-0"
|
||||
>
|
||||
{cell !== null && cell !== undefined ? String(cell) : ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{excelSheets[activeSheet]?.data.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-gray-500">
|
||||
Лист пуст
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : fileType === 'text' ? (
|
||||
<div className="h-full">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
|
||||
<span className="ml-3 text-sm text-gray-600">Загрузка файла...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 shadow-sm">
|
||||
<pre className="overflow-auto text-xs text-gray-900">
|
||||
<code>{textContent}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center rounded-xl bg-gray-50 p-12">
|
||||
<div className="text-center">
|
||||
<FileText size={48} className="mx-auto mb-4 text-gray-400" />
|
||||
<p className="mb-2 text-sm font-medium text-gray-900">
|
||||
Неизвестный тип файла
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
Предпросмотр недоступен для этого типа файла
|
||||
</p>
|
||||
@ -118,7 +396,7 @@ export function DocumentPreview({ fileUrl, fileName, onClose }: DocumentPreviewP
|
||||
</div>
|
||||
|
||||
{/* PDF Navigation */}
|
||||
{isPDF && numPages > 0 && (
|
||||
{fileType === 'pdf' && numPages > 0 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 px-6 py-4">
|
||||
<button
|
||||
onClick={goToPrevPage}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { Upload, X, FileText, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { Upload, X, FileText, AlertCircle, Loader2, FolderOpen } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { processDocument, needsProcessing, type ProcessingProgress } from "@/lib/document-utils";
|
||||
@ -10,6 +10,7 @@ import { processDocument, needsProcessing, type ProcessingProgress } from "@/lib
|
||||
interface DocumentUploadFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
currentFolderId?: number | null;
|
||||
}
|
||||
|
||||
// Сообщения для различных этапов обработки
|
||||
@ -21,7 +22,7 @@ const STAGE_MESSAGES: Record<string, string> = {
|
||||
complete: "Обработка завершена",
|
||||
};
|
||||
|
||||
export function DocumentUploadForm({ onSuccess, onCancel }: DocumentUploadFormProps) {
|
||||
export function DocumentUploadForm({ onSuccess, onCancel, currentFolderId }: DocumentUploadFormProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
@ -120,6 +121,7 @@ export function DocumentUploadForm({ onSuccess, onCancel }: DocumentUploadFormPr
|
||||
description,
|
||||
file: fileToUpload,
|
||||
extracted_text: extractedText || undefined, // Отправляем извлеченный текст, если есть
|
||||
folder_id: currentFolderId || undefined, // Сохраняем в текущей папке если она выбрана
|
||||
});
|
||||
|
||||
toast.success("Документ успешно загружен");
|
||||
@ -152,6 +154,16 @@ export function DocumentUploadForm({ onSuccess, onCancel }: DocumentUploadFormPr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current folder info */}
|
||||
{currentFolderId && (
|
||||
<div className="rounded-lg bg-sky-50 p-3 text-sm text-sky-700">
|
||||
<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-gray-700">
|
||||
|
||||
@ -3,3 +3,4 @@ export { DocumentStatusBadge } from "./DocumentStatusBadge";
|
||||
export { DocumentWorkflowButtons } from "./DocumentWorkflowButtons";
|
||||
export { DocumentPreview } from "./DocumentPreview";
|
||||
export { DocumentAcknowledgement } from "./DocumentAcknowledgement";
|
||||
export { DocumentAcknowledgementsReport } from "./DocumentAcknowledgementsReport";
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Document, Page, pdfjs } from "react-pdf";
|
||||
import "react-pdf/dist/esm/Page/AnnotationLayer.css";
|
||||
import "react-pdf/dist/esm/Page/TextLayer.css";
|
||||
import "react-pdf/dist/Page/AnnotationLayer.css";
|
||||
import "react-pdf/dist/Page/TextLayer.css";
|
||||
import {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
@ -17,8 +17,10 @@ import {
|
||||
Sidebar,
|
||||
} from "lucide-react";
|
||||
|
||||
// Настройка worker для react-pdf
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
|
||||
// Настройка worker для react-pdf - используем worker из node_modules
|
||||
if (typeof window !== 'undefined' && !pdfjs.GlobalWorkerOptions.workerSrc) {
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `/pdf.worker.min.mjs`;
|
||||
}
|
||||
|
||||
type FitMode = "width" | "page" | "custom";
|
||||
|
||||
|
||||
@ -97,10 +97,15 @@ class ApiClient {
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
// Устанавливаем Content-Type только если это не FormData
|
||||
// Для FormData браузер сам установит правильный Content-Type с boundary
|
||||
if (!(options.body instanceof FormData) && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
// Автоматически добавляем токен если он есть
|
||||
const token = this.getToken();
|
||||
if (token) {
|
||||
@ -283,13 +288,14 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// Документы
|
||||
async getDocuments(params?: { search?: string; type?: string; status?: string; page?: number; limit?: number }): Promise<any> {
|
||||
async getDocuments(params?: { search?: string; type?: string; status?: string; page?: number; limit?: number; folder_id?: number }): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.search) queryParams.append('search', params.search);
|
||||
if (params?.type) queryParams.append('type', params.type);
|
||||
if (params?.status) queryParams.append('status', params.status);
|
||||
if (params?.page) queryParams.append('page', params.page.toString());
|
||||
if (params?.limit) queryParams.append('limit', params.limit.toString());
|
||||
if (params?.folder_id !== undefined) queryParams.append('folder_id', params.folder_id.toString());
|
||||
|
||||
const url = `/api/v1/documents/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
return this.request(url);
|
||||
@ -304,17 +310,39 @@ class ApiClient {
|
||||
description?: string;
|
||||
file: File | Blob;
|
||||
extracted_text?: string;
|
||||
sent_to_all?: boolean;
|
||||
recipient_ids?: number[];
|
||||
department_ids?: number[];
|
||||
folder_id?: number | null;
|
||||
}): Promise<any> {
|
||||
const formData = new FormData();
|
||||
formData.append('title', data.title);
|
||||
if (data.description) formData.append('description', data.description);
|
||||
if (data.extracted_text) formData.append('extracted_text', data.extracted_text);
|
||||
if (data.folder_id) formData.append('folder', String(data.folder_id));
|
||||
|
||||
// Добавляем sent_to_all (по умолчанию true)
|
||||
formData.append('sent_to_all', String(data.sent_to_all ?? true));
|
||||
|
||||
// Добавляем получателей, если указаны
|
||||
if (data.recipient_ids && data.recipient_ids.length > 0) {
|
||||
data.recipient_ids.forEach(id => {
|
||||
formData.append('recipient_ids', String(id));
|
||||
});
|
||||
}
|
||||
|
||||
// Добавляем отделы, если указаны
|
||||
if (data.department_ids && data.department_ids.length > 0) {
|
||||
data.department_ids.forEach(id => {
|
||||
formData.append('department_ids', String(id));
|
||||
});
|
||||
}
|
||||
|
||||
formData.append('file', data.file);
|
||||
|
||||
return this.request('/api/v1/documents/', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {}, // Clear headers to let browser set Content-Type with boundary
|
||||
});
|
||||
}
|
||||
|
||||
@ -331,7 +359,6 @@ class ApiClient {
|
||||
return this.request(`/api/v1/documents/${id}/`, {
|
||||
method: 'PATCH',
|
||||
body: formData,
|
||||
headers: {}, // Clear headers to let browser set Content-Type with boundary
|
||||
});
|
||||
}
|
||||
|
||||
@ -392,6 +419,57 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Получить ведомость ознакомлений с документом (для начальника/автора)
|
||||
async getDocumentAcknowledgements(id: number, search?: string): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (search) queryParams.append('search', search);
|
||||
|
||||
const url = `/api/v1/documents/${id}/acknowledgements/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
return this.request(url);
|
||||
}
|
||||
|
||||
// Папки документов
|
||||
async getFolders(params?: { parent_id?: number; root?: boolean }): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.parent_id !== undefined) queryParams.append('parent_id', params.parent_id.toString());
|
||||
if (params?.root) queryParams.append('root', 'true');
|
||||
|
||||
const url = `/api/v1/folders/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
return this.request(url);
|
||||
}
|
||||
|
||||
async getFolder(id: number): Promise<any> {
|
||||
return this.request(`/api/v1/folders/${id}/`);
|
||||
}
|
||||
|
||||
async createFolder(data: { name: string; parent?: number | null }): Promise<any> {
|
||||
return this.request('/api/v1/folders/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateFolder(id: number, data: { name?: string; parent?: number | null }): Promise<any> {
|
||||
return this.request(`/api/v1/folders/${id}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFolder(id: number): Promise<void> {
|
||||
return this.request(`/api/v1/folders/${id}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async getFolderChildren(id: number): Promise<any> {
|
||||
return this.request(`/api/v1/folders/${id}/children/`);
|
||||
}
|
||||
|
||||
async getFolderDocuments(id: number): Promise<any> {
|
||||
return this.request(`/api/v1/folders/${id}/documents/`);
|
||||
}
|
||||
|
||||
// Заявки
|
||||
async getRequests(params?: { status?: string; type?: string; search?: string; page?: number; limit?: number }): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
@ -494,31 +572,7 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Чаты
|
||||
async getChats(params?: { search?: string; page?: number; limit?: number }): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.search) queryParams.append('search', params.search);
|
||||
if (params?.page) queryParams.append('page', params.page.toString());
|
||||
if (params?.limit) queryParams.append('limit', params.limit.toString());
|
||||
|
||||
const url = `/api/v1/communications/chats/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
return this.request(url);
|
||||
}
|
||||
|
||||
async getChat(chatId: number | string): Promise<any> {
|
||||
return this.request(`/api/v1/communications/chats/${chatId}/`);
|
||||
}
|
||||
|
||||
async getChatMessages(chatId: number | string, params?: { limit?: number; before?: number; after?: number }): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.limit) queryParams.append('limit', params.limit.toString());
|
||||
if (params?.before) queryParams.append('before', params.before.toString());
|
||||
if (params?.after) queryParams.append('after', params.after.toString());
|
||||
|
||||
const url = `/api/v1/communications/chats/${chatId}/messages/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
return this.request(url);
|
||||
}
|
||||
|
||||
// Чаты (основные методы чатов ниже в файле)
|
||||
async getChatMessagesAround(chatId: number | string, params?: { limit?: number; around_id?: number }): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.limit) queryParams.append('limit', params.limit.toString());
|
||||
|
||||
@ -6,8 +6,10 @@
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { PDFDocument, degrees } from 'pdf-lib';
|
||||
|
||||
// Настройка worker для PDF.js
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`;
|
||||
// Настройка worker для PDF.js - используем локальный worker из public
|
||||
if (typeof window !== 'undefined' && !pdfjsLib.GlobalWorkerOptions.workerSrc) {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = `/pdf.worker.min.mjs`;
|
||||
}
|
||||
|
||||
export interface PDFTextExtractionProgress {
|
||||
currentPage: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user