Files
mkd/components/finance/ExpenseDirectory.tsx
2026-02-04 00:17:04 +05:00

370 lines
15 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};