diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index c3d82f47..81c8bcd9 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -4,12 +4,29 @@ import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; import { useEffect, useMemo, useState } from "react"; import type { Document, DocumentStatus } from "@/types/api"; -import { Search, FileText, Plus, Eye, X } from "lucide-react"; +import { + Search, + FileText, + Plus, + Eye, + X, + LayoutDashboard, + FolderOpen, + Tags, + LayoutGrid, + CheckSquare, + SlidersHorizontal, +} 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 { 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"; function formatDate(value?: string): string { if (!value) return ""; @@ -22,15 +39,31 @@ function formatDate(value?: string): string { }); } +type ViewMode = "documents" | "dashboard"; + export default function DocumentsPage() { + // State const [documents, setDocuments] = useState([]); + const [folders, setFolders] = useState([]); + const [selectedFolderId, setSelectedFolderId] = useState(null); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [viewMode, setViewMode] = useState("documents"); + + // Modals const [showUploadForm, setShowUploadForm] = useState(false); + const [showAdvancedSearch, setShowAdvancedSearch] = useState(false); const [selectedDocument, setSelectedDocument] = useState(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); + + // Bulk selection + const selection = useDocumentSelection(documents); const loadDocuments = async () => { try { @@ -40,6 +73,9 @@ export default function DocumentsPage() { if (statusFilter !== "all") { params.status = statusFilter; } + if (selectedFolderId !== null) { + params.folder_id = selectedFolderId; + } const response = await apiClient.getDocuments(params); setDocuments(response.results || []); } catch (err) { @@ -50,9 +86,28 @@ export default function DocumentsPage() { } }; + const loadFolders = async () => { + try { + // TODO: Implement folders API endpoint + // const response = await apiClient.getFolders(); + // setFolders(response.results || []); + setFolders([]); + } catch (err) { + console.error("Ошибка загрузки папок:", err); + } + }; + useEffect(() => { loadDocuments(); - }, [statusFilter]); + }, [statusFilter, selectedFolderId]); + + useEffect(() => { + loadFolders(); + }, []); + + useEffect(() => { + loadFolders(); + }, []); const filteredDocuments = useMemo(() => { const q = search.trim().toLowerCase(); @@ -74,136 +129,381 @@ export default function DocumentsPage() { }); }, [documents, search]); + // Mock dashboard stats + const dashboardStats = useMemo(() => { + const byStatus = documents.reduce((acc: any[], doc) => { + const existing = acc.find((s) => s.status === doc.status); + if (existing) { + existing.count++; + } else { + acc.push({ status: doc.status, count: 1 }); + } + return acc; + }, []); + + return { + totalDocuments: documents.length, + documentsByType: [{ type: "Общие", count: documents.length }], + documentsByStatus: byStatus, + uploadsOverTime: [], + myDocuments: documents.filter((d) => d.created_by).length, + recentActivity: [], + }; + }, [documents]); + return ( - {loading ? ( -
-
-

Загрузка документов...

-
- ) : error ? ( -
-

{error}

-
- ) : ( - <> -
- {/* Header */} -
-

Документы

- -
- - {/* Filters */} -
-
- - 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" - /> +
+ {/* Sidebar */} + {showSidebar && ( + + )} + + {/* Main Content */} +
+ {loading ? ( +
+
+

Загрузка документов...

+ ) : error ? ( +
+

{error}

+
+ ) : ( + <> + {/* Dashboard View */} + {viewMode === "dashboard" && ( +
+ ({ + id: doc.id, + title: doc.title, + type: doc.status, + uploaded_at: doc.created_at, + uploaded_by: doc.created_by + ? `${doc.created_by.last_name} ${doc.created_by.first_name}` + : undefined, + }))} + myDocuments={filteredDocuments + .filter((d) => d.created_by) + .slice(0, 5) + .map((doc) => ({ + id: doc.id, + title: doc.title, + status: doc.status, + uploaded_at: doc.created_at, + }))} + /> +
+ )} - {/* Documents List */} -
- {filteredDocuments.length === 0 ? ( -
- -

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

-
- ) : ( - filteredDocuments.map((doc) => { - const authorName = doc.created_by - ? `${doc.created_by.last_name} ${doc.created_by.first_name}`.trim() - : "Неизвестно"; + {/* Documents View */} + {viewMode === "documents" && ( +
+ {/* Header */} +
+
+ +

+ {selectedFolderId ? "Документы в папке" : "Все документы"} +

+
+
+ + +
+
- return ( -
-
-
-
-

{doc.title}

- -
-
- -
- {doc.file_url && ( - - )} - -
-
- -

- {doc.description || "Описание не заполнено"} -

- - {/* Workflow Buttons */} -
- + { + setSearch(filters.query); + if (filters.statuses && filters.statuses.length > 0) { + setStatusFilter(filters.statuses[0] as DocumentStatus); + } + }} + results={filteredDocuments.map((doc) => ({ + id: doc.id, + title: doc.title, + description: doc.description || "", + type: doc.status, + status: doc.status, + uploaded_at: doc.created_at, + uploaded_by: doc.created_by + ? `${doc.created_by.last_name} ${doc.created_by.first_name}` + : undefined, + }))} + availableStatuses={[ + { id: "draft", name: "Черновик" }, + { id: "in_review", name: "На рассмотрении" }, + { id: "approved", name: "Утверждено" }, + { id: "published", name: "Опубликовано" }, + { id: "archived", name: "В архиве" }, + { id: "rejected", name: "Отклонено" }, + ]} + /> +
+ ) : ( + /* Simple Filters */ +
+
+ + 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" />
-
-

Автор: {authorName}

-

Создано: {formatDate(doc.created_at)}

-

Обновлено: {formatDate(doc.updated_at)}

+ +
+ )} + + {/* Bulk Actions Toolbar */} + {selection.selectedIds.length > 0 && ( +
+ ({ + id: d.id, + title: d.title, + status: d.status, + }))} + onClearSelection={selection.clearSelection} + /> +
+ )} + {/* Bulk Actions Toolbar */} + {selection.selectedIds.length > 0 && ( +
+ ({ + id: d.id, + title: d.title, + status: d.status, + }))} + onClearSelection={selection.clearSelection} + /> +
+ )} + + {/* Documents List */} +
+ {filteredDocuments.length === 0 ? ( +
+ +

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

-
- ); - }) + ) : ( + <> + {/* Select All */} + {filteredDocuments.length > 0 && ( +
+ + + Выбрать все ({filteredDocuments.length}) + +
+ )} + + {/* Document Cards */} + {filteredDocuments.map((doc) => { + const authorName = doc.created_by + ? `${doc.created_by.last_name} ${doc.created_by.first_name}`.trim() + : "Неизвестно"; + const isSelected = selection.isSelected(doc.id); + + return ( +
+
+ {/* Checkbox */} + selection.toggleDocument(doc.id)} + className="mt-1 h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500" + /> + +
+
+

+ {doc.title} +

+ +
+ +

+ {doc.description || "Описание не заполнено"} +

+ + {/* Workflow Buttons */} +
+ +
+ +
+

Автор: {authorName}

+

Создано: {formatDate(doc.created_at)}

+

Обновлено: {formatDate(doc.updated_at)}

+
+
+ +
+ {doc.file_url && ( + <> + {doc.file_name?.toLowerCase().endsWith(".pdf") ? ( + + ) : ( + + )} + + )} + +
+
+
+ ); + })} + + )} +
+
)} -
- + + )} + + {/* Upload Modal */} {showUploadForm && ( @@ -311,15 +611,22 @@ export default function DocumentsPage() { )} - {/* File Preview Modal */} - {previewFile && ( - setPreviewFile(null)} - /> - )} - + {/* File Preview Modal */} + {previewFile && ( + setPreviewFile(null)} + /> + )} + + {/* Enhanced PDF Viewer */} + {pdfViewerFile && ( + setPdfViewerFile(null)} + /> )}
); diff --git a/frontend/src/components/documents/folders/FolderTree.tsx b/frontend/src/components/documents/folders/FolderTree.tsx new file mode 100644 index 00000000..df6c72af --- /dev/null +++ b/frontend/src/components/documents/folders/FolderTree.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { ChevronRight, ChevronDown, Folder, FolderOpen, Plus, Edit2, Trash2 } from "lucide-react"; + +export interface FolderNode { + id: number; + name: string; + parent_id: number | null; + path: string; + children?: FolderNode[]; +} + +interface FolderTreeProps { + folders: FolderNode[]; + selectedFolderId: number | null; + onSelectFolder: (folderId: number | null) => void; + onCreateFolder?: (parentId: number | null) => void; + onEditFolder?: (folderId: number) => void; + onDeleteFolder?: (folderId: number) => void; +} + +export function FolderTree({ + folders, + selectedFolderId, + onSelectFolder, + onCreateFolder, + onEditFolder, + onDeleteFolder, +}: FolderTreeProps) { + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [hoveredId, setHoveredId] = useState(null); + + // Построение дерева из плоского списка + const buildTree = useCallback((flatFolders: FolderNode[]): FolderNode[] => { + const map = new Map(); + + flatFolders.forEach((folder) => { + const parentId = folder.parent_id; + if (!map.has(parentId)) { + map.set(parentId, []); + } + map.get(parentId)!.push({ ...folder }); + }); + + const attachChildren = (node: FolderNode) => { + const children = map.get(node.id) || []; + if (children.length > 0) { + node.children = children; + children.forEach(attachChildren); + } + }; + + const rootNodes = map.get(null) || []; + rootNodes.forEach(attachChildren); + + return rootNodes; + }, []); + + const tree = buildTree(folders); + + const toggleExpand = (folderId: number) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(folderId)) { + next.delete(folderId); + } else { + next.add(folderId); + } + return next; + }); + }; + + const renderNode = (node: FolderNode, level: number = 0) => { + const isExpanded = expandedIds.has(node.id); + const isSelected = selectedFolderId === node.id; + const hasChildren = node.children && node.children.length > 0; + const isHovered = hoveredId === node.id; + + return ( +
+
setHoveredId(node.id)} + onMouseLeave={() => setHoveredId(null)} + > + {/* Expand/Collapse */} + {hasChildren ? ( + + ) : ( + + )} + + {/* Folder Icon */} + + + {/* Actions */} + {isHovered && ( +
+ {onCreateFolder && ( + + )} + {onEditFolder && ( + + )} + {onDeleteFolder && ( + + )} +
+ )} +
+ + {/* Children */} + {hasChildren && isExpanded && ( +
{node.children!.map((child) => renderNode(child, level + 1))}
+ )} +
+ ); + }; + + return ( +
+ {/* Root folder */} +
+ + {onCreateFolder && ( + + )} +
+ + {/* Tree nodes */} + {tree.map((node) => renderNode(node))} + + {/* Empty state */} + {folders.length === 0 && ( +
+ Папок нет +
+ )} +
+ ); +} diff --git a/frontend/src/components/documents/folders/index.ts b/frontend/src/components/documents/folders/index.ts new file mode 100644 index 00000000..8e400806 --- /dev/null +++ b/frontend/src/components/documents/folders/index.ts @@ -0,0 +1 @@ +export { FolderTree, type FolderNode } from "./FolderTree";