feat(frontend): добавление новых компонентов управления документами

- Добавлен DocumentMetadataEditor для редактирования метаданных
- Добавлен TagColorPicker для выбора цветов тегов
- Добавлен TagForm для создания/редактирования тегов
- Добавлен TagManagementModal для управления тегами
This commit is contained in:
lizerk 2026-03-06 12:31:33 +03:00
parent 4ac50d4818
commit e689ee7e5a
4 changed files with 683 additions and 0 deletions

View File

@ -0,0 +1,241 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Modal } from '@/components/ui';
import { apiClient } from '@/lib/api';
import { toast } from 'sonner';
import { Folder, Tag as TagIcon, Loader2 } from 'lucide-react';
import type { Document } from '@/types/api';
interface DocumentMetadataEditorProps {
isOpen: boolean;
onClose: () => void;
document: Document;
onUpdate?: () => void;
}
export function DocumentMetadataEditor({
isOpen,
onClose,
document,
onUpdate,
}: DocumentMetadataEditorProps) {
// Form state
const [selectedTags, setSelectedTags] = useState<number[]>([]);
const [selectedFolder, setSelectedFolder] = useState<number | null>(document.folder?.id || null);
// Data lists
const [documentTags, setDocumentTags] = useState<any[]>([]);
const [folders, setFolders] = useState<any[]>([]);
// Loading states
const [loadingTags, setLoadingTags] = useState(false);
const [loadingFolders, setLoadingFolders] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load current document tags
useEffect(() => {
if (isOpen && document) {
// Load current tags
if (document.tags && Array.isArray(document.tags)) {
setSelectedTags(document.tags.map((t: any) => t.id));
}
}
}, [isOpen, document]);
// Load reference data
useEffect(() => {
if (isOpen) {
loadAllData();
}
}, [isOpen]);
const loadAllData = async () => {
// Load tags
try {
setLoadingTags(true);
const tagsResponse = await apiClient.getDocumentTags();
setDocumentTags(tagsResponse.results || tagsResponse);
} catch (err) {
console.error('Error loading tags:', err);
} finally {
setLoadingTags(false);
}
// Load folders
try {
setLoadingFolders(true);
const foldersResponse = await apiClient.getFolders({});
setFolders(foldersResponse.results || foldersResponse);
} catch (err) {
console.error('Error loading folders:', err);
} finally {
setLoadingFolders(false);
}
};
const handleSave = async () => {
setIsSaving(true);
setError(null);
try {
// Prepare update data
const updateData: any = {};
// Tags
const currentTagIds = (document.tags || []).map((t: any) => t.id).sort();
const newTagIds = [...selectedTags].sort();
if (JSON.stringify(currentTagIds) !== JSON.stringify(newTagIds)) {
updateData.tag_ids = selectedTags;
}
// Folder
if (selectedFolder !== (document.folder?.id || null)) {
updateData.folder = selectedFolder;
}
// If nothing changed
if (Object.keys(updateData).length === 0) {
toast.info('Нет изменений для сохранения');
onClose();
return;
}
// Send update request
await apiClient.updateDocument(document.id, updateData);
toast.success('Метаданные документа обновлены');
onUpdate?.();
onClose();
} catch (err: any) {
console.error('Error updating document metadata:', err);
const errorMsg = err.response?.data?.detail || 'Не удалось обновить метаданные';
setError(errorMsg);
toast.error(errorMsg);
} finally {
setIsSaving(false);
}
};
const toggleTag = (tagId: number) => {
setSelectedTags((prev) =>
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]
);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Редактирование метаданных"
size="lg"
>
<div className="space-y-4">
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
{/* Document info */}
<div className="rounded-lg bg-gray-50 p-3">
<p className="text-sm font-medium text-gray-900">{document.title}</p>
<p className="mt-1 text-xs text-gray-500">ID: {document.id}</p>
</div>
{/* Tags */}
<div>
<label className="mb-2 flex items-center gap-2 text-sm font-medium text-gray-700">
<TagIcon className="h-4 w-4" />
Теги
</label>
{loadingTags ? (
<div className="flex items-center justify-center rounded-md border border-gray-200 py-8">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
</div>
) : documentTags.length === 0 ? (
<div className="rounded-md border border-gray-200 bg-gray-50 p-3 text-center text-sm text-gray-500">
Нет доступных тегов
</div>
) : (
<div className="max-h-48 space-y-2 overflow-y-auto rounded-md border border-gray-200 bg-white p-3">
{documentTags.map((tag) => (
<label
key={tag.id}
className="flex cursor-pointer items-center gap-2 rounded p-2 transition hover:bg-gray-50"
>
<input
type="checkbox"
checked={selectedTags.includes(tag.id)}
onChange={() => toggleTag(tag.id)}
disabled={isSaving}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-100"
/>
<span className="text-sm text-gray-900">{tag.name}</span>
{tag.color && (
<span
className="ml-auto h-3 w-3 rounded-full"
style={{ backgroundColor: tag.color }}
/>
)}
</label>
))}
</div>
)}
<p className="mt-1 text-xs text-gray-500">
Выбрано: {selectedTags.length} {selectedTags.length === 1 ? 'тег' : 'тегов'}
</p>
</div>
{/* Folder */}
<div>
<label className="mb-2 flex items-center gap-2 text-sm font-medium text-gray-700">
<Folder className="h-4 w-4" />
Папка
</label>
<select
value={selectedFolder || ''}
onChange={(e) => setSelectedFolder(e.target.value ? Number(e.target.value) : null)}
disabled={loadingFolders || isSaving}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
>
<option value="">Без папки (корень)</option>
{folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.name}
</option>
))}
</select>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 border-t pt-4">
<button
type="button"
onClick={onClose}
disabled={isSaving}
className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
Отмена
</button>
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Сохранение...
</>
) : (
'Сохранить изменения'
)}
</button>
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,65 @@
'use client';
import React from 'react';
interface TagColorPickerProps {
value: string;
onChange: (color: string) => void;
label?: string;
}
const PRESET_COLORS = [
{ name: 'Красный', value: '#EF4444' },
{ name: 'Оранжевый', value: '#F97316' },
{ name: 'Желтый', value: '#EAB308' },
{ name: 'Зеленый', value: '#22C55E' },
{ name: 'Бирюзовый', value: '#14B8A6' },
{ name: 'Голубой', value: '#3B82F6' },
{ name: 'Синий', value: '#6366F1' },
{ name: 'Фиолетовый', value: '#A855F7' },
{ name: 'Розовый', value: '#EC4899' },
{ name: 'Серый', value: '#6B7280' },
{ name: 'Темно-серый', value: '#374151' },
{ name: 'Черный', value: '#1F2937' },
];
export const TagColorPicker: React.FC<TagColorPickerProps> = ({
value,
onChange,
label = 'Цвет тега',
}) => {
return (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{label}
</label>
<div className="grid grid-cols-6 gap-2">
{PRESET_COLORS.map((color) => (
<button
key={color.value}
type="button"
onClick={() => onChange(color.value)}
className={`
w-10 h-10 rounded-md border-2 transition-all
hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
${value === color.value ? 'border-blue-500 ring-2 ring-blue-500 ring-offset-2' : 'border-gray-300'}
`}
style={{ backgroundColor: color.value }}
title={color.name}
aria-label={color.name}
/>
))}
</div>
<div className="flex items-center gap-2 mt-3">
<label className="text-sm text-gray-600">Свой цвет:</label>
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-16 h-8 rounded border border-gray-300 cursor-pointer"
/>
<span className="text-xs text-gray-500 font-mono">{value}</span>
</div>
</div>
);
};

