feat(frontend): добавление новых компонентов управления документами
- Добавлен DocumentMetadataEditor для редактирования метаданных - Добавлен TagColorPicker для выбора цветов тегов - Добавлен TagForm для создания/редактирования тегов - Добавлен TagManagementModal для управления тегами
This commit is contained in:
parent
4ac50d4818
commit
e689ee7e5a
241
frontend/src/components/documents/DocumentMetadataEditor.tsx
Normal file
241
frontend/src/components/documents/DocumentMetadataEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/documents/tags/TagColorPicker.tsx
Normal file
65
frontend/src/components/documents/tags/TagColorPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
138
frontend/src/components/documents/tags/TagForm.tsx
Normal file
138
frontend/src/components/documents/tags/TagForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
239
frontend/src/components/documents/tags/TagManagementModal.tsx
Normal file
239
frontend/src/components/documents/tags/TagManagementModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user