feat(frontend): Полная интеграция документов с django-filer и django-fsm
- Установлены библиотеки: react-dropzone, react-pdf, pdfjs-dist, react-file-icon, @tanstack/react-table - Обновлены TypeScript типы для Document + FSM статусы - Обновлен API клиент: CRUD операции, FSM переходы, подтверждение прочтения - Создан DocumentUploadForm: drag&drop загрузка файлов - Создан DocumentStatusBadge: цветные статусы (draft/in_review/approved/published/archived/rejected) - Создан DocumentWorkflowButtons: динамические кнопки FSM переходов - Создан DocumentPreview: встроенный просмотр PDF с навигацией - Создан DocumentAcknowledgement: подтверждение прочтения с комментариями - Обновлена страница documents: фильтры, модальные окна, workflow UI - Добавлена документация в DOCUMENTS_IMPLEMENTATION.md
This commit is contained in:
parent
33ce8d646e
commit
12f3b7b81b
286
frontend/DOCUMENTS_IMPLEMENTATION.md
Normal file
286
frontend/DOCUMENTS_IMPLEMENTATION.md
Normal file
@ -0,0 +1,286 @@
|
||||
# Документы - Frontend Implementation
|
||||
|
||||
## Реализованный функционал
|
||||
|
||||
### 1. **Загрузка документов** (django-filer)
|
||||
|
||||
✅ Компонент `DocumentUploadForm`:
|
||||
- Drag & Drop интерфейс для загрузки файлов
|
||||
- Поддержка различных типов файлов (PDF, Word, Excel, изображения, текст)
|
||||
- Валидация размера файлов
|
||||
- Автоматическое заполнение названия из имени файла
|
||||
- Форма с полями: название, тип, описание, теги
|
||||
- Интеграция с API (`POST /api/v1/documents/`)
|
||||
|
||||
### 2. **FSM Workflow** (django-fsm)
|
||||
|
||||
✅ Компонент `DocumentStatusBadge`:
|
||||
- Цветовые индикаторы для каждого статуса:
|
||||
* 🟢 Черновик (серый)
|
||||
* 🔵 На рассмотрении (голубой)
|
||||
* 🟢 Утверждено (зеленый)
|
||||
* 🔵 Опубликовано (синий)
|
||||
* ⚪ В архиве (серый)
|
||||
* 🔴 Отклонено (красный)
|
||||
|
||||
✅ Компонент `DocumentWorkflowButtons`:
|
||||
- Динамические кнопки действий в зависимости от текущего статуса
|
||||
- Переходы:
|
||||
* Черновик → Отправить на рассмотрение
|
||||
* На рассмотрении → Утвердить / Отклонить / Вернуть в черновик
|
||||
* Утверждено → Опубликовать
|
||||
* Опубликовано → Архивировать
|
||||
* В архиве → Разархивировать
|
||||
- Подтверждающие диалоги для критичных действий
|
||||
- Toast уведомления об успехе/ошибке
|
||||
|
||||
### 3. **Preview файлов**
|
||||
|
||||
✅ Компонент `DocumentPreview`:
|
||||
- Встроенный просмотр PDF файлов с постраничной навигацией
|
||||
- Использует `react-pdf` и `pdfjs-dist`
|
||||
- Кнопки: следующая/предыдущая страница, скачать, закрыть
|
||||
- Для не-PDF файлов: предложение скачать
|
||||
- Полноэкранное модальное окно
|
||||
|
||||
### 4. **Подтверждение прочтения**
|
||||
|
||||
✅ Компонент `DocumentAcknowledgement`:
|
||||
- Форма подтверждения с опциональным комментарием
|
||||
- Индикатор "Прочитано" для текущего пользователя
|
||||
- Список всех подтверждений с именами пользователей и датами
|
||||
- Интеграция с API (`POST /api/v1/documents/{id}/acknowledge/`)
|
||||
|
||||
### 5. **Список документов**
|
||||
|
||||
✅ Обновленная страница `documents/page.tsx`:
|
||||
- Поиск по названию, описанию, типу, автору
|
||||
- Фильтрация по статусу (все/черновик/на рассмотрении/...)
|
||||
- Карточки документов с:
|
||||
* Статус badge
|
||||
* Workflow кнопки
|
||||
* Теги
|
||||
* Метаданные (автор, даты)
|
||||
* Кнопки "Просмотр" и "Детали"
|
||||
- Модальное окно с детальной информацией о документе
|
||||
- Модальное окно загрузки нового документа
|
||||
|
||||
## API интеграция
|
||||
|
||||
### Обновленный `apiClient` (`lib/api.ts`)
|
||||
|
||||
Добавлены методы:
|
||||
|
||||
```typescript
|
||||
// CRUD операции
|
||||
getDocuments(params?) // GET /api/v1/documents/
|
||||
getDocument(id) // GET /api/v1/documents/{id}/
|
||||
createDocument(data) // POST /api/v1/documents/
|
||||
updateDocument(id, data) // PATCH /api/v1/documents/{id}/
|
||||
deleteDocument(id) // DELETE /api/v1/documents/{id}/
|
||||
|
||||
// FSM переходы
|
||||
submitDocumentForReview(id) // POST /api/v1/documents/{id}/submit-for-review/
|
||||
approveDocument(id) // POST /api/v1/documents/{id}/approve/
|
||||
rejectDocument(id) // POST /api/v1/documents/{id}/reject/
|
||||
publishDocument(id) // POST /api/v1/documents/{id}/publish/
|
||||
returnDocumentToDraft(id) // POST /api/v1/documents/{id}/return-to-draft/
|
||||
archiveDocument(id) // POST /api/v1/documents/{id}/archive/
|
||||
unarchiveDocument(id) // POST /api/v1/documents/{id}/unarchive/
|
||||
|
||||
// Подтверждение прочтения
|
||||
acknowledgeDocument(id, notes?) // POST /api/v1/documents/{id}/acknowledge/
|
||||
```
|
||||
|
||||
## TypeScript типы
|
||||
|
||||
### Обновленный `types/api.ts`
|
||||
|
||||
```typescript
|
||||
export type DocumentStatus =
|
||||
| 'draft'
|
||||
| 'in_review'
|
||||
| 'approved'
|
||||
| 'published'
|
||||
| 'archived'
|
||||
| 'rejected';
|
||||
|
||||
export interface DocumentAcknowledgement {
|
||||
id: number;
|
||||
document: number;
|
||||
user: User;
|
||||
acknowledged_at: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
file?: string;
|
||||
file_url?: string;
|
||||
file_name?: string;
|
||||
file_size?: number;
|
||||
document_type: string;
|
||||
status: string; // human-readable
|
||||
status_code: DocumentStatus; // machine-readable
|
||||
created_by: User;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tags?: string[];
|
||||
acknowledgements?: DocumentAcknowledgement[];
|
||||
acknowledgement_required?: boolean;
|
||||
is_acknowledged?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
## Установленные библиотеки
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-pdf": "^9.1.1",
|
||||
"pdfjs-dist": "^4.8.69",
|
||||
"react-file-icon": "^1.5.0",
|
||||
"@tanstack/react-table": "^8.20.5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Структура файлов
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── components/
|
||||
│ └── documents/
|
||||
│ ├── index.ts
|
||||
│ ├── DocumentUploadForm.tsx
|
||||
│ ├── DocumentStatusBadge.tsx
|
||||
│ ├── DocumentWorkflowButtons.tsx
|
||||
│ ├── DocumentPreview.tsx
|
||||
│ └── DocumentAcknowledgement.tsx
|
||||
├── app/
|
||||
│ └── documents/
|
||||
│ └── page.tsx (обновлена)
|
||||
├── lib/
|
||||
│ └── api.ts (обновлен)
|
||||
└── types/
|
||||
└── api.ts (обновлен)
|
||||
```
|
||||
|
||||
## Использование компонентов
|
||||
|
||||
### DocumentUploadForm
|
||||
|
||||
```tsx
|
||||
import { DocumentUploadForm } from "@/components/documents";
|
||||
|
||||
<DocumentUploadForm
|
||||
onSuccess={() => {
|
||||
// Обновить список документов
|
||||
}}
|
||||
onCancel={() => {
|
||||
// Закрыть форму
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### DocumentStatusBadge
|
||||
|
||||
```tsx
|
||||
import { DocumentStatusBadge } from "@/components/documents";
|
||||
|
||||
<DocumentStatusBadge
|
||||
status="На рассмотрении"
|
||||
statusCode="in_review"
|
||||
/>
|
||||
```
|
||||
|
||||
### DocumentWorkflowButtons
|
||||
|
||||
```tsx
|
||||
import { DocumentWorkflowButtons } from "@/components/documents";
|
||||
|
||||
<DocumentWorkflowButtons
|
||||
documentId={doc.id}
|
||||
currentStatus={doc.status_code}
|
||||
onStatusChange={() => {
|
||||
// Обновить данные
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### DocumentPreview
|
||||
|
||||
```tsx
|
||||
import { DocumentPreview } from "@/components/documents";
|
||||
|
||||
<DocumentPreview
|
||||
fileUrl="http://backend/media/documents/file.pdf"
|
||||
fileName="document.pdf"
|
||||
onClose={() => setShowPreview(false)}
|
||||
/>
|
||||
```
|
||||
|
||||
### DocumentAcknowledgement
|
||||
|
||||
```tsx
|
||||
import { DocumentAcknowledgement } from "@/components/documents";
|
||||
|
||||
<DocumentAcknowledgement
|
||||
document={doc}
|
||||
onAcknowledge={() => {
|
||||
// Обновить данные
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Roadmap / Будущие улучшения
|
||||
|
||||
### Приоритет ВЫСОКИЙ
|
||||
- [ ] Добавить права доступа на UI уровне (скрывать кнопки для пользователей без прав)
|
||||
- [ ] Версионирование документов (интеграция с django-reversion)
|
||||
- [ ] История изменений статуса (timeline view)
|
||||
|
||||
### Приоритет СРЕДНИЙ
|
||||
- [ ] Редактирование документов (обновление файла, метаданных)
|
||||
- [ ] Массовые операции (архивирование нескольких документов)
|
||||
- [ ] Экспорт списка документов (CSV, Excel)
|
||||
- [ ] Уведомления о смене статуса
|
||||
|
||||
### Приоритет НИЗКИЙ
|
||||
- [ ] OCR для сканов (pytesseract на бэкенде)
|
||||
- [ ] Полнотекстовый поиск по содержимому PDF
|
||||
- [ ] Интеграция с внешними хранилищами (Google Drive, OneDrive)
|
||||
- [ ] Шаблоны документов
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Checklist для проверки
|
||||
|
||||
- [ ] Загрузка документа через Drag & Drop
|
||||
- [ ] Загрузка документа через выбор файла
|
||||
- [ ] Поиск по документам
|
||||
- [ ] Фильтрация по статусу
|
||||
- [ ] Переход: Черновик → На рассмотрении
|
||||
- [ ] Переход: На рассмотрении → Утверждено
|
||||
- [ ] Переход: Утверждено → Опубликовано
|
||||
- [ ] Preview PDF файла
|
||||
- [ ] Подтверждение прочтения документа
|
||||
- [ ] Просмотр списка подтверждений
|
||||
- [ ] Модальное окно с деталями документа
|
||||
|
||||
## Известные проблемы
|
||||
|
||||
1. **PDF.js Worker Warning**:
|
||||
- В консоли может появляться предупреждение о worker
|
||||
- Решение: уже настроен через unpkg CDN в `DocumentPreview.tsx`
|
||||
|
||||
2. **API Permission Tests**:
|
||||
- ~10 тестов в бэкенде падают (проблема с правами доступа)
|
||||
- Не блокирует работу frontend
|
||||
|
||||
## Контакты
|
||||
|
||||
Для вопросов и предложений обращайтесь к команде разработки.
|
||||
506
frontend/package-lock.json
generated
506
frontend/package-lock.json
generated
@ -10,12 +10,17 @@
|
||||
"dependencies": {
|
||||
"@chatscope/chat-ui-kit-react": "^2.1.1",
|
||||
"@chatscope/chat-ui-kit-styles": "^1.4.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.574.0",
|
||||
"next": "16.1.6",
|
||||
"pdfjs-dist": "^5.4.624",
|
||||
"react": "19.2.3",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-file-icon": "^1.6.0",
|
||||
"react-pdf": "^10.4.1",
|
||||
"sonner": "^2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -24,6 +29,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-big-calendar": "^1.16.3",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-pdf": "^6.2.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
@ -1065,6 +1071,256 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.95",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.95.tgz",
|
||||
"integrity": "sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.95",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.95",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.95",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.95",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.95",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.95",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.95",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.95",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.95",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.95",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.95"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.95",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.95.tgz",
|
||||
"integrity": "sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.95",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.95.tgz",
|
||||
"integrity": "sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.95",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.95.tgz",
|
||||
"integrity": "sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.95",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.95.tgz",
|
||||
"integrity": "sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.95",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.95.tgz",
|
||||
"integrity": "sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.95",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.95.tgz",
|
||||
"integrity": "sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.95",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.95.tgz",
|
||||
"integrity": "sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.95",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.95.tgz",
|
||||
"integrity": "sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.95",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.95.tgz",
|
||||
"integrity": "sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.95",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.95.tgz",
|
||||
"integrity": "sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.95",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.95.tgz",
|
||||
"integrity": "sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
@ -1547,6 +1803,39 @@
|
||||
"tailwindcss": "4.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@ -1628,6 +1917,36 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-pdf": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-pdf/-/react-pdf-6.2.0.tgz",
|
||||
"integrity": "sha512-OSCYmrfaJvpXkM5V4seUMAhUDOAOqbGQf9kwv14INyTf7AjDs2ukfkkQrLWRQ8OjWrDklbXYWh5l7pT7l0N76g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*",
|
||||
"pdfjs-dist": "^2.16.105"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-pdf/node_modules/pdfjs-dist": {
|
||||
"version": "2.16.105",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.16.105.tgz",
|
||||
"integrity": "sha512-J4dn41spsAwUxCpEoVf6GVoz908IAA3mYiLmNxg8J9kfRXc2jxpbUepcP0ocp0alVNLFthTAM8DZ1RaHh8sU0A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dommatrix": "^1.0.3",
|
||||
"web-streams-polyfill": "^3.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"worker-loader": "^3.0.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"worker-loader": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@types/warning": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
|
||||
@ -2387,6 +2706,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/attr-accept": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
@ -2619,6 +2947,12 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/colord": {
|
||||
"version": "2.9.3",
|
||||
"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
|
||||
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@ -2844,6 +3178,14 @@
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dommatrix": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dommatrix/-/dommatrix-1.0.3.tgz",
|
||||
"integrity": "sha512-l32Xp/TLgWb8ReqbVJAFIvXmY7go4nTxxlWiAFyhoQw9RKEOHBZNnyGvJWqDVSPmq3Y9HlM4npqF/T6VMOXhww==",
|
||||
"deprecated": "dommatrix is no longer maintained. Please use @thednp/dommatrix.",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@ -3551,6 +3893,18 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@ -4826,6 +5180,24 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/make-cancellable-promise": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
|
||||
"integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-event-props": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
|
||||
"integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@ -4841,6 +5213,23 @@
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge-refs": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz",
|
||||
"integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@ -5046,6 +5435,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@ -5266,6 +5662,19 @@
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true
|
||||
},
|
||||
"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==",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@ -5417,6 +5826,37 @@
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dropzone": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-15.0.0.tgz",
|
||||
"integrity": "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"attr-accept": "^2.2.4",
|
||||
"file-selector": "^2.1.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-file-icon": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-file-icon/-/react-file-icon-1.6.0.tgz",
|
||||
"integrity": "sha512-Ba4Qa2ya/kvhcCd4LJja77sV7JD7u1ZXcI1DUz+TII3nGmglG6QY+NZeHizThokgct3qI0glwb9eV8NqRGs5lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"colord": "^2.9.3",
|
||||
"prop-types": "^15.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.2.0",
|
||||
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@ -5448,6 +5888,56 @@
|
||||
"react-dom": ">=16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-pdf": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.4.1.tgz",
|
||||
"integrity": "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"dequal": "^2.0.3",
|
||||
"make-cancellable-promise": "^2.0.0",
|
||||
"make-event-props": "^2.0.0",
|
||||
"merge-refs": "^2.0.0",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"tiny-invariant": "^1.0.0",
|
||||
"warning": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-pdf/node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"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/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@ -6052,6 +6542,12 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@ -6395,6 +6891,16 @@
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@ -12,12 +12,17 @@
|
||||
"dependencies": {
|
||||
"@chatscope/chat-ui-kit-react": "^2.1.1",
|
||||
"@chatscope/chat-ui-kit-styles": "^1.4.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.574.0",
|
||||
"next": "16.1.6",
|
||||
"pdfjs-dist": "^5.4.624",
|
||||
"react": "19.2.3",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-file-icon": "^1.6.0",
|
||||
"react-pdf": "^10.4.1",
|
||||
"sonner": "^2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -26,6 +31,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-big-calendar": "^1.16.3",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-pdf": "^6.2.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
|
||||
@ -3,8 +3,13 @@
|
||||
import { AppShell } from "../../components/AppShell";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Document } from "@/types/api";
|
||||
import { Search, FileText, Download } from "lucide-react";
|
||||
import type { Document, DocumentStatus } from "@/types/api";
|
||||
import { Search, FileText, Plus, Eye, X } 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";
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return "";
|
||||
@ -20,26 +25,34 @@ function formatDate(value?: string): string {
|
||||
export default function DocumentsPage() {
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<DocumentStatus | "all">("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showUploadForm, setShowUploadForm] = useState(false);
|
||||
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
|
||||
const [previewFile, setPreviewFile] = useState<{ url: string; name: string } | null>(null);
|
||||
|
||||
const loadDocuments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const params: any = {};
|
||||
if (statusFilter !== "all") {
|
||||
params.status = statusFilter;
|
||||
}
|
||||
const response = await apiClient.getDocuments(params);
|
||||
setDocuments(response.results || []);
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки документов:", err);
|
||||
setError("Не удалось загрузить документы");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadDocuments() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await apiClient.getDocuments();
|
||||
setDocuments(response.results || []);
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки документов:", err);
|
||||
setError("Не удалось загрузить документы");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadDocuments();
|
||||
}, []);
|
||||
}, [statusFilter]);
|
||||
|
||||
const filteredDocuments = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
@ -74,73 +87,259 @@ export default function DocumentsPage() {
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="relative mb-4">
|
||||
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по документам"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<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">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Документы</h1>
|
||||
<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 className="space-y-3">
|
||||
{filteredDocuments.length === 0 ? (
|
||||
<div className="rounded-xl bg-gray-50 p-8 text-center">
|
||||
<FileText size={22} className="mx-auto mb-2 text-gray-400" />
|
||||
<p className="text-sm text-gray-500">Документы не найдены</p>
|
||||
{/* Filters */}
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по документам"
|
||||
className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
filteredDocuments.map((doc) => {
|
||||
const authorName = doc.created_by
|
||||
? `${doc.created_by.last_name} ${doc.created_by.first_name}`.trim()
|
||||
: "Неизвестно";
|
||||
|
||||
return (
|
||||
<article key={doc.id} className="rounded-xl border border-gray-100 bg-white p-4 transition hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-gray-900">{doc.title}</p>
|
||||
<p className="mt-1 text-xs text-gray-500">Тип: {doc.document_type || "—"}</p>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as DocumentStatus | "all")}
|
||||
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2.5 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="draft">Черновик</option>
|
||||
<option value="in_review">На рассмотрении</option>
|
||||
<option value="approved">Утверждено</option>
|
||||
<option value="published">Опубликовано</option>
|
||||
<option value="archived">В архиве</option>
|
||||
<option value="rejected">Отклонено</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Documents List */}
|
||||
<div className="space-y-3">
|
||||
{filteredDocuments.length === 0 ? (
|
||||
<div className="rounded-xl bg-gray-50 p-8 text-center">
|
||||
<FileText size={22} className="mx-auto mb-2 text-gray-400" />
|
||||
<p className="text-sm text-gray-500">Документы не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredDocuments.map((doc) => {
|
||||
const authorName = doc.created_by
|
||||
? `${doc.created_by.last_name} ${doc.created_by.first_name}`.trim()
|
||||
: "Неизвестно";
|
||||
|
||||
return (
|
||||
<article
|
||||
key={doc.id}
|
||||
className="rounded-xl border border-gray-100 bg-white p-4 transition hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold text-gray-900">{doc.title}</p>
|
||||
<DocumentStatusBadge
|
||||
status={doc.status}
|
||||
statusCode={doc.status_code}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Тип: {doc.document_type || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{doc.file_url && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setPreviewFile({
|
||||
url: doc.file_url!,
|
||||
name: doc.file_name || doc.title,
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-sky-50 px-2.5 py-1 text-xs text-sky-700 ring-1 ring-sky-100 hover:bg-sky-100"
|
||||
>
|
||||
<Eye size={12} />
|
||||
Просмотр
|
||||
</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"
|
||||
>
|
||||
Детали
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{doc.file ? (
|
||||
<a
|
||||
href={doc.file}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-sky-50 px-2.5 py-1 text-xs text-sky-700 ring-1 ring-sky-100 hover:bg-sky-100"
|
||||
>
|
||||
<Download size={12} />
|
||||
Открыть
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-700">
|
||||
{doc.description || "Описание не заполнено"}
|
||||
</p>
|
||||
|
||||
<p className="mt-3 text-sm text-gray-700">{doc.description || "Описание не заполнено"}</p>
|
||||
{doc.tags && doc.tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{doc.tags.map((tag) => (
|
||||
<span
|
||||
key={`${doc.id}-${tag}`}
|
||||
className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{doc.tags && doc.tags.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{doc.tags.map((tag) => (
|
||||
<span key={`${doc.id}-${tag}`} className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{/* Workflow Buttons */}
|
||||
<div className="mt-3">
|
||||
<DocumentWorkflowButtons
|
||||
documentId={doc.id}
|
||||
currentStatus={doc.status_code}
|
||||
onStatusChange={loadDocuments}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 text-xs text-gray-500 sm:grid-cols-2">
|
||||
<p>Автор: {authorName}</p>
|
||||
<p>Создано: {formatDate(doc.created_at)}</p>
|
||||
<p>Обновлено: {formatDate(doc.updated_at)}</p>
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 text-xs text-gray-500 sm:grid-cols-2">
|
||||
<p>Автор: {authorName}</p>
|
||||
<p>Создано: {formatDate(doc.created_at)}</p>
|
||||
<p>Обновлено: {formatDate(doc.updated_at)}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Загрузить документ</h2>
|
||||
<button
|
||||
onClick={() => setShowUploadForm(false)}
|
||||
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<DocumentUploadForm
|
||||
onSuccess={() => {
|
||||
setShowUploadForm(false);
|
||||
loadDocuments();
|
||||
}}
|
||||
onCancel={() => setShowUploadForm(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document Details Modal */}
|
||||
{selectedDocument && (
|
||||
<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">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{selectedDocument.title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setSelectedDocument(null)}
|
||||
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<DocumentStatusBadge
|
||||
status={selectedDocument.status}
|
||||
statusCode={selectedDocument.status_code}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-1 text-sm font-medium text-gray-700">Описание</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{selectedDocument.description || "Описание отсутствует"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-1 text-sm font-medium text-gray-700">Тип</h3>
|
||||
<p className="text-sm text-gray-600">{selectedDocument.document_type}</p>
|
||||
</div>
|
||||
|
||||
{selectedDocument.file_url && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPreviewFile({
|
||||
url: selectedDocument.file_url!,
|
||||
name: selectedDocument.file_name || selectedDocument.title,
|
||||
})
|
||||
}
|
||||
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"
|
||||
>
|
||||
<Eye size={16} />
|
||||
Открыть файл
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Workflow Actions */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium text-gray-700">Действия</h3>
|
||||
<DocumentWorkflowButtons
|
||||
documentId={selectedDocument.id}
|
||||
currentStatus={selectedDocument.status_code}
|
||||
onStatusChange={() => {
|
||||
loadDocuments();
|
||||
setSelectedDocument(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Acknowledgement */}
|
||||
{selectedDocument.acknowledgement_required && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium text-gray-700">
|
||||
Подтверждение прочтения
|
||||
</h3>
|
||||
<DocumentAcknowledgement
|
||||
document={selectedDocument}
|
||||
onAcknowledge={() => {
|
||||
loadDocuments();
|
||||
// Refresh selected document
|
||||
apiClient.getDocument(selectedDocument.id).then(setSelectedDocument);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Preview Modal */}
|
||||
{previewFile && (
|
||||
<DocumentPreview
|
||||
fileUrl={previewFile.url}
|
||||
fileName={previewFile.name}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
130
frontend/src/components/documents/DocumentAcknowledgement.tsx
Normal file
130
frontend/src/components/documents/DocumentAcknowledgement.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckCircle, Users } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import type { Document } from "@/types/api";
|
||||
|
||||
interface DocumentAcknowledgementProps {
|
||||
document: Document;
|
||||
onAcknowledge?: () => void;
|
||||
}
|
||||
|
||||
export function DocumentAcknowledgement({
|
||||
document,
|
||||
onAcknowledge,
|
||||
}: DocumentAcknowledgementProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showAcknowledgements, setShowAcknowledgements] = useState(false);
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const handleAcknowledge = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await apiClient.acknowledgeDocument(document.id, notes || undefined);
|
||||
toast.success("Прочтение подтверждено");
|
||||
setNotes("");
|
||||
if (onAcknowledge) {
|
||||
onAcknowledge();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Ошибка подтверждения:", err);
|
||||
toast.error("Не удалось подтвердить прочтение");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const acknowledgements = document.acknowledgements || [];
|
||||
const isAcknowledged = document.is_acknowledged || false;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Acknowledge Button/Status */}
|
||||
{!isAcknowledged ? (
|
||||
<div className="rounded-xl border-2 border-amber-200 bg-amber-50 p-4">
|
||||
<div className="mb-3 flex items-start gap-3">
|
||||
<CheckCircle size={20} className="mt-0.5 shrink-0 text-amber-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-900">
|
||||
Требуется подтверждение прочтения
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-amber-700">
|
||||
Пожалуйста, подтвердите, что вы прочитали этот документ
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Комментарий (необязательно)"
|
||||
rows={2}
|
||||
className="mb-3 w-full rounded-lg border border-amber-300 bg-white px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-100"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleAcknowledge}
|
||||
disabled={isSubmitting}
|
||||
className="w-full rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Подтверждение..." : "Подтвердить прочтение"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 text-sm text-green-800 ring-1 ring-green-200">
|
||||
<CheckCircle size={16} className="shrink-0" />
|
||||
<span>Вы подтвердили прочтение этого документа</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Acknowledgements List */}
|
||||
{acknowledgements.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowAcknowledgements(!showAcknowledgements)}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-gray-200 bg-gray-50 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-100"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Users size={16} />
|
||||
Подтверждений: {acknowledgements.length}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{showAcknowledgements ? "Скрыть" : "Показать"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showAcknowledgements && (
|
||||
<div className="mt-2 space-y-2 rounded-lg border border-gray-200 bg-white p-3">
|
||||
{acknowledgements.map((ack) => {
|
||||
const userName = ack.user
|
||||
? `${ack.user.last_name} ${ack.user.first_name}`.trim()
|
||||
: "Пользователь";
|
||||
const date = new Date(ack.acknowledged_at).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={ack.id} className="rounded-lg bg-gray-50 p-2 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">{userName}</span>
|
||||
<span className="text-gray-500">{date}</span>
|
||||
</div>
|
||||
{ack.notes && (
|
||||
<p className="mt-1 text-gray-600">{ack.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
frontend/src/components/documents/DocumentPreview.tsx
Normal file
147
frontend/src/components/documents/DocumentPreview.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Document as PDFDocument, Page, pdfjs } from "react-pdf";
|
||||
import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
|
||||
import "react-pdf/dist/Page/AnnotationLayer.css";
|
||||
import "react-pdf/dist/Page/TextLayer.css";
|
||||
|
||||
// Configure PDF.js worker
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
|
||||
|
||||
interface DocumentPreviewProps {
|
||||
fileUrl: string;
|
||||
fileName: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
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 isPDF = fileName.toLowerCase().endsWith('.pdf');
|
||||
|
||||
function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
|
||||
setNumPages(numPages);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function onDocumentLoadError(error: Error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
setError('Не удалось загрузить PDF файл');
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const goToPrevPage = () => setPageNumber((prev) => Math.max(prev - 1, 1));
|
||||
const goToNextPage = () => setPageNumber((prev) => Math.min(prev + 1, numPages));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||
<div className="relative flex h-full max-h-[90vh] w-full max-w-4xl flex-col rounded-2xl bg-white shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
<h3 className="truncate text-lg font-semibold text-gray-900">{fileName}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={fileUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-lg p-2 text-gray-600 transition hover:bg-gray-100"
|
||||
title="Скачать"
|
||||
>
|
||||
<Download size={20} />
|
||||
</a>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-gray-600 transition hover:bg-gray-100"
|
||||
title="Закрыть"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{isPDF ? (
|
||||
<div className="flex flex-col items-center">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PDFDocument
|
||||
file={fileUrl}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={onDocumentLoadError}
|
||||
loading={null}
|
||||
>
|
||||
<Page
|
||||
pageNumber={pageNumber}
|
||||
renderTextLayer={true}
|
||||
renderAnnotationLayer={true}
|
||||
className="shadow-lg"
|
||||
/>
|
||||
</PDFDocument>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center rounded-xl bg-gray-50 p-12">
|
||||
<div className="text-center">
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
Предпросмотр недоступен для этого типа файла
|
||||
</p>
|
||||
<a
|
||||
href={fileUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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"
|
||||
>
|
||||
<Download size={16} />
|
||||
Скачать файл
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PDF Navigation */}
|
||||
{isPDF && numPages > 0 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 px-6 py-4">
|
||||
<button
|
||||
onClick={goToPrevPage}
|
||||
disabled={pageNumber <= 1}
|
||||
className="rounded-lg p-2 text-gray-600 transition hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
|
||||
<span className="text-sm text-gray-600">
|
||||
Страница {pageNumber} из {numPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={goToNextPage}
|
||||
disabled={pageNumber >= numPages}
|
||||
className="rounded-lg p-2 text-gray-600 transition hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
frontend/src/components/documents/DocumentStatusBadge.tsx
Normal file
60
frontend/src/components/documents/DocumentStatusBadge.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import type { DocumentStatus } from "@/types/api";
|
||||
|
||||
interface DocumentStatusBadgeProps {
|
||||
status: string; // human-readable: "Черновик", "На рассмотрении", etc.
|
||||
statusCode: DocumentStatus; // machine-readable: "draft", "in_review", etc.
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const statusConfig: Record<DocumentStatus, { label: string; color: string; bgColor: string; ringColor: string }> = {
|
||||
draft: {
|
||||
label: "Черновик",
|
||||
color: "text-gray-700",
|
||||
bgColor: "bg-gray-50",
|
||||
ringColor: "ring-gray-200",
|
||||
},
|
||||
in_review: {
|
||||
label: "На рассмотрении",
|
||||
color: "text-cyan-700",
|
||||
bgColor: "bg-cyan-50",
|
||||
ringColor: "ring-cyan-200",
|
||||
},
|
||||
approved: {
|
||||
label: "Утверждено",
|
||||
color: "text-green-700",
|
||||
bgColor: "bg-green-50",
|
||||
ringColor: "ring-green-200",
|
||||
},
|
||||
published: {
|
||||
label: "Опубликовано",
|
||||
color: "text-blue-700",
|
||||
bgColor: "bg-blue-50",
|
||||
ringColor: "ring-blue-200",
|
||||
},
|
||||
archived: {
|
||||
label: "В архиве",
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-100",
|
||||
ringColor: "ring-gray-300",
|
||||
},
|
||||
rejected: {
|
||||
label: "Отклонено",
|
||||
color: "text-red-700",
|
||||
bgColor: "bg-red-50",
|
||||
ringColor: "ring-red-200",
|
||||
},
|
||||
};
|
||||
|
||||
export function DocumentStatusBadge({ status, statusCode, className = "" }: DocumentStatusBadgeProps) {
|
||||
const config = statusConfig[statusCode] || statusConfig.draft;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ring-1 ${config.color} ${config.bgColor} ${config.ringColor} ${className}`}
|
||||
>
|
||||
{status || config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
238
frontend/src/components/documents/DocumentUploadForm.tsx
Normal file
238
frontend/src/components/documents/DocumentUploadForm.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { Upload, X, FileText, AlertCircle } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface DocumentUploadFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function DocumentUploadForm({ onSuccess, onCancel }: DocumentUploadFormProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [documentType, setDocumentType] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [tags, setTags] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
setFile(acceptedFiles[0]);
|
||||
// Автоматически заполняем название, если оно пустое
|
||||
if (!title && acceptedFiles[0].name) {
|
||||
setTitle(acceptedFiles[0].name.replace(/\.[^/.]+$/, "")); // Убираем расширение
|
||||
}
|
||||
}
|
||||
}, [title]);
|
||||
|
||||
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'],
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!title || !documentType || !file) {
|
||||
setError("Заполните все обязательные поля");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const tagsList = tags
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(t => t.length > 0);
|
||||
|
||||
await apiClient.createDocument({
|
||||
title,
|
||||
description,
|
||||
document_type: documentType,
|
||||
file,
|
||||
tags: tagsList.length > 0 ? tagsList : undefined,
|
||||
});
|
||||
|
||||
toast.success("Документ успешно загружен");
|
||||
|
||||
// Сброс формы
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setDocumentType("");
|
||||
setFile(null);
|
||||
setTags("");
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки документа:", err);
|
||||
setError(err instanceof Error ? err.message : "Не удалось загрузить документ");
|
||||
toast.error("Ошибка загрузки документа");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-800">
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Файл <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-sky-500 bg-sky-50"
|
||||
: "border-gray-300 bg-gray-50 hover:border-sky-400 hover:bg-sky-50"
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload size={32} className="mx-auto mb-3 text-gray-400" />
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{isDragActive ? "Отпустите файл здесь" : "Перетащите файл или нажмите для выбора"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
PDF, Word, Excel, изображения, текстовые файлы
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-3">
|
||||
<FileText size={20} className="shrink-0 text-sky-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-900">{file.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{(file.size / 1024 / 1024).toFixed(2)} МБ
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFile(null)}
|
||||
className="shrink-0 rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Название <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Введите название документа"
|
||||
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-2 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Document Type */}
|
||||
<div>
|
||||
<label htmlFor="type" className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Тип документа <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="type"
|
||||
value={documentType}
|
||||
onChange={(e) => setDocumentType(e.target.value)}
|
||||
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-2 focus:ring-sky-100"
|
||||
>
|
||||
<option value="">Выберите тип</option>
|
||||
<option value="policy">Политика</option>
|
||||
<option value="procedure">Процедура</option>
|
||||
<option value="instruction">Инструкция</option>
|
||||
<option value="form">Форма</option>
|
||||
<option value="report">Отчет</option>
|
||||
<option value="contract">Договор</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Краткое описание документа"
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label htmlFor="tags" className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Теги
|
||||
</label>
|
||||
<input
|
||||
id="tags"
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="Введите теги через запятую"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Например: hr, политика, обязательно</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !title || !documentType || !file}
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Загрузка..." : "Загрузить документ"}
|
||||
</button>
|
||||
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
168
frontend/src/components/documents/DocumentWorkflowButtons.tsx
Normal file
168
frontend/src/components/documents/DocumentWorkflowButtons.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Globe,
|
||||
RotateCcw,
|
||||
Archive,
|
||||
ArchiveRestore
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import type { DocumentStatus } from "@/types/api";
|
||||
|
||||
interface DocumentWorkflowButtonsProps {
|
||||
documentId: number;
|
||||
currentStatus: DocumentStatus;
|
||||
onStatusChange?: () => void;
|
||||
}
|
||||
|
||||
interface WorkflowAction {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
action: () => Promise<any>;
|
||||
confirmMessage?: string;
|
||||
successMessage: string;
|
||||
variant: "primary" | "success" | "danger" | "secondary";
|
||||
}
|
||||
|
||||
export function DocumentWorkflowButtons({
|
||||
documentId,
|
||||
currentStatus,
|
||||
onStatusChange,
|
||||
}: DocumentWorkflowButtonsProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleAction = async (action: WorkflowAction) => {
|
||||
if (action.confirmMessage && !window.confirm(action.confirmMessage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await action.action();
|
||||
toast.success(action.successMessage);
|
||||
if (onStatusChange) {
|
||||
onStatusChange();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Ошибка изменения статуса:", err);
|
||||
toast.error("Не удалось изменить статус документа");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getAvailableActions = (): WorkflowAction[] => {
|
||||
const actions: WorkflowAction[] = [];
|
||||
|
||||
switch (currentStatus) {
|
||||
case "draft":
|
||||
actions.push({
|
||||
label: "Отправить на рассмотрение",
|
||||
icon: <Send size={14} />,
|
||||
action: () => apiClient.submitDocumentForReview(documentId),
|
||||
successMessage: "Документ отправлен на рассмотрение",
|
||||
variant: "primary",
|
||||
});
|
||||
break;
|
||||
|
||||
case "in_review":
|
||||
actions.push(
|
||||
{
|
||||
label: "Утвердить",
|
||||
icon: <CheckCircle size={14} />,
|
||||
action: () => apiClient.approveDocument(documentId),
|
||||
confirmMessage: "Вы уверены, что хотите утвердить этот документ?",
|
||||
successMessage: "Документ утвержден",
|
||||
variant: "success",
|
||||
},
|
||||
{
|
||||
label: "Отклонить",
|
||||
icon: <XCircle size={14} />,
|
||||
action: () => apiClient.rejectDocument(documentId),
|
||||
confirmMessage: "Вы уверены, что хотите отклонить этот документ?",
|
||||
successMessage: "Документ отклонен",
|
||||
variant: "danger",
|
||||
},
|
||||
{
|
||||
label: "Вернуть в черновик",
|
||||
icon: <RotateCcw size={14} />,
|
||||
action: () => apiClient.returnDocumentToDraft(documentId),
|
||||
successMessage: "Документ возвращен в черновик",
|
||||
variant: "secondary",
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
case "approved":
|
||||
actions.push({
|
||||
label: "Опубликовать",
|
||||
icon: <Globe size={14} />,
|
||||
action: () => apiClient.publishDocument(documentId),
|
||||
confirmMessage: "Опубликовать этот документ для всех пользователей?",
|
||||
successMessage: "Документ опубликован",
|
||||
variant: "primary",
|
||||
});
|
||||
break;
|
||||
|
||||
case "published":
|
||||
actions.push({
|
||||
label: "Архивировать",
|
||||
icon: <Archive size={14} />,
|
||||
action: () => apiClient.archiveDocument(documentId),
|
||||
confirmMessage: "Архивировать этот документ?",
|
||||
successMessage: "Документ архивирован",
|
||||
variant: "secondary",
|
||||
});
|
||||
break;
|
||||
|
||||
case "archived":
|
||||
actions.push({
|
||||
label: "Разархивировать",
|
||||
icon: <ArchiveRestore size={14} />,
|
||||
action: () => apiClient.unarchiveDocument(documentId),
|
||||
successMessage: "Документ разархивирован",
|
||||
variant: "primary",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
|
||||
const actions = getAvailableActions();
|
||||
|
||||
if (actions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
primary: "bg-sky-600 hover:bg-sky-700 text-white",
|
||||
success: "bg-green-600 hover:bg-green-700 text-white",
|
||||
danger: "bg-red-600 hover:bg-red-700 text-white",
|
||||
secondary: "bg-gray-200 hover:bg-gray-300 text-gray-800",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{actions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => handleAction(action)}
|
||||
disabled={isLoading}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
variantClasses[action.variant]
|
||||
}`}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
frontend/src/components/documents/index.ts
Normal file
5
frontend/src/components/documents/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { DocumentUploadForm } from "./DocumentUploadForm";
|
||||
export { DocumentStatusBadge } from "./DocumentStatusBadge";
|
||||
export { DocumentWorkflowButtons } from "./DocumentWorkflowButtons";
|
||||
export { DocumentPreview } from "./DocumentPreview";
|
||||
export { DocumentAcknowledgement } from "./DocumentAcknowledgement";
|
||||
@ -283,10 +283,11 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// Документы
|
||||
async getDocuments(params?: { search?: string; type?: string; page?: number; limit?: number }): Promise<any> {
|
||||
async getDocuments(params?: { search?: string; type?: string; status?: string; page?: number; limit?: 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());
|
||||
|
||||
@ -294,6 +295,111 @@ class ApiClient {
|
||||
return this.request(url);
|
||||
}
|
||||
|
||||
async getDocument(id: number): Promise<any> {
|
||||
return this.request(`/api/v1/documents/${id}/`);
|
||||
}
|
||||
|
||||
async createDocument(data: {
|
||||
title: string;
|
||||
description?: string;
|
||||
document_type: string;
|
||||
file: File;
|
||||
tags?: string[];
|
||||
}): Promise<any> {
|
||||
const formData = new FormData();
|
||||
formData.append('title', data.title);
|
||||
if (data.description) formData.append('description', data.description);
|
||||
formData.append('document_type', data.document_type);
|
||||
formData.append('file', data.file);
|
||||
if (data.tags && data.tags.length > 0) {
|
||||
formData.append('tags', JSON.stringify(data.tags));
|
||||
}
|
||||
|
||||
return this.request('/api/v1/documents/', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {}, // Clear headers to let browser set Content-Type with boundary
|
||||
});
|
||||
}
|
||||
|
||||
async updateDocument(id: number, data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
document_type?: string;
|
||||
file?: File;
|
||||
tags?: string[];
|
||||
}): Promise<any> {
|
||||
const formData = new FormData();
|
||||
if (data.title) formData.append('title', data.title);
|
||||
if (data.description) formData.append('description', data.description);
|
||||
if (data.document_type) formData.append('document_type', data.document_type);
|
||||
if (data.file) formData.append('file', data.file);
|
||||
if (data.tags) formData.append('tags', JSON.stringify(data.tags));
|
||||
|
||||
return this.request(`/api/v1/documents/${id}/`, {
|
||||
method: 'PATCH',
|
||||
body: formData,
|
||||
headers: {}, // Clear headers to let browser set Content-Type with boundary
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDocument(id: number): Promise<void> {
|
||||
return this.request(`/api/v1/documents/${id}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// FSM Workflow transitions
|
||||
async submitDocumentForReview(id: number): Promise<any> {
|
||||
return this.request(`/api/v1/documents/${id}/submit-for-review/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async approveDocument(id: number): Promise<any> {
|
||||
return this.request(`/api/v1/documents/${id}/approve/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async rejectDocument(id: number): Promise<any> {
|
||||
return this.request(`/api/v1/documents/${id}/reject/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async publishDocument(id: number): Promise<any> {
|
||||
return this.request(`/api/v1/documents/${id}/publish/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async returnDocumentToDraft(id: number): Promise<any> {
|
||||
return this.request(`/api/v1/documents/${id}/return-to-draft/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async archiveDocument(id: number): Promise<any> {
|
||||
return this.request(`/api/v1/documents/${id}/archive/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async unarchiveDocument(id: number): Promise<any> {
|
||||
return this.request(`/api/v1/documents/${id}/unarchive/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// Document acknowledgement
|
||||
async acknowledgeDocument(id: number, notes?: string): Promise<any> {
|
||||
return this.request(`/api/v1/documents/${id}/acknowledge/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notes }),
|
||||
});
|
||||
}
|
||||
|
||||
// Заявки
|
||||
async getRequests(params?: { status?: string; type?: string; search?: string; page?: number; limit?: number }): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
@ -103,16 +103,34 @@ export interface Comment {
|
||||
}
|
||||
|
||||
// Documents types
|
||||
export type DocumentStatus = 'draft' | 'in_review' | 'approved' | 'published' | 'archived' | 'rejected';
|
||||
|
||||
export interface DocumentAcknowledgement {
|
||||
id: number;
|
||||
document: number;
|
||||
user: User;
|
||||
acknowledged_at: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
file?: string;
|
||||
file_url?: string;
|
||||
file_name?: string;
|
||||
file_size?: number;
|
||||
document_type: string;
|
||||
status: string; // human-readable: "Черновик", "На рассмотрении", etc.
|
||||
status_code: DocumentStatus; // machine-readable: "draft", "in_review", etc.
|
||||
created_by: User;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tags?: string[];
|
||||
acknowledgements?: DocumentAcknowledgement[];
|
||||
acknowledgement_required?: boolean;
|
||||
is_acknowledged?: boolean;
|
||||
}
|
||||
|
||||
// Requests types
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user