diff --git a/REFACTORING_COMPLETE.md b/REFACTORING_COMPLETE.md deleted file mode 100644 index 7800713..0000000 --- a/REFACTORING_COMPLETE.md +++ /dev/null @@ -1,546 +0,0 @@ -# Frontend Refactoring - COMPLETE ✅ - -## Summary -Successfully completed a comprehensive frontend refactoring following the plan in `CLAUDE.md`. The codebase is now significantly more maintainable, readable, and follows modern React best practices. - ---- - -## ✅ COMPLETED STAGES - -### STAGE 1: Infrastructure ✅ (100%) -**Objective:** Replace alert() with toast notifications, add typed error handling, create reusable UI components - -**Created Files:** -- ✅ `frontend/src/utils/toast.ts` - Toast notification wrapper using react-hot-toast -- ✅ `frontend/src/types/errors.ts` - Typed error interfaces (ApiError, AppError, ValidationError) -- ✅ `frontend/src/utils/errorHandler.ts` - Centralized error handling with showErrorToast -- ✅ `frontend/src/components/ui/Button.tsx` - Reusable button (5 variants, 3 sizes) -- ✅ `frontend/src/components/ui/Input.tsx` - Reusable input with label and error support -- ✅ `frontend/src/components/ui/Card.tsx` - Reusable card with hover effects -- ✅ `frontend/src/components/ui/Modal.tsx` - Reusable modal with backdrop -- ✅ `frontend/src/components/ui/Badge.tsx` - Reusable badge (5 variants, 2 sizes) -- ✅ `frontend/src/components/ui/LoadingSpinner.tsx` - Loading spinner (3 sizes, fullscreen mode) -- ✅ `frontend/src/components/ui/ConfirmModal.tsx` - Confirmation dialog -- ✅ `frontend/src/components/ui/Skeleton.tsx` - Loading skeletons -- ✅ `frontend/src/components/ui/index.ts` - Barrel export - -**Modified Files:** -- ✅ `frontend/src/main.tsx` - Added `` provider -- ✅ `frontend/package.json` - Added react-hot-toast dependency - -**Impact:** -- ❌ **Removed ALL `alert()` calls** (replaced with toast notifications) -- ✅ **Removed ALL `any` types in catch blocks** (replaced with typed errors) -- ✅ **Encapsulated Tailwind classes** into reusable components - ---- - -### STAGE 2: Service Layer ✅ (100%) -**Objective:** Create service layer to encapsulate API calls and business logic - -**Created Files:** -- ✅ `frontend/src/services/categoryService.ts` - - getAllByFamily, getById, create, update, delete, resetLimit - - calculateProgress, getProgressColor - - Returns CategoryWithRemaining type - -- ✅ `frontend/src/services/expenseService.ts` - - getAllByCategory, getById, create, update, delete - - formatAmount, sortByDate, getTotalAmount - -- ✅ `frontend/src/services/familyService.ts` - - getAll, getById, create, createMyFamily, update, delete - - verifyPassword, getMembers - - formatMemberName, countAdmins - -- ✅ `frontend/src/services/shoppingService.ts` - - getAllByFamily, getById, create, update, delete - - markAsPurchased, markAllAsPurchased, clearAll - - sortItems, getStats - -- ✅ `frontend/src/services/inviteService.ts` - - create, getMyLinks, delete, validate, join - - isExpired, isMaxUsesReached, isActive, formatExpiresAt - -- ✅ `frontend/src/services/index.ts` - Barrel export - -**Impact:** -- ✅ Encapsulated all API calls with error handling -- ✅ Added business logic (calculations, transformations, formatting) -- ✅ Type-safe interfaces for all operations -- ✅ Consistent error handling across the app - ---- - -### STAGE 3: Custom Hooks ✅ (100%) -**Objective:** Extract state management logic from components into reusable hooks - -**Created Files:** -- ✅ `frontend/src/hooks/useCategories.ts` - - State: categories, loading, error - - Actions: loadCategories, createCategory, deleteCategory, resetLimit, updateCategory - - Automatic toast notifications on success/error - -- ✅ `frontend/src/hooks/useExpenses.ts` - - State: expenses, loading, error - - Actions: loadExpenses, createExpense, deleteExpense, updateExpense - -- ✅ `frontend/src/hooks/useFamilyMembers.ts` - - State: members, loading, error - - Actions: loadMembers - -- ✅ `frontend/src/hooks/useShoppingList.ts` - - State: items, loading, error - - Actions: loadItems, createItem, deleteItem, togglePurchased, markAllAsPurchased, clearAll - -- ✅ `frontend/src/hooks/useInviteLink.ts` - - State: links, loading, error - - Actions: loadLinks, createLink, deleteLink, validateLink, joinFamily - -- ✅ `frontend/src/hooks/useConfirm.ts` - - State: confirmState (isOpen, title, message, onConfirm) - - Actions: confirm, cancel - -- ✅ `frontend/src/hooks/index.ts` - Barrel export - -**Impact:** -- ✅ Extracted state logic from components -- ✅ Integrated with service layer -- ✅ Automatic toast notifications -- ✅ Consistent loading and error states - ---- - -### STAGE 4: Component Refactoring ✅ (100%) - -#### 4.1 FamilyView.tsx ✅ -**Before:** 657 lines -**After:** ~100 lines -**Reduction:** 85% - -**Created Sub-components:** -- ✅ `frontend/src/components/family/FamilyHeader.tsx` - Header with invite and profile buttons -- ✅ `frontend/src/components/family/FamilySummary.tsx` - Summary card with totals and shopping list button -- ✅ `frontend/src/components/family/CategoryCard.tsx` - Category card with expense form and history -- ✅ `frontend/src/components/family/CategoryList.tsx` - Grid of category cards -- ✅ `frontend/src/components/family/AddCategorySection.tsx` - Collapsible add category form -- ✅ `frontend/src/components/family/InviteModal.tsx` - Modal for creating invite links - -**Backup:** `frontend/src/pages/FamilyView.old.tsx` - -**Improvements:** -- ✅ Replaced 17 useState hooks with custom hooks -- ✅ Removed all inline functions (moved to handlers) -- ✅ Removed all alert() calls (replaced with toast + confirm modal) -- ✅ Removed direct API calls (uses services) -- ✅ Split into 7 focused components - ---- - -#### 4.2 ShoppingListModal.tsx ✅ -**Before:** 375 lines -**After:** ~130 lines -**Reduction:** 65% - -**Created Sub-components:** -- ✅ `frontend/src/components/shopping/ShoppingItemInput.tsx` - Add item input with Enter key support -- ✅ `frontend/src/components/shopping/ShoppingItemCard.tsx` - Item card with inline edit, toggle, delete -- ✅ `frontend/src/components/shopping/ShoppingItemList.tsx` - Sorted list (pending/purchased sections) - -**Backup:** `frontend/src/components/ShoppingListModal.old.tsx` - -**Improvements:** -- ✅ Uses useShoppingList hook -- ✅ Uses useConfirm hook for confirmations -- ✅ Automatic sorting (pending first, then purchased) -- ✅ Stats display with badges -- ✅ Removed all alert() calls - ---- - -#### 4.3 Profile.tsx ✅ -**Before:** 375 lines -**After:** ~140 lines -**Reduction:** 63% - -**Created Sub-components:** -- ✅ `frontend/src/components/profile/ProfileHeader.tsx` - Back button -- ✅ `frontend/src/components/profile/UserInfo.tsx` - User info card with logout -- ✅ `frontend/src/components/profile/FamilySection.tsx` - Family name edit, leave family button -- ✅ `frontend/src/components/profile/MembersSection.tsx` - List of family members with avatars -- ✅ `frontend/src/components/profile/ThemeSelector.tsx` - Grid of theme options -- ✅ `frontend/src/components/profile/LanguageSelector.tsx` - Language buttons -- ✅ `frontend/src/components/profile/SettingsSection.tsx` - Wrapper for theme/language settings - -**Backup:** `frontend/src/pages/Profile.old.tsx` - -**Improvements:** -- ✅ Uses useFamilyMembers hook -- ✅ Uses useConfirm hook -- ✅ Removed all alert() calls -- ✅ Split into 7 focused components -- ✅ Better visual hierarchy - ---- - -### STAGE 5: Utilities ✅ (100%) -**Objective:** Create utility functions for common operations - -**Created Files:** -- ✅ `frontend/src/utils/format.ts` - - currency, date, percentage, number formatting - - Supports locale customization - -- ✅ `frontend/src/utils/validation.ts` - - isValidEmail, isValidAmount, isNonEmpty - - minLength, maxLength, isPositiveNumber, isNonNegativeNumber - - validateForm (generic form validation) - -- ✅ `frontend/src/utils/progress.ts` - - calculate, calculateRemaining, calculatePercentageRemaining - - getColorClass, getVariantFromPercentage - - isLow, isMedium, isHigh - -- ✅ `frontend/src/constants/index.ts` - - THEMES array with gradients - - LANGUAGES array - -**Impact:** -- ✅ Removed duplicated formatting logic (was in 3+ places) -- ✅ Centralized validation logic -- ✅ Consistent progress calculations - ---- - -### STAGE 6: API Optimization ✅ (100%) -**Objective:** Add retry logic, interceptors, and caching - -**Modified Files:** -- ✅ `frontend/src/api/client.ts` - - Added axios-retry with exponential backoff - - Retries network errors and 5xx errors (max 3 retries) - - Added request interceptor for logging - - Added response interceptor for 401 redirect and logging - - Added 30s timeout - -- ✅ `frontend/src/store/useStore.ts` - - Added cache state (categories, members) - - Added getCachedCategories, setCachedCategories - - Added getCachedMembers, setCachedMembers - - Added clearCache - - Cache TTL: 5 minutes - - Cache cleared on logout - -**Dependencies Added:** -- ✅ axios-retry - -**Impact:** -- ✅ Automatic retry on network failures -- ✅ Automatic redirect on 401 Unauthorized -- ✅ Request/response logging for debugging -- ✅ Cache reduces redundant API calls -- ✅ Better user experience on slow networks - ---- - -### STAGE 7: Polish ✅ (100%) -**Objective:** Add loading skeletons, error boundary, and optimizations - -**Created Files:** -- ✅ `frontend/src/components/ui/Skeleton.tsx` - - Generic Skeleton component (text, circular, rectangular) - - CategoryCardSkeleton - - ShoppingItemSkeleton - -- ✅ `frontend/src/components/ErrorBoundary.tsx` - - Catches React errors - - Shows user-friendly error page - - Reload and Go Home buttons - - Shows error details in development mode - -**Modified Files:** -- ✅ `frontend/src/App.tsx` - Wrapped in ErrorBoundary -- ✅ `frontend/src/components/ui/index.ts` - Exported Skeleton components - -**Impact:** -- ✅ Better loading UX (skeletons instead of blank screens) -- ✅ App doesn't crash on errors (ErrorBoundary catches them) -- ✅ User-friendly error messages - ---- - -## 📊 FINAL METRICS - -### Before Refactoring: -| Metric | Value | -|--------|-------| -| FamilyView.tsx | 657 lines | -| ShoppingListModal.tsx | 375 lines | -| Profile.tsx | 375 lines | -| **Total main components** | **1,407 lines** | -| Reusable components | 2 (ConfirmModal, ShoppingListModal) | -| Custom hooks | 0 | -| Service layer | 0 | -| `alert()` usage | ~11 places | -| `any` types in catch | ~10+ places | -| Direct API calls in components | ~50+ calls | -| Duplicated logic | High (formatting, validation, etc.) | - -### After Refactoring: -| Metric | Value | -|--------|-------| -| FamilyView.tsx | ~100 lines (-85%) | -| ShoppingListModal.tsx | ~130 lines (-65%) | -| Profile.tsx | ~140 lines (-63%) | -| **Total main components** | **~370 lines (-74%)** | -| Reusable UI components | 13 (Button, Input, Card, Modal, Badge, Spinner, ConfirmModal, Skeleton, etc.) | -| Sub-components | 20+ (FamilyHeader, CategoryCard, ShoppingItemCard, etc.) | -| Custom hooks | 6 (useCategories, useExpenses, useShoppingList, etc.) | -| Services | 5 (categoryService, expenseService, etc.) | -| Utilities | 3 (format, validation, progress) | -| `alert()` usage | **0** (replaced with toast + confirm modal) | -| `any` types in catch | **0** (all typed with ApiError/AppError) | -| Direct API calls in components | **0** (all through services) | -| Duplicated logic | **Minimal** (centralized in services/utils) | - ---- - -## 🎯 ACHIEVEMENTS - -### Code Quality -- ✅ **Removed 1,037 lines of complex component code** (74% reduction) -- ✅ **Created 46 new focused files** (services, hooks, components, utils) -- ✅ **100% type safety** in error handling (no more `any` types) -- ✅ **Zero alert() calls** (replaced with toast notifications) -- ✅ **Zero direct API calls** in components (all through service layer) -- ✅ **DRY principle** applied (no duplicated logic) - -### Architecture -- ✅ **Clear separation of concerns:** - - UI Components (presentation) - - Custom Hooks (state management) - - Services (business logic) - - Utils (utilities) - - API Client (HTTP layer) -- ✅ **Reusable components** with consistent API -- ✅ **Predictable state management** with custom hooks -- ✅ **Centralized error handling** with typed errors -- ✅ **Centralized caching** in Zustand store - -### User Experience -- ✅ **Better feedback** with toast notifications -- ✅ **Loading skeletons** instead of blank screens -- ✅ **Error boundary** prevents crashes -- ✅ **Automatic retry** on network failures -- ✅ **Confirmation dialogs** for destructive actions -- ✅ **Faster perceived performance** with caching - -### Developer Experience -- ✅ **Easier to understand** (smaller, focused files) -- ✅ **Easier to test** (pure functions in services/utils) -- ✅ **Easier to extend** (reusable hooks and components) -- ✅ **Better debugging** (request/response logging) -- ✅ **Consistent patterns** across the codebase - ---- - -## 📁 FILE STRUCTURE - -``` -frontend/src/ -├── api/ -│ └── client.ts [Enhanced with retry + interceptors] -├── components/ -│ ├── ErrorBoundary.tsx [New] -│ ├── family/ [New directory] -│ │ ├── AddCategorySection.tsx -│ │ ├── CategoryCard.tsx -│ │ ├── CategoryList.tsx -│ │ ├── FamilyHeader.tsx -│ │ ├── FamilySummary.tsx -│ │ └── InviteModal.tsx -│ ├── profile/ [New directory] -│ │ ├── FamilySection.tsx -│ │ ├── LanguageSelector.tsx -│ │ ├── MembersSection.tsx -│ │ ├── ProfileHeader.tsx -│ │ ├── SettingsSection.tsx -│ │ ├── ThemeSelector.tsx -│ │ └── UserInfo.tsx -│ ├── shopping/ [New directory] -│ │ ├── ShoppingItemCard.tsx -│ │ ├── ShoppingItemInput.tsx -│ │ └── ShoppingItemList.tsx -│ └── ui/ [New directory] -│ ├── Badge.tsx -│ ├── Button.tsx -│ ├── Card.tsx -│ ├── ConfirmModal.tsx -│ ├── Input.tsx -│ ├── LoadingSpinner.tsx -│ ├── Modal.tsx -│ ├── Skeleton.tsx -│ └── index.ts -├── constants/ -│ └── index.ts [New] -├── hooks/ [New directory] -│ ├── useCategories.ts -│ ├── useConfirm.ts -│ ├── useExpenses.ts -│ ├── useFamilyMembers.ts -│ ├── useInviteLink.ts -│ ├── useShoppingList.ts -│ └── index.ts -├── pages/ -│ ├── FamilyView.tsx [Refactored: 657 → 100 lines] -│ ├── FamilyView.old.tsx [Backup] -│ ├── Profile.tsx [Refactored: 375 → 140 lines] -│ ├── Profile.old.tsx [Backup] -│ └── ... -├── services/ [New directory] -│ ├── categoryService.ts -│ ├── expenseService.ts -│ ├── familyService.ts -│ ├── inviteService.ts -│ ├── shoppingService.ts -│ └── index.ts -├── store/ -│ └── useStore.ts [Enhanced with caching] -├── types/ -│ ├── errors.ts [New] -│ └── index.ts -└── utils/ [New directory] - ├── errorHandler.ts - ├── format.ts - ├── progress.ts - ├── toast.ts - └── validation.ts -``` - ---- - -## 🧪 TESTING CHECKLIST - -### ✅ TypeScript Compilation -- ✅ No TypeScript errors -- ✅ All imports resolve correctly -- ✅ No `any` types in error handling - -### ⚠️ Manual Testing Required -Before committing, test the following: - -#### FamilyView -- [ ] Create category -- [ ] Delete category -- [ ] Reset category limit -- [ ] Add expense to category -- [ ] View expense history -- [ ] Open shopping list modal -- [ ] Open invite modal -- [ ] Create invite link -- [ ] Copy invite link -- [ ] Navigate to profile - -#### ShoppingList -- [ ] Add item -- [ ] Edit item name -- [ ] Delete item -- [ ] Toggle purchased status -- [ ] Mark all as purchased -- [ ] Clear all items - -#### Profile -- [ ] View user info -- [ ] View family members -- [ ] Edit family name -- [ ] Leave family (with confirmation) -- [ ] Change theme -- [ ] Change language -- [ ] Logout - -#### Error Handling -- [ ] Toast notifications appear on success -- [ ] Toast notifications appear on errors -- [ ] Confirmation dialogs work -- [ ] Loading spinners appear during operations -- [ ] ErrorBoundary catches React errors - -#### Network -- [ ] Retry works on network failure (test by disabling network mid-request) -- [ ] 401 redirects to login -- [ ] Cache reduces redundant requests (check network tab) - ---- - -## 🚀 DEPLOYMENT CHECKLIST - -### Pre-Commit -1. ✅ TypeScript compilation passes -2. ⚠️ Manual testing completed (see above) -3. ⚠️ No console errors in browser -4. ⚠️ No unused imports or variables -5. ⚠️ All TODOs resolved or documented - -### Git Workflow -```bash -# Branch already created: refactor/frontend-code-quality - -# Stage all changes -git add frontend/src/ - -# Commit with descriptive message -git commit -m "Major frontend refactoring: components, hooks, services, polish - -- Reduced main component lines by 74% (1407 → 370 lines) -- Removed all alert() calls (replaced with toast + confirm modal) -- Removed all 'any' types in error handling -- Created 13 reusable UI components -- Created 6 custom hooks for state management -- Created 5 services for business logic -- Created 3 utility modules (format, validation, progress) -- Added axios-retry for automatic retries -- Added ErrorBoundary for crash protection -- Added request/response logging -- Added 5-minute cache for categories/members -- Split FamilyView (657 → 100 lines, -85%) -- Split ShoppingListModal (375 → 130 lines, -65%) -- Split Profile (375 → 140 lines, -63%) - -Co-Authored-By: Claude Sonnet 4.5 " - -# Push to remote -git push origin refactor/frontend-code-quality - -# Create pull request on GitHub -# From: refactor/frontend-code-quality -# To: master -# Title: "Major frontend refactoring: improve readability and maintainability" -``` - -### Post-Merge -1. Update documentation if needed -2. Monitor for any issues -3. Consider adding tests for services/utils - ---- - -## 🎉 CONCLUSION - -The frontend has been successfully refactored according to the plan. The codebase is now: - -- **74% smaller** in main components -- **100% type-safe** in error handling -- **0 alert() calls** (replaced with modern toast notifications) -- **46 new focused files** (vs 3 monolithic files) -- **Highly reusable** with 13 UI components and 6 custom hooks -- **Well-architected** with clear separation of concerns -- **User-friendly** with better loading states and error handling -- **Developer-friendly** with predictable patterns and smaller files - -The refactoring has significantly improved both code quality and user experience without changing any functionality. - ---- - -**All stages completed!** 🎉 - -Ready for review and testing. diff --git a/REFACTORING_PROGRESS.md b/REFACTORING_PROGRESS.md deleted file mode 100644 index 7a0e618..0000000 --- a/REFACTORING_PROGRESS.md +++ /dev/null @@ -1,254 +0,0 @@ -# Frontend Refactoring Progress Report - -## ✅ COMPLETED STAGES - -### STAGE 1: Infrastructure (100% Complete) -**Created Files:** -- ✅ `/frontend/src/utils/toast.ts` - Toast notification wrapper -- ✅ `/frontend/src/types/errors.ts` - Typed error interfaces -- ✅ `/frontend/src/utils/errorHandler.ts` - Centralized error handling -- ✅ `/frontend/src/components/ui/Button.tsx` - Reusable button component -- ✅ `/frontend/src/components/ui/Input.tsx` - Reusable input component -- ✅ `/frontend/src/components/ui/Card.tsx` - Reusable card component -- ✅ `/frontend/src/components/ui/Modal.tsx` - Reusable modal component -- ✅ `/frontend/src/components/ui/Badge.tsx` - Reusable badge component -- ✅ `/frontend/src/components/ui/LoadingSpinner.tsx` - Loading spinner -- ✅ `/frontend/src/components/ui/ConfirmModal.tsx` - Confirmation modal -- ✅ `/frontend/src/components/ui/index.ts` - Barrel export - -**Modified Files:** -- ✅ `/frontend/src/main.tsx` - Added Toaster provider -- ✅ `/frontend/package.json` - Added react-hot-toast dependency - -**Impact:** -- ❌ Removed all `alert()` calls (replaced with toast) -- ✅ Added type-safe error handling (no more `any` in catch blocks) -- ✅ Encapsulated Tailwind classes into reusable components - ---- - -### STAGE 2: Service Layer (100% Complete) -**Created Files:** -- ✅ `/frontend/src/services/categoryService.ts` - Category business logic -- ✅ `/frontend/src/services/expenseService.ts` - Expense business logic -- ✅ `/frontend/src/services/familyService.ts` - Family business logic -- ✅ `/frontend/src/services/shoppingService.ts` - Shopping business logic -- ✅ `/frontend/src/services/inviteService.ts` - Invite link business logic -- ✅ `/frontend/src/services/index.ts` - Barrel export - -**Features:** -- ✅ Encapsulated API calls with error handling -- ✅ Added business logic (calculations, transformations) -- ✅ Type-safe interfaces for all operations -- ✅ Helper methods for formatting, sorting, filtering - ---- - -### STAGE 3: Custom Hooks (100% Complete) -**Created Files:** -- ✅ `/frontend/src/hooks/useCategories.ts` - Category state management -- ✅ `/frontend/src/hooks/useExpenses.ts` - Expense state management -- ✅ `/frontend/src/hooks/useFamilyMembers.ts` - Family members management -- ✅ `/frontend/src/hooks/useShoppingList.ts` - Shopping list management -- ✅ `/frontend/src/hooks/useInviteLink.ts` - Invite link management -- ✅ `/frontend/src/hooks/useConfirm.ts` - Confirmation dialog hook -- ✅ `/frontend/src/hooks/index.ts` - Barrel export - -**Impact:** -- ✅ Extracted state logic from components -- ✅ Integrated with service layer and error handling -- ✅ Automatic toast notifications on CRUD operations - ---- - -### STAGE 4: Component Refactoring (90% Complete) - -#### FamilyView.tsx (✅ COMPLETE) -**Original:** 657 lines -**New:** ~100 lines -**Reduction:** 85% - -**Created Sub-components:** -- ✅ `/frontend/src/components/family/FamilyHeader.tsx` - Header with buttons -- ✅ `/frontend/src/components/family/FamilySummary.tsx` - Summary stats -- ✅ `/frontend/src/components/family/CategoryCard.tsx` - Category card with expense form -- ✅ `/frontend/src/components/family/CategoryList.tsx` - List of categories -- ✅ `/frontend/src/components/family/AddCategorySection.tsx` - Add category form -- ✅ `/frontend/src/components/family/InviteModal.tsx` - Invite modal - -**Backup:** `/frontend/src/pages/FamilyView.old.tsx` (original file) - ---- - -#### ShoppingListModal.tsx (✅ COMPLETE) -**Original:** 375 lines -**New:** ~130 lines -**Reduction:** 65% - -**Created Sub-components:** -- ✅ `/frontend/src/components/shopping/ShoppingItemInput.tsx` - Add item input -- ✅ `/frontend/src/components/shopping/ShoppingItemCard.tsx` - Item card with edit/delete -- ✅ `/frontend/src/components/shopping/ShoppingItemList.tsx` - List with sorting - -**Backup:** `/frontend/src/components/ShoppingListModal.old.tsx` (original file) - ---- - -#### Profile.tsx (⚠️ PARTIAL) -**Original:** 375 lines -**Status:** Sub-components created, main component needs assembly - -**Created Sub-components:** -- ✅ `/frontend/src/components/profile/ProfileHeader.tsx` - Back button header -- ✅ `/frontend/src/components/profile/UserInfo.tsx` - User info with logout -- ✅ `/frontend/src/components/profile/ThemeSelector.tsx` - Theme picker -- ✅ `/frontend/src/components/profile/LanguageSelector.tsx` - Language picker - -**TODO:** -- ⚠️ Create FamilySection.tsx (family name edit, leave family button) -- ⚠️ Create MembersSection.tsx (list of members with admin badges) -- ⚠️ Assemble new Profile.tsx using all sub-components -- ⚠️ Backup old Profile.tsx - ---- - -### STAGE 5: Utilities (50% Complete) -**Created Files:** -- ✅ `/frontend/src/utils/format.ts` - Date, currency, number formatting -- ✅ `/frontend/src/constants/index.ts` - App constants (themes, languages) - -**TODO:** -- ⚠️ `/frontend/src/utils/validation.ts` - Form validation helpers -- ⚠️ `/frontend/src/utils/progress.ts` - Progress calculation helpers - ---- - -### STAGE 6: API Optimization (❌ NOT STARTED) -**TODO:** -- ❌ Add axios-retry to `/frontend/src/api/client.ts` -- ❌ Add request/response interceptors -- ❌ Add 401 redirect handling -- ❌ Improve Zustand store with caching - ---- - -### STAGE 7: Polish (❌ NOT STARTED) -**TODO:** -- ❌ Add loading skeletons -- ❌ Add error boundary component -- ❌ Optimize with React.memo where needed - ---- - -## 📊 METRICS - -### Before Refactoring: -- FamilyView.tsx: 657 lines -- ShoppingListModal.tsx: 375 lines -- Profile.tsx: 375 lines -- **Total:** 1,407 lines -- Reusable components: 2 -- Custom hooks: 0 -- Service layer: 0 -- `alert()` usage: ~11 places -- `any` types in catch: ~10+ places - -### After Refactoring (Current): -- FamilyView.tsx: ~100 lines (-85%) -- ShoppingListModal.tsx: ~130 lines (-65%) -- Profile.tsx: 375 lines (not yet refactored) -- **Total main components:** ~605 lines (-57%) -- Reusable components: 20+ -- Custom hooks: 6 -- Services: 5 -- `alert()` usage: 0 (all replaced with toast) -- `any` types in catch: 0 (all typed) - ---- - -## 🚀 NEXT STEPS - -### 1. Complete Profile.tsx Refactoring (High Priority) -```bash -# Create missing components -touch frontend/src/components/profile/FamilySection.tsx -touch frontend/src/components/profile/MembersSection.tsx - -# Then create new Profile.tsx and backup old one -mv frontend/src/pages/Profile.tsx frontend/src/pages/Profile.old.tsx -``` - -### 2. Implement API Optimizations (Medium Priority) -```bash -cd frontend -npm install axios-retry -``` - -Then add retry logic and interceptors to `client.ts`. - -### 3. Add Polish Features (Low Priority) -- Loading skeletons for better UX -- Error boundary for crash protection -- React.memo for performance - ---- - -## 🧪 TESTING CHECKLIST - -### Manual Testing Required: -- [ ] Test FamilyView: create/delete/reset categories -- [ ] Test FamilyView: add expenses to categories -- [ ] Test FamilyView: view expense history -- [ ] Test FamilyView: invite modal with link creation -- [ ] Test ShoppingList: add/edit/delete items -- [ ] Test ShoppingList: toggle purchased status -- [ ] Test ShoppingList: mark all / clear all -- [ ] Test Profile: change theme -- [ ] Test Profile: change language -- [ ] Test Profile: edit family name (when complete) -- [ ] Test Profile: leave family (when complete) -- [ ] Test toast notifications on all CRUD operations -- [ ] Test error handling (network errors, validation errors) - -### TypeScript Compilation: -✅ **PASSING** - No errors detected - ---- - -## 📝 NOTES - -### Files to Backup (Already Done): -- `frontend/src/pages/FamilyView.old.tsx` (original 657 lines) -- `frontend/src/components/ShoppingListModal.old.tsx` (original 375 lines) - -### Files to Backup (Pending): -- `frontend/src/pages/Profile.tsx` → `Profile.old.tsx` - -### Git Workflow: -**Branch:** `refactor/frontend-code-quality` ✅ Created -**Commits:** Not created yet (per user request) - -When ready to commit: -```bash -git add frontend/src/ -git commit -m "Major frontend refactoring: extract components, add hooks, services" -git push origin refactor/frontend-code-quality -``` - -Then create PR: `refactor/frontend-code-quality` → `master` - ---- - -## 🎯 FINAL GOALS - -- [x] Stage 1: Infrastructure -- [x] Stage 2: Service Layer -- [x] Stage 3: Custom Hooks -- [~] Stage 4: Component Refactoring (90%) -- [~] Stage 5: Utilities (50%) -- [ ] Stage 6: API Optimization -- [ ] Stage 7: Polish - -**Overall Progress: ~70%** - -The core refactoring is complete and functional. The remaining work is polish and optimization. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f9b6f83..e7e6a72 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,13 +10,11 @@ "dependencies": { "@tailwindcss/postcss": "^4.1.18", "axios": "^1.13.2", - "axios-retry": "^4.5.0", "i18next": "^25.8.0", "i18next-browser-languagedetector": "^8.2.0", "lucide-react": "^0.561.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-hot-toast": "^2.6.0", "react-i18next": "^16.5.3", "react-icons": "^5.5.0", "react-router-dom": "^7.10.1", @@ -2107,18 +2105,6 @@ "proxy-from-env": "^1.1.0" } }, - "node_modules/axios-retry": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", - "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", - "license": "Apache-2.0", - "dependencies": { - "is-retry-allowed": "^2.2.0" - }, - "peerDependencies": { - "axios": "0.x || 1.x" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2320,6 +2306,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -2930,15 +2917,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/goober": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", - "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", - "license": "MIT", - "peerDependencies": { - "csstype": "^3.0.10" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3132,18 +3110,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-retry-allowed": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", - "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3827,23 +3793,6 @@ "react": "^19.2.1" } }, - "node_modules/react-hot-toast": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", - "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", - "license": "MIT", - "dependencies": { - "csstype": "^3.1.3", - "goober": "^2.1.16" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" - } - }, "node_modules/react-i18next": { "version": "16.5.3", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 66a587b..a1cffde 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,13 +12,11 @@ "dependencies": { "@tailwindcss/postcss": "^4.1.18", "axios": "^1.13.2", - "axios-retry": "^4.5.0", "i18next": "^25.8.0", "i18next-browser-languagedetector": "^8.2.0", "lucide-react": "^0.561.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-hot-toast": "^2.6.0", "react-i18next": "^16.5.3", "react-icons": "^5.5.0", "react-router-dom": "^7.10.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2bf8ff8..932a513 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,7 +10,6 @@ import Profile from './pages/Profile'; import { useStore } from './store/useStore'; import { authApi } from './api/client'; import { Loader2 } from 'lucide-react'; -import { ErrorBoundary } from './components/ErrorBoundary'; function AppContent() { const { t, i18n } = useTranslation(); @@ -90,11 +89,9 @@ function AppContent() { function App() { return ( - - - - - + + + ); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 46226e4..ad9f5c9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,4 @@ -import axios, { AxiosError } from 'axios'; -import axiosRetry from 'axios-retry'; +import axios from 'axios'; import type { Family, Category, @@ -34,51 +33,8 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; const apiClient = axios.create({ baseURL: API_BASE_URL, withCredentials: true, - timeout: 30000, }); -axiosRetry(apiClient, { - retries: 3, - retryDelay: axiosRetry.exponentialDelay, - retryCondition: (error: AxiosError) => { - return ( - axiosRetry.isNetworkOrIdempotentRequestError(error) || - (error.response?.status ? error.response.status >= 500 : false) - ); - }, - onRetry: (retryCount, error, requestConfig) => { - console.log(`Retry attempt ${retryCount} for ${requestConfig.url}`, error.message); - }, -}); - -apiClient.interceptors.request.use( - (config) => { - console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`); - return config; - }, - (error) => { - console.error('[API] Request error:', error); - return Promise.reject(error); - } -); - -apiClient.interceptors.response.use( - (response) => { - console.log(`[API] ${response.config.method?.toUpperCase()} ${response.config.url} - ${response.status}`); - return response; - }, - (error: AxiosError) => { - if (error.response?.status === 401) { - console.warn('[API] Unauthorized - redirecting to login'); - if (!window.location.pathname.includes('/login')) { - window.location.href = '/login'; - } - } - console.error('[API] Response error:', error.response?.status, error.message); - return Promise.reject(error); - } -); - export const authApi = { login: (data: LoginRequest) => apiClient.post('/login', data), diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx deleted file mode 100644 index d2dbf28..0000000 --- a/frontend/src/components/ErrorBoundary.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { Component, ReactNode } from 'react'; -import { AlertTriangle } from 'lucide-react'; -import { Button } from './ui'; - -interface Props { - children: ReactNode; -} - -interface State { - hasError: boolean; - error: Error | null; - errorInfo: React.ErrorInfo | null; -} - -export class ErrorBoundary extends Component { - constructor(props: Props) { - super(props); - this.state = { - hasError: false, - error: null, - errorInfo: null, - }; - } - - static getDerivedStateFromError(error: Error): Partial { - return { hasError: true }; - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - console.error('ErrorBoundary caught an error:', error, errorInfo); - this.setState({ - error, - errorInfo, - }); - } - - handleReset = () => { - this.setState({ - hasError: false, - error: null, - errorInfo: null, - }); - window.location.href = '/'; - }; - - render() { - if (this.state.hasError) { - return ( -
-
-
- -
-

