@@ -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 (
-
setShowInviteModal(true)}
- onProfile={() => navigate('/profile')}
- />
+
+
+
+
+
+
+
+
+
+
+ {selectedFamily?.name || t('family.defaultName')}
+
+
+
+
+
{t('family.totalLimit')}
+
+ {getTotalLimit().toFixed(2)} ₽
+
+
+
+
{t('family.totalRemaining')}
+
+ {getTotalRemaining().toFixed(2)} ₽
+
+
+
+
+
+
+
- setShowShoppingList(true)}
- />
+ {error && (
+
+ )}
-
+
+ {categories.map((category) => {
+ const remaining = remainingLimits.get(category.id) || 0;
+ const limit = parseFloat(category.limit_amount.toString());
+ const percentage = getProgressPercentage(remaining, limit);
-
setShowAddCategory(!showAddCategory)}
- onCreate={createCategory}
- />
+ 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"
+ />
+
+
+
+
+
+
+ ) : (
+
+ )}
+
- {showShoppingList && (
+ {showShoppingList && familyId && (
setShowShoppingList(false)}
/>
)}
- {showInviteModal && setShowInviteModal(false)} />}
+ {showInviteModal && (
+
+
+
+
+
+
+
+
{t('invite.title')}
+
+
+
-
+ {!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
deleted file mode 100644
index 414d76f..0000000
--- a/frontend/src/pages/Profile.old.tsx
+++ /dev/null
@@ -1,375 +0,0 @@
-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 f84919b..414d76f 100644
--- a/frontend/src/pages/Profile.tsx
+++ b/frontend/src/pages/Profile.tsx
@@ -1,28 +1,44 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { User as UserIcon } from 'lucide-react';
-import { familyApi, authApi } from '../api/client';
+import { familyApi, userApi, authApi } from '../api/client';
import { useStore } from '../store/useStore';
-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';
+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 } = useStore();
- const { members, loading: membersLoading, loadMembers } = useFamilyMembers(user?.family_id || null);
- const { confirmState, confirm, cancel } = useConfirm();
+ 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) {
@@ -30,6 +46,12 @@ 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 {
@@ -40,53 +62,90 @@ 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 () => {
- await confirm(t('profile.leaveConfirm'), t('profile.leaveMessage'));
+ if (!confirm(t('profile.leaveConfirm'))) return;
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 (error) {
- showErrorToast(error);
+ } catch (err) {
+ console.error('Error leaving family:', err);
+ alert(t('profile.leaveError'));
} finally {
setLeavingFamily(false);
}
};
- const handleLogout = async () => {
+ 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 {
- await authApi.logout();
- setUser(null);
- setSelectedFamily(null);
- navigate('/login');
- } catch (error) {
- showErrorToast(error);
+ 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 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'));
+ const handleBack = () => {
+ if (user?.family_id) {
+ navigate(`/family/${user.family_id}`);
+ } else {
+ navigate('/');
+ }
};
return (
-
-
navigate(user?.family_id ? `/family/${user.family_id}` : '/')} />
+
+
@@ -96,63 +155,221 @@ 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
deleted file mode 100644
index 86aaf25..0000000
--- a/frontend/src/services/categoryService.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-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
deleted file mode 100644
index 355ad14..0000000
--- a/frontend/src/services/expenseService.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-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
deleted file mode 100644
index 31e24ad..0000000
--- a/frontend/src/services/familyService.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-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
deleted file mode 100644
index b236c1f..0000000
--- a/frontend/src/services/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-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
deleted file mode 100644
index 54def6a..0000000
--- a/frontend/src/services/inviteService.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-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
deleted file mode 100644
index e7e8790..0000000
--- a/frontend/src/services/shoppingService.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-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 b2f34ce..903cf1f 100644
--- a/frontend/src/store/useStore.ts
+++ b/frontend/src/store/useStore.ts
@@ -7,18 +7,6 @@ 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;
@@ -28,7 +16,6 @@ interface AppState {
categories: Category[];
familyMembers: FamilyMember[];
preferences: { theme: Theme; locale: Locale };
- cache: CacheState;
setUser: (user: User | null) => void;
setIsLoading: (loading: boolean) => void;
@@ -38,15 +25,9 @@ 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, get) => ({
+export const useStore = create((set) => ({
user: null,
isAuthenticated: false,
isLoading: true,
@@ -55,10 +36,6 @@ export const useStore = create((set, get) => ({
categories: [],
familyMembers: [],
preferences: getStoredPreferences(),
- cache: {
- categories: new Map(),
- members: new Map(),
- },
setUser: (user) => set({ user, isAuthenticated: !!user }),
@@ -79,59 +56,6 @@ export const useStore = create((set, get) => ({
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,
@@ -139,9 +63,5 @@ export const useStore = create((set, get) => ({
families: [],
categories: [],
familyMembers: [],
- cache: {
- categories: new Map(),
- members: new Map(),
- },
}),
}));
diff --git a/frontend/src/types/errors.ts b/frontend/src/types/errors.ts
deleted file mode 100644
index a12a6cc..0000000
--- a/frontend/src/types/errors.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-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
deleted file mode 100644
index ec2acd1..0000000
--- a/frontend/src/utils/errorHandler.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-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
deleted file mode 100644
index 4409a48..0000000
--- a/frontend/src/utils/format.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-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
deleted file mode 100644
index 600251e..0000000
--- a/frontend/src/utils/progress.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-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
deleted file mode 100644
index 282a5b8..0000000
--- a/frontend/src/utils/toast.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-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
deleted file mode 100644
index 963910a..0000000
--- a/frontend/src/utils/validation.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-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;
- },
-};