View File

@ -0,0 +1,138 @@
'use client';
import React, { useState, useEffect } from 'react';
import { TagColorPicker } from './TagColorPicker';
import type { DocumentTag } from '@/types/api';
interface TagFormProps {
tag?: DocumentTag | null;
onSubmit: (data: { name: string; color: string }) => void;
onCancel: () => void;
isLoading?: boolean;
existingTags?: DocumentTag[];
}
export const TagForm: React.FC<TagFormProps> = ({
tag,
onSubmit,
onCancel,
isLoading = false,
existingTags = [],
}) => {
const [name, setName] = useState(tag?.name || '');
const [color, setColor] = useState(tag?.color || '#3B82F6');
const [errors, setErrors] = useState<{ name?: string }>({});
useEffect(() => {
if (tag) {
setName(tag.name);
setColor(tag.color || '#3B82F6');
}
}, [tag]);
const validate = (): boolean => {
const newErrors: { name?: string } = {};
if (!name.trim()) {
newErrors.name = 'Название тега обязательно';
} else if (name.trim().length < 2) {
newErrors.name = 'Название должно содержать минимум 2 символа';
} else if (name.trim().length > 50) {
newErrors.name = 'Название не должно превышать 50 символов';
} else {
// Проверка уникальности (исключая текущий тег при редактировании)
const isDuplicate = existingTags.some(
(t) => t.name.toLowerCase() === name.trim().toLowerCase() && t.id !== tag?.id
);
if (isDuplicate) {
newErrors.name = 'Тег с таким названием уже существует';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validate()) {
onSubmit({ name: name.trim(), color });
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="tag-name" className="block text-sm font-medium text-gray-700 mb-1">
Название тега <span className="text-red-500">*</span>
</label>
<input
id="tag-name"
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
if (errors.name) setErrors({ ...errors, name: undefined });
}}
className={`
w-full px-3 py-2 border rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500
${errors.name ? 'border-red-500' : 'border-gray-300'}
`}
placeholder="Введите название тега"
disabled={isLoading}
maxLength={50}
autoFocus
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
)}
<p className="mt-1 text-xs text-gray-500">
{name.length}/50 символов
</p>
</div>
<TagColorPicker value={color} onChange={setColor} />
<div className="flex items-center gap-3 pt-4 border-t">
<button
type="submit"
disabled={isLoading || !name.trim()}
className="
px-4 py-2 bg-blue-600 text-white rounded-md font-medium
hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors
"
>
{isLoading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Сохранение...
</span>
) : tag ? (
'Сохранить изменения'
) : (
'Создать тег'
)}
</button>
<button
type="button"
onClick={onCancel}
disabled={isLoading}
className="
px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-md font-medium
hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors
"
>
Отмена
</button>
</div>
</form>
);
};

