diff --git a/frontend/src/components/documents/DocumentMetadataEditor.tsx b/frontend/src/components/documents/DocumentMetadataEditor.tsx new file mode 100644 index 00000000..ced01868 --- /dev/null +++ b/frontend/src/components/documents/DocumentMetadataEditor.tsx @@ -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([]); + const [selectedFolder, setSelectedFolder] = useState(document.folder?.id || null); + + // Data lists + const [documentTags, setDocumentTags] = useState([]); + const [folders, setFolders] = useState([]); + + // Loading states + const [loadingTags, setLoadingTags] = useState(false); + const [loadingFolders, setLoadingFolders] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(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 ( + +
+ {error && ( +
+

{error}

+
+ )} + + {/* Document info */} +
+

{document.title}

+

ID: {document.id}

+
+ + {/* Tags */} +
+ + {loadingTags ? ( +
+ +
+ ) : documentTags.length === 0 ? ( +
+ Нет доступных тегов +
+ ) : ( +
+ {documentTags.map((tag) => ( + + ))} +
+ )} +

+ Выбрано: {selectedTags.length} {selectedTags.length === 1 ? 'тег' : 'тегов'} +

+
+ + {/* Folder */} +
+ + +
+ + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/documents/tags/TagColorPicker.tsx b/frontend/src/components/documents/tags/TagColorPicker.tsx new file mode 100644 index 00000000..e29fed8e --- /dev/null +++ b/frontend/src/components/documents/tags/TagColorPicker.tsx @@ -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 = ({ + value, + onChange, + label = 'Цвет тега', +}) => { + return ( +
+ +
+ {PRESET_COLORS.map((color) => ( +
+
+ + onChange(e.target.value)} + className="w-16 h-8 rounded border border-gray-300 cursor-pointer" + /> + {value} +
+
+ ); +}; diff --git a/frontend/src/components/documents/tags/TagForm.tsx b/frontend/src/components/documents/tags/TagForm.tsx new file mode 100644 index 00000000..01b0147e --- /dev/null +++ b/frontend/src/components/documents/tags/TagForm.tsx @@ -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 = ({ + 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 ( +
+
+ + { + 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 && ( +

{errors.name}

+ )} +

+ {name.length}/50 символов +

+
+ + + +
+ + +
+ + ); +}; diff --git a/frontend/src/components/documents/tags/TagManagementModal.tsx b/frontend/src/components/documents/tags/TagManagementModal.tsx new file mode 100644 index 00000000..925028cb --- /dev/null +++ b/frontend/src/components/documents/tags/TagManagementModal.tsx @@ -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 = ({ + isOpen, + onClose, + onTagsUpdated, +}) => { + const [tags, setTags] = useState([]); + const [viewMode, setViewMode] = useState('list'); + const [selectedTag, setSelectedTag] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isDeleting, setIsDeleting] = useState(null); + const [error, setError] = useState(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 ( + +
+ {error && ( +
+ {error} +
+ )} + + {viewMode === 'list' && ( +
+
+

+ Всего тегов: {tags.length} +

+ +
+ + {isLoading && tags.length === 0 ? ( +
+
+
+ ) : tags.length === 0 ? ( +
+ +

Нет тегов

+

+ Создайте первый тег для организации документов +

+
+ ) : ( +
+ {tags.map((tag) => ( +
+
+
+
+

{tag.name}

+

+ ID: {tag.id} • Цвет: {tag.color} +

+
+
+
+ + +
+
+ ))} +
+ )} +
+ )} + + {viewMode === 'create' && ( + + )} + + {viewMode === 'edit' && selectedTag && ( + + )} +
+ + ); +};