Files
mkd/components/finance/ExpenseDirectory.tsx

370 lines
15 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
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>
);
};