View File

@ -0,0 +1,239 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Plus, Edit2, Trash2, Tag as TagIcon } from 'lucide-react';
import { toast } from 'sonner';
import { Modal } from '@/components/ui';
import { TagForm } from './TagForm';
import { apiClient } from '@/lib/api';
import type { DocumentTag } from '@/types/api';
interface TagManagementModalProps {
isOpen: boolean;
onClose: () => void;
onTagsUpdated?: () => void;
}
type ViewMode = 'list' | 'create' | 'edit';
export const TagManagementModal: React.FC<TagManagementModalProps> = ({
isOpen,
onClose,
onTagsUpdated,
}) => {
const [tags, setTags] = useState<DocumentTag[]>([]);
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [selectedTag, setSelectedTag] = useState<DocumentTag | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
loadTags();
}
}, [isOpen]);
const loadTags = async () => {
setIsLoading(true);
setError(null);
try {
const response = await apiClient.getDocumentTags();
setTags(response.results || []);
} catch (err) {
setError('Не удалось загрузить теги');
console.error('Error loading tags:', err);
} finally {
setIsLoading(false);
}
};
const handleCreateTag = async (data: { name: string; color: string }) => {
setIsLoading(true);
setError(null);
try {
await apiClient.createDocumentTag(data);
await loadTags();
setViewMode('list');
onTagsUpdated?.();
toast.success('Тег успешно создан');
} catch (err: any) {
const errorMsg = err.response?.data?.name?.[0] || 'Не удалось создать тег';
setError(errorMsg);
toast.error(errorMsg);
} finally {
setIsLoading(false);
}
};
const handleUpdateTag = async (data: { name: string; color: string }) => {
if (!selectedTag) return;
setIsLoading(true);
setError(null);
try {
await apiClient.updateDocumentTag(selectedTag.id, data);
await loadTags();
setViewMode('list');
setSelectedTag(null);
onTagsUpdated?.();
toast.success('Тег успешно обновлён');
} catch (err: any) {
const errorMsg = err.response?.data?.name?.[0] || 'Не удалось обновить тег';
setError(errorMsg);
toast.error(errorMsg);
} finally {
setIsLoading(false);
}
};
const handleDeleteTag = async (tagId: number, tagName: string) => {
if (!confirm(`Вы уверены, что хотите удалить тег "${tagName}"?\n\nТег будет удалён у всех документов, где он используется.`)) {
return;
}
setIsDeleting(tagId);
setError(null);
try {
await apiClient.deleteDocumentTag(tagId);
await loadTags();
onTagsUpdated?.();
toast.success('Тег успешно удалён');
} catch (err) {
const errorMsg = 'Не удалось удалить тег';
setError(errorMsg);
toast.error(errorMsg);
} finally {
setIsDeleting(null);
}
};
const handleEditClick = (tag: DocumentTag) => {
setSelectedTag(tag);
setViewMode('edit');
};
const handleCancel = () => {
setViewMode('list');
setSelectedTag(null);
setError(null);
};
const getTitle = () => {
if (viewMode === 'list') return 'Управление тегами';
if (viewMode === 'create') return 'Создание тега';
return 'Редактирование тега';
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={getTitle()}
size="lg"
>
<div className="space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-800 text-sm">
{error}
</div>
)}
{viewMode === 'list' && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-gray-600">
Всего тегов: <span className="font-semibold">{tags.length}</span>
</p>
<button
onClick={() => setViewMode('create')}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm"
>
<Plus className="h-4 w-4" />
Создать тег
</button>
</div>
{isLoading && tags.length === 0 ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : tags.length === 0 ? (
<div className="text-center py-12">
<TagIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">Нет тегов</h3>
<p className="mt-1 text-sm text-gray-500">
Создайте первый тег для организации документов
</p>
</div>
) : (
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
{tags.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between p-3 border border-gray-200 rounded-md hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-md border border-gray-300 flex-shrink-0"
style={{ backgroundColor: tag.color }}
title={tag.color}
/>
<div className="min-w-0">
<p className="font-medium text-gray-900 truncate">{tag.name}</p>
<p className="text-xs text-gray-500">
ID: {tag.id} • Цвет: {tag.color}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => handleEditClick(tag)}
disabled={isDeleting === tag.id}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-md transition-colors disabled:opacity-50"
title="Редактировать"
>
<Edit2 className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteTag(tag.id, tag.name)}
disabled={isDeleting === tag.id}
className="p-2 text-red-600 hover:bg-red-50 rounded-md transition-colors disabled:opacity-50"
title="Удалить"
>
{isDeleting === tag.id ? (
<div className="animate-spin h-4 w-4 border-2 border-red-600 border-t-transparent rounded-full" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{viewMode === 'create' && (
<TagForm
onSubmit={handleCreateTag}
onCancel={handleCancel}
isLoading={isLoading}
existingTags={tags}
/>
)}
{viewMode === 'edit' && selectedTag && (
<TagForm
tag={selectedTag}
onSubmit={handleUpdateTag}
onCancel={handleCancel}
isLoading={isLoading}
existingTags={tags}
/>
)}
</div>
</Modal>
);
};