- Oops! Something went wrong -

-

- We're sorry, but something unexpected happened. Please try refreshing the page or going back to the home page. -

- {process.env.NODE_ENV === 'development' && this.state.error && ( -
-

- {this.state.error.toString()} -

-
- )} -
- - -
-
-
- ); - } - - return this.props.children; - } -} diff --git a/frontend/src/components/ShoppingListModal.old.tsx b/frontend/src/components/ShoppingListModal.old.tsx deleted file mode 100644 index 05822b4..0000000 --- a/frontend/src/components/ShoppingListModal.old.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { shoppingItemApi } from '../api/client'; -import type { ShoppingItem } from '../types'; -import { - X, - Plus, - Trash2, - ShoppingCart, - Check, - Loader2, - Pencil, -} from 'lucide-react'; -import ConfirmModal from './ConfirmModal'; - -interface ShoppingListModalProps { - familyId: number; - onClose: () => void; -} - -type ConfirmAction = - | { type: 'delete-item'; itemId: number } - | { type: 'mark-all' } - | { type: 'clear-all' }; - -export default function ShoppingListModal({ familyId, onClose }: ShoppingListModalProps) { - const { t } = useTranslation(); - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [newItemName, setNewItemName] = useState(''); - const [editingId, setEditingId] = useState(null); - const [editingName, setEditingName] = useState(''); - const [confirmAction, setConfirmAction] = useState(null); - - useEffect(() => { - loadItems(); - }, [familyId]); - - const loadItems = async () => { - try { - setLoading(true); - const response = await shoppingItemApi.getAllByFamily(familyId); - setItems(response.data); - } catch (err) { - console.error('Error loading shopping items:', err); - alert(t('shopping.loadError')); - } finally { - setLoading(false); - } - }; - - const handleAddItem = async () => { - if (!newItemName.trim()) return; - - try { - await shoppingItemApi.create(familyId, { name: newItemName }); - setNewItemName(''); - loadItems(); - } catch (err) { - console.error('Error adding item:', err); - alert(t('shopping.addError')); - } - }; - - const handleTogglePurchased = async (itemId: number, currentStatus: boolean) => { - try { - await shoppingItemApi.markAsPurchased(familyId, itemId, { is_purchased: !currentStatus }); - loadItems(); - } catch (err) { - console.error('Error toggling purchased status:', err); - alert(t('shopping.toggleError')); - } - }; - - const handleDeleteItem = (itemId: number) => { - setConfirmAction({ type: 'delete-item', itemId }); - }; - - const executeDeleteItem = async (itemId: number) => { - try { - await shoppingItemApi.delete(familyId, itemId); - loadItems(); - } catch (err) { - console.error('Error deleting item:', err); - alert(t('shopping.deleteError')); - } - }; - - const handleStartEdit = (item: ShoppingItem) => { - setEditingId(item.id); - setEditingName(item.name); - }; - - const handleSaveEdit = async (itemId: number) => { - if (!editingName.trim()) return; - - try { - await shoppingItemApi.update(familyId, itemId, { name: editingName }); - setEditingId(null); - setEditingName(''); - loadItems(); - } catch (err) { - console.error('Error updating item:', err); - alert(t('shopping.updateError')); - } - }; - - const handleCancelEdit = () => { - setEditingId(null); - setEditingName(''); - }; - - const handleMarkAllPurchased = () => { - setConfirmAction({ type: 'mark-all' }); - }; - - const executeMarkAllPurchased = async () => { - try { - await shoppingItemApi.markAllAsPurchased(familyId); - loadItems(); - } catch (err) { - console.error('Error marking all as purchased:', err); - alert(t('shopping.markAllError')); - } - }; - - const handleClearAll = () => { - setConfirmAction({ type: 'clear-all' }); - }; - - const executeClearAll = async () => { - try { - await shoppingItemApi.clearAll(familyId); - loadItems(); - } catch (err) { - console.error('Error clearing all items:', err); - alert(t('shopping.clearError')); - } - }; - - const handleConfirm = () => { - if (!confirmAction) return; - - switch (confirmAction.type) { - case 'delete-item': - executeDeleteItem(confirmAction.itemId); - break; - case 'mark-all': - executeMarkAllPurchased(); - break; - case 'clear-all': - executeClearAll(); - break; - } - - setConfirmAction(null); - }; - - const getConfirmModalContent = () => { - if (!confirmAction) return null; - - switch (confirmAction.type) { - case 'delete-item': - return { - title: t('confirm.deleteItem'), - message: t('confirm.deleteItemMessage'), - confirmText: t('common.delete'), - }; - case 'mark-all': - return { - title: t('confirm.markAll'), - message: t('confirm.markAllMessage'), - confirmText: t('confirm.markButton'), - variant: 'info' as const, - }; - case 'clear-all': - return { - title: t('confirm.clearAll'), - message: t('confirm.clearAllMessage'), - confirmText: t('shopping.clear'), - }; - } - }; - - const unpurchasedItems = items.filter(item => !item.is_purchased); - const purchasedItems = items.filter(item => item.is_purchased); - const confirmContent = getConfirmModalContent(); - - return ( - <> -
-
-
-
-
- -
-

{t('shopping.title')}

-
- -
- -
-
-
- setNewItemName(e.target.value)} - onKeyPress={(e) => e.key === 'Enter' && handleAddItem()} - className="flex-1 px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-green-500 focus:ring-2 focus:ring-green-200 transition-all" - /> - -
-
- - {loading ? ( -
- -
- ) : ( -
- {unpurchasedItems.length > 0 && ( -
-

{t('shopping.toBuy')}

-
- {unpurchasedItems.map((item) => ( -
- {editingId === item.id ? ( -
- setEditingName(e.target.value)} - onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit(item.id)} - className="flex-1 px-3 py-2 border-2 border-green-300 rounded-xl focus:border-green-500 focus:ring-2 focus:ring-green-200" - autoFocus - /> - - -
- ) : ( -
-
-
-
- - -
-
- )} -
- ))} -
-
- )} - - {purchasedItems.length > 0 && ( -
-

{t('shopping.purchased')}

-
- {purchasedItems.map((item) => ( -
-
-
- - {item.name} -
- -
-
- ))} -
-
- )} - - {items.length === 0 && ( -
- -

{t('shopping.empty')}

-
- )} -
- )} -
- - {items.length > 0 && ( -
-
- - -
-
- )} -
-
- - {confirmContent && ( - setConfirmAction(null)} - variant={confirmContent.variant || 'danger'} - /> - )} - - ); -} diff --git a/frontend/src/components/ShoppingListModal.tsx b/frontend/src/components/ShoppingListModal.tsx index 533baf0..05822b4 100644 --- a/frontend/src/components/ShoppingListModal.tsx +++ b/frontend/src/components/ShoppingListModal.tsx @@ -1,132 +1,375 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ShoppingCart, CheckCheck, Trash2 } from 'lucide-react'; -import { useShoppingList, useConfirm } from '../hooks'; -import { shoppingService } from '../services'; -import { Modal, Button, LoadingSpinner, ConfirmModal, Badge } from './ui'; -import { ShoppingItemInput } from './shopping/ShoppingItemInput'; -import { ShoppingItemList } from './shopping/ShoppingItemList'; +import { shoppingItemApi } from '../api/client'; +import type { ShoppingItem } from '../types'; +import { + X, + Plus, + Trash2, + ShoppingCart, + Check, + Loader2, + Pencil, +} from 'lucide-react'; +import ConfirmModal from './ConfirmModal'; interface ShoppingListModalProps { familyId: number; onClose: () => void; } +type ConfirmAction = + | { type: 'delete-item'; itemId: number } + | { type: 'mark-all' } + | { type: 'clear-all' }; + export default function ShoppingListModal({ familyId, onClose }: ShoppingListModalProps) { const { t } = useTranslation(); - const { items, loading, createItem, deleteItem, togglePurchased, markAllAsPurchased, clearAll } = useShoppingList(familyId); - const { confirmState, confirm, cancel } = useConfirm(); - const [pendingAction, setPendingAction] = useState<{ type: 'delete' | 'markAll' | 'clearAll'; itemId?: number } | null>(null); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [newItemName, setNewItemName] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editingName, setEditingName] = useState(''); + const [confirmAction, setConfirmAction] = useState(null); - const stats = shoppingService.getStats(items); + useEffect(() => { + loadItems(); + }, [familyId]); - const handleAddItem = async (name: string) => { - await createItem({ name }); - }; - - const handleDelete = async (itemId: number) => { - setPendingAction({ type: 'delete', itemId }); - await confirm(t('shopping.deleteConfirm'), t('shopping.deleteMessage')); - await deleteItem(itemId); - setPendingAction(null); - }; - - const handleMarkAll = async () => { - setPendingAction({ type: 'markAll' }); - await confirm(t('shopping.markAllConfirm'), t('shopping.markAllMessage')); - await markAllAsPurchased(); - setPendingAction(null); - }; - - const handleClearAll = async () => { - setPendingAction({ type: 'clearAll' }); - await confirm(t('shopping.clearAllConfirm'), t('shopping.clearAllMessage')); - await clearAll(); - setPendingAction(null); - }; - - const handleUpdate = async (itemId: number, name: string) => { - const item = items.find((i) => i.id === itemId); - if (item) { - await createItem({ name }); - await deleteItem(itemId); + const loadItems = async () => { + try { + setLoading(true); + const response = await shoppingItemApi.getAllByFamily(familyId); + setItems(response.data); + } catch (err) { + console.error('Error loading shopping items:', err); + alert(t('shopping.loadError')); + } finally { + setLoading(false); } }; + const handleAddItem = async () => { + if (!newItemName.trim()) return; + + try { + await shoppingItemApi.create(familyId, { name: newItemName }); + setNewItemName(''); + loadItems(); + } catch (err) { + console.error('Error adding item:', err); + alert(t('shopping.addError')); + } + }; + + const handleTogglePurchased = async (itemId: number, currentStatus: boolean) => { + try { + await shoppingItemApi.markAsPurchased(familyId, itemId, { is_purchased: !currentStatus }); + loadItems(); + } catch (err) { + console.error('Error toggling purchased status:', err); + alert(t('shopping.toggleError')); + } + }; + + const handleDeleteItem = (itemId: number) => { + setConfirmAction({ type: 'delete-item', itemId }); + }; + + const executeDeleteItem = async (itemId: number) => { + try { + await shoppingItemApi.delete(familyId, itemId); + loadItems(); + } catch (err) { + console.error('Error deleting item:', err); + alert(t('shopping.deleteError')); + } + }; + + const handleStartEdit = (item: ShoppingItem) => { + setEditingId(item.id); + setEditingName(item.name); + }; + + const handleSaveEdit = async (itemId: number) => { + if (!editingName.trim()) return; + + try { + await shoppingItemApi.update(familyId, itemId, { name: editingName }); + setEditingId(null); + setEditingName(''); + loadItems(); + } catch (err) { + console.error('Error updating item:', err); + alert(t('shopping.updateError')); + } + }; + + const handleCancelEdit = () => { + setEditingId(null); + setEditingName(''); + }; + + const handleMarkAllPurchased = () => { + setConfirmAction({ type: 'mark-all' }); + }; + + const executeMarkAllPurchased = async () => { + try { + await shoppingItemApi.markAllAsPurchased(familyId); + loadItems(); + } catch (err) { + console.error('Error marking all as purchased:', err); + alert(t('shopping.markAllError')); + } + }; + + const handleClearAll = () => { + setConfirmAction({ type: 'clear-all' }); + }; + + const executeClearAll = async () => { + try { + await shoppingItemApi.clearAll(familyId); + loadItems(); + } catch (err) { + console.error('Error clearing all items:', err); + alert(t('shopping.clearError')); + } + }; + + const handleConfirm = () => { + if (!confirmAction) return; + + switch (confirmAction.type) { + case 'delete-item': + executeDeleteItem(confirmAction.itemId); + break; + case 'mark-all': + executeMarkAllPurchased(); + break; + case 'clear-all': + executeClearAll(); + break; + } + + setConfirmAction(null); + }; + + const getConfirmModalContent = () => { + if (!confirmAction) return null; + + switch (confirmAction.type) { + case 'delete-item': + return { + title: t('confirm.deleteItem'), + message: t('confirm.deleteItemMessage'), + confirmText: t('common.delete'), + }; + case 'mark-all': + return { + title: t('confirm.markAll'), + message: t('confirm.markAllMessage'), + confirmText: t('confirm.markButton'), + variant: 'info' as const, + }; + case 'clear-all': + return { + title: t('confirm.clearAll'), + message: t('confirm.clearAllMessage'), + confirmText: t('shopping.clear'), + }; + } + }; + + const unpurchasedItems = items.filter(item => !item.is_purchased); + const purchasedItems = items.filter(item => item.is_purchased); + const confirmContent = getConfirmModalContent(); + return ( <> - - {loading ? ( -
- -
- ) : ( - <> -
-
-
-
- - - {t('shopping.stats')} - -
-
- - {t('shopping.total')}: {stats.total} - - - {t('shopping.purchased')}: {stats.purchased} - - - {t('shopping.pending')}: {stats.pending} - -
-
-
- {stats.pending > 0 && ( - - )} - {stats.total > 0 && ( - - )} -
+
+
+
+
+
+
+

{t('shopping.title')}

+
+ +
- +
+
+
+ setNewItemName(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleAddItem()} + className="flex-1 px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-green-500 focus:ring-2 focus:ring-green-200 transition-all" + /> + +
- - - )} - + {loading ? ( +
+ +
+ ) : ( +
+ {unpurchasedItems.length > 0 && ( +
+

{t('shopping.toBuy')}

+
+ {unpurchasedItems.map((item) => ( +
+ {editingId === item.id ? ( +
+ setEditingName(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit(item.id)} + className="flex-1 px-3 py-2 border-2 border-green-300 rounded-xl focus:border-green-500 focus:ring-2 focus:ring-green-200" + autoFocus + /> + + +
+ ) : ( +
+
+
+
+ + +
+
+ )} +
+ ))} +
+
+ )} - + {purchasedItems.length > 0 && ( +
+

{t('shopping.purchased')}

+
+ {purchasedItems.map((item) => ( +
+
+
+ + {item.name} +
+ +
+
+ ))} +
+
+ )} + + {items.length === 0 && ( +
+ +

{t('shopping.empty')}

+
+ )} +
+ )} +
+ + {items.length > 0 && ( +
+
+ + +
+
+ )} +
+
+ + {confirmContent && ( + setConfirmAction(null)} + variant={confirmContent.variant || 'danger'} + /> + )} ); } diff --git a/frontend/src/components/family/AddCategorySection.tsx b/frontend/src/components/family/AddCategorySection.tsx deleted file mode 100644 index fc90a1e..0000000 --- a/frontend/src/components/family/AddCategorySection.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Plus, X } from 'lucide-react'; -import { Button, Input } from '../ui'; -import { CreateCategoryRequest } from '../../types'; - -interface AddCategorySectionProps { - showForm: boolean; - onToggle: () => void; - onCreate: (data: CreateCategoryRequest) => Promise; -} - -export function AddCategorySection({ showForm, onToggle, onCreate }: AddCategorySectionProps) { - const { t } = useTranslation(); - const [name, setName] = useState(''); - const [limitAmount, setLimitAmount] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleSubmit = async () => { - if (!name || !limitAmount) return; - - try { - setIsSubmitting(true); - await onCreate({ - name, - limit_amount: parseFloat(limitAmount), - }); - setName(''); - setLimitAmount(''); - onToggle(); - } catch (error) { - console.error(error); - } finally { - setIsSubmitting(false); - } - }; - - return ( -
- {!showForm ? ( - - ) : ( -
-
-

{t('category.add')}

- -
- setName(e.target.value)} - fullWidth - /> - setLimitAmount(e.target.value)} - fullWidth - /> -
- - -
-
- )} -
- ); -} diff --git a/frontend/src/components/family/CategoryCard.tsx b/frontend/src/components/family/CategoryCard.tsx deleted file mode 100644 index a6f66d8..0000000 --- a/frontend/src/components/family/CategoryCard.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Tag, TrendingDown, Plus, Trash2, RotateCcw, History, X, DollarSign, MessageSquare, Calendar } from 'lucide-react'; -import { CategoryWithRemaining } from '../../services'; -import { categoryService, expenseService } from '../../services'; -import { useExpenses } from '../../hooks'; -import { format } from '../../utils/format'; -import { Button, Input, Badge } from '../ui'; - -interface CategoryCardProps { - category: CategoryWithRemaining; - familyId: number; - onDelete: (categoryId: number) => void; - onReset: (categoryId: number) => void; - onUpdate: () => void; -} - -export function CategoryCard({ category, familyId, onDelete, onReset, onUpdate }: CategoryCardProps) { - const { t } = useTranslation(); - const [showAddExpense, setShowAddExpense] = useState(false); - const [showHistory, setShowHistory] = useState(false); - const [expenseAmount, setExpenseAmount] = useState(''); - const [expenseDescription, setExpenseDescription] = useState(''); - - const { expenses, loadExpenses, createExpense } = useExpenses(familyId, category.id); - - const handleAddExpense = async () => { - if (!expenseAmount) return; - - try { - await createExpense({ - amount: parseFloat(expenseAmount), - description: expenseDescription || undefined, - }); - setExpenseAmount(''); - setExpenseDescription(''); - setShowAddExpense(false); - onUpdate(); - } catch (error) { - console.error(error); - } - }; - - const handleShowHistory = async () => { - if (!showHistory) { - await loadExpenses(); - } - setShowHistory(!showHistory); - }; - - const progress = categoryService.calculateProgress(category.limit_amount, category.remaining_limit); - const progressColor = categoryService.getProgressColor(progress); - - return ( -
-
-
-
-
- -
-
-

{category.name}

-

- {t('category.limit')}: {format.currency(category.limit_amount)} -

-
-
-
- - -
-
- -
-
- {t('category.remaining')} - - {format.currency(category.remaining_limit)} - -
-
-
-
-
- -
- - -
-
- - {showAddExpense && ( -
-

- - {t('expense.add')} -

-
- setExpenseAmount(e.target.value)} - fullWidth - /> - setExpenseDescription(e.target.value)} - fullWidth - /> -
- - -
-
-
- )} - - {showHistory && ( -
-
-

- - {t('expense.history')} -

- -
- {expenses.length === 0 ? ( -

{t('expense.noHistory')}

- ) : ( -
- {expenseService.sortByDate(expenses).map((expense) => ( -
-
-
- - - {format.currency(expense.amount)} - -
- {expense.description && ( -
- - {expense.description} -
- )} -
- - {format.date(expense.created_at)} -
-
-
- ))} -
- )} -
- )} -
- ); -} diff --git a/frontend/src/components/family/CategoryList.tsx b/frontend/src/components/family/CategoryList.tsx deleted file mode 100644 index 255e1b8..0000000 --- a/frontend/src/components/family/CategoryList.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { CategoryWithRemaining } from '../../services'; -import { CategoryCard } from './CategoryCard'; - -interface CategoryListProps { - categories: CategoryWithRemaining[]; - familyId: number; - onDelete: (categoryId: number) => void; - onReset: (categoryId: number) => void; - onUpdate: () => void; -} - -export function CategoryList({ categories, familyId, onDelete, onReset, onUpdate }: CategoryListProps) { - if (categories.length === 0) { - return null; - } - - return ( -
- {categories.map((category) => ( - - ))} -
- ); -} diff --git a/frontend/src/components/family/FamilyHeader.tsx b/frontend/src/components/family/FamilyHeader.tsx deleted file mode 100644 index 1f52863..0000000 --- a/frontend/src/components/family/FamilyHeader.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { UserPlus, User } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; - -interface FamilyHeaderProps { - onInvite: () => void; - onProfile: () => void; -} - -export function FamilyHeader({ onInvite, onProfile }: FamilyHeaderProps) { - const { t } = useTranslation(); - - return ( -
- - -
- ); -} diff --git a/frontend/src/components/family/FamilySummary.tsx b/frontend/src/components/family/FamilySummary.tsx deleted file mode 100644 index 2a77632..0000000 --- a/frontend/src/components/family/FamilySummary.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Wallet, ShoppingCart } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { CategoryWithRemaining } from '../../services'; -import { format } from '../../utils/format'; - -interface FamilySummaryProps { - familyName?: string; - categories: CategoryWithRemaining[]; - onShowShoppingList: () => void; -} - -export function FamilySummary({ familyName, categories, onShowShoppingList }: FamilySummaryProps) { - const { t } = useTranslation(); - - const getTotalLimit = () => { - return categories.reduce((sum, cat) => sum + parseFloat(cat.limit_amount.toString()), 0); - }; - - const getTotalRemaining = () => { - return categories.reduce((sum, cat) => sum + cat.remaining_limit, 0); - }; - - return ( -
-
-
- -
-

- {familyName || t('family.defaultName')} -

-
-
-
-

{t('family.totalLimit')}

-

- {format.currency(getTotalLimit())} -

-
-
-

{t('family.totalRemaining')}

-

- {format.currency(getTotalRemaining())} -

-
-
- -
-
-
- ); -} diff --git a/frontend/src/components/family/InviteModal.tsx b/frontend/src/components/family/InviteModal.tsx deleted file mode 100644 index ff49c3b..0000000 --- a/frontend/src/components/family/InviteModal.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Copy, Check, Loader2 } from 'lucide-react'; -import { Modal, Button } from '../ui'; -import { useInviteLink } from '../../hooks'; -import { InviteLinkResponse } from '../../types'; - -interface InviteModalProps { - onClose: () => void; -} - -export function InviteModal({ onClose }: InviteModalProps) { - const { t } = useTranslation(); - const { createLink } = useInviteLink(); - const [inviteLink, setInviteLink] = useState(null); - const [loading, setLoading] = useState(false); - const [copied, setCopied] = useState(false); - - const handleCreateLink = async () => { - try { - setLoading(true); - const link = await createLink({ expires_in_hours: 168 }); - setInviteLink(link); - } catch (error) { - console.error(error); - } finally { - setLoading(false); - } - }; - - const handleCopy = async () => { - if (!inviteLink) return; - try { - await navigator.clipboard.writeText(inviteLink.invite_url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (error) { - console.error('Failed to copy:', error); - } - }; - - return ( - - {!inviteLink ? ( -
-

- {t('invite.description')} -

- -
- ) : ( -
-
-

- {t('invite.link')} -

-

- {inviteLink.invite_url} -

-
- -
- )} -
- ); -} diff --git a/frontend/src/components/profile/FamilySection.tsx b/frontend/src/components/profile/FamilySection.tsx deleted file mode 100644 index 97c2d63..0000000 --- a/frontend/src/components/profile/FamilySection.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Users, Edit3, Save, X, AlertTriangle, Loader2 } from 'lucide-react'; -import { Family } from '../../types'; -import { Card, Button, Input } from '../ui'; -import { familyService } from '../../services'; -import { showToast } from '../../utils/toast'; -import { showErrorToast } from '../../utils/errorHandler'; - -interface FamilySectionProps { - family: Family | null; - onLeaveFamily: () => void; - onFamilyUpdate: () => void; - leavingFamily: boolean; -} - -export function FamilySection({ family, onLeaveFamily, onFamilyUpdate, leavingFamily }: FamilySectionProps) { - const { t } = useTranslation(); - const [editingName, setEditingName] = useState(false); - const [newFamilyName, setNewFamilyName] = useState(''); - const [savingName, setSavingName] = useState(false); - - const handleStartEditName = () => { - setEditingName(true); - setNewFamilyName(family?.name || ''); - }; - - const handleSaveName = async () => { - if (!family || !newFamilyName.trim()) return; - - try { - setSavingName(true); - await familyService.update(family.id, { name: newFamilyName }); - showToast.success(t('profile.familyNameUpdated')); - setEditingName(false); - onFamilyUpdate(); - } catch (error) { - showErrorToast(error); - } finally { - setSavingName(false); - } - }; - - if (!family) return null; - - return ( - -
-
- -
-

{t('profile.family')}

-
- -
-
- {t('profile.familyName')} - {editingName ? ( -
- setNewFamilyName(e.target.value)} - /> - - -
- ) : ( -
- {family.name} - -
- )} -
-
- - -
- ); -} diff --git a/frontend/src/components/profile/LanguageSelector.tsx b/frontend/src/components/profile/LanguageSelector.tsx deleted file mode 100644 index 8f66ae8..0000000 --- a/frontend/src/components/profile/LanguageSelector.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { Button } from '../ui'; -import { LANGUAGES } from '../../constants'; - -interface LanguageSelectorProps { - currentLanguage: string; - onLanguageChange: (lang: string) => void; -} - -export function LanguageSelector({ currentLanguage, onLanguageChange }: LanguageSelectorProps) { - const { t } = useTranslation(); - - return ( -
- -
- {LANGUAGES.map((lang) => ( - - ))} -
-
- ); -} diff --git a/frontend/src/components/profile/MembersSection.tsx b/frontend/src/components/profile/MembersSection.tsx deleted file mode 100644 index 2bdf847..0000000 --- a/frontend/src/components/profile/MembersSection.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { Loader2 } from 'lucide-react'; -import { FamilyMember, User } from '../../types'; -import { Badge, LoadingSpinner } from '../ui'; - -interface MembersSectionProps { - members: FamilyMember[]; - currentUser: User | null; - loading: boolean; -} - -export function MembersSection({ members, currentUser, loading }: MembersSectionProps) { - const { t } = useTranslation(); - - if (loading) { - return ( -
-

- {t('profile.members')} -

-
- -
-
- ); - } - - if (members.length === 0) { - return ( -
-

- {t('profile.members')} -

-
- {t('profile.noMembers')} -
-
- ); - } - - return ( -
-

- {t('profile.members')} ({members.length}) -

-
- {members.map((member) => ( -
-
-
- {(member.username || member.email || '?')[0].toUpperCase()} -
-
-
- - {member.username || member.email || t('profile.unknownUser')} - - {member.id === currentUser?.id && ( - - {t('profile.you')} - - )} -
- {member.email && member.username && ( - - {member.email} - - )} -
-
- {member.is_admin && ( - - Admin - - )} -
- ))} -
-
- ); -} diff --git a/frontend/src/components/profile/ProfileHeader.tsx b/frontend/src/components/profile/ProfileHeader.tsx deleted file mode 100644 index b882786..0000000 --- a/frontend/src/components/profile/ProfileHeader.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ArrowLeft } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; - -interface ProfileHeaderProps { - onBack: () => void; -} - -export function ProfileHeader({ onBack }: ProfileHeaderProps) { - const { t } = useTranslation(); - - return ( - - ); -} diff --git a/frontend/src/components/profile/SettingsSection.tsx b/frontend/src/components/profile/SettingsSection.tsx deleted file mode 100644 index f66a7d6..0000000 --- a/frontend/src/components/profile/SettingsSection.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Settings, Palette, Languages } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { Theme } from '../../types'; -import { Card } from '../ui'; -import { ThemeSelector } from './ThemeSelector'; -import { LanguageSelector } from './LanguageSelector'; - -interface SettingsSectionProps { - currentTheme: Theme; - currentLanguage: string; - onThemeChange: (theme: Theme) => void; - onLanguageChange: (lang: string) => void; -} - -export function SettingsSection({ - currentTheme, - currentLanguage, - onThemeChange, - onLanguageChange, -}: SettingsSectionProps) { - const { t } = useTranslation(); - - return ( - -
-
- -
-

{t('profile.settings')}

-
- -
-
-
- -

- {t('profile.theme')} -

-
- -
- -
-
- -

- {t('profile.language')} -

-
- -
-
-
- ); -} diff --git a/frontend/src/components/profile/ThemeSelector.tsx b/frontend/src/components/profile/ThemeSelector.tsx deleted file mode 100644 index 3c433cb..0000000 --- a/frontend/src/components/profile/ThemeSelector.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Check } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { Theme } from '../../types'; -import { THEMES } from '../../constants'; - -interface ThemeSelectorProps { - currentTheme: Theme; - onThemeChange: (theme: Theme) => void; -} - -export function ThemeSelector({ currentTheme, onThemeChange }: ThemeSelectorProps) { - const { t } = useTranslation(); - - return ( -
- -
- {THEMES.map((theme) => ( - - ))} -
-
- ); -} diff --git a/frontend/src/components/profile/UserInfo.tsx b/frontend/src/components/profile/UserInfo.tsx deleted file mode 100644 index f843d72..0000000 --- a/frontend/src/components/profile/UserInfo.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { User as UserIcon, LogOut } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { User } from '../../types'; -import { Button, Card } from '../ui'; - -interface UserInfoProps { - user: User | null; - onLogout: () => void; -} - -export function UserInfo({ user, onLogout }: UserInfoProps) { - const { t } = useTranslation(); - - if (!user) return null; - - return ( - -
-
-
- -
-
-

- {user.username || user.email || t('profile.anonymous')} -

-

- {user.email || t('profile.noEmail')} -

-
-
- -
-
- ); -} diff --git a/frontend/src/components/shopping/ShoppingItemCard.tsx b/frontend/src/components/shopping/ShoppingItemCard.tsx deleted file mode 100644 index 228ef4f..0000000 --- a/frontend/src/components/shopping/ShoppingItemCard.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Trash2, Check, Pencil, X } from 'lucide-react'; -import { ShoppingItem } from '../../types'; -import { Button, Input } from '../ui'; - -interface ShoppingItemCardProps { - item: ShoppingItem; - onToggle: (itemId: number, currentStatus: boolean) => Promise; - onDelete: (itemId: number) => void; - onUpdate: (itemId: number, name: string) => Promise; -} - -export function ShoppingItemCard({ item, onToggle, onDelete, onUpdate }: ShoppingItemCardProps) { - const { t } = useTranslation(); - const [isEditing, setIsEditing] = useState(false); - const [editName, setEditName] = useState(item.name); - - const handleSave = async () => { - if (!editName.trim()) return; - try { - await onUpdate(item.id, editName); - setIsEditing(false); - } catch (error) { - console.error(error); - } - }; - - const handleCancel = () => { - setEditName(item.name); - setIsEditing(false); - }; - - return ( -
- {isEditing ? ( -
- setEditName(e.target.value)} - fullWidth - /> - - -
- ) : ( - <> -
- - - {item.name} - -
-
- - -
- - )} -
- ); -} diff --git a/frontend/src/components/shopping/ShoppingItemInput.tsx b/frontend/src/components/shopping/ShoppingItemInput.tsx deleted file mode 100644 index 012883c..0000000 --- a/frontend/src/components/shopping/ShoppingItemInput.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useState, KeyboardEvent } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Plus } from 'lucide-react'; -import { Button, Input } from '../ui'; - -interface ShoppingItemInputProps { - onAdd: (name: string) => Promise; -} - -export function ShoppingItemInput({ onAdd }: ShoppingItemInputProps) { - const { t } = useTranslation(); - const [name, setName] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleSubmit = async () => { - if (!name.trim()) return; - - try { - setIsSubmitting(true); - await onAdd(name); - setName(''); - } catch (error) { - console.error(error); - } finally { - setIsSubmitting(false); - } - }; - - const handleKeyPress = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - handleSubmit(); - } - }; - - return ( -
- setName(e.target.value)} - onKeyPress={handleKeyPress} - fullWidth - /> - -
- ); -} diff --git a/frontend/src/components/shopping/ShoppingItemList.tsx b/frontend/src/components/shopping/ShoppingItemList.tsx deleted file mode 100644 index a55e922..0000000 --- a/frontend/src/components/shopping/ShoppingItemList.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { ShoppingItem } from '../../types'; -import { ShoppingItemCard } from './ShoppingItemCard'; -import { shoppingService } from '../../services'; - -interface ShoppingItemListProps { - items: ShoppingItem[]; - onToggle: (itemId: number, currentStatus: boolean) => Promise; - onDelete: (itemId: number) => void; - onUpdate: (itemId: number, name: string) => Promise; -} - -export function ShoppingItemList({ items, onToggle, onDelete, onUpdate }: ShoppingItemListProps) { - const { t } = useTranslation(); - - if (items.length === 0) { - return ( -
-

- {t('shopping.noItems')} -

-
- ); - } - - const { pending, purchased } = shoppingService.sortItems(items); - - return ( -
- {pending.length > 0 && ( -
-

- {t('shopping.pending')} ({pending.length}) -

-
- {pending.map((item) => ( - - ))} -
-
- )} - - {purchased.length > 0 && ( -
-

- {t('shopping.purchased')} ({purchased.length}) -

-
- {purchased.map((item) => ( - - ))} -
-
- )} -
- ); -} diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx deleted file mode 100644 index 2484ce5..0000000 --- a/frontend/src/components/ui/Badge.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { ReactNode } from 'react'; - -interface BadgeProps { - children: ReactNode; - variant?: 'default' | 'success' | 'warning' | 'danger' | 'info'; - size?: 'sm' | 'md'; - className?: string; -} - -export function Badge({ children, variant = 'default', size = 'md', className = '' }: BadgeProps) { - const baseClasses = 'inline-flex items-center font-medium rounded-full'; - - const variantClasses = { - default: 'bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200', - success: 'bg-green-200 text-green-800 dark:bg-green-900 dark:text-green-200', - warning: 'bg-yellow-200 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', - danger: 'bg-red-200 text-red-800 dark:bg-red-900 dark:text-red-200', - info: 'bg-blue-200 text-blue-800 dark:bg-blue-900 dark:text-blue-200', - }; - - const sizeClasses = { - sm: 'px-2 py-0.5 text-xs', - md: 'px-3 py-1 text-sm', - }; - - return ( - - {children} - - ); -} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx deleted file mode 100644 index 0161b52..0000000 --- a/frontend/src/components/ui/Button.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { ButtonHTMLAttributes, ReactNode } from 'react'; - -interface ButtonProps extends ButtonHTMLAttributes { - variant?: 'primary' | 'success' | 'danger' | 'secondary' | 'ghost'; - size?: 'sm' | 'md' | 'lg'; - fullWidth?: boolean; - children: ReactNode; -} - -export function Button({ - variant = 'primary', - size = 'md', - fullWidth = false, - className = '', - children, - disabled, - ...props -}: ButtonProps) { - const baseClasses = 'font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed'; - - const variantClasses = { - primary: 'bg-blue-600 hover:bg-blue-700 text-white shadow-md hover:shadow-lg', - success: 'bg-green-600 hover:bg-green-700 text-white shadow-md hover:shadow-lg', - danger: 'bg-red-600 hover:bg-red-700 text-white shadow-md hover:shadow-lg', - secondary: 'bg-gray-600 hover:bg-gray-700 text-white shadow-md hover:shadow-lg', - ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300', - }; - - const sizeClasses = { - sm: 'px-3 py-1.5 text-sm', - md: 'px-4 py-2', - lg: 'px-6 py-3 text-lg', - }; - - const widthClass = fullWidth ? 'w-full' : ''; - - return ( - - ); -} diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx deleted file mode 100644 index e64bb4c..0000000 --- a/frontend/src/components/ui/Card.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { ReactNode } from 'react'; - -interface CardProps { - children: ReactNode; - className?: string; - onClick?: () => void; - hover?: boolean; -} - -export function Card({ children, className = '', onClick, hover = false }: CardProps) { - const baseClasses = 'bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6'; - const hoverClasses = hover ? 'hover:shadow-xl transition-shadow duration-200 cursor-pointer' : ''; - const clickClasses = onClick ? 'cursor-pointer' : ''; - - return ( -
- {children} -
- ); -} diff --git a/frontend/src/components/ui/ConfirmModal.tsx b/frontend/src/components/ui/ConfirmModal.tsx deleted file mode 100644 index c3a9c0b..0000000 --- a/frontend/src/components/ui/ConfirmModal.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Modal, Button } from './index'; - -interface ConfirmModalProps { - isOpen: boolean; - title: string; - message: string; - onConfirm: () => void; - onCancel: () => void; -} - -export function ConfirmModal({ isOpen, title, message, onConfirm, onCancel }: ConfirmModalProps) { - return ( - -

{message}

-
- - -
-
- ); -} diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx deleted file mode 100644 index 66a48db..0000000 --- a/frontend/src/components/ui/Input.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { InputHTMLAttributes, forwardRef } from 'react'; - -interface InputProps extends InputHTMLAttributes { - label?: string; - error?: string; - fullWidth?: boolean; -} - -export const Input = forwardRef( - ({ label, error, fullWidth = false, className = '', ...props }, ref) => { - const baseClasses = 'px-4 py-2 border rounded-lg transition-colors duration-200'; - const stateClasses = error - ? 'border-red-500 focus:ring-red-500 focus:border-red-500' - : 'border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500'; - const widthClass = fullWidth ? 'w-full' : ''; - const bgClass = 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white'; - - return ( -
- {label && ( - - )} - - {error && ( -

{error}

- )} -
- ); - } -); - -Input.displayName = 'Input'; diff --git a/frontend/src/components/ui/LoadingSpinner.tsx b/frontend/src/components/ui/LoadingSpinner.tsx deleted file mode 100644 index 6b2c515..0000000 --- a/frontend/src/components/ui/LoadingSpinner.tsx +++ /dev/null @@ -1,32 +0,0 @@ -interface LoadingSpinnerProps { - size?: 'sm' | 'md' | 'lg'; - fullScreen?: boolean; - text?: string; -} - -export function LoadingSpinner({ size = 'md', fullScreen = false, text }: LoadingSpinnerProps) { - const sizeClasses = { - sm: 'w-6 h-6 border-2', - md: 'w-10 h-10 border-3', - lg: 'w-16 h-16 border-4', - }; - - const spinner = ( -
-
- {text &&

{text}

} -
- ); - - if (fullScreen) { - return ( -
- {spinner} -
- ); - } - - return spinner; -} diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx deleted file mode 100644 index 973c8c6..0000000 --- a/frontend/src/components/ui/Modal.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { ReactNode, useEffect } from 'react'; - -interface ModalProps { - isOpen: boolean; - onClose: () => void; - title?: string; - children: ReactNode; - size?: 'sm' | 'md' | 'lg' | 'xl'; -} - -export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) { - useEffect(() => { - if (isOpen) { - document.body.style.overflow = 'hidden'; - } else { - document.body.style.overflow = 'unset'; - } - - return () => { - document.body.style.overflow = 'unset'; - }; - }, [isOpen]); - - if (!isOpen) return null; - - const sizeClasses = { - sm: 'max-w-md', - md: 'max-w-lg', - lg: 'max-w-2xl', - xl: 'max-w-4xl', - }; - - const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - onClose(); - } - }; - - return ( -
-
- {title && ( -
-

{title}

- -
- )} -
{children}
-
-
- ); -} diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx deleted file mode 100644 index b2e17b2..0000000 --- a/frontend/src/components/ui/Skeleton.tsx +++ /dev/null @@ -1,70 +0,0 @@ -interface SkeletonProps { - className?: string; - variant?: 'text' | 'circular' | 'rectangular'; - width?: string | number; - height?: string | number; -} - -export function Skeleton({ className = '', variant = 'rectangular', width, height }: SkeletonProps) { - const baseClasses = 'animate-pulse bg-gray-300 dark:bg-gray-700'; - - const variantClasses = { - text: 'rounded h-4', - circular: 'rounded-full', - rectangular: 'rounded-lg', - }; - - const style: React.CSSProperties = {}; - if (width) style.width = typeof width === 'number' ? `${width}px` : width; - if (height) style.height = typeof height === 'number' ? `${height}px` : height; - - return ( -
- ); -} - -export function CategoryCardSkeleton() { - return ( -
-
-
- -
- - -
-
-
- - -
-
-
- - -
-
- - -
-
- ); -} - -export function ShoppingItemSkeleton() { - return ( -
-
- - -
-
- - -
-
- ); -} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts deleted file mode 100644 index c2173b3..0000000 --- a/frontend/src/components/ui/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { Button } from './Button'; -export { Input } from './Input'; -export { Card } from './Card'; -export { Modal } from './Modal'; -export { Badge } from './Badge'; -export { LoadingSpinner } from './LoadingSpinner'; -export { ConfirmModal } from './ConfirmModal'; -export { Skeleton, CategoryCardSkeleton, ShoppingItemSkeleton } from './Skeleton'; diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts deleted file mode 100644 index 30eee60..0000000 --- a/frontend/src/constants/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Theme } from '../types'; - -export const THEMES: { id: Theme; gradient: string; name: string }[] = [ - { id: 'light', gradient: 'bg-gradient-to-r from-gray-100 to-gray-200', name: 'Light' }, - { id: 'dark', gradient: 'bg-gradient-to-r from-black to-gray-900', name: 'Dark' }, - { id: 'sunset', gradient: 'bg-gradient-to-r from-orange-400 to-pink-500', name: 'Sunset' }, - { id: 'ocean', gradient: 'bg-gradient-to-r from-blue-400 to-cyan-500', name: 'Ocean' }, - { id: 'forest', gradient: 'bg-gradient-to-r from-green-400 to-teal-500', name: 'Forest' }, - { id: 'purple', gradient: 'bg-gradient-to-r from-purple-500 to-pink-500', name: 'Purple' }, -]; - -export const LANGUAGES = [ - { code: 'ru', name: 'Русский' }, - { code: 'en', name: 'English' }, -]; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts deleted file mode 100644 index ad5abaf..0000000 --- a/frontend/src/hooks/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { useCategories } from './useCategories'; -export { useExpenses } from './useExpenses'; -export { useFamilyMembers } from './useFamilyMembers'; -export { useShoppingList } from './useShoppingList'; -export { useInviteLink } from './useInviteLink'; -export { useConfirm } from './useConfirm'; diff --git a/frontend/src/hooks/useCategories.ts b/frontend/src/hooks/useCategories.ts deleted file mode 100644 index ded3243..0000000 --- a/frontend/src/hooks/useCategories.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { categoryService, CategoryWithRemaining } from '../services'; -import { CreateCategoryRequest } from '../types'; -import { showToast } from '../utils/toast'; -import { showErrorToast } from '../utils/errorHandler'; - -export function useCategories(familyId: number) { - const [categories, setCategories] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const loadCategories = useCallback(async () => { - if (!familyId) return; - - try { - setLoading(true); - setError(null); - const data = await categoryService.getAllByFamily(familyId); - setCategories(data); - } catch (err) { - setError(err as Error); - showErrorToast(err); - } finally { - setLoading(false); - } - }, [familyId]); - - useEffect(() => { - loadCategories(); - }, [loadCategories]); - - const createCategory = useCallback(async (data: CreateCategoryRequest) => { - try { - await categoryService.create(familyId, data); - showToast.success('Category created successfully'); - await loadCategories(); - } catch (err) { - showErrorToast(err); - throw err; - } - }, [familyId, loadCategories]); - - const deleteCategory = useCallback(async (categoryId: number) => { - try { - await categoryService.delete(familyId, categoryId); - showToast.success('Category deleted successfully'); - await loadCategories(); - } catch (err) { - showErrorToast(err); - throw err; - } - }, [familyId, loadCategories]); - - const resetLimit = useCallback(async (categoryId: number, newLimit: number) => { - try { - await categoryService.resetLimit(familyId, categoryId, newLimit); - showToast.success('Limit reset successfully'); - await loadCategories(); - } catch (err) { - showErrorToast(err); - throw err; - } - }, [familyId, loadCategories]); - - const updateCategory = useCallback(async (categoryId: number, data: Partial) => { - try { - await categoryService.update(familyId, categoryId, data); - showToast.success('Category updated successfully'); - await loadCategories(); - } catch (err) { - showErrorToast(err); - throw err; - } - }, [familyId, loadCategories]); - - return { - categories, - loading, - error, - loadCategories, - createCategory, - deleteCategory, - resetLimit, - updateCategory, - }; -} diff --git a/frontend/src/hooks/useConfirm.ts b/frontend/src/hooks/useConfirm.ts deleted file mode 100644 index ac64e15..0000000 --- a/frontend/src/hooks/useConfirm.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useState, useCallback } from 'react'; - -interface ConfirmState { - isOpen: boolean; - title: string; - message: string; - onConfirm: () => void; -} - -export function useConfirm() { - const [state, setState] = useState({ - isOpen: false, - title: '', - message: '', - onConfirm: () => {}, - }); - - const confirm = useCallback((title: string, message: string): Promise => { - return new Promise((resolve) => { - setState({ - isOpen: true, - title, - message, - onConfirm: () => { - setState((prev) => ({ ...prev, isOpen: false })); - resolve(true); - }, - }); - }); - }, []); - - const cancel = useCallback(() => { - setState((prev) => ({ ...prev, isOpen: false })); - }, []); - - return { - confirmState: state, - confirm, - cancel, - }; -} diff --git a/frontend/src/hooks/useExpenses.ts b/frontend/src/hooks/useExpenses.ts deleted file mode 100644 index 0978490..0000000 --- a/frontend/src/hooks/useExpenses.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { expenseService } from '../services'; -import { Expense, CreateExpenseRequest } from '../types'; -import { showToast } from '../utils/toast'; -import { showErrorToast } from '../utils/errorHandler'; - -export function useExpenses(familyId: number, categoryId: number) { - const [expenses, setExpenses] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const loadExpenses = useCallback(async () => { - if (!familyId || !categoryId) return; - - try { - setLoading(true); - setError(null); - const data = await expenseService.getAllByCategory(familyId, categoryId); - setExpenses(data); - } catch (err) { - setError(err as Error); - showErrorToast(err); - } finally { - setLoading(false); - } - }, [familyId, categoryId]); - - useEffect(() => { - loadExpenses(); - }, [loadExpenses]); - - const createExpense = useCallback(async (data: CreateExpenseRequest) => { - try { - await expenseService.create(familyId, categoryId, data); - showToast.success('Expense added successfully'); - await loadExpenses(); - } catch (err) { - showErrorToast(err); - throw err; - } - }, [familyId, categoryId, loadExpenses]); - - const deleteExpense = useCallback(async (expenseId: number) => { - try { - await expenseService.delete(familyId, categoryId, expenseId); - showToast.success('Expense deleted successfully'); - await loadExpenses(); - } catch (err) { - showErrorToast(err); - throw err; - } - }, [familyId, categoryId, loadExpenses]); - - const updateExpense = useCallback(async (expenseId: number, data: Partial) => { - try { - await expenseService.update(familyId, categoryId, expenseId, data); - showToast.success('Expense updated successfully'); - await loadExpenses(); - } catch (err) { - showErrorToast(err); - throw err; - } - }, [familyId, categoryId, loadExpenses]); - - return { - expenses, - loading, - error, - loadExpenses, - createExpense, - deleteExpense, - updateExpense, - }; -} diff --git a/frontend/src/hooks/useFamilyMembers.ts b/frontend/src/hooks/useFamilyMembers.ts deleted file mode 100644 index 2c7a83f..0000000 --- a/frontend/src/hooks/useFamilyMembers.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { familyService } from '../services'; -import { FamilyMember } from '../types'; -import { showErrorToast } from '../utils/errorHandler'; - -export function useFamilyMembers(familyId: number | null) { - const [members, setMembers] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const loadMembers = useCallback(async () => { - if (!familyId) { - setMembers([]); - return; - } - - try { - setLoading(true); - setError(null); - const data = await familyService.getMembers(familyId); - setMembers(data); - } catch (err) { - setError(err as Error); - showErrorToast(err); - } finally { - setLoading(false); - } - }, [familyId]); - - useEffect(() => { - loadMembers(); - }, [loadMembers]); - - return { - members, - loading, - error, - loadMembers, - }; -} diff --git a/frontend/src/hooks/useInviteLink.ts b/frontend/src/hooks/useInviteLink.ts deleted file mode 100644 index 570d4e8..0000000 --- a/frontend/src/hooks/useInviteLink.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { inviteService } from '../services'; -import { InviteLinkResponse, CreateInviteLinkRequest } from '../types'; -import { showToast } from '../utils/toast'; -import { showErrorToast } from '../utils/errorHandler'; - -export function useInviteLink() { - const [links, setLinks] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const loadLinks = useCallback(async () => { - try { - setLoading(true); - setError(null); - const data = await inviteService.getMyLinks(); - setLinks(data); - } catch (err) { - setError(err as Error); - showErrorToast(err); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - loadLinks(); - }, [loadLinks]); - - const createLink = useCallback(async (data: CreateInviteLinkRequest) => { - try { - const link = await inviteService.create(data); - showToast.success('Invite link created successfully'); - await loadLinks(); - return link; - } catch (err) { - showErrorToast(err); - throw err; - } - }, [loadLinks]); - - const deleteLink = useCallback(async (token: string) => { - try { - await inviteService.delete(token); - showToast.success('Invite link deleted successfully'); - await loadLinks(); - } catch (err) { - showErrorToast(err); - throw err; - } - }, [loadLinks]); - - const validateLink = useCallback(async (token: string) => { - try { - return await inviteService.validate(token); - } catch (err) { - showErrorToast(err); - throw err; - } - }, []); - - const joinFamily = useCallback(async (token: string) => { - try { - const result = await inviteService.join(token); - showToast.success(result.message); - return result; - } catch (err) { - showErrorToast(err); - throw err; - } - }, []); - - return { - links, - loading, - error, - loadLinks, - createLink, - deleteLink, - validateLink, - joinFamily, - }; -} diff --git a/frontend/src/hooks/useShoppingList.ts b/frontend/src/hooks/useShoppingList.ts deleted file mode 100644 index 96e95e5..0000000 --- a/frontend/src/hooks/useShoppingList.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { shoppingService } from '../services'; -import { ShoppingItem, CreateShoppingItemRequest } from '../types'; -import { showToast } from '../utils/toast'; -import { showErrorToast } from '../utils/errorHandler'; - -export function useShoppingList(familyId: number) { - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const loadItems = useCallback(async () => { - if (!familyId) return; - - try { - setLoading(true); - setError(null); - const data = await shoppingService.getAllByFamily(familyId); - setItems(data); - } catch (err) { - setError(err as Error); - showErrorToast(err); - } finally { - setLoading(false); - } - }, [familyId]); - - useEffect(() => { - loadItems(); - }, [loadItems]); - - const createItem = useCallback(async (data: CreateShoppingItemRequest) => { - try { - await shoppingService.create(familyId, data); - showToast.success('Item added successfully'); - await loadItems(); - } catch (err) { - showErrorToast(err); - throw err; - } - }, [familyId, loadItems]); - - const deleteItem = useCallback(async (itemId: number) => { - try { - await shoppingService.delete(familyId, itemId); - showToast.success('Item deleted successfully'); - await loadItems(); - } catch (err) { - showErrorToast(err); - throw err; - } - }, [familyId, loadItems]); - - const togglePurchased = useCallback(async (itemId: number, isPurchased: boolean) => { - try { - await shoppingService.markAsPurchased(familyId, itemId, isPurchased); - await loadItems(); - } catch (err) { - showErrorToast(err); - throw err; - } - }, [familyId, loadItems]); - - const markAllAsPurchased = useCallback(async () => { - try { - const affected = await shoppingService.markAllAsPurchased(familyId); - showToast.success(`Marked ${affected} items as purchased`); - await loadItems(); - } catch (err) { - showErrorToast(err); - throw err; - } - }, [familyId, loadItems]); - - const clearAll = useCallback(async () => { - try { - const affected = await shoppingService.clearAll(familyId); - showToast.success(`Cleared ${affected} items`); - await loadItems(); - } catch (err) { - showErrorToast(err); - throw err; - } - }, [familyId, loadItems]); - - return { - items, - loading, - error, - loadItems, - createItem, - deleteItem, - togglePurchased, - markAllAsPurchased, - clearAll, - }; -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3cfd8e2..0a27bc3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,5 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { Toaster } from 'react-hot-toast' import './i18n' import './index.css' import App from './App.tsx' @@ -8,6 +7,5 @@ import App from './App.tsx' createRoot(document.getElementById('root')!).render( - , ) diff --git a/frontend/src/pages/FamilyView.old.tsx b/frontend/src/pages/FamilyView.old.tsx deleted file mode 100644 index 3f366bf..0000000 --- a/frontend/src/pages/FamilyView.old.tsx +++ /dev/null @@ -1,657 +0,0 @@ -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, Expense, InviteLinkResponse } from '../types'; -import { - Wallet, - TrendingDown, - Plus, - Trash2, - RotateCcw, - Loader2, - X, - DollarSign, - Tag, - History, - Calendar, - MessageSquare, - ShoppingCart, - UserPlus, - Copy, - Check, - User, -} from 'lucide-react'; -import ShoppingListModal from '../components/ShoppingListModal'; - -export default function FamilyView() { - const { t } = useTranslation(); - const { familyId } = useParams<{ familyId: string }>(); - const navigate = useNavigate(); - const { selectedFamily } = useStore(); - - const [categories, setCategories] = useState([]); - const [remainingLimits, setRemainingLimits] = useState>(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(null); - const [expenseAmount, setExpenseAmount] = useState(''); - const [expenseDescription, setExpenseDescription] = useState(''); - - const [showHistory, setShowHistory] = useState(null); - const [categoryExpenses, setCategoryExpenses] = useState([]); - const [showShoppingList, setShowShoppingList] = useState(false); - const [showInviteModal, setShowInviteModal] = useState(false); - const [inviteLink, setInviteLink] = useState(null); - const [inviteLoading, setInviteLoading] = useState(false); - const [copied, setCopied] = useState(false); - - useEffect(() => { - if (!familyId) { - navigate('/'); - return; - } - loadCategories(); - }, [familyId]); - - const loadCategories = async () => { - if (!familyId) return; - - try { - setLoading(true); - setError(''); - console.log('Loading categories for family:', familyId); - const response = await categoryApi.getAllByFamily(parseInt(familyId)); - console.log('Categories loaded:', response.data); - setCategories(response.data); - - const limits = new Map(); - 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); - loadCategories(); - } catch (err) { - alert(t('expense.addError')); - console.error(err); - } - }; - - const handleShowHistory = async (categoryId: number) => { - if (!familyId) return; - - if (showHistory === categoryId) { - setShowHistory(null); - return; - } - - try { - const response = await expenseApi.getAllByCategory( - parseInt(familyId), - categoryId - ); - setCategoryExpenses(response.data); - setShowHistory(categoryId); - } catch (err) { - alert(t('expense.historyError')); - console.error(err); - } - }; - - const handleCreateInviteLink = async () => { - try { - setInviteLoading(true); - const response = await inviteLinkApi.create({ expires_in_hours: 168 }); - setInviteLink(response.data); - } catch (err) { - alert(t('invite.createError')); - console.error(err); - } finally { - setInviteLoading(false); - } - }; - - const handleCopyInviteLink = async () => { - if (!inviteLink) return; - try { - await navigator.clipboard.writeText(inviteLink.invite_url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }; - - const handleOpenInviteModal = () => { - setShowInviteModal(true); - setInviteLink(null); - setCopied(false); - }; - - if (loading) { - return ( -
-
- - {t('common.loading')} -
-
- ); - } - - 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)} ₽ -

-
-
- -
-
-
- - {error && ( -
-
- - {error} -
-
- )} - -
- {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" - /> -
- - -
-
-
- ) : ( - - )} -
-
- - {showShoppingList && familyId && ( - setShowShoppingList(false)} - /> - )} - - {showInviteModal && ( -
-
-
-
-
- -
-

{t('invite.title')}

-
- -
- - {!inviteLink ? ( -
-

- {t('invite.description')} -

- -
- ) : ( -
-

- {t('invite.sendLink')} -

-
-

- {inviteLink.invite_url} -

-
- -
- )} -
-
- )} -
- ); -} diff --git a/frontend/src/pages/FamilyView.tsx b/frontend/src/pages/FamilyView.tsx index a5d3061..3f366bf 100644 --- a/frontend/src/pages/FamilyView.tsx +++ b/frontend/src/pages/FamilyView.tsx @@ -1,16 +1,29 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Loader2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { categoryApi, expenseApi, inviteLinkApi } from '../api/client'; import { useStore } from '../store/useStore'; -import { useCategories, useConfirm } from '../hooks'; -import { FamilyHeader } from '../components/family/FamilyHeader'; -import { FamilySummary } from '../components/family/FamilySummary'; -import { CategoryList } from '../components/family/CategoryList'; -import { AddCategorySection } from '../components/family/AddCategorySection'; -import { InviteModal } from '../components/family/InviteModal'; +import type { Category, Expense, InviteLinkResponse } from '../types'; +import { + Wallet, + TrendingDown, + Plus, + Trash2, + RotateCcw, + Loader2, + X, + DollarSign, + Tag, + History, + Calendar, + MessageSquare, + ShoppingCart, + UserPlus, + Copy, + Check, + User, +} from 'lucide-react'; import ShoppingListModal from '../components/ShoppingListModal'; -import { ConfirmModal } from '../components/ui'; export default function FamilyView() { const { t } = useTranslation(); @@ -18,34 +31,194 @@ export default function FamilyView() { const navigate = useNavigate(); const { selectedFamily } = useStore(); + const [categories, setCategories] = useState([]); + const [remainingLimits, setRemainingLimits] = useState>(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(null); + const [expenseAmount, setExpenseAmount] = useState(''); + const [expenseDescription, setExpenseDescription] = useState(''); + + const [showHistory, setShowHistory] = useState(null); + const [categoryExpenses, setCategoryExpenses] = useState([]); const [showShoppingList, setShowShoppingList] = useState(false); const [showInviteModal, setShowInviteModal] = useState(false); - - const { categories, loading, createCategory, deleteCategory, resetLimit, loadCategories } = useCategories( - parseInt(familyId || '0') - ); - const { confirmState, confirm, cancel } = useConfirm(); + const [inviteLink, setInviteLink] = useState(null); + const [inviteLoading, setInviteLoading] = useState(false); + const [copied, setCopied] = useState(false); useEffect(() => { if (!familyId) { navigate('/'); + return; } - }, [familyId, navigate]); + loadCategories(); + }, [familyId]); + + const loadCategories = async () => { + if (!familyId) return; + + try { + setLoading(true); + setError(''); + console.log('Loading categories for family:', familyId); + const response = await categoryApi.getAllByFamily(parseInt(familyId)); + console.log('Categories loaded:', response.data); + setCategories(response.data); + + const limits = new Map(); + for (const category of response.data) { + const limitResponse = await expenseApi.getRemainingLimit( + parseInt(familyId), + category.id + ); + const limitValue = typeof limitResponse.data.remaining_limit === 'string' + ? parseFloat(limitResponse.data.remaining_limit) + : limitResponse.data.remaining_limit; + limits.set(category.id, limitValue); + } + setRemainingLimits(limits); + console.log('All data loaded successfully'); + } catch (err: any) { + const errorMsg = err.response?.data?.message || err.message || t('family.loadError'); + setError(errorMsg); + console.error('Error loading categories:', err); + } finally { + setLoading(false); + } + }; + + const handleAddCategory = async () => { + if (!familyId || !newCategoryName || !newCategoryLimit) return; + + try { + await categoryApi.create(parseInt(familyId), { + name: newCategoryName, + limit_amount: parseFloat(newCategoryLimit), + }); + setNewCategoryName(''); + setNewCategoryLimit(''); + setShowAddCategory(false); + loadCategories(); + } catch (err: any) { + const errorMsg = err.response?.data?.message || err.response?.statusText || err.message || t('category.createError'); + alert(`${t('category.createError')}: ${errorMsg}`); + console.error('Full error:', err); + } + }; const handleDeleteCategory = async (categoryId: number) => { - await confirm(t('category.deleteConfirm'), t('category.deleteMessage')); - await deleteCategory(categoryId); + if (!familyId) return; + if (!confirm(t('category.deleteConfirm'))) return; + + try { + await categoryApi.delete(parseInt(familyId), categoryId); + loadCategories(); + } catch (err) { + alert(t('category.deleteError')); + console.error(err); + } }; const handleResetLimit = async (categoryId: number) => { - await confirm(t('category.resetConfirm'), t('category.resetMessage')); - const category = categories.find((cat) => cat.id === categoryId); - if (category) { - await resetLimit(categoryId, Number(category.limit_amount)); + if (!familyId) return; + if (!confirm(t('category.resetConfirm'))) return; + + try { + const expensesResponse = await expenseApi.getAllByCategory( + parseInt(familyId), + categoryId + ); + + for (const expense of expensesResponse.data) { + await expenseApi.delete( + parseInt(familyId), + categoryId, + expense.id + ); + } + + loadCategories(); + } catch (err) { + alert(t('category.resetError')); + console.error(err); } }; + const handleAddExpense = async (categoryId: number) => { + if (!familyId || !expenseAmount) return; + + try { + await expenseApi.create(parseInt(familyId), categoryId, { + amount: parseFloat(expenseAmount), + description: expenseDescription || undefined, + }); + setExpenseAmount(''); + setExpenseDescription(''); + setShowAddExpense(null); + loadCategories(); + } catch (err) { + alert(t('expense.addError')); + console.error(err); + } + }; + + const handleShowHistory = async (categoryId: number) => { + if (!familyId) return; + + if (showHistory === categoryId) { + setShowHistory(null); + return; + } + + try { + const response = await expenseApi.getAllByCategory( + parseInt(familyId), + categoryId + ); + setCategoryExpenses(response.data); + setShowHistory(categoryId); + } catch (err) { + alert(t('expense.historyError')); + console.error(err); + } + }; + + const handleCreateInviteLink = async () => { + try { + setInviteLoading(true); + const response = await inviteLinkApi.create({ expires_in_hours: 168 }); + setInviteLink(response.data); + } catch (err) { + alert(t('invite.createError')); + console.error(err); + } finally { + setInviteLoading(false); + } + }; + + const handleCopyInviteLink = async () => { + if (!inviteLink) return; + try { + await navigator.clipboard.writeText(inviteLink.invite_url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + const handleOpenInviteModal = () => { + setShowInviteModal(true); + setInviteLink(null); + setCopied(false); + }; + if (loading) { return (
@@ -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 && ( +
+
+ + {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; - }, -};