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:
igor_nadein 2026-02-28 13:15:06 +03:00
parent f4b5b75c01
commit 6def6d2d43
5 changed files with 89 additions and 2 deletions

View File

@ -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:
"""Возвращает полный путь папки."""

View File

@ -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

View File

@ -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>

View File

@ -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 */}

View File

@ -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;