979 lines
40 KiB
TypeScript
979 lines
40 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { categoryApi, expenseApi, inviteLinkApi } from '../api/client';
|
|
import { useStore } from '../store/useStore';
|
|
import type { Category, InviteLinkResponse, ExpenseHistoryResponse } from '../types';
|
|
import {
|
|
Wallet,
|
|
TrendingDown,
|
|
Plus,
|
|
Trash2,
|
|
RotateCcw,
|
|
Archive,
|
|
Loader2,
|
|
X,
|
|
DollarSign,
|
|
Tag,
|
|
History,
|
|
Calendar,
|
|
MessageSquare,
|
|
ShoppingCart,
|
|
UserPlus,
|
|
Copy,
|
|
Check,
|
|
User,
|
|
Settings,
|
|
GripVertical,
|
|
} from 'lucide-react';
|
|
import ShoppingListModal from '../components/ShoppingListModal';
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
PointerSensor,
|
|
KeyboardSensor,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from '@dnd-kit/core';
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
|
|
function SortableItem({
|
|
id,
|
|
children,
|
|
}: {
|
|
id: number;
|
|
children: (props: { listeners: ReturnType<typeof useSortable>['listeners']; attributes: ReturnType<typeof useSortable>['attributes'] }) => React.ReactNode;
|
|
}) {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
zIndex: isDragging ? 10 : 'auto' as any,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{children({ listeners, attributes })}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const getCategoryOrder = (fid: string): number[] => {
|
|
try {
|
|
const stored = localStorage.getItem(`cat_order_${fid}`);
|
|
return stored ? JSON.parse(stored) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const saveCategoryOrder = (fid: string, order: number[]) => {
|
|
localStorage.setItem(`cat_order_${fid}`, JSON.stringify(order));
|
|
};
|
|
|
|
export default function FamilyView() {
|
|
const { t } = useTranslation();
|
|
const { familyId } = useParams<{ familyId: string }>();
|
|
const navigate = useNavigate();
|
|
const { selectedFamily } = useStore();
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
);
|
|
|
|
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 [showEditCategory, setShowEditCategory] = useState<number | null>(null);
|
|
const [editCategoryName, setEditCategoryName] = useState('');
|
|
const [editCategoryLimit, setEditCategoryLimit] = useState('');
|
|
|
|
const [showHistory, setShowHistory] = useState<number | null>(null);
|
|
const [showArchive, setShowArchive] = useState<number | null>(null);
|
|
const [historyData, setHistoryData] = useState<ExpenseHistoryResponse | null>(null);
|
|
const [archiveData, setArchiveData] = useState<ExpenseHistoryResponse | null>(null);
|
|
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);
|
|
const savedOrder = getCategoryOrder(familyId);
|
|
if (savedOrder.length > 0) {
|
|
const sorted = [...response.data].sort((a, b) => {
|
|
const ai = savedOrder.indexOf(a.id);
|
|
const bi = savedOrder.indexOf(b.id);
|
|
if (ai === -1) return 1;
|
|
if (bi === -1) return -1;
|
|
return ai - bi;
|
|
});
|
|
setCategories(sorted);
|
|
} else {
|
|
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) => {
|
|
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) => {
|
|
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);
|
|
|
|
const limitResponse = await expenseApi.getRemainingLimit(parseInt(familyId), categoryId);
|
|
const limitValue = typeof limitResponse.data.remaining_limit === 'string'
|
|
? parseFloat(limitResponse.data.remaining_limit)
|
|
: limitResponse.data.remaining_limit;
|
|
setRemainingLimits(prev => new Map(prev).set(categoryId, limitValue));
|
|
|
|
if (showHistory === categoryId) {
|
|
const historyResponse = await expenseApi.getHistory(parseInt(familyId), categoryId, false);
|
|
setHistoryData(historyResponse.data);
|
|
}
|
|
} catch (err) {
|
|
alert(t('expense.addError'));
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleOpenEditCategory = (category: Category) => {
|
|
setEditCategoryName(category.name);
|
|
setEditCategoryLimit(parseFloat(category.limit_amount.toString()).toString());
|
|
setShowEditCategory(category.id);
|
|
setShowAddExpense(null);
|
|
};
|
|
|
|
const handleUpdateCategory = async (categoryId: number) => {
|
|
if (!familyId || !editCategoryName || !editCategoryLimit) return;
|
|
|
|
try {
|
|
await categoryApi.update(parseInt(familyId), categoryId, {
|
|
name: editCategoryName,
|
|
limit_amount: parseFloat(editCategoryLimit),
|
|
});
|
|
setShowEditCategory(null);
|
|
loadCategories();
|
|
} catch (err: any) {
|
|
const errorMsg = err.response?.data?.message || err.message || t('category.editError');
|
|
alert(`${t('category.editError')}: ${errorMsg}`);
|
|
}
|
|
};
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id || !familyId) return;
|
|
setCategories((prev) => {
|
|
const oldIndex = prev.findIndex(c => c.id === active.id);
|
|
const newIndex = prev.findIndex(c => c.id === over.id);
|
|
const reordered = arrayMove(prev, oldIndex, newIndex);
|
|
saveCategoryOrder(familyId, reordered.map(c => c.id));
|
|
return reordered;
|
|
});
|
|
};
|
|
|
|
const handleShowHistory = async (categoryId: number) => {
|
|
if (!familyId) return;
|
|
|
|
if (showHistory === categoryId) {
|
|
setShowHistory(null);
|
|
return;
|
|
}
|
|
|
|
setShowArchive(null);
|
|
|
|
try {
|
|
const response = await expenseApi.getHistory(
|
|
parseInt(familyId),
|
|
categoryId,
|
|
false
|
|
);
|
|
setHistoryData(response.data);
|
|
setShowHistory(categoryId);
|
|
} catch (err) {
|
|
alert(t('expense.historyError'));
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleShowArchive = async (categoryId: number) => {
|
|
if (!familyId) return;
|
|
|
|
if (showArchive === categoryId) {
|
|
setShowArchive(null);
|
|
return;
|
|
}
|
|
|
|
setShowHistory(null);
|
|
|
|
try {
|
|
const response = await expenseApi.getHistory(
|
|
parseInt(familyId),
|
|
categoryId,
|
|
true
|
|
);
|
|
setArchiveData(response.data);
|
|
setShowArchive(categoryId);
|
|
} catch (err) {
|
|
alert(t('expense.archiveError'));
|
|
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">
|
|
<div className="flex items-center gap-3 text-white">
|
|
<Loader2 className="w-8 h-8 animate-spin" />
|
|
<span className="text-xl font-medium">{t('common.loading')}</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',
|
|
});
|
|
};
|
|
|
|
const getMonthName = (month: number) => {
|
|
const months = [
|
|
t('months.1'), t('months.2'), t('months.3'), t('months.4'),
|
|
t('months.5'), t('months.6'), t('months.7'), t('months.8'),
|
|
t('months.9'), t('months.10'), t('months.11'), t('months.12')
|
|
];
|
|
return months[month - 1] || month;
|
|
};
|
|
|
|
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">
|
|
<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>
|
|
|
|
{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>
|
|
)}
|
|
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
<SortableContext items={categories.map(c => c.id)} strategy={verticalListSortingStrategy}>
|
|
<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 (
|
|
<SortableItem key={category.id} id={category.id}>
|
|
{({ listeners, attributes }) => (
|
|
<div
|
|
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">
|
|
<button
|
|
className="cursor-grab active:cursor-grabbing p-1 text-gray-400 hover:text-gray-600 touch-none flex-shrink-0"
|
|
{...listeners}
|
|
{...attributes}
|
|
>
|
|
<GripVertical className="w-5 h-5" />
|
|
</button>
|
|
<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 && showEditCategory !== category.id && (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handleOpenEditCategory(category)}
|
|
className="p-2 bg-gray-100 hover:bg-gray-200 border border-gray-300 text-gray-500 hover:text-gray-700 rounded-xl transition-all duration-300 shadow-sm"
|
|
title={t('category.editTitle')}
|
|
>
|
|
<Settings className="w-4 h-4" />
|
|
</button>
|
|
<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>
|
|
|
|
<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="grid grid-cols-2 gap-2 mb-2">
|
|
<button
|
|
onClick={() => handleResetLimit(category.id)}
|
|
className="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 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={() => handleShowArchive(category.id)}
|
|
className="flex items-center justify-center gap-1.5 px-3 py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
|
|
>
|
|
<Archive className="w-4 h-4" />
|
|
<span>{t('category.archive')}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteCategory(category.id)}
|
|
className="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>
|
|
|
|
{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>
|
|
)}
|
|
|
|
{showEditCategory === 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('category.editTitle')}
|
|
</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
{t('category.categoryName')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={editCategoryName}
|
|
onChange={(e) => setEditCategoryName(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 font-medium"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
{t('category.categoryLimit')}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={editCategoryLimit}
|
|
onChange={(e) => setEditCategoryLimit(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 className="flex gap-3">
|
|
<button
|
|
onClick={() => handleUpdateCategory(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"
|
|
>
|
|
<Check className="w-5 h-5" />
|
|
{t('common.save')}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowEditCategory(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>
|
|
)}
|
|
|
|
{showHistory === category.id && historyData && (
|
|
<div className="mt-4 glass-effect 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" />
|
|
{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>
|
|
|
|
{historyData.months.length === 0 ? (
|
|
<p className="text-center text-gray-500 py-4">{t('expense.noExpenses')}</p>
|
|
) : (
|
|
<div className="space-y-4 max-h-96 overflow-y-auto">
|
|
{historyData.months.map((monthGroup) => (
|
|
<div key={`${monthGroup.year}-${monthGroup.month}`} className="border-l-4 border-blue-500 pl-4">
|
|
<div className="flex justify-between items-center mb-3">
|
|
<h4 className="font-bold text-gray-900">
|
|
{getMonthName(monthGroup.month)} {monthGroup.year}
|
|
</h4>
|
|
<span className="text-lg font-semibold text-blue-600">
|
|
{parseFloat(monthGroup.total_amount.toString()).toFixed(2)} ₽
|
|
</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{monthGroup.expenses.map((expense) => (
|
|
<div
|
|
key={expense.id}
|
|
className="expense-history-item p-3 rounded-xl shadow-sm border bg-white"
|
|
>
|
|
<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>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{showArchive === category.id && archiveData && (
|
|
<div className="mt-4 glass-effect p-4 rounded-2xl border-2 border-purple-200">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-semibold text-gray-800 text-lg flex items-center gap-2">
|
|
<Archive className="w-5 h-5" />
|
|
{t('expense.archiveTitle')}
|
|
</h3>
|
|
<button
|
|
onClick={() => setShowArchive(null)}
|
|
className="p-2 hover:bg-white/50 rounded-xl transition-all"
|
|
>
|
|
<X className="w-5 h-5 text-gray-600" />
|
|
</button>
|
|
</div>
|
|
|
|
{archiveData.months.length === 0 ? (
|
|
<p className="text-center text-gray-500 py-4">{t('expense.noArchive')}</p>
|
|
) : (
|
|
<div className="space-y-4 max-h-96 overflow-y-auto">
|
|
{archiveData.months.map((monthGroup) => (
|
|
<div key={`archive-${monthGroup.year}-${monthGroup.month}`} className="border-l-4 border-purple-500 pl-4">
|
|
<div className="flex justify-between items-center mb-3">
|
|
<h4 className="font-bold text-gray-700">
|
|
{getMonthName(monthGroup.month)} {monthGroup.year}
|
|
</h4>
|
|
<span className="text-lg font-semibold text-purple-600">
|
|
{parseFloat(monthGroup.total_amount.toString()).toFixed(2)} ₽
|
|
</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{monthGroup.expenses.map((expense) => (
|
|
<div
|
|
key={expense.id}
|
|
className="expense-history-item p-3 rounded-xl shadow-sm border bg-gray-50 opacity-75"
|
|
>
|
|
<div className="flex justify-between items-start mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="p-1.5 bg-gray-200 rounded-lg">
|
|
<TrendingDown className="w-4 h-4 text-gray-500" />
|
|
</div>
|
|
<span className="font-bold text-gray-600 text-lg line-through">
|
|
{parseFloat(expense.amount.toString()).toFixed(2)} ₽
|
|
</span>
|
|
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded-full">
|
|
{t('expense.archived')}
|
|
</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-500 expense-description p-2 rounded-lg">
|
|
<MessageSquare className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
|
<span className="line-through">{expense.description}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
)}
|
|
</SortableItem>
|
|
);
|
|
})}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
|
|
<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 && 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="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>
|
|
|
|
{!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>
|
|
);
|
|
}
|