Files
family_budget/frontend/src/pages/FamilyView.tsx

647 lines
27 KiB
TypeScript
Raw 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 { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { categoryApi, expenseApi, inviteLinkApi } from '../api/client';
import { useStore } from '../store/useStore';
import type { Category, Expense, InviteLinkResponse } from '../types';
import {
Wallet,
TrendingDown,
Plus,
Trash2,
RotateCcw,
Loader2,
X,
DollarSign,
Tag,
History,
Calendar,
MessageSquare,
ShoppingCart,
UserPlus,
Copy,
Check,
} from 'lucide-react';
import ShoppingListModal from '../components/ShoppingListModal';
export default function FamilyView() {
const { familyId } = useParams<{ familyId: string }>();
const navigate = useNavigate();
const { selectedFamily } = useStore();
const [categories, setCategories] = useState<Category[]>([]);
const [remainingLimits, setRemainingLimits] = useState<Map<number, number>>(new Map());
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showAddCategory, setShowAddCategory] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const [newCategoryLimit, setNewCategoryLimit] = useState('');
const [showAddExpense, setShowAddExpense] = useState<number | null>(null);
const [expenseAmount, setExpenseAmount] = useState('');
const [expenseDescription, setExpenseDescription] = useState('');
const [showHistory, setShowHistory] = useState<number | null>(null);
const [categoryExpenses, setCategoryExpenses] = useState<Expense[]>([]);
const [showShoppingList, setShowShoppingList] = useState(false);
const [showInviteModal, setShowInviteModal] = useState(false);
const [inviteLink, setInviteLink] = useState<InviteLinkResponse | null>(null);
const [inviteLoading, setInviteLoading] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!familyId) {
navigate('/');
return;
}
loadCategories();
}, [familyId]);
const loadCategories = async () => {
if (!familyId) return;
try {
setLoading(true);
setError('');
console.log('Loading categories for family:', familyId);
const response = await categoryApi.getAllByFamily(parseInt(familyId));
console.log('Categories loaded:', response.data);
setCategories(response.data);
const limits = new Map<number, number>();
for (const category of response.data) {
const limitResponse = await expenseApi.getRemainingLimit(
parseInt(familyId),
category.id
);
const limitValue = typeof limitResponse.data.remaining_limit === 'string'
? parseFloat(limitResponse.data.remaining_limit)
: limitResponse.data.remaining_limit;
limits.set(category.id, limitValue);
}
setRemainingLimits(limits);
console.log('All data loaded successfully');
} catch (err: any) {
const errorMsg = err.response?.data?.message || err.message || 'Ошибка загрузки категорий';
setError(errorMsg);
console.error('Error loading categories:', err);
} finally {
setLoading(false);
}
};
const handleAddCategory = async () => {
if (!familyId || !newCategoryName || !newCategoryLimit) return;
try {
await categoryApi.create(parseInt(familyId), {
name: newCategoryName,
limit_amount: parseFloat(newCategoryLimit),
});
setNewCategoryName('');
setNewCategoryLimit('');
setShowAddCategory(false);
loadCategories();
} catch (err: any) {
const errorMsg = err.response?.data?.message || err.response?.statusText || err.message || 'Ошибка создания категории';
alert(`Ошибка создания категории: ${errorMsg} (Статус: ${err.response?.status})`);
console.error('Full error:', err);
}
};
const handleDeleteCategory = async (categoryId: number) => {
if (!familyId) return;
if (!confirm('Удалить категорию?')) return;
try {
await categoryApi.delete(parseInt(familyId), categoryId);
loadCategories();
} catch (err) {
alert('Ошибка удаления категории');
console.error(err);
}
};
const handleResetLimit = async (categoryId: number) => {
if (!familyId) return;
if (!confirm('Удалить все траты по этой категории?')) return;
try {
const expensesResponse = await expenseApi.getAllByCategory(
parseInt(familyId),
categoryId
);
for (const expense of expensesResponse.data) {
await expenseApi.delete(
parseInt(familyId),
categoryId,
expense.id
);
}
loadCategories();
} catch (err) {
alert('Ошибка сброса трат');
console.error(err);
}
};
const handleAddExpense = async (categoryId: number) => {
if (!familyId || !expenseAmount) return;
try {
await expenseApi.create(parseInt(familyId), categoryId, {
amount: parseFloat(expenseAmount),
description: expenseDescription || undefined,
});
setExpenseAmount('');
setExpenseDescription('');
setShowAddExpense(null);
loadCategories();
} catch (err) {
alert('Ошибка добавления расхода');
console.error(err);
}
};
const handleShowHistory = async (categoryId: number) => {
if (!familyId) return;
if (showHistory === categoryId) {
setShowHistory(null);
return;
}
try {
const response = await expenseApi.getAllByCategory(
parseInt(familyId),
categoryId
);
setCategoryExpenses(response.data);
setShowHistory(categoryId);
} catch (err) {
alert('Ошибка загрузки истории трат');
console.error(err);
}
};
const handleCreateInviteLink = async () => {
try {
setInviteLoading(true);
const response = await inviteLinkApi.create({ expires_in_hours: 168 });
setInviteLink(response.data);
} catch (err) {
alert('Ошибка создания ссылки-приглашения');
console.error(err);
} finally {
setInviteLoading(false);
}
};
const handleCopyInviteLink = async () => {
if (!inviteLink) return;
try {
await navigator.clipboard.writeText(inviteLink.invite_url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const handleOpenInviteModal = () => {
setShowInviteModal(true);
setInviteLink(null);
setCopied(false);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center gradient-bg">
<div className="flex items-center gap-3 text-white">
<Loader2 className="w-8 h-8 animate-spin" />
<span className="text-xl font-medium">Загрузка...</span>
</div>
</div>
);
}
const getProgressColor = (remaining: number, limit: number) => {
const percentage = (remaining / limit) * 100;
if (percentage > 50) return 'bg-green-500';
if (percentage > 25) return 'bg-yellow-500';
return 'bg-red-500';
};
const getProgressPercentage = (remaining: number, limit: number) => {
return Math.max(0, Math.min(100, (remaining / limit) * 100));
};
const getTotalLimit = () => {
return categories.reduce((sum, cat) => sum + parseFloat(cat.limit_amount.toString()), 0);
};
const getTotalRemaining = () => {
return Array.from(remainingLimits.values()).reduce((sum, val) => sum + val, 0);
};
const formatDate = (dateString: string) => {
let dateStr = dateString;
if (!dateStr.endsWith('Z') && !dateStr.includes('+')) {
dateStr = dateStr + 'Z';
}
const date = new Date(dateStr);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
<div className="max-w-5xl mx-auto">
<div className="mb-6 sm:mb-8">
<button
onClick={handleOpenInviteModal}
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md mb-6 transition-all duration-300 group"
>
<UserPlus className="w-5 h-5 group-hover:scale-110 transition-transform" />
<span className="font-medium">Пригласить участника</span>
</button>
<div className="text-center">
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
<Wallet className="w-12 h-12 text-white" />
</div>
<h1 className="text-4xl sm:text-5xl font-bold text-white mb-6">
{selectedFamily?.name || 'Семья'}
</h1>
<div className="max-w-2xl mx-auto glass-effect rounded-2xl shadow-lg p-5">
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="text-center">
<p className="text-gray-600 font-medium text-sm mb-2">Общий лимит</p>
<p className="text-2xl sm:text-3xl font-bold text-gray-900">
{getTotalLimit().toFixed(2)}
</p>
</div>
<div className="text-center border-l-2 border-gray-300">
<p className="text-gray-600 font-medium text-sm mb-2">Общий остаток</p>
<p className="text-2xl sm:text-3xl font-bold text-gray-900">
{getTotalRemaining().toFixed(2)}
</p>
</div>
</div>
<button
onClick={() => setShowShoppingList(true)}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
>
<ShoppingCart className="w-5 h-5" />
Список покупок
</button>
</div>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-500/90 backdrop-blur-md border border-red-300/50 text-white rounded-2xl shadow-lg max-w-2xl mx-auto">
<div className="flex items-center gap-2">
<X className="w-5 h-5 flex-shrink-0" />
<span>{error}</span>
</div>
</div>
)}
<div className="space-y-5 mb-6 max-w-3xl mx-auto">
{categories.map((category) => {
const remaining = remainingLimits.get(category.id) || 0;
const limit = parseFloat(category.limit_amount.toString());
const percentage = getProgressPercentage(remaining, limit);
return (
<div
key={category.id}
className="glass-effect rounded-2xl shadow-lg p-4 sm:p-5 card-hover"
>
<div className="flex items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-linear-to-br from-purple-500 to-blue-500 text-white rounded-xl shadow-lg">
<Tag className="w-6 h-6" />
</div>
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">
{category.name}
</h2>
</div>
{showAddExpense !== category.id && (
<button
onClick={() => setShowAddExpense(category.id)}
className="flex items-center gap-2 px-4 py-2 bg-linear-to-r from-red-500 to-pink-500 text-white rounded-xl hover:shadow-lg transition-all duration-300 font-semibold whitespace-nowrap text-sm"
>
<TrendingDown className="w-4 h-4" />
<span className="hidden sm:inline">Добавить расход</span>
<span className="sm:hidden">Расход</span>
</button>
)}
</div>
<div className="space-y-3 mb-4">
<div className="flex justify-between items-baseline">
<span className="text-gray-600 font-medium text-sm">Остаток:</span>
<span className="text-2xl sm:text-3xl font-bold text-gray-900">
{remaining.toFixed(2)}
</span>
</div>
<div className="flex justify-between items-baseline text-gray-500 text-sm">
<span>Лимит:</span>
<span className="text-base font-semibold">{limit.toFixed(2)} </span>
</div>
<div className="relative h-3 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full ${getProgressColor(remaining, limit)} transition-all duration-500 rounded-full shadow-inner`}
style={{ width: `${percentage}%` }}
/>
</div>
<p className="text-xs text-gray-500 text-center font-medium">
{percentage.toFixed(0)}% осталось
</p>
</div>
<div className="flex gap-2 justify-between">
<button
onClick={() => handleResetLimit(category.id)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-yellow-500 hover:bg-yellow-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
>
<RotateCcw className="w-4 h-4" />
<span>Обнулить</span>
</button>
<button
onClick={() => handleShowHistory(category.id)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
>
<History className="w-4 h-4" />
<span>История</span>
</button>
<button
onClick={() => handleDeleteCategory(category.id)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
>
<Trash2 className="w-4 h-4" />
<span>Удалить</span>
</button>
</div>
{showHistory === category.id && (
<div className="mt-4 bg-linear-to-br from-blue-50 to-purple-50 p-4 rounded-2xl border-2 border-blue-200">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-800 text-lg flex items-center gap-2">
<History className="w-5 h-5" />
История трат
</h3>
<button
onClick={() => setShowHistory(null)}
className="p-2 hover:bg-white/50 rounded-xl transition-all"
>
<X className="w-5 h-5 text-gray-600" />
</button>
</div>
{categoryExpenses.length === 0 ? (
<p className="text-center text-gray-500 py-4">Нет трат</p>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{categoryExpenses.map((expense) => (
<div
key={expense.id}
className="bg-white p-3 rounded-xl shadow-sm border border-gray-200"
>
<div className="flex justify-between items-start mb-2">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-red-100 rounded-lg">
<TrendingDown className="w-4 h-4 text-red-600" />
</div>
<span className="font-bold text-gray-900 text-lg">
{parseFloat(expense.amount.toString()).toFixed(2)}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-500">
<Calendar className="w-3 h-3" />
<span>{formatDate(expense.created_at)}</span>
</div>
</div>
{expense.description && (
<div className="flex items-start gap-2 text-sm text-gray-600 bg-gray-50 p-2 rounded-lg">
<MessageSquare className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{expense.description}</span>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{showAddExpense === category.id && (
<div className="bg-linear-to-br from-purple-50 to-blue-50 p-6 rounded-2xl border-2 border-purple-200">
<h3 className="font-semibold text-gray-800 mb-4 text-center">
Добавить расход
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Сумма ()
</label>
<input
type="number"
placeholder="0.00"
value={expenseAmount}
onChange={(e) => setExpenseAmount(e.target.value)}
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all text-center font-semibold text-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Описание
</label>
<input
type="text"
placeholder="Опционально"
value={expenseDescription}
onChange={(e) => setExpenseDescription(e.target.value)}
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all"
/>
</div>
<div className="flex gap-3">
<button
onClick={() => handleAddExpense(category.id)}
className="flex-1 flex items-center justify-center gap-2 px-5 py-3 bg-linear-to-r from-green-500 to-green-600 text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
>
<Plus className="w-5 h-5" />
Добавить
</button>
<button
onClick={() => setShowAddExpense(null)}
className="px-5 py-3 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-medium"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
<div className="glass-effect rounded-3xl shadow-xl p-6 sm:p-8 max-w-3xl mx-auto">
<div className="flex items-center justify-center gap-3 mb-8">
<div className="p-3 bg-linear-to-br from-purple-500 to-blue-500 rounded-2xl">
<DollarSign className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl sm:text-3xl font-bold text-gray-800">
Управление категориями
</h2>
</div>
{showAddCategory ? (
<div className="mb-8 p-6 bg-linear-to-br from-purple-50 to-blue-50 rounded-2xl border-2 border-purple-200">
<h3 className="font-bold text-gray-800 mb-5 text-center text-lg">
Новая категория
</h3>
<div className="space-y-4">
<input
type="text"
placeholder="Название категории"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="w-full px-5 py-4 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium"
/>
<input
type="number"
placeholder="Лимит (₽)"
value={newCategoryLimit}
onChange={(e) => setNewCategoryLimit(e.target.value)}
className="w-full px-5 py-4 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium text-center"
/>
<div className="flex gap-3">
<button
onClick={handleAddCategory}
className="flex-1 flex items-center justify-center gap-2 px-6 py-4 bg-linear-to-r from-green-500 to-green-600 text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
>
<Plus className="w-5 h-5" />
Создать
</button>
<button
onClick={() => setShowAddCategory(false)}
className="px-6 py-4 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-medium"
>
Отмена
</button>
</div>
</div>
</div>
) : (
<button
onClick={() => setShowAddCategory(true)}
className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-linear-to-r from-purple-600 to-blue-600 text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
>
<Plus className="w-5 h-5" />
Добавить категорию
</button>
)}
</div>
</div>
{showShoppingList && familyId && (
<ShoppingListModal
familyId={parseInt(familyId)}
onClose={() => setShowShoppingList(false)}
/>
)}
{showInviteModal && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md p-6 sm:p-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-purple-500 to-blue-500 rounded-2xl">
<UserPlus className="w-6 h-6 text-white" />
</div>
<h2 className="text-xl font-bold text-gray-800">Пригласить участника</h2>
</div>
<button
onClick={() => setShowInviteModal(false)}
className="p-2 hover:bg-gray-100 rounded-xl transition-all"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{!inviteLink ? (
<div className="text-center">
<p className="text-gray-600 mb-6">
Создайте ссылку-приглашение, чтобы добавить нового участника в семью.
Ссылка будет действительна 7 дней.
</p>
<button
onClick={handleCreateInviteLink}
disabled={inviteLoading}
className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-2xl hover:shadow-xl transition-all font-semibold disabled:opacity-50"
>
{inviteLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Создание...
</>
) : (
<>
<UserPlus className="w-5 h-5" />
Создать ссылку
</>
)}
</button>
</div>
) : (
<div>
<p className="text-gray-600 mb-4 text-center">
Отправьте эту ссылку участнику, которого хотите пригласить:
</p>
<div className="bg-gray-100 rounded-2xl p-4 mb-4">
<p className="text-sm text-gray-800 break-all font-mono">
{inviteLink.invite_url}
</p>
</div>
<button
onClick={handleCopyInviteLink}
className={`w-full flex items-center justify-center gap-2 px-6 py-4 rounded-2xl transition-all font-semibold ${
copied
? 'bg-green-500 text-white'
: 'bg-gradient-to-r from-purple-600 to-blue-600 text-white hover:shadow-xl'
}`}
>
{copied ? (
<>
<Check className="w-5 h-5" />
Скопировано!
</>
) : (
<>
<Copy className="w-5 h-5" />
Скопировать ссылку
</>
)}
</button>
</div>
)}
</div>
</div>
)}
</div>
);
}