370 lines
15 KiB
TypeScript
Executable File
370 lines
15 KiB
TypeScript
Executable File
import React, { useState, useEffect } from 'react';
|
||
import { Plus, Edit2, Trash2, ChevronDown, ChevronRight, Folder, FileText, Save, X } from 'lucide-react';
|
||
import { apiClient } from '../../services/apiClient';
|
||
|
||
interface ExpenseCategory {
|
||
id: number;
|
||
name: string;
|
||
code?: string;
|
||
description?: string;
|
||
sortOrder: number;
|
||
isActive: boolean;
|
||
items?: ExpenseItem[];
|
||
}
|
||
|
||
interface ExpenseItem {
|
||
id: number;
|
||
categoryId: number;
|
||
name: string;
|
||
code?: string;
|
||
description?: string;
|
||
parentItemId?: number;
|
||
sortOrder: number;
|
||
isActive: boolean;
|
||
children?: ExpenseItem[];
|
||
}
|
||
|
||
export const ExpenseDirectory: React.FC = () => {
|
||
const [categories, setCategories] = useState<ExpenseCategory[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [expandedCategories, setExpandedCategories] = useState<Set<number>>(new Set());
|
||
const [editingItem, setEditingItem] = useState<{ type: 'category' | 'item'; id?: number; categoryId?: number; parentId?: number } | null>(null);
|
||
const [formData, setFormData] = useState({ name: '', code: '', description: '', sortOrder: 0 });
|
||
|
||
useEffect(() => {
|
||
fetchDirectory();
|
||
}, []);
|
||
|
||
const fetchDirectory = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const data = await apiClient.get<ExpenseCategory[]>('/api/finance/expense-directory/tree');
|
||
setCategories(data);
|
||
// Раскрываем все категории по умолчанию
|
||
setExpandedCategories(new Set(data.map(c => c.id)));
|
||
} catch (err) {
|
||
console.error('Ошибка загрузки справочника:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleInitialize = async () => {
|
||
if (!confirm('Инициализировать справочник из стандартного списка? Существующие данные не будут удалены.')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await apiClient.post('/api/finance/expense-directory/initialize');
|
||
alert('Справочник успешно инициализирован');
|
||
fetchDirectory();
|
||
} catch (err: any) {
|
||
alert(`Ошибка: ${err.message || 'Не удалось инициализировать справочник'}`);
|
||
}
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
if (!formData.name.trim()) {
|
||
alert('Название обязательно');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (editingItem?.type === 'category') {
|
||
if (editingItem.id) {
|
||
await apiClient.put(`/api/finance/expense-categories/${editingItem.id}`, {
|
||
name: formData.name,
|
||
code: formData.code || null,
|
||
description: formData.description || null,
|
||
sortOrder: formData.sortOrder
|
||
});
|
||
} else {
|
||
await apiClient.post('/api/finance/expense-categories', {
|
||
name: formData.name,
|
||
code: formData.code || null,
|
||
description: formData.description || null,
|
||
sortOrder: formData.sortOrder
|
||
});
|
||
}
|
||
} else if (editingItem?.type === 'item') {
|
||
if (!editingItem.categoryId) {
|
||
alert('Не указана категория');
|
||
return;
|
||
}
|
||
|
||
if (editingItem.id) {
|
||
await apiClient.put(`/api/finance/expense-items/${editingItem.id}`, {
|
||
categoryId: editingItem.categoryId,
|
||
name: formData.name,
|
||
code: formData.code || null,
|
||
description: formData.description || null,
|
||
parentItemId: editingItem.parentId || null,
|
||
sortOrder: formData.sortOrder
|
||
});
|
||
} else {
|
||
await apiClient.post('/api/finance/expense-items', {
|
||
categoryId: editingItem.categoryId,
|
||
name: formData.name,
|
||
code: formData.code || null,
|
||
description: formData.description || null,
|
||
parentItemId: editingItem.parentId || null,
|
||
sortOrder: formData.sortOrder
|
||
});
|
||
}
|
||
}
|
||
|
||
setEditingItem(null);
|
||
setFormData({ name: '', code: '', description: '', sortOrder: 0 });
|
||
fetchDirectory();
|
||
} catch (err: any) {
|
||
alert(`Ошибка: ${err.message || 'Не удалось сохранить'}`);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (type: 'category' | 'item', id: number) => {
|
||
if (!confirm(`Удалить ${type === 'category' ? 'категорию' : 'статью'}?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (type === 'category') {
|
||
await apiClient.delete(`/api/finance/expense-categories/${id}`);
|
||
} else {
|
||
await apiClient.delete(`/api/finance/expense-items/${id}`);
|
||
}
|
||
fetchDirectory();
|
||
} catch (err: any) {
|
||
alert(`Ошибка: ${err.message || 'Не удалось удалить'}`);
|
||
}
|
||
};
|
||
|
||
const toggleCategory = (categoryId: number) => {
|
||
const newExpanded = new Set(expandedCategories);
|
||
if (newExpanded.has(categoryId)) {
|
||
newExpanded.delete(categoryId);
|
||
} else {
|
||
newExpanded.add(categoryId);
|
||
}
|
||
setExpandedCategories(newExpanded);
|
||
};
|
||
|
||
const renderItem = (item: ExpenseItem, categoryId: number, level: number = 0) => {
|
||
const hasChildren = item.children && item.children.length > 0;
|
||
|
||
return (
|
||
<div key={item.id} className="ml-4">
|
||
<div className={`flex items-center gap-2 p-2 hover:bg-slate-50 rounded ${level > 0 ? 'ml-6' : ''}`}>
|
||
<div className="flex-1 flex items-center gap-2">
|
||
{hasChildren ? (
|
||
<ChevronRight className="w-4 h-4 text-slate-400" />
|
||
) : (
|
||
<div className="w-4" />
|
||
)}
|
||
<FileText className="w-4 h-4 text-slate-500" />
|
||
<span className="text-sm text-slate-700">{item.name}</span>
|
||
{item.code && (
|
||
<span className="text-xs text-slate-400">({item.code})</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => {
|
||
setEditingItem({ type: 'item', id: item.id, categoryId, parentId: item.parentItemId });
|
||
setFormData({ name: item.name, code: item.code || '', description: item.description || '', sortOrder: item.sortOrder });
|
||
}}
|
||
className="p-1 hover:bg-slate-200 rounded"
|
||
title="Редактировать"
|
||
>
|
||
<Edit2 className="w-4 h-4 text-slate-600" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete('item', item.id)}
|
||
className="p-1 hover:bg-red-100 rounded"
|
||
title="Удалить"
|
||
>
|
||
<Trash2 className="w-4 h-4 text-red-600" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{hasChildren && item.children && (
|
||
<div className="ml-4">
|
||
{item.children.map(child => renderItem(child, categoryId, level + 1))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||
<p className="text-slate-600">Загрузка справочника...</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<h2 className="text-2xl font-bold text-slate-800">Справочник статей расходов</h2>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={handleInitialize}
|
||
className="bg-primary-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-primary-700 transition-colors"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Инициализировать справочник
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setEditingItem({ type: 'category' });
|
||
setFormData({ name: '', code: '', description: '', sortOrder: categories.length });
|
||
}}
|
||
className="bg-emerald-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-emerald-700 transition-colors"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Добавить категорию
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Форма редактирования */}
|
||
{editingItem && (
|
||
<div className="bg-slate-50 rounded-lg p-4 mb-4 border border-slate-200">
|
||
<h3 className="font-bold text-slate-800 mb-3">
|
||
{editingItem.id ? 'Редактировать' : 'Добавить'} {editingItem.type === 'category' ? 'категорию' : 'статью'}
|
||
</h3>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Название *</label>
|
||
<input
|
||
type="text"
|
||
value={formData.name}
|
||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
placeholder="Введите название"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Код</label>
|
||
<input
|
||
type="text"
|
||
value={formData.code}
|
||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
placeholder="Код (опционально)"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Порядок сортировки</label>
|
||
<input
|
||
type="number"
|
||
value={formData.sortOrder}
|
||
onChange={(e) => setFormData({ ...formData, sortOrder: parseInt(e.target.value) || 0 })}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">Описание</label>
|
||
<textarea
|
||
value={formData.description}
|
||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
rows={2}
|
||
placeholder="Описание (опционально)"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={handleSave}
|
||
className="bg-primary-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-primary-700 transition-colors"
|
||
>
|
||
<Save className="w-4 h-4" />
|
||
Сохранить
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setEditingItem(null);
|
||
setFormData({ name: '', code: '', description: '', sortOrder: 0 });
|
||
}}
|
||
className="bg-slate-200 text-slate-700 px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-slate-300 transition-colors"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Список категорий */}
|
||
<div className="space-y-2">
|
||
{categories.map(category => (
|
||
<div key={category.id} className="border border-slate-200 rounded-lg">
|
||
<div className="flex items-center gap-2 p-3 bg-slate-50 hover:bg-slate-100">
|
||
<button
|
||
onClick={() => toggleCategory(category.id)}
|
||
className="p-1 hover:bg-slate-200 rounded"
|
||
>
|
||
{expandedCategories.has(category.id) ? (
|
||
<ChevronDown className="w-4 h-4 text-slate-600" />
|
||
) : (
|
||
<ChevronRight className="w-4 h-4 text-slate-600" />
|
||
)}
|
||
</button>
|
||
<Folder className="w-5 h-5 text-primary-600" />
|
||
<span className="font-bold text-slate-800 flex-1">{category.name}</span>
|
||
{category.code && (
|
||
<span className="text-xs text-slate-400">({category.code})</span>
|
||
)}
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => {
|
||
setEditingItem({ type: 'category', id: category.id });
|
||
setFormData({ name: category.name, code: category.code || '', description: category.description || '', sortOrder: category.sortOrder });
|
||
}}
|
||
className="p-1 hover:bg-slate-200 rounded"
|
||
title="Редактировать"
|
||
>
|
||
<Edit2 className="w-4 h-4 text-slate-600" />
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setEditingItem({ type: 'item', categoryId: category.id });
|
||
setFormData({ name: '', code: '', description: '', sortOrder: (category.items?.length || 0) });
|
||
}}
|
||
className="p-1 hover:bg-emerald-100 rounded"
|
||
title="Добавить статью"
|
||
>
|
||
<Plus className="w-4 h-4 text-emerald-600" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete('category', category.id)}
|
||
className="p-1 hover:bg-red-100 rounded"
|
||
title="Удалить"
|
||
>
|
||
<Trash2 className="w-4 h-4 text-red-600" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{expandedCategories.has(category.id) && category.items && (
|
||
<div className="p-2">
|
||
{category.items.length === 0 ? (
|
||
<p className="text-sm text-slate-400 p-2">Нет статей в этой категории</p>
|
||
) : (
|
||
category.items.map(item => renderItem(item, category.id))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|