@@ -230,428 +57,51 @@ 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 (
-
-
-
-
-
-
-
-
-
-
- {selectedFamily?.name || t('family.defaultName')}
-
-
-
-
-
{t('family.totalLimit')}
-
- {getTotalLimit().toFixed(2)} ₽
-
-
-
-
{t('family.totalRemaining')}
-
- {getTotalRemaining().toFixed(2)} ₽
-
-
-
-
-
-
-
+
setShowInviteModal(true)}
+ onProfile={() => navigate('/profile')}
+ />
- {error && (
-
- )}
+ setShowShoppingList(true)}
+ />
-
- {categories.map((category) => {
- const remaining = remainingLimits.get(category.id) || 0;
- const limit = parseFloat(category.limit_amount.toString());
- const percentage = getProgressPercentage(remaining, limit);
+
- return (
-
-
-
-
-
-
-
- {category.name}
-
-
-
- {showAddExpense !== category.id && (
-
- )}
-
-
-
-
- {t('category.remaining')}
-
- {remaining.toFixed(2)} ₽
-
-
-
- {t('category.limit')}
- {limit.toFixed(2)} ₽
-
-
-
-
- {percentage.toFixed(0)}{t('category.percentRemaining')}
-
-
-
-
-
-
-
-
-
- {showHistory === category.id && (
-
-
-
-
- {t('expense.historyTitle')}
-
-
-
-
- {categoryExpenses.length === 0 ? (
-
{t('expense.noExpenses')}
- ) : (
-
- {categoryExpenses.map((expense) => (
-
-
-
-
-
-
-
- {parseFloat(expense.amount.toString()).toFixed(2)} ₽
-
-
-
-
- {formatDate(expense.created_at)}
-
-
- {expense.description && (
-
-
- {expense.description}
-
- )}
-
- ))}
-
- )}
-
- )}
-
- {showAddExpense === category.id && (
-
-
- {t('expense.addTitle')}
-
-
-
-
- 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"
- />
-
-
-
- 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"
- />
-
-
-
-
-
-
-
- )}
-
- );
- })}
-
-
-
-
-
-
-
-
- {t('category.management')}
-
-
-
- {showAddCategory ? (
-
-
- {t('category.newCategory')}
-
-
-
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"
- />
-
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"
- />
-
-
-
-
-
-
- ) : (
-
- )}
-
+ setShowAddCategory(!showAddCategory)}
+ onCreate={createCategory}
+ />
- {showShoppingList && familyId && (
+ {showShoppingList && (
setShowShoppingList(false)}
/>
)}
- {showInviteModal && (
-
-
-
-
-
-
-
-
{t('invite.title')}
-
-
-
+ {showInviteModal &&
setShowInviteModal(false)} />}
- {!inviteLink ? (
-
-
- {t('invite.description')}
-
-
-
- ) : (
-
-
- {t('invite.sendLink')}
-
-
-
- {inviteLink.invite_url}
-
-
-
-
- )}
-
-
- )}
+
);
}
diff --git a/frontend/src/pages/Profile.old.tsx b/frontend/src/pages/Profile.old.tsx
new file mode 100644
index 0000000..414d76f
--- /dev/null
+++ b/frontend/src/pages/Profile.old.tsx
@@ -0,0 +1,375 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { familyApi, userApi, authApi } from '../api/client';
+import { useStore } from '../store/useStore';
+import type { Theme } from '../types';
+import {
+ User as UserIcon,
+ Users,
+ Settings,
+ AlertTriangle,
+ ArrowLeft,
+ Loader2,
+ Check,
+ Palette,
+ Languages,
+ LogOut,
+ Edit3,
+ Save,
+ X,
+} from 'lucide-react';
+
+const THEMES: { id: Theme; gradient: string }[] = [
+ { id: 'light', gradient: 'bg-gradient-to-r from-gray-100 to-gray-200' },
+ { id: 'dark', gradient: 'bg-gradient-to-r from-black to-gray-900' },
+ { id: 'sunset', gradient: 'bg-gradient-to-r from-orange-400 to-pink-500' },
+ { id: 'ocean', gradient: 'bg-gradient-to-r from-blue-400 to-cyan-500' },
+ { id: 'forest', gradient: 'bg-gradient-to-r from-green-400 to-teal-500' },
+ { id: 'purple', gradient: 'bg-gradient-to-r from-purple-500 to-pink-500' },
+];
+
+export default function Profile() {
+ const { t, i18n } = useTranslation();
+ const navigate = useNavigate();
+ const { user, selectedFamily, setSelectedFamily, setUser, preferences, setPreferences, familyMembers, setFamilyMembers } = useStore();
+
+ const [membersLoading, setMembersLoading] = useState(false);
+ const [leavingFamily, setLeavingFamily] = useState(false);
+ const [editingName, setEditingName] = useState(false);
+ const [newFamilyName, setNewFamilyName] = useState('');
+ const [savingName, setSavingName] = useState(false);
+
+ useEffect(() => {
+ if (user?.family_id) {
+ loadFamily();
+ }
+ }, [user?.family_id]);
+
+ useEffect(() => {
+ if (user?.family_id && selectedFamily) {
+ loadMembers();
+ }
+ }, [user?.family_id, selectedFamily]);
+
+ const loadFamily = async () => {
+ if (!user?.family_id) return;
+ try {
+ const response = await familyApi.getById(user.family_id);
+ setSelectedFamily(response.data);
+ } catch (err) {
+ console.error('Error loading family:', err);
+ }
+ };
+
+ const loadMembers = async () => {
+ if (!user?.family_id) return;
+ try {
+ setMembersLoading(true);
+ const response = await familyApi.getMembers(user.family_id);
+ console.log('Loaded members:', response.data);
+ setFamilyMembers(response.data);
+ } catch (err) {
+ console.error('Error loading members:', err);
+ } finally {
+ setMembersLoading(false);
+ }
+ };
+
+ const handleLeaveFamily = async () => {
+ if (!confirm(t('profile.leaveConfirm'))) return;
+
+ try {
+ setLeavingFamily(true);
+ await userApi.leaveFamily();
+
+ const meResponse = await authApi.me();
+ setUser(meResponse.data);
+ setSelectedFamily(null);
+ setFamilyMembers([]);
+
+ navigate('/');
+ } catch (err) {
+ console.error('Error leaving family:', err);
+ alert(t('profile.leaveError'));
+ } finally {
+ setLeavingFamily(false);
+ }
+ };
+
+ const handleThemeChange = (theme: Theme) => {
+ setPreferences({ theme });
+ document.documentElement.setAttribute('data-theme', theme);
+ };
+
+ const handleLocaleChange = (locale: 'ru' | 'en') => {
+ setPreferences({ locale });
+ i18n.changeLanguage(locale);
+ };
+
+ const handleStartEditName = () => {
+ setNewFamilyName(selectedFamily?.name || '');
+ setEditingName(true);
+ };
+
+ const handleSaveName = async () => {
+ if (!selectedFamily || !newFamilyName.trim()) return;
+
+ try {
+ setSavingName(true);
+ const response = await familyApi.update(selectedFamily.id, { name: newFamilyName.trim() });
+ setSelectedFamily(response.data);
+ setEditingName(false);
+ } catch (err) {
+ console.error('Error updating family name:', err);
+ alert(t('profile.renameError'));
+ } finally {
+ setSavingName(false);
+ }
+ };
+
+ const handleBack = () => {
+ if (user?.family_id) {
+ navigate(`/family/${user.family_id}`);
+ } else {
+ navigate('/');
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
{t('profile.title')}
+
+
+
+
+
+
+
+
+
{t('profile.info')}
+
+
+
+ {t('profile.username')}
+ {user?.username || '-'}
+
+
+ {t('profile.email')}
+ {user?.email || '-'}
+
+
+
+
+ {selectedFamily && (
+
+
+
+
+
+
{t('profile.family')}
+
+
+
+
+
{t('profile.familyName')}
+ {editingName ? (
+
+ setNewFamilyName(e.target.value)}
+ className="px-3 py-1 border border-gray-300 rounded-lg focus:border-purple-500 focus:ring-1 focus:ring-purple-200"
+ autoFocus
+ />
+
+
+
+ ) : (
+
+ {selectedFamily.name}
+
+
+ )}
+
+
+
+
+
{t('profile.members')}
+ {membersLoading ? (
+
+
+
+ ) : familyMembers.length === 0 ? (
+
+ {t('profile.noMembers') || 'Нет участников'}
+
+ ) : (
+
+ {familyMembers.map((member) => (
+
+
+
+ {(member.username || member.email || '?')[0].toUpperCase()}
+
+
+ {member.username || member.email || t('profile.unknownUser')}
+
+ {member.id === user?.id && (
+
+ {t('profile.you')}
+
+ )}
+
+ {member.is_admin && (
+
+ Admin
+
+ )}
+
+ ))}
+
+ )}
+
+
+ )}
+
+
+
+
+
+
+
{t('profile.settings')}
+
+
+
+
+
+
+
{t('profile.language')}
+
+
+
+
+
+
+
+
+
+
+
{t('profile.theme')}
+
+
+ {THEMES.map((theme) => (
+
+ ))}
+
+
+
+
+
+ {selectedFamily && (
+
+
+
+
{t('profile.dangerZone')}
+
+
+
{t('profile.leaveDescription')}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx
index 414d76f..f84919b 100644
--- a/frontend/src/pages/Profile.tsx
+++ b/frontend/src/pages/Profile.tsx
@@ -1,44 +1,28 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { familyApi, userApi, authApi } from '../api/client';
+import { User as UserIcon } from 'lucide-react';
+import { familyApi, authApi } from '../api/client';
import { useStore } from '../store/useStore';
-import type { Theme } from '../types';
-import {
- User as UserIcon,
- Users,
- Settings,
- AlertTriangle,
- ArrowLeft,
- Loader2,
- Check,
- Palette,
- Languages,
- LogOut,
- Edit3,
- Save,
- X,
-} from 'lucide-react';
-
-const THEMES: { id: Theme; gradient: string }[] = [
- { id: 'light', gradient: 'bg-gradient-to-r from-gray-100 to-gray-200' },
- { id: 'dark', gradient: 'bg-gradient-to-r from-black to-gray-900' },
- { id: 'sunset', gradient: 'bg-gradient-to-r from-orange-400 to-pink-500' },
- { id: 'ocean', gradient: 'bg-gradient-to-r from-blue-400 to-cyan-500' },
- { id: 'forest', gradient: 'bg-gradient-to-r from-green-400 to-teal-500' },
- { id: 'purple', gradient: 'bg-gradient-to-r from-purple-500 to-pink-500' },
-];
+import { useFamilyMembers, useConfirm } from '../hooks';
+import { Theme } from '../types';
+import { ProfileHeader } from '../components/profile/ProfileHeader';
+import { UserInfo } from '../components/profile/UserInfo';
+import { FamilySection } from '../components/profile/FamilySection';
+import { MembersSection } from '../components/profile/MembersSection';
+import { SettingsSection } from '../components/profile/SettingsSection';
+import { ConfirmModal, Card } from '../components/ui';
+import { showToast } from '../utils/toast';
+import { showErrorToast } from '../utils/errorHandler';
export default function Profile() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
- const { user, selectedFamily, setSelectedFamily, setUser, preferences, setPreferences, familyMembers, setFamilyMembers } = useStore();
+ const { user, selectedFamily, setSelectedFamily, setUser, preferences, setPreferences } = useStore();
+ const { members, loading: membersLoading, loadMembers } = useFamilyMembers(user?.family_id || null);
+ const { confirmState, confirm, cancel } = useConfirm();
- const [membersLoading, setMembersLoading] = useState(false);
const [leavingFamily, setLeavingFamily] = useState(false);
- const [editingName, setEditingName] = useState(false);
- const [newFamilyName, setNewFamilyName] = useState('');
- const [savingName, setSavingName] = useState(false);
useEffect(() => {
if (user?.family_id) {
@@ -46,12 +30,6 @@ export default function Profile() {
}
}, [user?.family_id]);
- useEffect(() => {
- if (user?.family_id && selectedFamily) {
- loadMembers();
- }
- }, [user?.family_id, selectedFamily]);
-
const loadFamily = async () => {
if (!user?.family_id) return;
try {
@@ -62,90 +40,53 @@ export default function Profile() {
}
};
- const loadMembers = async () => {
- if (!user?.family_id) return;
- try {
- setMembersLoading(true);
- const response = await familyApi.getMembers(user.family_id);
- console.log('Loaded members:', response.data);
- setFamilyMembers(response.data);
- } catch (err) {
- console.error('Error loading members:', err);
- } finally {
- setMembersLoading(false);
- }
- };
-
const handleLeaveFamily = async () => {
- if (!confirm(t('profile.leaveConfirm'))) return;
+ await confirm(t('profile.leaveConfirm'), t('profile.leaveMessage'));
try {
setLeavingFamily(true);
+ const { userApi } = await import('../api/client');
await userApi.leaveFamily();
const meResponse = await authApi.me();
setUser(meResponse.data);
setSelectedFamily(null);
- setFamilyMembers([]);
+ showToast.success(t('profile.leftFamily'));
navigate('/');
- } catch (err) {
- console.error('Error leaving family:', err);
- alert(t('profile.leaveError'));
+ } catch (error) {
+ showErrorToast(error);
} finally {
setLeavingFamily(false);
}
};
- const handleThemeChange = (theme: Theme) => {
- setPreferences({ theme });
- document.documentElement.setAttribute('data-theme', theme);
- };
-
- const handleLocaleChange = (locale: 'ru' | 'en') => {
- setPreferences({ locale });
- i18n.changeLanguage(locale);
- };
-
- const handleStartEditName = () => {
- setNewFamilyName(selectedFamily?.name || '');
- setEditingName(true);
- };
-
- const handleSaveName = async () => {
- if (!selectedFamily || !newFamilyName.trim()) return;
-
+ const handleLogout = async () => {
try {
- setSavingName(true);
- const response = await familyApi.update(selectedFamily.id, { name: newFamilyName.trim() });
- setSelectedFamily(response.data);
- setEditingName(false);
- } catch (err) {
- console.error('Error updating family name:', err);
- alert(t('profile.renameError'));
- } finally {
- setSavingName(false);
+ await authApi.logout();
+ setUser(null);
+ setSelectedFamily(null);
+ navigate('/login');
+ } catch (error) {
+ showErrorToast(error);
}
};
- const handleBack = () => {
- if (user?.family_id) {
- navigate(`/family/${user.family_id}`);
- } else {
- navigate('/');
- }
+ const handleThemeChange = (theme: Theme) => {
+ setPreferences({ ...preferences, theme });
+ showToast.success(t('profile.themeChanged'));
+ };
+
+ const handleLocaleChange = (locale: string) => {
+ i18n.changeLanguage(locale);
+ setPreferences({ ...preferences, locale: locale as 'ru' | 'en' });
+ showToast.success(t('profile.languageChanged'));
};
return (
-
-
+
+
navigate(user?.family_id ? `/family/${user.family_id}` : '/')} />
@@ -155,221 +96,63 @@ export default function Profile() {
-
+
-
+
-
{t('profile.info')}
+
{t('profile.info')}
-
-
{t('profile.username')}
-
{user?.username || '-'}
+
+ {t('profile.username')}
+
+ {user?.username || '-'}
+
-
-
{t('profile.email')}
-
{user?.email || '-'}
+
+ {t('profile.email')}
+
+ {user?.email || '-'}
+
-
+
{selectedFamily && (
-
-
-
-
-
-
{t('profile.family')}
+
+
+
+
-
-
-
-
{t('profile.familyName')}
- {editingName ? (
-
- setNewFamilyName(e.target.value)}
- className="px-3 py-1 border border-gray-300 rounded-lg focus:border-purple-500 focus:ring-1 focus:ring-purple-200"
- autoFocus
- />
-
-
-
- ) : (
-
- {selectedFamily.name}
-
-
- )}
-
-
-
-
-
{t('profile.members')}
- {membersLoading ? (
-
-
-
- ) : familyMembers.length === 0 ? (
-
- {t('profile.noMembers') || 'Нет участников'}
-
- ) : (
-
- {familyMembers.map((member) => (
-
-
-
- {(member.username || member.email || '?')[0].toUpperCase()}
-
-
- {member.username || member.email || t('profile.unknownUser')}
-
- {member.id === user?.id && (
-
- {t('profile.you')}
-
- )}
-
- {member.is_admin && (
-
- Admin
-
- )}
-
- ))}
-
- )}
-
-
+
)}
-
-
-
-
-
-
{t('profile.settings')}
-
-
-
-
-
-
-
{t('profile.language')}
-
-
-
-
-
-
-
-
-
-
-
{t('profile.theme')}
-
-
- {THEMES.map((theme) => (
-
- ))}
-
-
-
-
-
- {selectedFamily && (
-
-
-
-
{t('profile.dangerZone')}
-
-
-
{t('profile.leaveDescription')}
-
-
-
- )}
+
+
+
);
}
diff --git a/frontend/src/services/categoryService.ts b/frontend/src/services/categoryService.ts
new file mode 100644
index 0000000..86aaf25
--- /dev/null
+++ b/frontend/src/services/categoryService.ts
@@ -0,0 +1,102 @@
+import { categoryApi, expenseApi } from '../api/client';
+import { Category, CreateCategoryRequest, RemainingLimit } from '../types';
+import { handleApiError } from '../utils/errorHandler';
+
+export interface CategoryWithRemaining extends Category {
+ remaining_limit: number;
+}
+
+export const categoryService = {
+ async getAllByFamily(familyId: number): Promise {
+ try {
+ const categoriesRes = await categoryApi.getAllByFamily(familyId);
+ const categories = categoriesRes.data;
+
+ const categoriesWithRemaining = await Promise.all(
+ categories.map(async (category) => {
+ try {
+ const remainingRes = await expenseApi.getRemainingLimit(familyId, category.id);
+ return {
+ ...category,
+ remaining_limit: Number(remainingRes.data.remaining_limit),
+ };
+ } catch {
+ return {
+ ...category,
+ remaining_limit: Number(category.limit_amount),
+ };
+ }
+ })
+ );
+
+ return categoriesWithRemaining;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async getById(familyId: number, categoryId: number): Promise {
+ try {
+ const [categoryRes, remainingRes] = await Promise.all([
+ categoryApi.getById(familyId, categoryId),
+ expenseApi.getRemainingLimit(familyId, categoryId),
+ ]);
+
+ return {
+ ...categoryRes.data,
+ remaining_limit: Number(remainingRes.data.remaining_limit),
+ };
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async create(familyId: number, data: CreateCategoryRequest): Promise {
+ try {
+ const res = await categoryApi.create(familyId, data);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async update(familyId: number, categoryId: number, data: Partial): Promise {
+ try {
+ const res = await categoryApi.update(familyId, categoryId, data);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async delete(familyId: number, categoryId: number): Promise {
+ try {
+ await categoryApi.delete(familyId, categoryId);
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async resetLimit(familyId: number, categoryId: number, newLimit: number): Promise {
+ try {
+ const res = await categoryApi.resetLimit(familyId, categoryId, newLimit);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ calculateProgress(limitAmount: number | string, remainingLimit: number): number {
+ const limit = Number(limitAmount);
+ const remaining = Number(remainingLimit);
+ if (limit === 0) return 0;
+ const spent = limit - remaining;
+ return Math.min(100, Math.max(0, (spent / limit) * 100));
+ },
+
+ getProgressColor(progress: number): string {
+ if (progress >= 90) return 'danger';
+ if (progress >= 70) return 'warning';
+ return 'success';
+ },
+};
diff --git a/frontend/src/services/expenseService.ts b/frontend/src/services/expenseService.ts
new file mode 100644
index 0000000..355ad14
--- /dev/null
+++ b/frontend/src/services/expenseService.ts
@@ -0,0 +1,71 @@
+import { expenseApi } from '../api/client';
+import { Expense, CreateExpenseRequest } from '../types';
+import { handleApiError } from '../utils/errorHandler';
+
+export const expenseService = {
+ async getAllByCategory(familyId: number, categoryId: number): Promise {
+ try {
+ const res = await expenseApi.getAllByCategory(familyId, categoryId);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async getById(familyId: number, categoryId: number, expenseId: number): Promise {
+ try {
+ const res = await expenseApi.getById(familyId, categoryId, expenseId);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async create(familyId: number, categoryId: number, data: CreateExpenseRequest): Promise {
+ try {
+ const res = await expenseApi.create(familyId, categoryId, data);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async update(familyId: number, categoryId: number, expenseId: number, data: Partial): Promise {
+ try {
+ const res = await expenseApi.update(familyId, categoryId, expenseId, data);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async delete(familyId: number, categoryId: number, expenseId: number): Promise {
+ try {
+ await expenseApi.delete(familyId, categoryId, expenseId);
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ formatAmount(amount: number | string): string {
+ const num = typeof amount === 'string' ? parseFloat(amount) : amount;
+ return new Intl.NumberFormat('ru-RU', {
+ style: 'currency',
+ currency: 'RUB',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(num);
+ },
+
+ sortByDate(expenses: Expense[], order: 'asc' | 'desc' = 'desc'): Expense[] {
+ return [...expenses].sort((a, b) => {
+ const dateA = new Date(a.created_at).getTime();
+ const dateB = new Date(b.created_at).getTime();
+ return order === 'desc' ? dateB - dateA : dateA - dateB;
+ });
+ },
+
+ getTotalAmount(expenses: Expense[]): number {
+ return expenses.reduce((sum, expense) => sum + Number(expense.amount), 0);
+ },
+};
diff --git a/frontend/src/services/familyService.ts b/frontend/src/services/familyService.ts
new file mode 100644
index 0000000..31e24ad
--- /dev/null
+++ b/frontend/src/services/familyService.ts
@@ -0,0 +1,84 @@
+import { familyApi } from '../api/client';
+import { Family, CreateFamilyRequest, CreateMyFamilyRequest, CreateMyFamilyResponse, VerifyFamilyPasswordRequest, FamilyMember } from '../types';
+import { handleApiError } from '../utils/errorHandler';
+
+export const familyService = {
+ async getAll(): Promise {
+ try {
+ const res = await familyApi.getAll();
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async getById(id: number): Promise {
+ try {
+ const res = await familyApi.getById(id);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async create(data: CreateFamilyRequest): Promise {
+ try {
+ const res = await familyApi.create(data);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async createMyFamily(data: CreateMyFamilyRequest): Promise {
+ try {
+ const res = await familyApi.createMyFamily(data);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async update(id: number, data: { name: string }): Promise {
+ try {
+ const res = await familyApi.update(id, data);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async delete(id: number): Promise {
+ try {
+ await familyApi.delete(id);
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async verifyPassword(id: number, data: VerifyFamilyPasswordRequest): Promise {
+ try {
+ const res = await familyApi.verifyPassword(id, data);
+ return res.data.valid;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async getMembers(familyId: number): Promise {
+ try {
+ const res = await familyApi.getMembers(familyId);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ formatMemberName(member: FamilyMember): string {
+ return member.username || member.email || 'Unknown User';
+ },
+
+ countAdmins(members: FamilyMember[]): number {
+ return members.filter((m) => m.is_admin).length;
+ },
+};
diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts
new file mode 100644
index 0000000..b236c1f
--- /dev/null
+++ b/frontend/src/services/index.ts
@@ -0,0 +1,7 @@
+export { categoryService } from './categoryService';
+export { expenseService } from './expenseService';
+export { familyService } from './familyService';
+export { shoppingService } from './shoppingService';
+export { inviteService } from './inviteService';
+
+export type { CategoryWithRemaining } from './categoryService';
diff --git a/frontend/src/services/inviteService.ts b/frontend/src/services/inviteService.ts
new file mode 100644
index 0000000..54def6a
--- /dev/null
+++ b/frontend/src/services/inviteService.ts
@@ -0,0 +1,75 @@
+import { inviteLinkApi } from '../api/client';
+import { CreateInviteLinkRequest, InviteLinkResponse, ValidateInviteResponse, JoinFamilyResponse } from '../types';
+import { handleApiError } from '../utils/errorHandler';
+
+export const inviteService = {
+ async create(data: CreateInviteLinkRequest): Promise {
+ try {
+ const res = await inviteLinkApi.create(data);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async getMyLinks(): Promise {
+ try {
+ const res = await inviteLinkApi.getMyLinks();
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async delete(token: string): Promise {
+ try {
+ await inviteLinkApi.delete(token);
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async validate(token: string): Promise {
+ try {
+ const res = await inviteLinkApi.validate(token);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async join(token: string): Promise {
+ try {
+ const res = await inviteLinkApi.join(token);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ isExpired(expiresAt: string | null): boolean {
+ if (!expiresAt) return false;
+ return new Date(expiresAt) < new Date();
+ },
+
+ isMaxUsesReached(link: InviteLinkResponse): boolean {
+ if (link.max_uses === null) return false;
+ return link.uses_count >= link.max_uses;
+ },
+
+ isActive(link: InviteLinkResponse): boolean {
+ return !this.isExpired(link.expires_at) && !this.isMaxUsesReached(link);
+ },
+
+ formatExpiresAt(expiresAt: string | null): string {
+ if (!expiresAt) return 'Never';
+ const date = new Date(expiresAt);
+ return date.toLocaleString('ru-RU', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ },
+};
diff --git a/frontend/src/services/shoppingService.ts b/frontend/src/services/shoppingService.ts
new file mode 100644
index 0000000..e7e8790
--- /dev/null
+++ b/frontend/src/services/shoppingService.ts
@@ -0,0 +1,91 @@
+import { shoppingItemApi } from '../api/client';
+import { ShoppingItem, CreateShoppingItemRequest, UpdateShoppingItemRequest, MarkAsPurchasedRequest } from '../types';
+import { handleApiError } from '../utils/errorHandler';
+
+export const shoppingService = {
+ async getAllByFamily(familyId: number): Promise {
+ try {
+ const res = await shoppingItemApi.getAllByFamily(familyId);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async getById(familyId: number, itemId: number): Promise {
+ try {
+ const res = await shoppingItemApi.getById(familyId, itemId);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async create(familyId: number, data: CreateShoppingItemRequest): Promise {
+ try {
+ const res = await shoppingItemApi.create(familyId, data);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async update(familyId: number, itemId: number, data: UpdateShoppingItemRequest): Promise {
+ try {
+ const res = await shoppingItemApi.update(familyId, itemId, data);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async delete(familyId: number, itemId: number): Promise {
+ try {
+ await shoppingItemApi.delete(familyId, itemId);
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async markAsPurchased(familyId: number, itemId: number, isPurchased: boolean): Promise {
+ try {
+ const data: MarkAsPurchasedRequest = { is_purchased: isPurchased };
+ const res = await shoppingItemApi.markAsPurchased(familyId, itemId, data);
+ return res.data;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async markAllAsPurchased(familyId: number): Promise {
+ try {
+ const res = await shoppingItemApi.markAllAsPurchased(familyId);
+ return res.data.affected_rows;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ async clearAll(familyId: number): Promise {
+ try {
+ const res = await shoppingItemApi.clearAll(familyId);
+ return res.data.affected_rows;
+ } catch (error) {
+ handleApiError(error);
+ }
+ },
+
+ sortItems(items: ShoppingItem[]): { pending: ShoppingItem[]; purchased: ShoppingItem[] } {
+ const pending = items.filter((item) => !item.is_purchased);
+ const purchased = items.filter((item) => item.is_purchased);
+ return { pending, purchased };
+ },
+
+ getStats(items: ShoppingItem[]): { total: number; purchased: number; pending: number; progress: number } {
+ const total = items.length;
+ const purchased = items.filter((item) => item.is_purchased).length;
+ const pending = total - purchased;
+ const progress = total > 0 ? (purchased / total) * 100 : 0;
+ return { total, purchased, pending, progress };
+ },
+};
diff --git a/frontend/src/store/useStore.ts b/frontend/src/store/useStore.ts
index 903cf1f..b2f34ce 100644
--- a/frontend/src/store/useStore.ts
+++ b/frontend/src/store/useStore.ts
@@ -7,6 +7,18 @@ const getStoredPreferences = () => {
return { theme, locale };
};
+interface CacheEntry {
+ data: T;
+ timestamp: number;
+}
+
+interface CacheState {
+ categories: Map>;
+ members: Map>;
+}
+
+const CACHE_TTL = 5 * 60 * 1000;
+
interface AppState {
user: User | null;
isAuthenticated: boolean;
@@ -16,6 +28,7 @@ interface AppState {
categories: Category[];
familyMembers: FamilyMember[];
preferences: { theme: Theme; locale: Locale };
+ cache: CacheState;
setUser: (user: User | null) => void;
setIsLoading: (loading: boolean) => void;
@@ -25,9 +38,15 @@ interface AppState {
setFamilyMembers: (members: FamilyMember[]) => void;
setPreferences: (prefs: Partial<{ theme: Theme; locale: Locale }>) => void;
logout: () => void;
+
+ getCachedCategories: (familyId: number) => Category[] | null;
+ setCachedCategories: (familyId: number, categories: Category[]) => void;
+ getCachedMembers: (familyId: number) => FamilyMember[] | null;
+ setCachedMembers: (familyId: number, members: FamilyMember[]) => void;
+ clearCache: () => void;
}
-export const useStore = create((set) => ({
+export const useStore = create((set, get) => ({
user: null,
isAuthenticated: false,
isLoading: true,
@@ -36,6 +55,10 @@ export const useStore = create((set) => ({
categories: [],
familyMembers: [],
preferences: getStoredPreferences(),
+ cache: {
+ categories: new Map(),
+ members: new Map(),
+ },
setUser: (user) => set({ user, isAuthenticated: !!user }),
@@ -56,6 +79,59 @@ export const useStore = create((set) => ({
return { preferences: newPrefs };
}),
+ getCachedCategories: (familyId: number) => {
+ const cached = get().cache.categories.get(familyId);
+ if (!cached) return null;
+ if (Date.now() - cached.timestamp > CACHE_TTL) {
+ set((state) => {
+ const newCache = { ...state.cache };
+ newCache.categories.delete(familyId);
+ return { cache: newCache };
+ });
+ return null;
+ }
+ return cached.data;
+ },
+
+ setCachedCategories: (familyId: number, categories: Category[]) => {
+ set((state) => {
+ const newCache = { ...state.cache };
+ newCache.categories.set(familyId, { data: categories, timestamp: Date.now() });
+ return { cache: newCache };
+ });
+ },
+
+ getCachedMembers: (familyId: number) => {
+ const cached = get().cache.members.get(familyId);
+ if (!cached) return null;
+ if (Date.now() - cached.timestamp > CACHE_TTL) {
+ set((state) => {
+ const newCache = { ...state.cache };
+ newCache.members.delete(familyId);
+ return { cache: newCache };
+ });
+ return null;
+ }
+ return cached.data;
+ },
+
+ setCachedMembers: (familyId: number, members: FamilyMember[]) => {
+ set((state) => {
+ const newCache = { ...state.cache };
+ newCache.members.set(familyId, { data: members, timestamp: Date.now() });
+ return { cache: newCache };
+ });
+ },
+
+ clearCache: () => {
+ set((state) => ({
+ cache: {
+ categories: new Map(),
+ members: new Map(),
+ },
+ }));
+ },
+
logout: () => set({
user: null,
isAuthenticated: false,
@@ -63,5 +139,9 @@ export const useStore = create((set) => ({
families: [],
categories: [],
familyMembers: [],
+ cache: {
+ categories: new Map(),
+ members: new Map(),
+ },
}),
}));
diff --git a/frontend/src/types/errors.ts b/frontend/src/types/errors.ts
new file mode 100644
index 0000000..a12a6cc
--- /dev/null
+++ b/frontend/src/types/errors.ts
@@ -0,0 +1,39 @@
+export interface ApiError {
+ message: string;
+ status?: number;
+ code?: string;
+}
+
+export interface ValidationError extends ApiError {
+ field?: string;
+ errors?: Record;
+}
+
+export class AppError extends Error {
+ status?: number;
+ code?: string;
+
+ constructor(message: string, status?: number, code?: string) {
+ super(message);
+ this.name = 'AppError';
+ this.status = status;
+ this.code = code;
+ }
+}
+
+export function isApiError(error: unknown): error is ApiError {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ 'message' in error &&
+ typeof (error as ApiError).message === 'string'
+ );
+}
+
+export function isValidationError(error: unknown): error is ValidationError {
+ return (
+ isApiError(error) &&
+ 'field' in error &&
+ typeof (error as ValidationError).field === 'string'
+ );
+}
diff --git a/frontend/src/utils/errorHandler.ts b/frontend/src/utils/errorHandler.ts
new file mode 100644
index 0000000..ec2acd1
--- /dev/null
+++ b/frontend/src/utils/errorHandler.ts
@@ -0,0 +1,53 @@
+import { AxiosError } from 'axios';
+import { ApiError, AppError } from '../types/errors';
+import { showToast } from './toast';
+
+export function handleApiError(error: unknown): never {
+ if (error instanceof AppError) {
+ throw error;
+ }
+
+ if (error instanceof AxiosError) {
+ const status = error.response?.status;
+ const message = error.response?.data?.message || error.message;
+ const code = error.response?.data?.code;
+
+ throw new AppError(message, status, code);
+ }
+
+ if (error instanceof Error) {
+ throw new AppError(error.message);
+ }
+
+ throw new AppError('Unknown error occurred');
+}
+
+export function showErrorToast(error: unknown): void {
+ let message = 'An unexpected error occurred';
+
+ if (error instanceof AppError) {
+ message = error.message;
+ } else if (error instanceof AxiosError) {
+ message = error.response?.data?.message || error.message;
+ } else if (error instanceof Error) {
+ message = error.message;
+ }
+
+ showToast.error(message);
+}
+
+export function getErrorMessage(error: unknown): string {
+ if (error instanceof AppError) {
+ return error.message;
+ }
+
+ if (error instanceof AxiosError) {
+ return error.response?.data?.message || error.message;
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return 'An unexpected error occurred';
+}
diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts
new file mode 100644
index 0000000..4409a48
--- /dev/null
+++ b/frontend/src/utils/format.ts
@@ -0,0 +1,35 @@
+export const format = {
+ currency(amount: number | string, locale: string = 'ru-RU', currency: string = 'RUB'): string {
+ const num = typeof amount === 'string' ? parseFloat(amount) : amount;
+ return new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency: currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(num);
+ },
+
+ date(dateString: string, locale: string = 'ru-RU'): string {
+ let dateStr = dateString;
+ if (!dateStr.endsWith('Z') && !dateStr.includes('+')) {
+ dateStr = dateStr + 'Z';
+ }
+ const date = new Date(dateStr);
+ return date.toLocaleString(locale, {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ },
+
+ percentage(value: number, decimals: number = 0): string {
+ return `${value.toFixed(decimals)}%`;
+ },
+
+ number(value: number | string, locale: string = 'ru-RU'): string {
+ const num = typeof value === 'string' ? parseFloat(value) : value;
+ return new Intl.NumberFormat(locale).format(num);
+ },
+};
diff --git a/frontend/src/utils/progress.ts b/frontend/src/utils/progress.ts
new file mode 100644
index 0000000..600251e
--- /dev/null
+++ b/frontend/src/utils/progress.ts
@@ -0,0 +1,40 @@
+export const progress = {
+ calculate(current: number, total: number): number {
+ if (total === 0) return 0;
+ return Math.min(100, Math.max(0, (current / total) * 100));
+ },
+
+ calculateRemaining(limit: number, spent: number): number {
+ return Math.max(0, limit - spent);
+ },
+
+ calculatePercentageRemaining(limit: number, remaining: number): number {
+ if (limit === 0) return 0;
+ return Math.min(100, Math.max(0, (remaining / limit) * 100));
+ },
+
+ getColorClass(percentage: number): string {
+ if (percentage >= 90) return 'red';
+ if (percentage >= 70) return 'yellow';
+ if (percentage >= 50) return 'orange';
+ return 'green';
+ },
+
+ getVariantFromPercentage(percentage: number): 'success' | 'warning' | 'danger' {
+ if (percentage >= 90) return 'danger';
+ if (percentage >= 70) return 'warning';
+ return 'success';
+ },
+
+ isLow(percentage: number): boolean {
+ return percentage < 25;
+ },
+
+ isMedium(percentage: number): boolean {
+ return percentage >= 25 && percentage < 75;
+ },
+
+ isHigh(percentage: number): boolean {
+ return percentage >= 75;
+ },
+};
diff --git a/frontend/src/utils/toast.ts b/frontend/src/utils/toast.ts
new file mode 100644
index 0000000..282a5b8
--- /dev/null
+++ b/frontend/src/utils/toast.ts
@@ -0,0 +1,64 @@
+import toast from 'react-hot-toast';
+
+export const showToast = {
+ success: (message: string) => {
+ toast.success(message, {
+ duration: 3000,
+ position: 'top-right',
+ style: {
+ background: 'var(--toast-bg, #10b981)',
+ color: 'var(--toast-text, #ffffff)',
+ },
+ });
+ },
+
+ error: (message: string) => {
+ toast.error(message, {
+ duration: 4000,
+ position: 'top-right',
+ style: {
+ background: 'var(--toast-error-bg, #ef4444)',
+ color: 'var(--toast-text, #ffffff)',
+ },
+ });
+ },
+
+ loading: (message: string) => {
+ return toast.loading(message, {
+ position: 'top-right',
+ style: {
+ background: 'var(--toast-bg, #3b82f6)',
+ color: 'var(--toast-text, #ffffff)',
+ },
+ });
+ },
+
+ dismiss: (toastId?: string) => {
+ toast.dismiss(toastId);
+ },
+
+ promise: (
+ promise: Promise,
+ messages: {
+ loading: string;
+ success: string;
+ error: string;
+ }
+ ) => {
+ return toast.promise(
+ promise,
+ {
+ loading: messages.loading,
+ success: messages.success,
+ error: messages.error,
+ },
+ {
+ position: 'top-right',
+ style: {
+ background: 'var(--toast-bg)',
+ color: 'var(--toast-text)',
+ },
+ }
+ );
+ },
+};
diff --git a/frontend/src/utils/validation.ts b/frontend/src/utils/validation.ts
new file mode 100644
index 0000000..963910a
--- /dev/null
+++ b/frontend/src/utils/validation.ts
@@ -0,0 +1,52 @@
+export const validation = {
+ isValidEmail(email: string): boolean {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return emailRegex.test(email);
+ },
+
+ isValidAmount(amount: string | number): boolean {
+ const num = typeof amount === 'string' ? parseFloat(amount) : amount;
+ return !isNaN(num) && num > 0;
+ },
+
+ isNonEmpty(value: string): boolean {
+ return value.trim().length > 0;
+ },
+
+ minLength(value: string, min: number): boolean {
+ return value.trim().length >= min;
+ },
+
+ maxLength(value: string, max: number): boolean {
+ return value.trim().length <= max;
+ },
+
+ isPositiveNumber(value: string | number): boolean {
+ const num = typeof value === 'string' ? parseFloat(value) : value;
+ return !isNaN(num) && num > 0;
+ },
+
+ isNonNegativeNumber(value: string | number): boolean {
+ const num = typeof value === 'string' ? parseFloat(value) : value;
+ return !isNaN(num) && num >= 0;
+ },
+
+ validateForm>(
+ values: T,
+ rules: Partial string | null>>
+ ): Partial> {
+ const errors: Partial> = {};
+
+ for (const field in rules) {
+ const validator = rules[field];
+ if (validator) {
+ const error = validator(values[field]);
+ if (error) {
+ errors[field] = error;
+ }
+ }
+ }
+
+ return errors;
+ },
+};