feat: Improve folders UI with breadcrumbs and document counts
Backend improvements: - Add document_count annotation to FolderViewSet queryset - Add document_count field to FolderSerializer Frontend improvements: - Add folder_path display in document cards with folder icon - Add document count badges in folder tree - Add breadcrumbs navigation showing current folder path - Improve folder selector button to show selected folder name - Add clear filter button (X) to folder selector - Add highlighted style for selected folder in selector - Update Document interface to include folder and folder_path fields UI enhancements: - Folder path shown in sky-blue color with FolderOpen icon in cards - Document count displayed in gray badge next to folder names - Breadcrumbs allow quick navigation back to root - Selected folder highlighted with sky-blue background - Visual feedback for folder selection state
This commit is contained in:
parent
f4b5b75c01
commit
6def6d2d43
@ -115,6 +115,7 @@ class FolderSerializer(serializers.Serializer):
|
||||
name = serializers.CharField()
|
||||
parent_id = serializers.IntegerField(source='parent.id', read_only=True, allow_null=True)
|
||||
path = serializers.SerializerMethodField()
|
||||
document_count = serializers.IntegerField(read_only=True, default=0)
|
||||
|
||||
def get_path(self, obj) -> str:
|
||||
"""Возвращает полный путь папки."""
|
||||
|
||||
@ -349,9 +349,14 @@ class FolderViewSet(ModelViewSet):
|
||||
?parent_id=<id> - показать только дочерние папки указанного parent
|
||||
?root=true - показать только корневые папки (parent=null)
|
||||
"""
|
||||
from django.db.models import Count
|
||||
|
||||
qs = super().get_queryset()
|
||||
request = getattr(self, "request", None)
|
||||
|
||||
# Аннотируем количество документов в каждой папке
|
||||
qs = qs.annotate(document_count=Count('documents'))
|
||||
|
||||
if not request:
|
||||
return qs
|
||||
|
||||
|
||||
@ -151,6 +151,29 @@ export default function DocumentsPage() {
|
||||
});
|
||||
}, [documents, search]);
|
||||
|
||||
// Find selected folder and build breadcrumb path
|
||||
const selectedFolder = useMemo(() => {
|
||||
if (!selectedFolderId) return null;
|
||||
|
||||
const findFolder = (foldersArray: FolderNode[]): FolderNode | null => {
|
||||
for (const folder of foldersArray) {
|
||||
if (folder.id === selectedFolderId) return folder;
|
||||
if (folder.children) {
|
||||
const found = findFolder(folder.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return findFolder(folders);
|
||||
}, [selectedFolderId, folders]);
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
if (!selectedFolder) return [];
|
||||
return selectedFolder.path.split(' / ');
|
||||
}, [selectedFolder]);
|
||||
|
||||
// Mock dashboard stats
|
||||
const dashboardStats = useMemo(() => {
|
||||
const byStatus = documents.reduce((acc: any[], doc) => {
|
||||
@ -211,10 +234,28 @@ export default function DocumentsPage() {
|
||||
<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"
|
||||
className={`flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition ${
|
||||
selectedFolderId
|
||||
? "border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
{selectedFolderId ? "Папка выбрана" : "Все папки"}
|
||||
<span className="max-w-[200px] truncate">
|
||||
{selectedFolder ? selectedFolder.name : "Все папки"}
|
||||
</span>
|
||||
{selectedFolderId && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFolderId(null);
|
||||
}}
|
||||
className="rounded p-0.5 hover:bg-sky-200"
|
||||
title="Сбросить фильтр"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</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">
|
||||
@ -384,6 +425,29 @@ export default function DocumentsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
{breadcrumbs.length > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-gray-50 px-4 py-2 text-sm">
|
||||
<FolderOpen size={14} className="text-gray-500" />
|
||||
<nav className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setSelectedFolderId(null)}
|
||||
className="text-gray-600 hover:text-sky-600"
|
||||
>
|
||||
Все документы
|
||||
</button>
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={index} className="flex items-center gap-1">
|
||||
<span className="text-gray-400">/</span>
|
||||
<span className={index === breadcrumbs.length - 1 ? "font-medium text-gray-900" : "text-gray-600"}>
|
||||
{crumb}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents List */}
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||||
<div className="space-y-3">
|
||||
@ -473,6 +537,12 @@ export default function DocumentsPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 text-xs text-gray-500 sm:grid-cols-2">
|
||||
{doc.folder_path && (
|
||||
<p className="flex items-center gap-1 text-sky-600">
|
||||
<FolderOpen size={12} />
|
||||
{doc.folder_path}
|
||||
</p>
|
||||
)}
|
||||
<p>Автор: {authorName}</p>
|
||||
<p>Создано: {formatDate(doc.created_at)}</p>
|
||||
<p>Обновлено: {formatDate(doc.updated_at)}</p>
|
||||
|
||||
@ -8,6 +8,7 @@ export interface FolderNode {
|
||||
name: string;
|
||||
parent_id: number | null;
|
||||
path: string;
|
||||
document_count?: number;
|
||||
children?: FolderNode[];
|
||||
}
|
||||
|
||||
@ -116,6 +117,11 @@ export function FolderTree({
|
||||
<Folder size={16} className="shrink-0 text-gray-500" />
|
||||
)}
|
||||
<span className="truncate text-sm font-medium">{node.name}</span>
|
||||
{node.document_count !== undefined && node.document_count > 0 && (
|
||||
<span className="ml-auto shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
||||
{node.document_count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@ -121,6 +121,11 @@ export interface Document {
|
||||
file_url?: string;
|
||||
file_name?: string;
|
||||
file_size?: number;
|
||||
folder?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
folder_path?: string;
|
||||
status: string; // human-readable: "Черновик", "На рассмотрении", etc.
|
||||
status_code: DocumentStatus; // machine-readable: "draft", "in_review", etc.
|
||||
created_by: User;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user