revert try to do better
This commit is contained in:
2026-01-29 12:43:22 +00:00
parent 75fa8bd4e2
commit c7b9a14ff6
60 changed files with 1255 additions and 5336 deletions

View File

@@ -1,16 +1,29 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { categoryApi, expenseApi, inviteLinkApi } from '../api/client';
import { useStore } from '../store/useStore';
import { useCategories, useConfirm } from '../hooks';
import { FamilyHeader } from '../components/family/FamilyHeader';
import { FamilySummary } from '../components/family/FamilySummary';
import { CategoryList } from '../components/family/CategoryList';
import { AddCategorySection } from '../components/family/AddCategorySection';
import { InviteModal } from '../components/family/InviteModal';
import type { Category, Expense, InviteLinkResponse } from '../types';
import {
Wallet,
TrendingDown,
Plus,
Trash2,
RotateCcw,
Loader2,
X,
DollarSign,
Tag,
History,
Calendar,
MessageSquare,
ShoppingCart,
UserPlus,
Copy,
Check,
User,
} from 'lucide-react';
import ShoppingListModal from '../components/ShoppingListModal';
import { ConfirmModal } from '../components/ui';
export default function FamilyView() {
const { t } = useTranslation();
@@ -18,34 +31,194 @@ export default function FamilyView() {
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 { categories, loading, createCategory, deleteCategory, resetLimit, loadCategories } = useCategories(
parseInt(familyId || '0')
);
const { confirmState, confirm, cancel } = useConfirm();
const [inviteLink, setInviteLink] = useState<InviteLinkResponse | null>(null);
const [inviteLoading, setInviteLoading] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!familyId) {
navigate('/');
return;
}
}, [familyId, navigate]);
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 || t('family.loadError');
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 || t('category.createError');
alert(`${t('category.createError')}: ${errorMsg}`);
console.error('Full error:', err);
}
};
const handleDeleteCategory = async (categoryId: number) => {
await confirm(t('category.deleteConfirm'), t('category.deleteMessage'));
await deleteCategory(categoryId);
if (!familyId) return;
if (!confirm(t('category.deleteConfirm'))) return;
try {
await categoryApi.delete(parseInt(familyId), categoryId);
loadCategories();
} catch (err) {
alert(t('category.deleteError'));
console.error(err);
}
};
const handleResetLimit = async (categoryId: number) => {
await confirm(t('category.resetConfirm'), t('category.resetMessage'));
const category = categories.find((cat) => cat.id === categoryId);
if (category) {
await resetLimit(categoryId, Number(category.limit_amount));
if (!familyId) return;
if (!confirm(t('category.resetConfirm'))) 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(t('category.resetError'));
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(t('expense.addError'));
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(t('expense.historyError'));
console.error(err);
}
};
const handleCreateInviteLink = async () => {
try {
setInviteLoading(true);
const response = await inviteLinkApi.create({ expires_in_hours: 168 });
setInviteLink(response.data);
} catch (err) {
alert(t('invite.createError'));
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">
@@ -57,51 +230,428 @@ export default function FamilyView() {
);
}
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">
<FamilyHeader
onInvite={() => setShowInviteModal(true)}
onProfile={() => navigate('/profile')}
/>
<div className="mb-6 sm:mb-8">
<div className="flex items-center gap-3 mb-6">
<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 transition-all duration-300 group"
>
<UserPlus className="w-5 h-5 group-hover:scale-110 transition-transform" />
<span className="font-medium">{t('family.inviteMember')}</span>
</button>
<button
onClick={() => navigate('/profile')}
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 transition-all duration-300 group"
>
<User className="w-5 h-5 group-hover:scale-110 transition-transform" />
<span className="font-medium">{t('profile.title')}</span>
</button>
</div>
<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 || t('family.defaultName')}
</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">{t('family.totalLimit')}</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">{t('family.totalRemaining')}</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 btn-success text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
>
<ShoppingCart className="w-5 h-5" />
{t('family.shoppingList')}
</button>
</div>
</div>
</div>
<FamilySummary
familyName={selectedFamily?.name}
categories={categories}
onShowShoppingList={() => setShowShoppingList(true)}
/>
{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>
)}
<CategoryList
categories={categories}
familyId={parseInt(familyId || '0')}
onDelete={handleDeleteCategory}
onReset={handleResetLimit}
onUpdate={loadCategories}
/>
<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);
<AddCategorySection
showForm={showAddCategory}
onToggle={() => setShowAddCategory(!showAddCategory)}
onCreate={createCategory}
/>
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 category-icon 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 btn-danger 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">{t('category.addExpense')}</span>
<span className="sm:hidden">{t('category.expense')}</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">{t('category.remaining')}</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>{t('category.limit')}</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)}{t('category.percentRemaining')}
</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>{t('category.reset')}</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>{t('category.history')}</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>{t('common.delete')}</span>
</button>
</div>
{showHistory === category.id && (
<div className="mt-4 glass-effect p-4 rounded-2xl border-2 border-gray-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" />
{t('expense.historyTitle')}
</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">{t('expense.noExpenses')}</p>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{categoryExpenses.map((expense) => (
<div
key={expense.id}
className="expense-history-item p-3 rounded-xl shadow-sm border"
>
<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 expense-description 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="glass-effect p-6 rounded-2xl border-2 border-gray-200 mt-4">
<h3 className="font-semibold text-gray-800 mb-4 text-center">
{t('expense.addTitle')}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('expense.amount')}
</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">
{t('expense.description')}
</label>
<input
type="text"
placeholder={t('expense.descriptionPlaceholder')}
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 btn-success text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
>
<Plus className="w-5 h-5" />
{t('common.add')}
</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 category-icon rounded-2xl">
<DollarSign className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl sm:text-3xl font-bold text-gray-800">
{t('category.management')}
</h2>
</div>
{showAddCategory ? (
<div className="mb-8 p-6 glass-effect rounded-2xl border-2 border-gray-200">
<h3 className="font-bold text-gray-800 mb-5 text-center text-lg">
{t('category.newCategory')}
</h3>
<div className="space-y-4">
<input
type="text"
placeholder={t('category.categoryName')}
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={t('category.categoryLimit')}
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 btn-success text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
>
<Plus className="w-5 h-5" />
{t('common.create')}
</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"
>
{t('common.cancel')}
</button>
</div>
</div>
</div>
) : (
<button
onClick={() => setShowAddCategory(true)}
className="w-full flex items-center justify-center gap-2 px-6 py-4 btn-primary text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
>
<Plus className="w-5 h-5" />
{t('category.addCategory')}
</button>
)}
</div>
</div>
{showShoppingList && (
{showShoppingList && familyId && (
<ShoppingListModal
familyId={parseInt(familyId || '0')}
familyId={parseInt(familyId)}
onClose={() => setShowShoppingList(false)}
/>
)}
{showInviteModal && <InviteModal onClose={() => setShowInviteModal(false)} />}
{showInviteModal && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass-effect 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 category-icon rounded-2xl">
<UserPlus className="w-6 h-6 text-white" />
</div>
<h2 className="text-xl font-bold text-gray-800">{t('invite.title')}</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>
<ConfirmModal
isOpen={confirmState.isOpen}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={cancel}
/>
{!inviteLink ? (
<div className="text-center">
<p className="text-gray-600 mb-6">
{t('invite.description')}
</p>
<button
onClick={handleCreateInviteLink}
disabled={inviteLoading}
className="w-full flex items-center justify-center gap-2 px-6 py-4 btn-primary text-white rounded-2xl hover:shadow-xl transition-all font-semibold disabled:opacity-50"
>
{inviteLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{t('invite.creating')}
</>
) : (
<>
<UserPlus className="w-5 h-5" />
{t('invite.createLink')}
</>
)}
</button>
</div>
) : (
<div>
<p className="text-gray-600 mb-4 text-center">
{t('invite.sendLink')}
</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
? 'btn-success text-white'
: 'btn-primary text-white hover:shadow-xl'
}`}
>
{copied ? (
<>
<Check className="w-5 h-5" />
{t('invite.copied')}
</>
) : (
<>
<Copy className="w-5 h-5" />
{t('invite.copyLink')}
</>
)}
</button>
</div>
)}
</div>
</div>
)}
</div>
);
}