Compare commits
39 Commits
refactor/f
...
90127d1e0d
| Author | SHA1 | Date | |
|---|---|---|---|
| 90127d1e0d | |||
|
|
8daea3ea47 | ||
| 318e2144f0 | |||
|
|
7c352d9e82 | ||
| fe1de2bbf9 | |||
|
|
035e6b20c7 | ||
| 7b7554c84b | |||
|
|
1832997ebe | ||
| c884bf812c | |||
|
|
45eefeb1f5 | ||
| 91f9ed5474 | |||
|
|
265c29d542 | ||
|
|
d7802cf584 | ||
| adad656df2 | |||
|
|
6f679a5066 | ||
| 0f72d62d3e | |||
|
|
5b4ff98cc5 | ||
| 50f5610459 | |||
|
|
67f72e05a1 | ||
| 7e5d5332df | |||
|
|
7dc0ebf3c1 | ||
| f48abe37ea | |||
|
|
e9a588e479 | ||
| b000efe886 | |||
|
|
9eaa3de231 | ||
| 22dd91f7ce | |||
|
|
80f2fa55cd | ||
| 38ffa260b3 | |||
|
|
b90e002f28 | ||
| 0e856b8904 | |||
|
|
62aa6b2215 | ||
| 8d47aa336e | |||
|
|
2f4e8af2a0 | ||
| 3410786da7 | |||
| c7b9a14ff6 | |||
| 75fa8bd4e2 | |||
| 5bcabb2736 | |||
| 30b1c97043 | |||
| 8334c848f1 |
7
.github/workflows/docker-publish.yml
vendored
7
.github/workflows/docker-publish.yml
vendored
@@ -29,3 +29,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Logout
|
- name: Logout
|
||||||
run: docker logout 192.168.31.100:3847
|
run: docker logout 192.168.31.100:3847
|
||||||
|
|
||||||
|
- name: Trigger Coolify redeploy
|
||||||
|
run: |
|
||||||
|
curl -s -H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" \
|
||||||
|
"http://192.168.31.100:8000/api/v1/deploy?uuid=msoc4skogk44ckc84wokocw0&force=true"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,3 +11,9 @@ Cargo.lock
|
|||||||
certbot/
|
certbot/
|
||||||
nginx/conf.d/app-ssl.conf
|
nginx/conf.d/app-ssl.conf
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
mobile/src-tauri/gen/android/build/
|
||||||
|
mobile/src-tauri/gen/android/.gradle/
|
||||||
|
mobile/src-tauri/gen/android/app/build/
|
||||||
|
mobile/src-tauri/gen/android/buildSrc/build/
|
||||||
|
mobile/src-tauri/gen/schemas/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["backend"]
|
members = ["backend", "mobile/src-tauri"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|||||||
@@ -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 `<Toaster />` 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 <noreply@anthropic.com>"
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -25,3 +25,8 @@ time = "0.3"
|
|||||||
oauth2 = { version = "5.0.0", features = ["reqwest"] }
|
oauth2 = { version = "5.0.0", features = ["reqwest"] }
|
||||||
reqwest = { version = "0.13.1", features = ["json"] }
|
reqwest = { version = "0.13.1", features = ["json"] }
|
||||||
rand = "0.9.2"
|
rand = "0.9.2"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -14,6 +14,7 @@ use time::Duration;
|
|||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
use axum::http::{Method, HeaderValue};
|
use axum::http::{Method, HeaderValue};
|
||||||
|
|
||||||
|
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod migration;
|
pub mod migration;
|
||||||
@@ -50,6 +51,7 @@ pub use middleware::{require_admin, require_family_access};
|
|||||||
routes::expense::update_expense,
|
routes::expense::update_expense,
|
||||||
routes::expense::delete_expense,
|
routes::expense::delete_expense,
|
||||||
routes::expense::get_remaining_limit,
|
routes::expense::get_remaining_limit,
|
||||||
|
routes::expense::get_history,
|
||||||
routes::shopping_item::create_shopping_item,
|
routes::shopping_item::create_shopping_item,
|
||||||
routes::shopping_item::get_shopping_items_by_family,
|
routes::shopping_item::get_shopping_items_by_family,
|
||||||
routes::shopping_item::get_shopping_item,
|
routes::shopping_item::get_shopping_item,
|
||||||
@@ -87,6 +89,8 @@ pub use middleware::{require_admin, require_family_access};
|
|||||||
routes::expense::CreateExpenseRequest,
|
routes::expense::CreateExpenseRequest,
|
||||||
routes::expense::UpdateExpenseRequest,
|
routes::expense::UpdateExpenseRequest,
|
||||||
routes::expense::RemainingLimitResponse,
|
routes::expense::RemainingLimitResponse,
|
||||||
|
routes::expense::ExpenseHistoryResponse,
|
||||||
|
routes::expense::MonthlyExpenseGroup,
|
||||||
routes::shopping_item::CreateShoppingItemRequest,
|
routes::shopping_item::CreateShoppingItemRequest,
|
||||||
routes::shopping_item::UpdateShoppingItemRequest,
|
routes::shopping_item::UpdateShoppingItemRequest,
|
||||||
routes::shopping_item::MarkAsPurchasedRequest,
|
routes::shopping_item::MarkAsPurchasedRequest,
|
||||||
@@ -168,6 +172,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
|
|||||||
let oauth_routes = Router::new()
|
let oauth_routes = Router::new()
|
||||||
.route("/auth/google", get(routes::oauth::google_auth))
|
.route("/auth/google", get(routes::oauth::google_auth))
|
||||||
.route("/auth/google/callback", get(routes::oauth::google_callback))
|
.route("/auth/google/callback", get(routes::oauth::google_callback))
|
||||||
|
.route("/auth/mobile-callback", get(routes::oauth::mobile_callback))
|
||||||
.layer(auth_layer.clone())
|
.layer(auth_layer.clone())
|
||||||
.with_state(db.clone());
|
.with_state(db.clone());
|
||||||
|
|
||||||
@@ -187,6 +192,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
|
|||||||
.route("/families/{family_id}/categories/{category_id}", delete(routes::category::delete_category))
|
.route("/families/{family_id}/categories/{category_id}", delete(routes::category::delete_category))
|
||||||
.route("/families/{family_id}/categories/{category_id}/expenses", post(routes::expense::create_expense))
|
.route("/families/{family_id}/categories/{category_id}/expenses", post(routes::expense::create_expense))
|
||||||
.route("/families/{family_id}/categories/{category_id}/expenses", get(routes::expense::get_expenses_by_category))
|
.route("/families/{family_id}/categories/{category_id}/expenses", get(routes::expense::get_expenses_by_category))
|
||||||
|
.route("/families/{family_id}/categories/{category_id}/expenses/history", get(routes::expense::get_history))
|
||||||
.route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", get(routes::expense::get_expense))
|
.route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", get(routes::expense::get_expense))
|
||||||
.route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", put(routes::expense::update_expense))
|
.route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", put(routes::expense::update_expense))
|
||||||
.route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", delete(routes::expense::delete_expense))
|
.route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", delete(routes::expense::delete_expense))
|
||||||
@@ -225,7 +231,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
|
|||||||
.url("/api-docs/openapi.json", ApiDoc::openapi());
|
.url("/api-docs/openapi.json", ApiDoc::openapi());
|
||||||
|
|
||||||
let allowed_origins = std::env::var("ALLOWED_ORIGINS")
|
let allowed_origins = std::env::var("ALLOWED_ORIGINS")
|
||||||
.unwrap_or_else(|_| "http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:8080".to_string());
|
.unwrap_or_else(|_| "http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:8080,http://localhost:1420,http://tauri.localhost,https://tauri.localhost".to_string());
|
||||||
|
|
||||||
let origins: Vec<HeaderValue> = allowed_origins
|
let origins: Vec<HeaderValue> = allowed_origins
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
use family_budget::*;
|
use family_budget::*;
|
||||||
use sea_orm::DbErr;
|
use sea_orm::DbErr;
|
||||||
use sea_orm_migration::prelude::*;
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), DbErr> {
|
async fn main() -> Result<(), DbErr> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "family_budget=debug,info".parse().unwrap()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
let db = establish_connection().await?;
|
let db = establish_connection().await?;
|
||||||
println!("Successfully connected to database!");
|
println!("Successfully connected to database!");
|
||||||
|
|
||||||
|
|||||||
40
backend/src/migration/m20260212_000001_add_expense_active.rs
Normal file
40
backend/src/migration/m20260212_000001_add_expense_active.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Expense::Table)
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Expense::Active)
|
||||||
|
.boolean()
|
||||||
|
.not_null()
|
||||||
|
.default(true)
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Expense::Table)
|
||||||
|
.drop_column(Expense::Active)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Expense {
|
||||||
|
Table,
|
||||||
|
Active,
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ mod m20241215_000001_add_family_password;
|
|||||||
mod m20241224_000001_create_shopping_items;
|
mod m20241224_000001_create_shopping_items;
|
||||||
mod m20250116_000001_add_oauth_fields;
|
mod m20250116_000001_add_oauth_fields;
|
||||||
mod m20250117_000001_create_invite_links;
|
mod m20250117_000001_create_invite_links;
|
||||||
|
mod m20260212_000001_add_expense_active;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20241224_000001_create_shopping_items::Migration),
|
Box::new(m20241224_000001_create_shopping_items::Migration),
|
||||||
Box::new(m20250116_000001_add_oauth_fields::Migration),
|
Box::new(m20250116_000001_add_oauth_fields::Migration),
|
||||||
Box::new(m20250117_000001_create_invite_links::Migration),
|
Box::new(m20250117_000001_create_invite_links::Migration),
|
||||||
|
Box::new(m20260212_000001_add_expense_active::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub struct Model {
|
|||||||
pub amount: Decimal,
|
pub amount: Decimal,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub created_at: DateTime,
|
pub created_at: DateTime,
|
||||||
|
pub active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use sea_orm::{prelude::Decimal, DatabaseConnection};
|
use sea_orm::{prelude::Decimal, DatabaseConnection};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
|
||||||
use crate::models::expense::Model as ExpenseModel;
|
use crate::models::expense::Model as ExpenseModel;
|
||||||
use crate::services::{CategoryService, ExpenseService};
|
use crate::services::{CategoryService, ExpenseService};
|
||||||
|
|
||||||
@@ -31,6 +30,27 @@ pub struct RemainingLimitResponse {
|
|||||||
pub remaining_limit: Decimal,
|
pub remaining_limit: Decimal,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct MonthlyExpenseGroup {
|
||||||
|
pub year: i32,
|
||||||
|
pub month: u32,
|
||||||
|
pub total_amount: Decimal,
|
||||||
|
pub expenses: Vec<ExpenseModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ExpenseHistoryResponse {
|
||||||
|
pub months: Vec<MonthlyExpenseGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, IntoParams)]
|
||||||
|
pub struct HistoryQueryParams {
|
||||||
|
#[serde(default)]
|
||||||
|
pub sort_order: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub show_archive: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/families/{family_id}/categories/{category_id}/expenses",
|
path = "/families/{family_id}/categories/{category_id}/expenses",
|
||||||
@@ -183,6 +203,53 @@ pub async fn update_expense(
|
|||||||
.map(Json)
|
.map(Json)
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
}
|
}
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/families/{family_id}/categories/{category_id}/expenses/history",
|
||||||
|
tag = "expenses",
|
||||||
|
params(
|
||||||
|
("family_id" = i32, Path, description = "Family ID"),
|
||||||
|
("category_id" = i32, Path, description = "Category ID"),
|
||||||
|
HistoryQueryParams
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Expense history grouped by month", body = ExpenseHistoryResponse),
|
||||||
|
(status = 404, description = "Category not found"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn get_history(
|
||||||
|
State(db): State<DatabaseConnection>,
|
||||||
|
Path((family_id, category_id)): Path<(i32, i32)>,
|
||||||
|
Query(params): Query<HistoryQueryParams>,
|
||||||
|
) -> Result<Json<ExpenseHistoryResponse>, StatusCode> {
|
||||||
|
let category = CategoryService::find_by_id(&db, category_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
if category.family_id != family_id {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
let groups = ExpenseService::get_expense_history(
|
||||||
|
&db,
|
||||||
|
category_id,
|
||||||
|
params.sort_order,
|
||||||
|
params.show_archive.unwrap_or(false)
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let months = groups.into_iter().map(|g| MonthlyExpenseGroup {
|
||||||
|
year: g.year,
|
||||||
|
month: g.month,
|
||||||
|
total_amount: g.total_amount,
|
||||||
|
expenses: g.expenses,
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Json(ExpenseHistoryResponse { months }))
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
delete,
|
delete,
|
||||||
@@ -194,7 +261,7 @@ pub async fn update_expense(
|
|||||||
("expense_id" = i32, Path, description = "Expense ID")
|
("expense_id" = i32, Path, description = "Expense ID")
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 204, description = "Expense deleted successfully"),
|
(status = 200, description = "Expense deactivated successfully", body = ExpenseModel),
|
||||||
(status = 404, description = "Expense not found"),
|
(status = 404, description = "Expense not found"),
|
||||||
(status = 500, description = "Internal server error")
|
(status = 500, description = "Internal server error")
|
||||||
)
|
)
|
||||||
@@ -202,7 +269,7 @@ pub async fn update_expense(
|
|||||||
pub async fn delete_expense(
|
pub async fn delete_expense(
|
||||||
State(db): State<DatabaseConnection>,
|
State(db): State<DatabaseConnection>,
|
||||||
Path((family_id, category_id, expense_id)): Path<(i32, i32, i32)>,
|
Path((family_id, category_id, expense_id)): Path<(i32, i32, i32)>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<Json<ExpenseModel>, StatusCode> {
|
||||||
let category = CategoryService::find_by_id(&db, category_id)
|
let category = CategoryService::find_by_id(&db, category_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
@@ -223,7 +290,7 @@ pub async fn delete_expense(
|
|||||||
|
|
||||||
ExpenseService::delete(&db, expense_id)
|
ExpenseService::delete(&db, expense_id)
|
||||||
.await
|
.await
|
||||||
.map(|_| StatusCode::NO_CONTENT)
|
.map(Json)
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,87 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::Redirect,
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use axum_login::AuthSession;
|
use axum_login::AuthSession;
|
||||||
use sea_orm::DatabaseConnection;
|
use sea_orm::{DatabaseConnection, EntityTrait};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
|
use tracing::{info, warn};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::auth::AuthBackend;
|
use crate::auth::AuthBackend;
|
||||||
|
use crate::models::User;
|
||||||
use crate::services::OAuthService;
|
use crate::services::OAuthService;
|
||||||
|
|
||||||
const CSRF_TOKEN_KEY: &str = "oauth_csrf_token";
|
const CSRF_TOKEN_KEY: &str = "oauth_csrf_token";
|
||||||
const FRONTEND_URL_KEY: &str = "oauth_frontend_url";
|
const FRONTEND_URL_KEY: &str = "oauth_frontend_url";
|
||||||
|
|
||||||
|
fn mobile_secret() -> String {
|
||||||
|
std::env::var("MOBILE_SECRET").unwrap_or_else(|_| "family-budget-mobile-secret".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign(data: &str) -> String {
|
||||||
|
let secret = mobile_secret();
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(format!("{}:{}", secret, data).as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_mobile_csrf_state(nonce: &str) -> String {
|
||||||
|
let sig = sign(&format!("csrf.mobile.{}", nonce));
|
||||||
|
format!("mobile.{}.{}", nonce, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_mobile_csrf_state(state: &str) -> bool {
|
||||||
|
let mut parts = state.splitn(3, '.');
|
||||||
|
match (parts.next(), parts.next(), parts.next()) {
|
||||||
|
(Some("mobile"), Some(nonce), Some(sig)) => {
|
||||||
|
sign(&format!("csrf.mobile.{}", nonce)) == sig
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_auth_token(user_id: i32) -> String {
|
||||||
|
let timestamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
let payload = format!("{}.{}", user_id, timestamp);
|
||||||
|
let sig = sign(&format!("auth.{}", payload));
|
||||||
|
format!("{}.{}", payload, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_auth_token(token: &str) -> Option<i32> {
|
||||||
|
let mut parts = token.splitn(3, '.');
|
||||||
|
let user_id_str = parts.next()?;
|
||||||
|
let timestamp_str = parts.next()?;
|
||||||
|
let sig = parts.next()?;
|
||||||
|
|
||||||
|
let payload = format!("{}.{}", user_id_str, timestamp_str);
|
||||||
|
if sign(&format!("auth.{}", payload)) != sig {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestamp: u64 = timestamp_str.parse().ok()?;
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
if now.saturating_sub(timestamp) > 300 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
user_id_str.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
pub struct GoogleAuthQuery {
|
pub struct GoogleAuthQuery {
|
||||||
pub redirect_url: Option<String>,
|
pub redirect_url: Option<String>,
|
||||||
|
pub mobile: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -37,7 +100,8 @@ pub struct OAuthUrlResponse {
|
|||||||
path = "/auth/google",
|
path = "/auth/google",
|
||||||
tag = "auth",
|
tag = "auth",
|
||||||
params(
|
params(
|
||||||
("redirect_url" = Option<String>, Query, description = "Frontend URL to redirect after auth")
|
("redirect_url" = Option<String>, Query, description = "Frontend URL to redirect after auth"),
|
||||||
|
("mobile" = Option<bool>, Query, description = "Mobile OAuth flow")
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Returns Google OAuth URL", body = OAuthUrlResponse)
|
(status = 200, description = "Returns Google OAuth URL", body = OAuthUrlResponse)
|
||||||
@@ -48,6 +112,15 @@ pub async fn google_auth(
|
|||||||
Query(query): Query<GoogleAuthQuery>,
|
Query(query): Query<GoogleAuthQuery>,
|
||||||
) -> Result<Json<OAuthUrlResponse>, StatusCode> {
|
) -> Result<Json<OAuthUrlResponse>, StatusCode> {
|
||||||
let oauth_service = OAuthService::new();
|
let oauth_service = OAuthService::new();
|
||||||
|
|
||||||
|
if query.mobile.unwrap_or(false) {
|
||||||
|
let nonce = uuid::Uuid::new_v4().to_string();
|
||||||
|
let mobile_state = make_mobile_csrf_state(&nonce);
|
||||||
|
let auth_url = oauth_service.get_auth_url_with_state(mobile_state);
|
||||||
|
info!("mobile google_auth: generated signed state for nonce={}", nonce);
|
||||||
|
return Ok(Json(OAuthUrlResponse { url: auth_url }));
|
||||||
|
}
|
||||||
|
|
||||||
let (auth_url, csrf_token) = oauth_service.get_auth_url();
|
let (auth_url, csrf_token) = oauth_service.get_auth_url();
|
||||||
|
|
||||||
session
|
session
|
||||||
@@ -79,24 +152,32 @@ pub async fn google_callback(
|
|||||||
session: Session,
|
session: Session,
|
||||||
State(db): State<DatabaseConnection>,
|
State(db): State<DatabaseConnection>,
|
||||||
Query(query): Query<GoogleCallbackQuery>,
|
Query(query): Query<GoogleCallbackQuery>,
|
||||||
) -> Result<Redirect, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let stored_csrf: Option<String> = session
|
let is_mobile = verify_mobile_csrf_state(&query.state);
|
||||||
|
info!("google_callback: state={} is_mobile={}", &query.state[..query.state.len().min(20)], is_mobile);
|
||||||
|
|
||||||
|
if !is_mobile {
|
||||||
|
let session_csrf: Option<String> = session
|
||||||
.get(CSRF_TOKEN_KEY)
|
.get(CSRF_TOKEN_KEY)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.unwrap_or(None);
|
||||||
|
session.remove::<String>(CSRF_TOKEN_KEY).await.ok();
|
||||||
|
|
||||||
|
match session_csrf {
|
||||||
|
Some(csrf) if csrf == query.state => {}
|
||||||
|
_ => {
|
||||||
|
warn!("google_callback: CSRF mismatch, session_csrf={:?}", session_csrf.as_deref().map(|s| &s[..s.len().min(10)]));
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let frontend_url: Option<String> = session
|
let frontend_url: Option<String> = session
|
||||||
.get(FRONTEND_URL_KEY)
|
.get(FRONTEND_URL_KEY)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.unwrap_or(None);
|
||||||
|
|
||||||
session.remove::<String>(CSRF_TOKEN_KEY).await.ok();
|
|
||||||
session.remove::<String>(FRONTEND_URL_KEY).await.ok();
|
session.remove::<String>(FRONTEND_URL_KEY).await.ok();
|
||||||
|
|
||||||
if stored_csrf.as_deref() != Some(&query.state) {
|
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
let oauth_service = OAuthService::new();
|
let oauth_service = OAuthService::new();
|
||||||
|
|
||||||
let access_token = oauth_service
|
let access_token = oauth_service
|
||||||
@@ -114,6 +195,17 @@ pub async fn google_callback(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if is_mobile {
|
||||||
|
let token = make_auth_token(user.id);
|
||||||
|
info!("google_callback: mobile auth for user_id={}, token_prefix={}", user.id, &token[..token.len().min(20)]);
|
||||||
|
let deep_link = format!("com.arrelin.family-budget-android://auth?token={}", token);
|
||||||
|
let html = format!(
|
||||||
|
r#"<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url={0}"></head><body><script>window.location="{0}"</script></body></html>"#,
|
||||||
|
deep_link
|
||||||
|
);
|
||||||
|
return Ok(Html(html).into_response());
|
||||||
|
}
|
||||||
|
|
||||||
auth_session
|
auth_session
|
||||||
.login(&user)
|
.login(&user)
|
||||||
.await
|
.await
|
||||||
@@ -127,14 +219,57 @@ pub async fn google_callback(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if !authorized_families.contains(&family_id) {
|
if !authorized_families.contains(&family_id) {
|
||||||
authorized_families.push(family_id);
|
authorized_families.push(family_id);
|
||||||
session
|
session.insert("authorized_families", authorized_families).await.ok();
|
||||||
.insert("authorized_families", authorized_families)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let redirect_url = frontend_url.unwrap_or_else(|| "http://localhost:3000".to_string());
|
let redirect_url = frontend_url.unwrap_or_else(|| "http://localhost:3000".to_string());
|
||||||
|
Ok(Redirect::temporary(&redirect_url).into_response())
|
||||||
Ok(Redirect::temporary(&redirect_url))
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct MobileCallbackQuery {
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mobile_callback(
|
||||||
|
mut auth_session: AuthSession<AuthBackend>,
|
||||||
|
session: Session,
|
||||||
|
State(db): State<DatabaseConnection>,
|
||||||
|
Query(query): Query<MobileCallbackQuery>,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
info!("mobile_callback: received token_prefix={}", &query.token[..query.token.len().min(20)]);
|
||||||
|
let user_id = match verify_auth_token(&query.token) {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
warn!("mobile_callback: token verification failed for token={}", &query.token[..query.token.len().min(40)]);
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!("mobile_callback: token valid for user_id={}", user_id);
|
||||||
|
|
||||||
|
let user = User::find_by_id(user_id)
|
||||||
|
.one(&db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
auth_session
|
||||||
|
.login(&user)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if let Some(family_id) = user.family_id {
|
||||||
|
let mut authorized_families: Vec<i32> = session
|
||||||
|
.get("authorized_families")
|
||||||
|
.await
|
||||||
|
.unwrap_or(None)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !authorized_families.contains(&family_id) {
|
||||||
|
authorized_families.push(family_id);
|
||||||
|
session.insert("authorized_families", authorized_families).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({"success": true})))
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
use sea_orm::*;
|
use sea_orm::*;
|
||||||
use sea_orm::prelude::Decimal;
|
use sea_orm::prelude::Decimal;
|
||||||
use chrono::Utc;
|
use chrono::{Utc, Datelike};
|
||||||
|
use std::collections::HashMap;
|
||||||
use crate::models::expense::{self, Entity as Expense, Model as ExpenseModel};
|
use crate::models::expense::{self, Entity as Expense, Model as ExpenseModel};
|
||||||
use crate::models::category::{Entity as Category};
|
use crate::models::category::{Entity as Category};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MonthlyExpenseGroup {
|
||||||
|
pub year: i32,
|
||||||
|
pub month: u32,
|
||||||
|
pub total_amount: Decimal,
|
||||||
|
pub expenses: Vec<ExpenseModel>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ExpenseService;
|
pub struct ExpenseService;
|
||||||
|
|
||||||
impl ExpenseService {
|
impl ExpenseService {
|
||||||
@@ -18,6 +27,7 @@ impl ExpenseService {
|
|||||||
amount: Set(amount),
|
amount: Set(amount),
|
||||||
description: Set(description),
|
description: Set(description),
|
||||||
created_at: Set(Utc::now().naive_utc()),
|
created_at: Set(Utc::now().naive_utc()),
|
||||||
|
active: Set(true),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,6 +48,28 @@ impl ExpenseService {
|
|||||||
) -> Result<Vec<ExpenseModel>, DbErr> {
|
) -> Result<Vec<ExpenseModel>, DbErr> {
|
||||||
Expense::find()
|
Expense::find()
|
||||||
.filter(expense::Column::CategoryId.eq(category_id))
|
.filter(expense::Column::CategoryId.eq(category_id))
|
||||||
|
.filter(expense::Column::Active.eq(true))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_all_by_category_id(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
category_id: i32,
|
||||||
|
) -> Result<Vec<ExpenseModel>, DbErr> {
|
||||||
|
Expense::find()
|
||||||
|
.filter(expense::Column::CategoryId.eq(category_id))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_inactive_by_category_id(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
category_id: i32,
|
||||||
|
) -> Result<Vec<ExpenseModel>, DbErr> {
|
||||||
|
Expense::find()
|
||||||
|
.filter(expense::Column::CategoryId.eq(category_id))
|
||||||
|
.filter(expense::Column::Active.eq(false))
|
||||||
.all(db)
|
.all(db)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -66,14 +98,15 @@ impl ExpenseService {
|
|||||||
expense.update(db).await
|
expense.update(db).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(db: &DatabaseConnection, id: i32) -> Result<DeleteResult, DbErr> {
|
pub async fn delete(db: &DatabaseConnection, id: i32) -> Result<ExpenseModel, DbErr> {
|
||||||
let expense = Expense::find_by_id(id)
|
let expense = Expense::find_by_id(id)
|
||||||
.one(db)
|
.one(db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(DbErr::RecordNotFound("Expense not found".to_string()))?;
|
.ok_or(DbErr::RecordNotFound("Expense not found".to_string()))?;
|
||||||
|
|
||||||
let expense: expense::ActiveModel = expense.into();
|
let mut expense: expense::ActiveModel = expense.into();
|
||||||
expense.delete(db).await
|
expense.active = Set(false);
|
||||||
|
expense.update(db).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn calculate_remaining_limit(
|
pub async fn calculate_remaining_limit(
|
||||||
@@ -92,4 +125,60 @@ impl ExpenseService {
|
|||||||
|
|
||||||
Ok(remaining)
|
Ok(remaining)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_expense_history(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
category_id: i32,
|
||||||
|
sort_order: Option<String>,
|
||||||
|
show_archive: bool,
|
||||||
|
) -> Result<Vec<MonthlyExpenseGroup>, DbErr> {
|
||||||
|
let expenses = if show_archive {
|
||||||
|
Self::find_inactive_by_category_id(db, category_id).await?
|
||||||
|
} else {
|
||||||
|
Self::find_by_category_id(db, category_id).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut grouped: HashMap<(i32, u32), Vec<ExpenseModel>> = HashMap::new();
|
||||||
|
|
||||||
|
for expense in expenses {
|
||||||
|
let year = expense.created_at.year();
|
||||||
|
let month = expense.created_at.month();
|
||||||
|
grouped.entry((year, month))
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(expense);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result: Vec<MonthlyExpenseGroup> = grouped
|
||||||
|
.into_iter()
|
||||||
|
.map(|((year, month), mut expenses)| {
|
||||||
|
let total_amount: Decimal = expenses
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.amount)
|
||||||
|
.sum();
|
||||||
|
expenses.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
|
MonthlyExpenseGroup {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
total_amount,
|
||||||
|
expenses,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let sort_desc = sort_order
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| s.to_lowercase() == "desc")
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
result.sort_by(|a, b| {
|
||||||
|
let cmp = a.year.cmp(&b.year).then(a.month.cmp(&b.month));
|
||||||
|
if sort_desc {
|
||||||
|
cmp.reverse()
|
||||||
|
} else {
|
||||||
|
cmp
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ impl OAuthService {
|
|||||||
let redirect_url = std::env::var("GOOGLE_REDIRECT_URL")
|
let redirect_url = std::env::var("GOOGLE_REDIRECT_URL")
|
||||||
.unwrap_or_else(|_| "http://localhost:8080/api/auth/google/callback".to_string());
|
.unwrap_or_else(|_| "http://localhost:8080/api/auth/google/callback".to_string());
|
||||||
|
|
||||||
let client = Self::getClient(client_id, client_secret, redirect_url);
|
let client = Self::get_client(client_id, client_secret, redirect_url);
|
||||||
|
|
||||||
let (auth_url, csrf_token) = client
|
let (auth_url, csrf_token) = client
|
||||||
.authorize_url(CsrfToken::new_random)
|
.authorize_url(CsrfToken::new_random)
|
||||||
@@ -44,6 +44,26 @@ impl OAuthService {
|
|||||||
(auth_url.to_string(), csrf_token)
|
(auth_url.to_string(), csrf_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_auth_url_with_state(&self, state: String) -> String {
|
||||||
|
let client_id = std::env::var("GOOGLE_CLIENT_ID")
|
||||||
|
.expect("GOOGLE_CLIENT_ID must be set");
|
||||||
|
let client_secret = std::env::var("GOOGLE_CLIENT_SECRET")
|
||||||
|
.expect("GOOGLE_CLIENT_SECRET must be set");
|
||||||
|
let redirect_url = std::env::var("GOOGLE_REDIRECT_URL")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:8080/api/auth/google/callback".to_string());
|
||||||
|
|
||||||
|
let client = Self::get_client(client_id, client_secret, redirect_url);
|
||||||
|
|
||||||
|
let (auth_url, _) = client
|
||||||
|
.authorize_url(move || CsrfToken::new(state))
|
||||||
|
.add_scope(Scope::new("openid".to_string()))
|
||||||
|
.add_scope(Scope::new("email".to_string()))
|
||||||
|
.add_scope(Scope::new("profile".to_string()))
|
||||||
|
.url();
|
||||||
|
|
||||||
|
auth_url.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn exchange_code(&self, code: String) -> Result<String, OAuthError> {
|
pub async fn exchange_code(&self, code: String) -> Result<String, OAuthError> {
|
||||||
let client_id = std::env::var("GOOGLE_CLIENT_ID")
|
let client_id = std::env::var("GOOGLE_CLIENT_ID")
|
||||||
.expect("GOOGLE_CLIENT_ID must be set");
|
.expect("GOOGLE_CLIENT_ID must be set");
|
||||||
@@ -52,7 +72,7 @@ impl OAuthService {
|
|||||||
let redirect_url = std::env::var("GOOGLE_REDIRECT_URL")
|
let redirect_url = std::env::var("GOOGLE_REDIRECT_URL")
|
||||||
.unwrap_or_else(|_| "http://localhost:8080/api/auth/google/callback".to_string());
|
.unwrap_or_else(|_| "http://localhost:8080/api/auth/google/callback".to_string());
|
||||||
|
|
||||||
let client = Self::getClient(client_id, client_secret, redirect_url);
|
let client = Self::get_client(client_id, client_secret, redirect_url);
|
||||||
|
|
||||||
let http_client = oauth2::reqwest::ClientBuilder::new()
|
let http_client = oauth2::reqwest::ClientBuilder::new()
|
||||||
.build()
|
.build()
|
||||||
@@ -67,7 +87,7 @@ impl OAuthService {
|
|||||||
Ok(token.access_token().secret().clone())
|
Ok(token.access_token().secret().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn getClient(client_id: String, client_secret: String, redirect_url: String) -> Client<BasicErrorResponse,
|
fn get_client(client_id: String, client_secret: String, redirect_url: String) -> Client<BasicErrorResponse,
|
||||||
BasicTokenResponse,
|
BasicTokenResponse,
|
||||||
BasicTokenIntrospectionResponse,
|
BasicTokenIntrospectionResponse,
|
||||||
StandardRevocableToken,
|
StandardRevocableToken,
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#667eea" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<title>Family budget</title>
|
<title>Family budget</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
94
frontend/package-lock.json
generated
94
frontend/package-lock.json
generated
@@ -9,14 +9,16 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
"@tauri-apps/api": "^2.10.1",
|
||||||
|
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||||
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"axios-retry": "^4.5.0",
|
|
||||||
"i18next": "^25.8.0",
|
"i18next": "^25.8.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
|
||||||
"react-i18next": "^16.5.3",
|
"react-i18next": "^16.5.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
@@ -1610,6 +1612,43 @@
|
|||||||
"tailwindcss": "4.1.18"
|
"tailwindcss": "4.1.18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/api": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/tauri"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-deep-link": {
|
||||||
|
"version": "2.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.7.tgz",
|
||||||
|
"integrity": "sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
|
"version": "2.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||||
|
"integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-shell": {
|
||||||
|
"version": "2.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
|
||||||
|
"integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -2107,18 +2146,6 @@
|
|||||||
"proxy-from-env": "^1.1.0"
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -2320,6 +2347,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
@@ -2930,15 +2958,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -3132,18 +3151,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -3827,23 +3834,6 @@
|
|||||||
"react": "^19.2.1"
|
"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": {
|
"node_modules/react-i18next": {
|
||||||
"version": "16.5.3",
|
"version": "16.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.3.tgz",
|
||||||
|
|||||||
@@ -11,14 +11,16 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
"@tauri-apps/api": "^2.10.1",
|
||||||
|
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||||
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"axios-retry": "^4.5.0",
|
|
||||||
"i18next": "^25.8.0",
|
"i18next": "^25.8.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
|
||||||
"react-i18next": "^16.5.3",
|
"react-i18next": "^16.5.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
|
|||||||
@@ -10,16 +10,26 @@ import Profile from './pages/Profile';
|
|||||||
import { useStore } from './store/useStore';
|
import { useStore } from './store/useStore';
|
||||||
import { authApi } from './api/client';
|
import { authApi } from './api/client';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { user, isAuthenticated, isLoading, setUser, setIsLoading } = useStore();
|
const { user, isAuthenticated, isLoading, setUser, setIsLoading } = useStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
const themeColors: Record<string, string> = {
|
||||||
|
light: '#667eea',
|
||||||
|
dark: '#000000',
|
||||||
|
sunset: '#f97316',
|
||||||
|
ocean: '#3b82f6',
|
||||||
|
forest: '#22c55e',
|
||||||
|
purple: '#8b5cf6',
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedTheme = localStorage.getItem('theme') || 'light';
|
const storedTheme = localStorage.getItem('theme') || 'light';
|
||||||
document.documentElement.setAttribute('data-theme', storedTheme);
|
document.documentElement.setAttribute('data-theme', storedTheme);
|
||||||
|
const metaTheme = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (metaTheme) metaTheme.setAttribute('content', themeColors[storedTheme] ?? '#667eea');
|
||||||
|
|
||||||
const storedLocale = localStorage.getItem('locale');
|
const storedLocale = localStorage.getItem('locale');
|
||||||
if (storedLocale && storedLocale !== i18n.language) {
|
if (storedLocale && storedLocale !== i18n.language) {
|
||||||
@@ -90,11 +100,9 @@ function AppContent() {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppContent />
|
<AppContent />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import axios, { AxiosError } from 'axios';
|
import axios from 'axios';
|
||||||
import axiosRetry from 'axios-retry';
|
|
||||||
import type {
|
import type {
|
||||||
Family,
|
Family,
|
||||||
Category,
|
Category,
|
||||||
@@ -27,6 +26,7 @@ import type {
|
|||||||
JoinFamilyResponse,
|
JoinFamilyResponse,
|
||||||
FamilyMember,
|
FamilyMember,
|
||||||
LeaveFamilyResponse,
|
LeaveFamilyResponse,
|
||||||
|
ExpenseHistoryResponse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
||||||
@@ -34,51 +34,8 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
|||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
withCredentials: true,
|
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 = {
|
export const authApi = {
|
||||||
login: (data: LoginRequest) =>
|
login: (data: LoginRequest) =>
|
||||||
apiClient.post<LoginResponse>('/login', data),
|
apiClient.post<LoginResponse>('/login', data),
|
||||||
@@ -89,10 +46,16 @@ export const authApi = {
|
|||||||
me: () =>
|
me: () =>
|
||||||
apiClient.get<User>('/me'),
|
apiClient.get<User>('/me'),
|
||||||
|
|
||||||
getGoogleAuthUrl: (redirectUrl?: string) =>
|
getGoogleAuthUrl: (redirectUrl?: string, mobile?: boolean) =>
|
||||||
apiClient.get<OAuthUrlResponse>('/auth/google', {
|
apiClient.get<OAuthUrlResponse>('/auth/google', {
|
||||||
params: redirectUrl ? { redirect_url: redirectUrl } : undefined,
|
params: {
|
||||||
|
...(redirectUrl ? { redirect_url: redirectUrl } : {}),
|
||||||
|
...(mobile ? { mobile: true } : {}),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
mobileCallback: (token: string) =>
|
||||||
|
apiClient.get('/auth/mobile-callback', { params: { token } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const familyApi = {
|
export const familyApi = {
|
||||||
@@ -164,6 +127,11 @@ export const expenseApi = {
|
|||||||
|
|
||||||
getRemainingLimit: (familyId: number, categoryId: number) =>
|
getRemainingLimit: (familyId: number, categoryId: number) =>
|
||||||
apiClient.get<RemainingLimit>(`/families/${familyId}/categories/${categoryId}/remaining`),
|
apiClient.get<RemainingLimit>(`/families/${familyId}/categories/${categoryId}/remaining`),
|
||||||
|
|
||||||
|
getHistory: (familyId: number, categoryId: number, showArchive: boolean = false, sortOrder: string = 'desc') =>
|
||||||
|
apiClient.get<ExpenseHistoryResponse>(`/families/${familyId}/categories/${categoryId}/expenses/history`, {
|
||||||
|
params: { show_archive: showArchive, sort_order: sortOrder },
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shoppingItemApi = {
|
export const shoppingItemApi = {
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
import type { 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<Props, State> {
|
|
||||||
constructor(props: Props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
hasError: false,
|
|
||||||
error: null,
|
|
||||||
errorInfo: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(_error: Error): Partial<State> {
|
|
||||||
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 (
|
|
||||||
<div className="min-h-screen flex items-center justify-center gradient-bg p-4">
|
|
||||||
<div className="max-w-lg w-full glass-effect rounded-2xl shadow-2xl p-8 text-center">
|
|
||||||
<div className="inline-flex p-4 bg-red-100 dark:bg-red-900/20 rounded-full mb-6">
|
|
||||||
<AlertTriangle className="w-12 h-12 text-red-600 dark:text-red-400" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
|
||||||
Oops! Something went wrong
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
|
||||||
We're sorry, but something unexpected happened. Please try refreshing the page or going back to the home page.
|
|
||||||
</p>
|
|
||||||
{import.meta.env.DEV && this.state.error && (
|
|
||||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/10 rounded-lg text-left">
|
|
||||||
<p className="text-sm font-mono text-red-800 dark:text-red-300 break-all">
|
|
||||||
{this.state.error.toString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button variant="primary" fullWidth onClick={() => window.location.reload()}>
|
|
||||||
Reload Page
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" fullWidth onClick={this.handleReset}>
|
|
||||||
Go to Home
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<ShoppingItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [newItemName, setNewItemName] = useState('');
|
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
|
||||||
const [editingName, setEditingName] = useState('');
|
|
||||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(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 (
|
|
||||||
<>
|
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
|
||||||
<div className="glass-effect rounded-3xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
|
||||||
<div className="btn-success p-6 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl">
|
|
||||||
<ShoppingCart className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-bold text-white">{t('shopping.title')}</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-2 hover:bg-white/20 rounded-xl transition-all"
|
|
||||||
>
|
|
||||||
<X className="w-6 h-6 text-white" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 flex-1 overflow-y-auto">
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={t('shopping.addPlaceholder')}
|
|
||||||
value={newItemName}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleAddItem}
|
|
||||||
className="px-6 py-3 btn-success text-white rounded-2xl hover:shadow-lg transition-all font-semibold flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
<span className="hidden sm:inline">{t('common.add')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{unpurchasedItems.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-700 mb-3">{t('shopping.toBuy')}</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{unpurchasedItems.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="bg-gray-50 p-4 rounded-2xl border-2 border-gray-200 hover:border-green-300 transition-all"
|
|
||||||
>
|
|
||||||
{editingId === item.id ? (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editingName}
|
|
||||||
onChange={(e) => 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
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSaveEdit(item.id)}
|
|
||||||
className="px-4 py-2 bg-green-500 text-white rounded-xl hover:bg-green-600 transition-all"
|
|
||||||
>
|
|
||||||
<Check className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCancelEdit}
|
|
||||||
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-xl hover:bg-gray-400 transition-all"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleTogglePurchased(item.id, item.is_purchased)}
|
|
||||||
className="w-6 h-6 border-2 border-gray-400 rounded-lg hover:border-green-500 transition-all"
|
|
||||||
/>
|
|
||||||
<span className="text-gray-800 font-medium">{item.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleStartEdit(item)}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-xl transition-all"
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteItem(item.id)}
|
|
||||||
className="p-2 hover:bg-red-100 rounded-xl transition-all"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 text-red-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{purchasedItems.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-700 mb-3">{t('shopping.purchased')}</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{purchasedItems.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="shopping-purchased p-4 rounded-2xl border-2"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleTogglePurchased(item.id, item.is_purchased)}
|
|
||||||
className="w-6 h-6 bg-green-500 border-2 border-green-500 rounded-lg flex items-center justify-center hover:bg-green-600 transition-all"
|
|
||||||
>
|
|
||||||
<Check className="w-4 h-4 text-white" />
|
|
||||||
</button>
|
|
||||||
<span className="text-gray-500 line-through">{item.name}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteItem(item.id)}
|
|
||||||
className="p-2 hover:bg-red-100 rounded-xl transition-all"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 text-red-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{items.length === 0 && (
|
|
||||||
<div className="text-center py-12 text-gray-500">
|
|
||||||
<ShoppingCart className="w-16 h-16 mx-auto mb-4 opacity-30" />
|
|
||||||
<p className="text-lg">{t('shopping.empty')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{items.length > 0 && (
|
|
||||||
<div className="p-6 border-t-2 border-gray-200 bg-gray-50">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleMarkAllPurchased}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 btn-success text-white rounded-2xl hover:shadow-lg transition-all font-semibold"
|
|
||||||
>
|
|
||||||
<Check className="w-5 h-5" />
|
|
||||||
{t('shopping.allPurchased')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleClearAll}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 btn-danger text-white rounded-2xl hover:shadow-lg transition-all font-semibold"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-5 h-5" />
|
|
||||||
{t('shopping.clear')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{confirmContent && (
|
|
||||||
<ConfirmModal
|
|
||||||
title={confirmContent.title}
|
|
||||||
message={confirmContent.message}
|
|
||||||
confirmText={confirmContent.confirmText}
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={() => setConfirmAction(null)}
|
|
||||||
variant={confirmContent.variant || 'danger'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,124 +1,375 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ShoppingCart, CheckCheck, Trash2 } from 'lucide-react';
|
import { shoppingItemApi } from '../api/client';
|
||||||
import { useShoppingList, useConfirm } from '../hooks';
|
import type { ShoppingItem } from '../types';
|
||||||
import { shoppingService } from '../services';
|
import {
|
||||||
import { Modal, Button, LoadingSpinner, ConfirmModal, Badge } from './ui';
|
X,
|
||||||
import { ShoppingItemInput } from './shopping/ShoppingItemInput';
|
Plus,
|
||||||
import { ShoppingItemList } from './shopping/ShoppingItemList';
|
Trash2,
|
||||||
|
ShoppingCart,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
interface ShoppingListModalProps {
|
interface ShoppingListModalProps {
|
||||||
familyId: number;
|
familyId: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConfirmAction =
|
||||||
|
| { type: 'delete-item'; itemId: number }
|
||||||
|
| { type: 'mark-all' }
|
||||||
|
| { type: 'clear-all' };
|
||||||
|
|
||||||
export default function ShoppingListModal({ familyId, onClose }: ShoppingListModalProps) {
|
export default function ShoppingListModal({ familyId, onClose }: ShoppingListModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { items, loading, createItem, deleteItem, togglePurchased, markAllAsPurchased, clearAll } = useShoppingList(familyId);
|
const [items, setItems] = useState<ShoppingItem[]>([]);
|
||||||
const { confirmState, confirm, cancel } = useConfirm();
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [newItemName, setNewItemName] = useState('');
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editingName, setEditingName] = useState('');
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
|
||||||
const stats = shoppingService.getStats(items);
|
useEffect(() => {
|
||||||
|
loadItems();
|
||||||
|
}, [familyId]);
|
||||||
|
|
||||||
const handleAddItem = async (name: string) => {
|
const loadItems = async () => {
|
||||||
await createItem({ name });
|
try {
|
||||||
};
|
setLoading(true);
|
||||||
|
const response = await shoppingItemApi.getAllByFamily(familyId);
|
||||||
const handleDelete = async (itemId: number) => {
|
setItems(response.data);
|
||||||
await confirm(t('shopping.deleteConfirm'), t('shopping.deleteMessage'));
|
} catch (err) {
|
||||||
await deleteItem(itemId);
|
console.error('Error loading shopping items:', err);
|
||||||
};
|
alert(t('shopping.loadError'));
|
||||||
|
} finally {
|
||||||
const handleMarkAll = async () => {
|
setLoading(false);
|
||||||
await confirm(t('shopping.markAllConfirm'), t('shopping.markAllMessage'));
|
|
||||||
await markAllAsPurchased();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearAll = async () => {
|
|
||||||
await confirm(t('shopping.clearAllConfirm'), t('shopping.clearAllMessage'));
|
|
||||||
await clearAll();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async (itemId: number, name: string) => {
|
|
||||||
const item = items.find((i) => i.id === itemId);
|
|
||||||
if (item) {
|
|
||||||
await createItem({ name });
|
|
||||||
await deleteItem(itemId);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal isOpen={true} onClose={onClose} title={t('shopping.title')} size="lg">
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="glass-effect rounded-3xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="btn-success p-6 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl">
|
||||||
|
<ShoppingCart className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold text-white">{t('shopping.title')}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-white/20 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6 text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 flex-1 overflow-y-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('shopping.addPlaceholder')}
|
||||||
|
value={newItemName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAddItem}
|
||||||
|
className="px-6 py-3 btn-success text-white rounded-2xl hover:shadow-lg transition-all font-semibold flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
<span className="hidden sm:inline">{t('common.add')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<LoadingSpinner size="lg" text={t('common.loading')} />
|
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<div className="mb-6">
|
{unpurchasedItems.length > 0 && (
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div>
|
||||||
<div className="flex-1">
|
<h3 className="text-lg font-semibold text-gray-700 mb-3">{t('shopping.toBuy')}</h3>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="space-y-2">
|
||||||
<ShoppingCart className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
{unpurchasedItems.map((item) => (
|
||||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
|
<div
|
||||||
{t('shopping.stats')}
|
key={item.id}
|
||||||
</span>
|
className="bg-gray-50 p-4 rounded-2xl border-2 border-gray-200 hover:border-green-300 transition-all"
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Badge variant="default">
|
|
||||||
{t('shopping.total')}: {stats.total}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="success">
|
|
||||||
{t('shopping.purchased')}: {stats.purchased}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="warning">
|
|
||||||
{t('shopping.pending')}: {stats.pending}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{stats.pending > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="success"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleMarkAll}
|
|
||||||
>
|
>
|
||||||
<CheckCheck className="w-4 h-4 mr-2" />
|
{editingId === item.id ? (
|
||||||
{t('shopping.markAll')}
|
<div className="flex gap-2">
|
||||||
</Button>
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingName}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveEdit(item.id)}
|
||||||
|
className="px-4 py-2 bg-green-500 text-white rounded-xl hover:bg-green-600 transition-all"
|
||||||
|
>
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-xl hover:bg-gray-400 transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTogglePurchased(item.id, item.is_purchased)}
|
||||||
|
className="w-6 h-6 border-2 border-gray-400 rounded-lg hover:border-green-500 transition-all"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-800 font-medium">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartEdit(item)}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteItem(item.id)}
|
||||||
|
className="p-2 hover:bg-red-100 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{stats.total > 0 && (
|
</div>
|
||||||
<Button
|
))}
|
||||||
variant="danger"
|
</div>
|
||||||
size="sm"
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{purchasedItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-700 mb-3">{t('shopping.purchased')}</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{purchasedItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="shopping-purchased p-4 rounded-2xl border-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTogglePurchased(item.id, item.is_purchased)}
|
||||||
|
className="w-6 h-6 bg-green-500 border-2 border-green-500 rounded-lg flex items-center justify-center hover:bg-green-600 transition-all"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 text-white" />
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-500 line-through">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteItem(item.id)}
|
||||||
|
className="p-2 hover:bg-red-100 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
<ShoppingCart className="w-16 h-16 mx-auto mb-4 opacity-30" />
|
||||||
|
<p className="text-lg">{t('shopping.empty')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="p-6 border-t-2 border-gray-200 bg-gray-50">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleMarkAllPurchased}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 btn-success text-white rounded-2xl hover:shadow-lg transition-all font-semibold"
|
||||||
|
>
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
{t('shopping.allPurchased')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
onClick={handleClearAll}
|
onClick={handleClearAll}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 btn-danger text-white rounded-2xl hover:shadow-lg transition-all font-semibold"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-5 h-5" />
|
||||||
{t('shopping.clearAll')}
|
{t('shopping.clear')}
|
||||||
</Button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ShoppingItemInput onAdd={handleAddItem} />
|
{confirmContent && (
|
||||||
</div>
|
|
||||||
|
|
||||||
<ShoppingItemList
|
|
||||||
items={items}
|
|
||||||
onToggle={togglePurchased}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onUpdate={handleUpdate}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={confirmState.isOpen}
|
title={confirmContent.title}
|
||||||
title={confirmState.title}
|
message={confirmContent.message}
|
||||||
message={confirmState.message}
|
confirmText={confirmContent.confirmText}
|
||||||
onConfirm={confirmState.onConfirm}
|
onConfirm={handleConfirm}
|
||||||
onCancel={cancel}
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
variant={confirmContent.variant || 'danger'}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 type { CreateCategoryRequest } from '../../types';
|
|
||||||
|
|
||||||
interface AddCategorySectionProps {
|
|
||||||
showForm: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
onCreate: (data: CreateCategoryRequest) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="glass-effect rounded-2xl shadow-lg p-6">
|
|
||||||
{!showForm ? (
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className="w-full flex items-center justify-center gap-3 py-4 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-xl transition-all duration-300 group"
|
|
||||||
>
|
|
||||||
<Plus className="w-6 h-6 group-hover:scale-110 transition-transform" />
|
|
||||||
<span>{t('category.add')}</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-xl font-bold text-gray-900">{t('category.add')}</h3>
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder={t('category.name')}
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder={t('category.limit')}
|
|
||||||
value={limitAmount}
|
|
||||||
onChange={(e) => setLimitAmount(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button
|
|
||||||
variant="success"
|
|
||||||
fullWidth
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSubmitting || !name || !limitAmount}
|
|
||||||
>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
fullWidth
|
|
||||||
onClick={onToggle}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 type { CategoryWithRemaining } from '../../services';
|
|
||||||
import { categoryService, expenseService } from '../../services';
|
|
||||||
import { useExpenses } from '../../hooks';
|
|
||||||
import { format } from '../../utils/format';
|
|
||||||
import { Button, Input } 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 (
|
|
||||||
<div className="glass-effect rounded-2xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl">
|
|
||||||
<div className="p-5">
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-3 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl">
|
|
||||||
<Tag className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold text-gray-900">{category.name}</h3>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{t('category.limit')}: {format.currency(category.limit_amount)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onDelete(category.id)}
|
|
||||||
className="p-2 hover:bg-red-100 rounded-lg transition-colors group"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-5 h-5 text-red-600 group-hover:scale-110 transition-transform" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onReset(category.id)}
|
|
||||||
className="p-2 hover:bg-blue-100 rounded-lg transition-colors group"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-5 h-5 text-blue-600 group-hover:scale-110 transition-transform" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex justify-between items-center mb-2">
|
|
||||||
<span className="text-sm font-medium text-gray-700">{t('category.remaining')}</span>
|
|
||||||
<span className="text-lg font-bold text-green-600">
|
|
||||||
{format.currency(category.remaining_limit)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full transition-all duration-500 ${
|
|
||||||
progressColor === 'danger' ? 'bg-red-500' :
|
|
||||||
progressColor === 'warning' ? 'bg-yellow-500' :
|
|
||||||
'bg-green-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${100 - progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
fullWidth
|
|
||||||
onClick={() => setShowAddExpense(!showAddExpense)}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
{t('expense.add')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
fullWidth
|
|
||||||
onClick={handleShowHistory}
|
|
||||||
>
|
|
||||||
<History className="w-4 h-4 mr-2" />
|
|
||||||
{t('expense.history')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAddExpense && (
|
|
||||||
<div className="border-t border-gray-200 p-5 bg-gray-50">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
|
||||||
<TrendingDown className="w-5 h-5 text-red-600" />
|
|
||||||
{t('expense.add')}
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder={t('expense.amount')}
|
|
||||||
value={expenseAmount}
|
|
||||||
onChange={(e) => setExpenseAmount(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder={t('expense.description')}
|
|
||||||
value={expenseDescription}
|
|
||||||
onChange={(e) => setExpenseDescription(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="success" fullWidth onClick={handleAddExpense}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" fullWidth onClick={() => setShowAddExpense(false)}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showHistory && (
|
|
||||||
<div className="border-t border-gray-200 p-5 bg-gray-50 max-h-96 overflow-y-auto">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h4 className="font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
<History className="w-5 h-5 text-blue-600" />
|
|
||||||
{t('expense.history')}
|
|
||||||
</h4>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowHistory(false)}
|
|
||||||
className="p-1 hover:bg-gray-200 rounded transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{expenses.length === 0 ? (
|
|
||||||
<p className="text-gray-600 text-center py-4">{t('expense.noHistory')}</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{expenseService.sortByDate(expenses).map((expense) => (
|
|
||||||
<div
|
|
||||||
key={expense.id}
|
|
||||||
className="flex items-start justify-between p-3 bg-white rounded-lg border border-gray-200"
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<DollarSign className="w-4 h-4 text-red-600" />
|
|
||||||
<span className="font-semibold text-gray-900">
|
|
||||||
{format.currency(expense.amount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{expense.description && (
|
|
||||||
<div className="flex items-start gap-2 text-sm text-gray-600">
|
|
||||||
<MessageSquare className="w-4 h-4 mt-0.5" />
|
|
||||||
<span>{expense.description}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
|
|
||||||
<Calendar className="w-3 h-3" />
|
|
||||||
<span>{format.date(expense.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { 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 (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
|
||||||
{categories.map((category) => (
|
|
||||||
<CategoryCard
|
|
||||||
key={category.id}
|
|
||||||
category={category}
|
|
||||||
familyId={familyId}
|
|
||||||
onDelete={onDelete}
|
|
||||||
onReset={onReset}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<button
|
|
||||||
onClick={onInvite}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 group"
|
|
||||||
>
|
|
||||||
<UserPlus className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
|
||||||
<span className="font-medium">{t('family.inviteMember')}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onProfile}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 group"
|
|
||||||
>
|
|
||||||
<User className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
|
||||||
<span className="font-medium">{t('profile.title')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { Wallet, ShoppingCart } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type { 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 (
|
|
||||||
<div className="mb-6 sm:mb-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
|
|
||||||
<Wallet className="w-12 h-12 text-white" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl sm:text-5xl font-bold text-white mb-6">
|
|
||||||
{familyName || t('family.defaultName')}
|
|
||||||
</h1>
|
|
||||||
<div className="max-w-2xl mx-auto glass-effect rounded-2xl shadow-lg p-5">
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-gray-600 font-medium text-sm mb-2">{t('family.totalLimit')}</p>
|
|
||||||
<p className="text-2xl sm:text-3xl font-bold text-gray-900">
|
|
||||||
{format.currency(getTotalLimit())}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-gray-600 font-medium text-sm mb-2">{t('family.totalRemaining')}</p>
|
|
||||||
<p className="text-2xl sm:text-3xl font-bold text-green-600">
|
|
||||||
{format.currency(getTotalRemaining())}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onShowShoppingList}
|
|
||||||
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white font-semibold py-3 px-6 rounded-xl shadow-md transition-all duration-300 flex items-center justify-center gap-3 group"
|
|
||||||
>
|
|
||||||
<ShoppingCart className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
|
||||||
<span>{t('shopping.title')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 type { InviteLinkResponse } from '../../types';
|
|
||||||
|
|
||||||
interface InviteModalProps {
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InviteModal({ onClose }: InviteModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { createLink } = useInviteLink();
|
|
||||||
const [inviteLink, setInviteLink] = useState<InviteLinkResponse | null>(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 (
|
|
||||||
<Modal isOpen={true} onClose={onClose} title={t('family.inviteMember')}>
|
|
||||||
{!inviteLink ? (
|
|
||||||
<div className="text-center py-6">
|
|
||||||
<p className="text-gray-700 dark:text-gray-300 mb-6">
|
|
||||||
{t('invite.description')}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={handleCreateLink}
|
|
||||||
disabled={loading}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
|
||||||
{t('common.loading')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t('invite.create')
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="p-4 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
{t('invite.link')}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-mono break-all text-gray-900 dark:text-white">
|
|
||||||
{inviteLink.invite_url}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={handleCopy}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<>
|
|
||||||
<Check className="w-5 h-5 mr-2" />
|
|
||||||
{t('invite.copied')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Copy className="w-5 h-5 mr-2" />
|
|
||||||
{t('invite.copy')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Users, Edit3, Save, X, AlertTriangle, Loader2 } from 'lucide-react';
|
|
||||||
import type { 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 (
|
|
||||||
<Card>
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="p-2 bg-gradient-to-br from-green-500 to-green-600 text-white rounded-xl">
|
|
||||||
<Users className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold text-gray-800 dark:text-white">{t('profile.family')}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex justify-between items-center py-2 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">{t('profile.familyName')}</span>
|
|
||||||
{editingName ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={newFamilyName}
|
|
||||||
onChange={(e) => setNewFamilyName(e.target.value)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveName}
|
|
||||||
disabled={savingName}
|
|
||||||
className="p-1.5 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
|
|
||||||
>
|
|
||||||
{savingName ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingName(false)}
|
|
||||||
className="p-1.5 bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white">{family.name}</span>
|
|
||||||
<button
|
|
||||||
onClick={handleStartEditName}
|
|
||||||
className="p-1.5 text-gray-500 hover:text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Edit3 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
fullWidth
|
|
||||||
onClick={onLeaveFamily}
|
|
||||||
disabled={leavingFamily}
|
|
||||||
>
|
|
||||||
{leavingFamily ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
|
||||||
{t('common.loading')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<AlertTriangle className="w-5 h-5 mr-2" />
|
|
||||||
{t('profile.leaveFamily')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
|
||||||
{t('profile.language')}
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{LANGUAGES.map((lang) => (
|
|
||||||
<Button
|
|
||||||
key={lang.code}
|
|
||||||
variant={currentLanguage === lang.code ? 'primary' : 'secondary'}
|
|
||||||
onClick={() => onLanguageChange(lang.code)}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{lang.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type { 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 (
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3">
|
|
||||||
{t('profile.members')}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<LoadingSpinner size="sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (members.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3">
|
|
||||||
{t('profile.members')}
|
|
||||||
</h3>
|
|
||||||
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
|
|
||||||
{t('profile.noMembers')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3">
|
|
||||||
{t('profile.members')} ({members.length})
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{members.map((member) => (
|
|
||||||
<div
|
|
||||||
key={member.id}
|
|
||||||
className={`flex items-center justify-between p-3 rounded-xl ${
|
|
||||||
member.id === currentUser?.id
|
|
||||||
? 'bg-purple-50 dark:bg-purple-900/20 border-2 border-purple-200 dark:border-purple-800'
|
|
||||||
: 'bg-gray-50 dark:bg-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white text-sm font-bold">
|
|
||||||
{(member.username || member.email || '?')[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white">
|
|
||||||
{member.username || member.email || t('profile.unknownUser')}
|
|
||||||
</span>
|
|
||||||
{member.id === currentUser?.id && (
|
|
||||||
<Badge variant="info" size="sm">
|
|
||||||
{t('profile.you')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{member.email && member.username && (
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{member.email}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{member.is_admin && (
|
|
||||||
<Badge variant="warning" size="sm">
|
|
||||||
Admin
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<button
|
|
||||||
onClick={onBack}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 mb-6 group"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
|
||||||
<span className="font-medium">{t('common.back')}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { Settings, Palette, Languages } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type { 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 (
|
|
||||||
<Card>
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<div className="p-2 bg-gradient-to-br from-blue-500 to-purple-600 text-white rounded-xl">
|
|
||||||
<Settings className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold text-gray-800 dark:text-white">{t('profile.settings')}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Palette className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
|
||||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400">
|
|
||||||
{t('profile.theme')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<ThemeSelector currentTheme={currentTheme} onThemeChange={onThemeChange} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Languages className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
|
||||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400">
|
|
||||||
{t('profile.language')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<LanguageSelector
|
|
||||||
currentLanguage={currentLanguage}
|
|
||||||
onLanguageChange={onLanguageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { Check } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type { 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 (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
|
||||||
{t('profile.theme')}
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
||||||
{THEMES.map((theme) => (
|
|
||||||
<button
|
|
||||||
key={theme.id}
|
|
||||||
onClick={() => onThemeChange(theme.id)}
|
|
||||||
className={`relative p-4 rounded-xl ${theme.gradient} transition-all duration-300 ${
|
|
||||||
currentTheme === theme.id
|
|
||||||
? 'ring-4 ring-blue-500 scale-105'
|
|
||||||
: 'hover:scale-105'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{currentTheme === theme.id && (
|
|
||||||
<div className="absolute top-2 right-2 bg-white rounded-full p-1">
|
|
||||||
<Check className="w-4 h-4 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-sm font-medium text-center text-white drop-shadow-lg">
|
|
||||||
{theme.name}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { User as UserIcon, LogOut } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type { 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 (
|
|
||||||
<Card>
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="p-4 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl">
|
|
||||||
<UserIcon className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
{user.username || user.email || t('profile.anonymous')}
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
{user.email || t('profile.noEmail')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="danger" onClick={onLogout}>
|
|
||||||
<LogOut className="w-5 h-5 mr-2" />
|
|
||||||
{t('profile.logout')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Trash2, Check, Pencil, X } from 'lucide-react';
|
|
||||||
import type { ShoppingItem } from '../../types';
|
|
||||||
import { Button, Input } from '../ui';
|
|
||||||
|
|
||||||
interface ShoppingItemCardProps {
|
|
||||||
item: ShoppingItem;
|
|
||||||
onToggle: (itemId: number, currentStatus: boolean) => Promise<void>;
|
|
||||||
onDelete: (itemId: number) => void;
|
|
||||||
onUpdate: (itemId: number, name: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShoppingItemCard({ item, onToggle, onDelete, onUpdate }: ShoppingItemCardProps) {
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
className={`flex items-center justify-between p-4 rounded-xl transition-all duration-300 ${
|
|
||||||
item.is_purchased
|
|
||||||
? 'bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800'
|
|
||||||
: 'bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isEditing ? (
|
|
||||||
<div className="flex-1 flex gap-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={editName}
|
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<Button variant="success" size="sm" onClick={handleSave}>
|
|
||||||
<Check className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" size="sm" onClick={handleCancel}>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-3 flex-1">
|
|
||||||
<button
|
|
||||||
onClick={() => onToggle(item.id, item.is_purchased)}
|
|
||||||
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all duration-300 ${
|
|
||||||
item.is_purchased
|
|
||||||
? 'bg-green-500 border-green-500'
|
|
||||||
: 'border-gray-300 dark:border-gray-600 hover:border-green-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.is_purchased && <Check className="w-4 h-4 text-white" />}
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
className={`text-lg ${
|
|
||||||
item.is_purchased
|
|
||||||
? 'line-through text-gray-500 dark:text-gray-400'
|
|
||||||
: 'text-gray-900 dark:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
className="p-2 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-lg transition-colors group"
|
|
||||||
>
|
|
||||||
<Pencil className="w-5 h-5 text-blue-600 group-hover:scale-110 transition-transform" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onDelete(item.id)}
|
|
||||||
className="p-2 hover:bg-red-100 dark:hover:bg-red-900 rounded-lg transition-colors group"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-5 h-5 text-red-600 group-hover:scale-110 transition-transform" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { KeyboardEvent } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Plus } from 'lucide-react';
|
|
||||||
import { Button, Input } from '../ui';
|
|
||||||
|
|
||||||
interface ShoppingItemInputProps {
|
|
||||||
onAdd: (name: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
handleSubmit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex gap-3 mb-6">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder={t('shopping.addItem')}
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
onKeyPress={handleKeyPress}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSubmitting || !name.trim()}
|
|
||||||
>
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type { ShoppingItem } from '../../types';
|
|
||||||
import { ShoppingItemCard } from './ShoppingItemCard';
|
|
||||||
import { shoppingService } from '../../services';
|
|
||||||
|
|
||||||
interface ShoppingItemListProps {
|
|
||||||
items: ShoppingItem[];
|
|
||||||
onToggle: (itemId: number, currentStatus: boolean) => Promise<void>;
|
|
||||||
onDelete: (itemId: number) => void;
|
|
||||||
onUpdate: (itemId: number, name: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShoppingItemList({ items, onToggle, onDelete, onUpdate }: ShoppingItemListProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-lg">
|
|
||||||
{t('shopping.noItems')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pending, purchased } = shoppingService.sortItems(items);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{pending.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
|
||||||
{t('shopping.pending')} ({pending.length})
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{pending.map((item) => (
|
|
||||||
<ShoppingItemCard
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
onToggle={onToggle}
|
|
||||||
onDelete={onDelete}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{purchased.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
|
|
||||||
{t('shopping.purchased')} ({purchased.length})
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{purchased.map((item) => (
|
|
||||||
<ShoppingItemCard
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
onToggle={onToggle}
|
|
||||||
onDelete={onDelete}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { 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 (
|
|
||||||
<span className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
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 (
|
|
||||||
<button
|
|
||||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${className}`}
|
|
||||||
disabled={disabled}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { 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 (
|
|
||||||
<div
|
|
||||||
className={`${baseClasses} ${hoverClasses} ${clickClasses} ${className}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<Modal isOpen={isOpen} onClose={onCancel} title={title} size="sm">
|
|
||||||
<p className="text-gray-700 dark:text-gray-300 mb-6">{message}</p>
|
|
||||||
<div className="flex gap-3 justify-end">
|
|
||||||
<Button variant="secondary" onClick={onCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="danger" onClick={onConfirm}>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { forwardRef } from 'react';
|
|
||||||
import type { InputHTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
label?: string;
|
|
||||||
error?: string;
|
|
||||||
fullWidth?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
||||||
({ 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 (
|
|
||||||
<div className={widthClass}>
|
|
||||||
{label && (
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
ref={ref}
|
|
||||||
className={`${baseClasses} ${stateClasses} ${bgClass} ${widthClass} ${className}`}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Input.displayName = 'Input';
|
|
||||||
@@ -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 = (
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<div
|
|
||||||
className={`${sizeClasses[size]} border-blue-600 border-t-transparent rounded-full animate-spin`}
|
|
||||||
></div>
|
|
||||||
{text && <p className="text-gray-600 dark:text-gray-400">{text}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fullScreen) {
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-white dark:bg-gray-900 flex items-center justify-center z-50">
|
|
||||||
{spinner}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return spinner;
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import type { ReactNode } 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 (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
|
||||||
onClick={handleBackdropClick}
|
|
||||||
>
|
|
||||||
<div className={`bg-white dark:bg-gray-800 rounded-xl shadow-2xl ${sizeClasses[size]} w-full max-h-[90vh] overflow-y-auto`}>
|
|
||||||
{title && (
|
|
||||||
<div className="flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{title}</h2>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-2xl"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="p-6">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div
|
|
||||||
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CategoryCardSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="glass-effect rounded-2xl shadow-lg p-5">
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Skeleton variant="rectangular" width={48} height={48} />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton variant="text" width={150} />
|
|
||||||
<Skeleton variant="text" width={100} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Skeleton variant="circular" width={32} height={32} />
|
|
||||||
<Skeleton variant="circular" width={32} height={32} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<Skeleton variant="text" width="100%" className="mb-2" />
|
|
||||||
<Skeleton variant="rectangular" width="100%" height={12} />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Skeleton variant="rectangular" width="50%" height={40} />
|
|
||||||
<Skeleton variant="rectangular" width="50%" height={40} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShoppingItemSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex items-center gap-3 flex-1">
|
|
||||||
<Skeleton variant="circular" width={24} height={24} />
|
|
||||||
<Skeleton variant="text" width={200} />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Skeleton variant="circular" width={32} height={32} />
|
|
||||||
<Skeleton variant="circular" width={32} height={32} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { 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' },
|
|
||||||
];
|
|
||||||
@@ -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';
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { categoryService } from '../services';
|
|
||||||
import type { CategoryWithRemaining } from '../services';
|
|
||||||
import type { CreateCategoryRequest } from '../types';
|
|
||||||
import { showToast } from '../utils/toast';
|
|
||||||
import { showErrorToast } from '../utils/errorHandler';
|
|
||||||
|
|
||||||
export function useCategories(familyId: number) {
|
|
||||||
const [categories, setCategories] = useState<CategoryWithRemaining[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<Error | null>(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<CreateCategoryRequest>) => {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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<ConfirmState>({
|
|
||||||
isOpen: false,
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
onConfirm: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const confirm = useCallback((title: string, message: string): Promise<boolean> => {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { expenseService } from '../services';
|
|
||||||
import type { 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<Expense[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<Error | null>(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<CreateExpenseRequest>) => {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { familyService } from '../services';
|
|
||||||
import type { FamilyMember } from '../types';
|
|
||||||
import { showErrorToast } from '../utils/errorHandler';
|
|
||||||
|
|
||||||
export function useFamilyMembers(familyId: number | null) {
|
|
||||||
const [members, setMembers] = useState<FamilyMember[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<Error | null>(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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { inviteService } from '../services';
|
|
||||||
import type { InviteLinkResponse, CreateInviteLinkRequest } from '../types';
|
|
||||||
import { showToast } from '../utils/toast';
|
|
||||||
import { showErrorToast } from '../utils/errorHandler';
|
|
||||||
|
|
||||||
export function useInviteLink() {
|
|
||||||
const [links, setLinks] = useState<InviteLinkResponse[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<Error | null>(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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { shoppingService } from '../services';
|
|
||||||
import type { ShoppingItem, CreateShoppingItemRequest } from '../types';
|
|
||||||
import { showToast } from '../utils/toast';
|
|
||||||
import { showErrorToast } from '../utils/errorHandler';
|
|
||||||
|
|
||||||
export function useShoppingList(familyId: number) {
|
|
||||||
const [items, setItems] = useState<ShoppingItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<Error | null>(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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
"expense": "Expense",
|
"expense": "Expense",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
|
"archive": "Archive",
|
||||||
"management": "Category management",
|
"management": "Category management",
|
||||||
"newCategory": "New category",
|
"newCategory": "New category",
|
||||||
"categoryName": "Category name",
|
"categoryName": "Category name",
|
||||||
@@ -62,6 +63,8 @@
|
|||||||
"addCategory": "Add category",
|
"addCategory": "Add category",
|
||||||
"deleteConfirm": "Delete category?",
|
"deleteConfirm": "Delete category?",
|
||||||
"resetConfirm": "Delete all expenses for this category?",
|
"resetConfirm": "Delete all expenses for this category?",
|
||||||
|
"editTitle": "Category settings",
|
||||||
|
"editError": "Error updating category",
|
||||||
"createError": "Error creating category",
|
"createError": "Error creating category",
|
||||||
"deleteError": "Error deleting category",
|
"deleteError": "Error deleting category",
|
||||||
"resetError": "Error resetting expenses"
|
"resetError": "Error resetting expenses"
|
||||||
@@ -73,9 +76,13 @@
|
|||||||
"description": "Description",
|
"description": "Description",
|
||||||
"descriptionPlaceholder": "Optional",
|
"descriptionPlaceholder": "Optional",
|
||||||
"historyTitle": "Expense history",
|
"historyTitle": "Expense history",
|
||||||
|
"archiveTitle": "Expense archive",
|
||||||
"noExpenses": "No expenses",
|
"noExpenses": "No expenses",
|
||||||
|
"noArchive": "Archive is empty",
|
||||||
|
"archived": "Archived",
|
||||||
"addError": "Error adding expense",
|
"addError": "Error adding expense",
|
||||||
"historyError": "Error loading expense history"
|
"historyError": "Error loading expense history",
|
||||||
|
"archiveError": "Error loading archive"
|
||||||
},
|
},
|
||||||
"invite": {
|
"invite": {
|
||||||
"title": "Invite member",
|
"title": "Invite member",
|
||||||
@@ -168,5 +175,19 @@
|
|||||||
"forest": "Forest",
|
"forest": "Forest",
|
||||||
"purple": "Purple"
|
"purple": "Purple"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"months": {
|
||||||
|
"1": "January",
|
||||||
|
"2": "February",
|
||||||
|
"3": "March",
|
||||||
|
"4": "April",
|
||||||
|
"5": "May",
|
||||||
|
"6": "June",
|
||||||
|
"7": "July",
|
||||||
|
"8": "August",
|
||||||
|
"9": "September",
|
||||||
|
"10": "October",
|
||||||
|
"11": "November",
|
||||||
|
"12": "December"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"expense": "Расход",
|
"expense": "Расход",
|
||||||
"reset": "Обнулить",
|
"reset": "Обнулить",
|
||||||
"history": "История",
|
"history": "История",
|
||||||
|
"archive": "Архив",
|
||||||
"management": "Управление категориями",
|
"management": "Управление категориями",
|
||||||
"newCategory": "Новая категория",
|
"newCategory": "Новая категория",
|
||||||
"categoryName": "Название категории",
|
"categoryName": "Название категории",
|
||||||
@@ -62,6 +63,8 @@
|
|||||||
"addCategory": "Добавить категорию",
|
"addCategory": "Добавить категорию",
|
||||||
"deleteConfirm": "Удалить категорию?",
|
"deleteConfirm": "Удалить категорию?",
|
||||||
"resetConfirm": "Удалить все траты по этой категории?",
|
"resetConfirm": "Удалить все траты по этой категории?",
|
||||||
|
"editTitle": "Настройки категории",
|
||||||
|
"editError": "Ошибка обновления категории",
|
||||||
"createError": "Ошибка создания категории",
|
"createError": "Ошибка создания категории",
|
||||||
"deleteError": "Ошибка удаления категории",
|
"deleteError": "Ошибка удаления категории",
|
||||||
"resetError": "Ошибка сброса трат"
|
"resetError": "Ошибка сброса трат"
|
||||||
@@ -73,9 +76,13 @@
|
|||||||
"description": "Описание",
|
"description": "Описание",
|
||||||
"descriptionPlaceholder": "Опционально",
|
"descriptionPlaceholder": "Опционально",
|
||||||
"historyTitle": "История трат",
|
"historyTitle": "История трат",
|
||||||
|
"archiveTitle": "Архив трат",
|
||||||
"noExpenses": "Нет трат",
|
"noExpenses": "Нет трат",
|
||||||
|
"noArchive": "Архив пуст",
|
||||||
|
"archived": "Архив",
|
||||||
"addError": "Ошибка добавления расхода",
|
"addError": "Ошибка добавления расхода",
|
||||||
"historyError": "Ошибка загрузки истории трат"
|
"historyError": "Ошибка загрузки истории трат",
|
||||||
|
"archiveError": "Ошибка загрузки архива"
|
||||||
},
|
},
|
||||||
"invite": {
|
"invite": {
|
||||||
"title": "Пригласить участника",
|
"title": "Пригласить участника",
|
||||||
@@ -168,5 +175,19 @@
|
|||||||
"forest": "Лес",
|
"forest": "Лес",
|
||||||
"purple": "Фиолетовая"
|
"purple": "Фиолетовая"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"months": {
|
||||||
|
"1": "Январь",
|
||||||
|
"2": "Февраль",
|
||||||
|
"3": "Март",
|
||||||
|
"4": "Апрель",
|
||||||
|
"5": "Май",
|
||||||
|
"6": "Июнь",
|
||||||
|
"7": "Июль",
|
||||||
|
"8": "Август",
|
||||||
|
"9": "Сентябрь",
|
||||||
|
"10": "Октябрь",
|
||||||
|
"11": "Ноябрь",
|
||||||
|
"12": "Декабрь"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
|
||||||
:root,
|
:root,
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--gradient-start: #667eea;
|
--gradient-start: #667eea;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { Toaster } from 'react-hot-toast'
|
|
||||||
import './i18n'
|
import './i18n'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
@@ -8,6 +7,5 @@ import App from './App.tsx'
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
<Toaster />
|
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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<Category[]>([]);
|
|
||||||
const [remainingLimits, setRemainingLimits] = useState<Map<number, number>>(new Map());
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const [showAddCategory, setShowAddCategory] = useState(false);
|
|
||||||
const [newCategoryName, setNewCategoryName] = useState('');
|
|
||||||
const [newCategoryLimit, setNewCategoryLimit] = useState('');
|
|
||||||
|
|
||||||
const [showAddExpense, setShowAddExpense] = useState<number | null>(null);
|
|
||||||
const [expenseAmount, setExpenseAmount] = useState('');
|
|
||||||
const [expenseDescription, setExpenseDescription] = useState('');
|
|
||||||
|
|
||||||
const [showHistory, setShowHistory] = useState<number | null>(null);
|
|
||||||
const [categoryExpenses, setCategoryExpenses] = useState<Expense[]>([]);
|
|
||||||
const [showShoppingList, setShowShoppingList] = useState(false);
|
|
||||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
|
||||||
const [inviteLink, setInviteLink] = useState<InviteLinkResponse | null>(null);
|
|
||||||
const [inviteLoading, setInviteLoading] = useState(false);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!familyId) {
|
|
||||||
navigate('/');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadCategories();
|
|
||||||
}, [familyId]);
|
|
||||||
|
|
||||||
const loadCategories = async () => {
|
|
||||||
if (!familyId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
console.log('Loading categories for family:', familyId);
|
|
||||||
const response = await categoryApi.getAllByFamily(parseInt(familyId));
|
|
||||||
console.log('Categories loaded:', response.data);
|
|
||||||
setCategories(response.data);
|
|
||||||
|
|
||||||
const limits = new Map<number, number>();
|
|
||||||
for (const category of response.data) {
|
|
||||||
const limitResponse = await expenseApi.getRemainingLimit(
|
|
||||||
parseInt(familyId),
|
|
||||||
category.id
|
|
||||||
);
|
|
||||||
const limitValue = typeof limitResponse.data.remaining_limit === 'string'
|
|
||||||
? parseFloat(limitResponse.data.remaining_limit)
|
|
||||||
: limitResponse.data.remaining_limit;
|
|
||||||
limits.set(category.id, limitValue);
|
|
||||||
}
|
|
||||||
setRemainingLimits(limits);
|
|
||||||
console.log('All data loaded successfully');
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMsg = err.response?.data?.message || err.message || t('family.loadError');
|
|
||||||
setError(errorMsg);
|
|
||||||
console.error('Error loading categories:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddCategory = async () => {
|
|
||||||
if (!familyId || !newCategoryName || !newCategoryLimit) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await categoryApi.create(parseInt(familyId), {
|
|
||||||
name: newCategoryName,
|
|
||||||
limit_amount: parseFloat(newCategoryLimit),
|
|
||||||
});
|
|
||||||
setNewCategoryName('');
|
|
||||||
setNewCategoryLimit('');
|
|
||||||
setShowAddCategory(false);
|
|
||||||
loadCategories();
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMsg = err.response?.data?.message || err.response?.statusText || err.message || t('category.createError');
|
|
||||||
alert(`${t('category.createError')}: ${errorMsg}`);
|
|
||||||
console.error('Full error:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteCategory = async (categoryId: number) => {
|
|
||||||
if (!familyId) return;
|
|
||||||
if (!confirm(t('category.deleteConfirm'))) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await categoryApi.delete(parseInt(familyId), categoryId);
|
|
||||||
loadCategories();
|
|
||||||
} catch (err) {
|
|
||||||
alert(t('category.deleteError'));
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetLimit = async (categoryId: number) => {
|
|
||||||
if (!familyId) return;
|
|
||||||
if (!confirm(t('category.resetConfirm'))) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const expensesResponse = await expenseApi.getAllByCategory(
|
|
||||||
parseInt(familyId),
|
|
||||||
categoryId
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const expense of expensesResponse.data) {
|
|
||||||
await expenseApi.delete(
|
|
||||||
parseInt(familyId),
|
|
||||||
categoryId,
|
|
||||||
expense.id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadCategories();
|
|
||||||
} catch (err) {
|
|
||||||
alert(t('category.resetError'));
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddExpense = async (categoryId: number) => {
|
|
||||||
if (!familyId || !expenseAmount) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await expenseApi.create(parseInt(familyId), categoryId, {
|
|
||||||
amount: parseFloat(expenseAmount),
|
|
||||||
description: expenseDescription || undefined,
|
|
||||||
});
|
|
||||||
setExpenseAmount('');
|
|
||||||
setExpenseDescription('');
|
|
||||||
setShowAddExpense(null);
|
|
||||||
loadCategories();
|
|
||||||
} catch (err) {
|
|
||||||
alert(t('expense.addError'));
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShowHistory = async (categoryId: number) => {
|
|
||||||
if (!familyId) return;
|
|
||||||
|
|
||||||
if (showHistory === categoryId) {
|
|
||||||
setShowHistory(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await expenseApi.getAllByCategory(
|
|
||||||
parseInt(familyId),
|
|
||||||
categoryId
|
|
||||||
);
|
|
||||||
setCategoryExpenses(response.data);
|
|
||||||
setShowHistory(categoryId);
|
|
||||||
} catch (err) {
|
|
||||||
alert(t('expense.historyError'));
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateInviteLink = async () => {
|
|
||||||
try {
|
|
||||||
setInviteLoading(true);
|
|
||||||
const response = await inviteLinkApi.create({ expires_in_hours: 168 });
|
|
||||||
setInviteLink(response.data);
|
|
||||||
} catch (err) {
|
|
||||||
alert(t('invite.createError'));
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
setInviteLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyInviteLink = async () => {
|
|
||||||
if (!inviteLink) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(inviteLink.invite_url);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenInviteModal = () => {
|
|
||||||
setShowInviteModal(true);
|
|
||||||
setInviteLink(null);
|
|
||||||
setCopied(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center gradient-bg">
|
|
||||||
<div className="flex items-center gap-3 text-white">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin" />
|
|
||||||
<span className="text-xl font-medium">{t('common.loading')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getProgressColor = (remaining: number, limit: number) => {
|
|
||||||
const percentage = (remaining / limit) * 100;
|
|
||||||
if (percentage > 50) return 'bg-green-500';
|
|
||||||
if (percentage > 25) return 'bg-yellow-500';
|
|
||||||
return 'bg-red-500';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getProgressPercentage = (remaining: number, limit: number) => {
|
|
||||||
return Math.max(0, Math.min(100, (remaining / limit) * 100));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTotalLimit = () => {
|
|
||||||
return categories.reduce((sum, cat) => sum + parseFloat(cat.limit_amount.toString()), 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTotalRemaining = () => {
|
|
||||||
return Array.from(remainingLimits.values()).reduce((sum, val) => sum + val, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
let dateStr = dateString;
|
|
||||||
if (!dateStr.endsWith('Z') && !dateStr.includes('+')) {
|
|
||||||
dateStr = dateStr + 'Z';
|
|
||||||
}
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleString('ru-RU', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
|
|
||||||
<div className="max-w-5xl mx-auto">
|
|
||||||
<div className="mb-6 sm:mb-8">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<button
|
|
||||||
onClick={handleOpenInviteModal}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 group"
|
|
||||||
>
|
|
||||||
<UserPlus className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
|
||||||
<span className="font-medium">{t('family.inviteMember')}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/profile')}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 group"
|
|
||||||
>
|
|
||||||
<User className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
|
||||||
<span className="font-medium">{t('profile.title')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
|
|
||||||
<Wallet className="w-12 h-12 text-white" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl sm:text-5xl font-bold text-white mb-6">
|
|
||||||
{selectedFamily?.name || t('family.defaultName')}
|
|
||||||
</h1>
|
|
||||||
<div className="max-w-2xl mx-auto glass-effect rounded-2xl shadow-lg p-5">
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-gray-600 font-medium text-sm mb-2">{t('family.totalLimit')}</p>
|
|
||||||
<p className="text-2xl sm:text-3xl font-bold text-gray-900">
|
|
||||||
{getTotalLimit().toFixed(2)} ₽
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center border-l-2 border-gray-300">
|
|
||||||
<p className="text-gray-600 font-medium text-sm mb-2">{t('family.totalRemaining')}</p>
|
|
||||||
<p className="text-2xl sm:text-3xl font-bold text-gray-900">
|
|
||||||
{getTotalRemaining().toFixed(2)} ₽
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowShoppingList(true)}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 btn-success text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
|
|
||||||
>
|
|
||||||
<ShoppingCart className="w-5 h-5" />
|
|
||||||
{t('family.shoppingList')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-6 p-4 bg-red-500/90 backdrop-blur-md border border-red-300/50 text-white rounded-2xl shadow-lg max-w-2xl mx-auto">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<X className="w-5 h-5 flex-shrink-0" />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-5 mb-6 max-w-3xl mx-auto">
|
|
||||||
{categories.map((category) => {
|
|
||||||
const remaining = remainingLimits.get(category.id) || 0;
|
|
||||||
const limit = parseFloat(category.limit_amount.toString());
|
|
||||||
const percentage = getProgressPercentage(remaining, limit);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={category.id}
|
|
||||||
className="glass-effect rounded-2xl shadow-lg p-4 sm:p-5 card-hover"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3 mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 category-icon text-white rounded-xl shadow-lg">
|
|
||||||
<Tag className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">
|
|
||||||
{category.name}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAddExpense !== category.id && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddExpense(category.id)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 btn-danger text-white rounded-xl hover:shadow-lg transition-all duration-300 font-semibold whitespace-nowrap text-sm"
|
|
||||||
>
|
|
||||||
<TrendingDown className="w-4 h-4" />
|
|
||||||
<span className="hidden sm:inline">{t('category.addExpense')}</span>
|
|
||||||
<span className="sm:hidden">{t('category.expense')}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 mb-4">
|
|
||||||
<div className="flex justify-between items-baseline">
|
|
||||||
<span className="text-gray-600 font-medium text-sm">{t('category.remaining')}</span>
|
|
||||||
<span className="text-2xl sm:text-3xl font-bold text-gray-900">
|
|
||||||
{remaining.toFixed(2)} ₽
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-baseline text-gray-500 text-sm">
|
|
||||||
<span>{t('category.limit')}</span>
|
|
||||||
<span className="text-base font-semibold">{limit.toFixed(2)} ₽</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative h-3 bg-gray-200 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full ${getProgressColor(remaining, limit)} transition-all duration-500 rounded-full shadow-inner`}
|
|
||||||
style={{ width: `${percentage}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 text-center font-medium">
|
|
||||||
{percentage.toFixed(0)}{t('category.percentRemaining')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 justify-between">
|
|
||||||
<button
|
|
||||||
onClick={() => handleResetLimit(category.id)}
|
|
||||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-yellow-500 hover:bg-yellow-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-4 h-4" />
|
|
||||||
<span>{t('category.reset')}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleShowHistory(category.id)}
|
|
||||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
|
|
||||||
>
|
|
||||||
<History className="w-4 h-4" />
|
|
||||||
<span>{t('category.history')}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteCategory(category.id)}
|
|
||||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
<span>{t('common.delete')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showHistory === category.id && (
|
|
||||||
<div className="mt-4 glass-effect p-4 rounded-2xl border-2 border-gray-200">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="font-semibold text-gray-800 text-lg flex items-center gap-2">
|
|
||||||
<History className="w-5 h-5" />
|
|
||||||
{t('expense.historyTitle')}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowHistory(null)}
|
|
||||||
className="p-2 hover:bg-white/50 rounded-xl transition-all"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{categoryExpenses.length === 0 ? (
|
|
||||||
<p className="text-center text-gray-500 py-4">{t('expense.noExpenses')}</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
||||||
{categoryExpenses.map((expense) => (
|
|
||||||
<div
|
|
||||||
key={expense.id}
|
|
||||||
className="expense-history-item p-3 rounded-xl shadow-sm border"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="p-1.5 bg-red-100 rounded-lg">
|
|
||||||
<TrendingDown className="w-4 h-4 text-red-600" />
|
|
||||||
</div>
|
|
||||||
<span className="font-bold text-gray-900 text-lg">
|
|
||||||
{parseFloat(expense.amount.toString()).toFixed(2)} ₽
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
|
||||||
<Calendar className="w-3 h-3" />
|
|
||||||
<span>{formatDate(expense.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{expense.description && (
|
|
||||||
<div className="flex items-start gap-2 text-sm text-gray-600 expense-description p-2 rounded-lg">
|
|
||||||
<MessageSquare className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
|
||||||
<span>{expense.description}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showAddExpense === category.id && (
|
|
||||||
<div className="glass-effect p-6 rounded-2xl border-2 border-gray-200 mt-4">
|
|
||||||
<h3 className="font-semibold text-gray-800 mb-4 text-center">
|
|
||||||
{t('expense.addTitle')}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
{t('expense.amount')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="0.00"
|
|
||||||
value={expenseAmount}
|
|
||||||
onChange={(e) => setExpenseAmount(e.target.value)}
|
|
||||||
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all text-center font-semibold text-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
{t('expense.description')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={t('expense.descriptionPlaceholder')}
|
|
||||||
value={expenseDescription}
|
|
||||||
onChange={(e) => setExpenseDescription(e.target.value)}
|
|
||||||
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleAddExpense(category.id)}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-5 py-3 btn-success text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
|
|
||||||
>
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
{t('common.add')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddExpense(null)}
|
|
||||||
className="px-5 py-3 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-medium"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="glass-effect rounded-3xl shadow-xl p-6 sm:p-8 max-w-3xl mx-auto">
|
|
||||||
<div className="flex items-center justify-center gap-3 mb-8">
|
|
||||||
<div className="p-3 category-icon rounded-2xl">
|
|
||||||
<DollarSign className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl sm:text-3xl font-bold text-gray-800">
|
|
||||||
{t('category.management')}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAddCategory ? (
|
|
||||||
<div className="mb-8 p-6 glass-effect rounded-2xl border-2 border-gray-200">
|
|
||||||
<h3 className="font-bold text-gray-800 mb-5 text-center text-lg">
|
|
||||||
{t('category.newCategory')}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={t('category.categoryName')}
|
|
||||||
value={newCategoryName}
|
|
||||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
|
||||||
className="w-full px-5 py-4 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder={t('category.categoryLimit')}
|
|
||||||
value={newCategoryLimit}
|
|
||||||
onChange={(e) => setNewCategoryLimit(e.target.value)}
|
|
||||||
className="w-full px-5 py-4 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium text-center"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleAddCategory}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-6 py-4 btn-success text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
|
|
||||||
>
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
{t('common.create')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddCategory(false)}
|
|
||||||
className="px-6 py-4 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-medium"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddCategory(true)}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-6 py-4 btn-primary text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
|
|
||||||
>
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
{t('category.addCategory')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showShoppingList && familyId && (
|
|
||||||
<ShoppingListModal
|
|
||||||
familyId={parseInt(familyId)}
|
|
||||||
onClose={() => setShowShoppingList(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showInviteModal && (
|
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
|
||||||
<div className="glass-effect rounded-3xl shadow-2xl w-full max-w-md p-6 sm:p-8">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-3 category-icon rounded-2xl">
|
|
||||||
<UserPlus className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold text-gray-800">{t('invite.title')}</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowInviteModal(false)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-xl transition-all"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5 text-gray-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!inviteLink ? (
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
{t('invite.description')}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleCreateInviteLink}
|
|
||||||
disabled={inviteLoading}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-6 py-4 btn-primary text-white rounded-2xl hover:shadow-xl transition-all font-semibold disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{inviteLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
|
||||||
{t('invite.creating')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<UserPlus className="w-5 h-5" />
|
|
||||||
{t('invite.createLink')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<p className="text-gray-600 mb-4 text-center">
|
|
||||||
{t('invite.sendLink')}
|
|
||||||
</p>
|
|
||||||
<div className="bg-gray-100 rounded-2xl p-4 mb-4">
|
|
||||||
<p className="text-sm text-gray-800 break-all font-mono">
|
|
||||||
{inviteLink.invite_url}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleCopyInviteLink}
|
|
||||||
className={`w-full flex items-center justify-center gap-2 px-6 py-4 rounded-2xl transition-all font-semibold ${
|
|
||||||
copied
|
|
||||||
? 'btn-success text-white'
|
|
||||||
: 'btn-primary text-white hover:shadow-xl'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<>
|
|
||||||
<Check className="w-5 h-5" />
|
|
||||||
{t('invite.copied')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Copy className="w-5 h-5" />
|
|
||||||
{t('invite.copyLink')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,31 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Loader2 } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { categoryApi, expenseApi, inviteLinkApi } from '../api/client';
|
||||||
import { useStore } from '../store/useStore';
|
import { useStore } from '../store/useStore';
|
||||||
import { useCategories, useConfirm } from '../hooks';
|
import type { Category, InviteLinkResponse, ExpenseHistoryResponse } from '../types';
|
||||||
import { FamilyHeader } from '../components/family/FamilyHeader';
|
import {
|
||||||
import { FamilySummary } from '../components/family/FamilySummary';
|
Wallet,
|
||||||
import { CategoryList } from '../components/family/CategoryList';
|
TrendingDown,
|
||||||
import { AddCategorySection } from '../components/family/AddCategorySection';
|
Plus,
|
||||||
import { InviteModal } from '../components/family/InviteModal';
|
Trash2,
|
||||||
|
RotateCcw,
|
||||||
|
Archive,
|
||||||
|
Loader2,
|
||||||
|
X,
|
||||||
|
DollarSign,
|
||||||
|
Tag,
|
||||||
|
History,
|
||||||
|
Calendar,
|
||||||
|
MessageSquare,
|
||||||
|
ShoppingCart,
|
||||||
|
UserPlus,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
User,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-react';
|
||||||
import ShoppingListModal from '../components/ShoppingListModal';
|
import ShoppingListModal from '../components/ShoppingListModal';
|
||||||
import { ConfirmModal } from '../components/ui';
|
|
||||||
|
|
||||||
export default function FamilyView() {
|
export default function FamilyView() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -18,32 +33,258 @@ export default function FamilyView() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { selectedFamily } = useStore();
|
const { selectedFamily } = useStore();
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [remainingLimits, setRemainingLimits] = useState<Map<number, number>>(new Map());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const [showAddCategory, setShowAddCategory] = useState(false);
|
const [showAddCategory, setShowAddCategory] = useState(false);
|
||||||
|
const [newCategoryName, setNewCategoryName] = useState('');
|
||||||
|
const [newCategoryLimit, setNewCategoryLimit] = useState('');
|
||||||
|
|
||||||
|
const [showAddExpense, setShowAddExpense] = useState<number | null>(null);
|
||||||
|
const [expenseAmount, setExpenseAmount] = useState('');
|
||||||
|
const [expenseDescription, setExpenseDescription] = useState('');
|
||||||
|
|
||||||
|
const [showEditCategory, setShowEditCategory] = useState<number | null>(null);
|
||||||
|
const [editCategoryName, setEditCategoryName] = useState('');
|
||||||
|
const [editCategoryLimit, setEditCategoryLimit] = useState('');
|
||||||
|
|
||||||
|
const [showHistory, setShowHistory] = useState<number | null>(null);
|
||||||
|
const [showArchive, setShowArchive] = useState<number | null>(null);
|
||||||
|
const [historyData, setHistoryData] = useState<ExpenseHistoryResponse | null>(null);
|
||||||
|
const [archiveData, setArchiveData] = useState<ExpenseHistoryResponse | null>(null);
|
||||||
const [showShoppingList, setShowShoppingList] = useState(false);
|
const [showShoppingList, setShowShoppingList] = useState(false);
|
||||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||||
|
const [inviteLink, setInviteLink] = useState<InviteLinkResponse | null>(null);
|
||||||
const { categories, loading, createCategory, deleteCategory, resetLimit, loadCategories } = useCategories(
|
const [inviteLoading, setInviteLoading] = useState(false);
|
||||||
parseInt(familyId || '0')
|
const [copied, setCopied] = useState(false);
|
||||||
);
|
|
||||||
const { confirmState, confirm, cancel } = useConfirm();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!familyId) {
|
if (!familyId) {
|
||||||
navigate('/');
|
navigate('/');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [familyId, navigate]);
|
loadCategories();
|
||||||
|
}, [familyId]);
|
||||||
|
|
||||||
|
const loadCategories = async () => {
|
||||||
|
if (!familyId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
console.log('Loading categories for family:', familyId);
|
||||||
|
const response = await categoryApi.getAllByFamily(parseInt(familyId));
|
||||||
|
console.log('Categories loaded:', response.data);
|
||||||
|
setCategories(response.data);
|
||||||
|
|
||||||
|
const limits = new Map<number, number>();
|
||||||
|
for (const category of response.data) {
|
||||||
|
const limitResponse = await expenseApi.getRemainingLimit(
|
||||||
|
parseInt(familyId),
|
||||||
|
category.id
|
||||||
|
);
|
||||||
|
const limitValue = typeof limitResponse.data.remaining_limit === 'string'
|
||||||
|
? parseFloat(limitResponse.data.remaining_limit)
|
||||||
|
: limitResponse.data.remaining_limit;
|
||||||
|
limits.set(category.id, limitValue);
|
||||||
|
}
|
||||||
|
setRemainingLimits(limits);
|
||||||
|
console.log('All data loaded successfully');
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = err.response?.data?.message || err.message || t('family.loadError');
|
||||||
|
setError(errorMsg);
|
||||||
|
console.error('Error loading categories:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCategory = async () => {
|
||||||
|
if (!familyId || !newCategoryName || !newCategoryLimit) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await categoryApi.create(parseInt(familyId), {
|
||||||
|
name: newCategoryName,
|
||||||
|
limit_amount: parseFloat(newCategoryLimit),
|
||||||
|
});
|
||||||
|
setNewCategoryName('');
|
||||||
|
setNewCategoryLimit('');
|
||||||
|
setShowAddCategory(false);
|
||||||
|
loadCategories();
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = err.response?.data?.message || err.response?.statusText || err.message || t('category.createError');
|
||||||
|
alert(`${t('category.createError')}: ${errorMsg}`);
|
||||||
|
console.error('Full error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteCategory = async (categoryId: number) => {
|
const handleDeleteCategory = async (categoryId: number) => {
|
||||||
await confirm(t('category.deleteConfirm'), t('category.deleteMessage'));
|
if (!familyId) return;
|
||||||
await deleteCategory(categoryId);
|
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) => {
|
const handleResetLimit = async (categoryId: number) => {
|
||||||
await confirm(t('category.resetConfirm'), t('category.resetMessage'));
|
if (!familyId) return;
|
||||||
const category = categories.find((cat) => cat.id === categoryId);
|
if (!confirm(t('category.resetConfirm'))) return;
|
||||||
if (category) {
|
|
||||||
await resetLimit(categoryId, Number(category.limit_amount));
|
try {
|
||||||
|
const expensesResponse = await expenseApi.getAllByCategory(
|
||||||
|
parseInt(familyId),
|
||||||
|
categoryId
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const expense of expensesResponse.data) {
|
||||||
|
await expenseApi.delete(
|
||||||
|
parseInt(familyId),
|
||||||
|
categoryId,
|
||||||
|
expense.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadCategories();
|
||||||
|
} catch (err) {
|
||||||
|
alert(t('category.resetError'));
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddExpense = async (categoryId: number) => {
|
||||||
|
if (!familyId || !expenseAmount) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expenseApi.create(parseInt(familyId), categoryId, {
|
||||||
|
amount: parseFloat(expenseAmount),
|
||||||
|
description: expenseDescription || undefined,
|
||||||
|
});
|
||||||
|
setExpenseAmount('');
|
||||||
|
setExpenseDescription('');
|
||||||
|
setShowAddExpense(null);
|
||||||
|
|
||||||
|
const limitResponse = await expenseApi.getRemainingLimit(parseInt(familyId), categoryId);
|
||||||
|
const limitValue = typeof limitResponse.data.remaining_limit === 'string'
|
||||||
|
? parseFloat(limitResponse.data.remaining_limit)
|
||||||
|
: limitResponse.data.remaining_limit;
|
||||||
|
setRemainingLimits(prev => new Map(prev).set(categoryId, limitValue));
|
||||||
|
|
||||||
|
if (showHistory === categoryId) {
|
||||||
|
const historyResponse = await expenseApi.getHistory(parseInt(familyId), categoryId, false);
|
||||||
|
setHistoryData(historyResponse.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(t('expense.addError'));
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditCategory = (category: Category) => {
|
||||||
|
setEditCategoryName(category.name);
|
||||||
|
setEditCategoryLimit(parseFloat(category.limit_amount.toString()).toString());
|
||||||
|
setShowEditCategory(category.id);
|
||||||
|
setShowAddExpense(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateCategory = async (categoryId: number) => {
|
||||||
|
if (!familyId || !editCategoryName || !editCategoryLimit) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await categoryApi.update(parseInt(familyId), categoryId, {
|
||||||
|
name: editCategoryName,
|
||||||
|
limit_amount: parseFloat(editCategoryLimit),
|
||||||
|
});
|
||||||
|
setShowEditCategory(null);
|
||||||
|
loadCategories();
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = err.response?.data?.message || err.message || t('category.editError');
|
||||||
|
alert(`${t('category.editError')}: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowHistory = async (categoryId: number) => {
|
||||||
|
if (!familyId) return;
|
||||||
|
|
||||||
|
if (showHistory === categoryId) {
|
||||||
|
setShowHistory(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowArchive(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await expenseApi.getHistory(
|
||||||
|
parseInt(familyId),
|
||||||
|
categoryId,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
setHistoryData(response.data);
|
||||||
|
setShowHistory(categoryId);
|
||||||
|
} catch (err) {
|
||||||
|
alert(t('expense.historyError'));
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowArchive = async (categoryId: number) => {
|
||||||
|
if (!familyId) return;
|
||||||
|
|
||||||
|
if (showArchive === categoryId) {
|
||||||
|
setShowArchive(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowHistory(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await expenseApi.getHistory(
|
||||||
|
parseInt(familyId),
|
||||||
|
categoryId,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
setArchiveData(response.data);
|
||||||
|
setShowArchive(categoryId);
|
||||||
|
} catch (err) {
|
||||||
|
alert(t('expense.archiveError'));
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateInviteLink = async () => {
|
||||||
|
try {
|
||||||
|
setInviteLoading(true);
|
||||||
|
const response = await inviteLinkApi.create({ expires_in_hours: 168 });
|
||||||
|
setInviteLink(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
alert(t('invite.createError'));
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setInviteLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyInviteLink = async () => {
|
||||||
|
if (!inviteLink) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteLink.invite_url);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenInviteModal = () => {
|
||||||
|
setShowInviteModal(true);
|
||||||
|
setInviteLink(null);
|
||||||
|
setCopied(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -57,51 +298,583 @@ 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',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMonthName = (month: number) => {
|
||||||
|
const months = [
|
||||||
|
t('months.1'), t('months.2'), t('months.3'), t('months.4'),
|
||||||
|
t('months.5'), t('months.6'), t('months.7'), t('months.8'),
|
||||||
|
t('months.9'), t('months.10'), t('months.11'), t('months.12')
|
||||||
|
];
|
||||||
|
return months[month - 1] || month;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
|
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<FamilyHeader
|
<div className="mb-6 sm:mb-8">
|
||||||
onInvite={() => setShowInviteModal(true)}
|
<div className="flex items-center gap-3 mb-6">
|
||||||
onProfile={() => navigate('/profile')}
|
<button
|
||||||
/>
|
onClick={handleOpenInviteModal}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 group"
|
||||||
<FamilySummary
|
>
|
||||||
familyName={selectedFamily?.name}
|
<UserPlus className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||||
categories={categories}
|
<span className="font-medium">{t('family.inviteMember')}</span>
|
||||||
onShowShoppingList={() => setShowShoppingList(true)}
|
</button>
|
||||||
/>
|
<button
|
||||||
|
onClick={() => navigate('/profile')}
|
||||||
<CategoryList
|
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 group"
|
||||||
categories={categories}
|
>
|
||||||
familyId={parseInt(familyId || '0')}
|
<User className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||||
onDelete={handleDeleteCategory}
|
<span className="font-medium">{t('profile.title')}</span>
|
||||||
onReset={handleResetLimit}
|
</button>
|
||||||
onUpdate={loadCategories}
|
</div>
|
||||||
/>
|
<div className="text-center">
|
||||||
|
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
|
||||||
<AddCategorySection
|
<Wallet className="w-12 h-12 text-white" />
|
||||||
showForm={showAddCategory}
|
</div>
|
||||||
onToggle={() => setShowAddCategory(!showAddCategory)}
|
<h1 className="text-4xl sm:text-5xl font-bold text-white mb-6">
|
||||||
onCreate={createCategory}
|
{selectedFamily?.name || t('family.defaultName')}
|
||||||
/>
|
</h1>
|
||||||
|
<div className="max-w-2xl mx-auto glass-effect rounded-2xl shadow-lg p-5">
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-gray-600 font-medium text-sm mb-2">{t('family.totalLimit')}</p>
|
||||||
|
<p className="text-2xl sm:text-3xl font-bold text-gray-900">
|
||||||
|
{getTotalLimit().toFixed(2)} ₽
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center border-l-2 border-gray-300">
|
||||||
|
<p className="text-gray-600 font-medium text-sm mb-2">{t('family.totalRemaining')}</p>
|
||||||
|
<p className="text-2xl sm:text-3xl font-bold text-gray-900">
|
||||||
|
{getTotalRemaining().toFixed(2)} ₽
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowShoppingList(true)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 btn-success text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
|
||||||
|
>
|
||||||
|
<ShoppingCart className="w-5 h-5" />
|
||||||
|
{t('family.shoppingList')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showShoppingList && (
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-500/90 backdrop-blur-md border border-red-300/50 text-white rounded-2xl shadow-lg max-w-2xl mx-auto">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<X className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-5 mb-6 max-w-3xl mx-auto">
|
||||||
|
{categories.map((category) => {
|
||||||
|
const remaining = remainingLimits.get(category.id) || 0;
|
||||||
|
const limit = parseFloat(category.limit_amount.toString());
|
||||||
|
const percentage = getProgressPercentage(remaining, limit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={category.id}
|
||||||
|
className="glass-effect rounded-2xl shadow-lg p-4 sm:p-5 card-hover"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 category-icon text-white rounded-xl shadow-lg">
|
||||||
|
<Tag className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">
|
||||||
|
{category.name}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddExpense !== category.id && showEditCategory !== category.id && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEditCategory(category)}
|
||||||
|
className="p-2 bg-gray-200 hover:bg-gray-300 text-gray-600 rounded-xl transition-all duration-300"
|
||||||
|
title={t('category.editTitle')}
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddExpense(category.id)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 btn-danger text-white rounded-xl hover:shadow-lg transition-all duration-300 font-semibold whitespace-nowrap text-sm"
|
||||||
|
>
|
||||||
|
<TrendingDown className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">{t('category.addExpense')}</span>
|
||||||
|
<span className="sm:hidden">{t('category.expense')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
<div className="flex justify-between items-baseline">
|
||||||
|
<span className="text-gray-600 font-medium text-sm">{t('category.remaining')}</span>
|
||||||
|
<span className="text-2xl sm:text-3xl font-bold text-gray-900">
|
||||||
|
{remaining.toFixed(2)} ₽
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-baseline text-gray-500 text-sm">
|
||||||
|
<span>{t('category.limit')}</span>
|
||||||
|
<span className="text-base font-semibold">{limit.toFixed(2)} ₽</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-3 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${getProgressColor(remaining, limit)} transition-all duration-500 rounded-full shadow-inner`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 text-center font-medium">
|
||||||
|
{percentage.toFixed(0)}{t('category.percentRemaining')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleResetLimit(category.id)}
|
||||||
|
className="flex items-center justify-center gap-1.5 px-3 py-2 bg-yellow-500 hover:bg-yellow-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
<span>{t('category.reset')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleShowHistory(category.id)}
|
||||||
|
className="flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
|
||||||
|
>
|
||||||
|
<History className="w-4 h-4" />
|
||||||
|
<span>{t('category.history')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleShowArchive(category.id)}
|
||||||
|
className="flex items-center justify-center gap-1.5 px-3 py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
|
||||||
|
>
|
||||||
|
<Archive className="w-4 h-4" />
|
||||||
|
<span>{t('category.archive')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteCategory(category.id)}
|
||||||
|
className="flex items-center justify-center gap-1.5 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
<span>{t('common.delete')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddExpense === category.id && (
|
||||||
|
<div className="glass-effect p-6 rounded-2xl border-2 border-gray-200 mt-4">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4 text-center">
|
||||||
|
{t('expense.addTitle')}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('expense.amount')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={expenseAmount}
|
||||||
|
onChange={(e) => setExpenseAmount(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all text-center font-semibold text-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('expense.description')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('expense.descriptionPlaceholder')}
|
||||||
|
value={expenseDescription}
|
||||||
|
onChange={(e) => setExpenseDescription(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAddExpense(category.id)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-5 py-3 btn-success text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
{t('common.add')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddExpense(null)}
|
||||||
|
className="px-5 py-3 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-medium"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEditCategory === category.id && (
|
||||||
|
<div className="glass-effect p-6 rounded-2xl border-2 border-gray-200 mt-4">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-4 text-center">
|
||||||
|
{t('category.editTitle')}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('category.categoryName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editCategoryName}
|
||||||
|
onChange={(e) => setEditCategoryName(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{t('category.categoryLimit')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editCategoryLimit}
|
||||||
|
onChange={(e) => setEditCategoryLimit(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all text-center font-semibold text-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateCategory(category.id)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-5 py-3 btn-success text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
|
||||||
|
>
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEditCategory(null)}
|
||||||
|
className="px-5 py-3 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-medium"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showHistory === category.id && historyData && (
|
||||||
|
<div className="mt-4 glass-effect p-4 rounded-2xl border-2 border-blue-200">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-gray-800 text-lg flex items-center gap-2">
|
||||||
|
<History className="w-5 h-5" />
|
||||||
|
{t('expense.historyTitle')}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHistory(null)}
|
||||||
|
className="p-2 hover:bg-white/50 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{historyData.months.length === 0 ? (
|
||||||
|
<p className="text-center text-gray-500 py-4">{t('expense.noExpenses')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||||||
|
{historyData.months.map((monthGroup) => (
|
||||||
|
<div key={`${monthGroup.year}-${monthGroup.month}`} className="border-l-4 border-blue-500 pl-4">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h4 className="font-bold text-gray-900">
|
||||||
|
{getMonthName(monthGroup.month)} {monthGroup.year}
|
||||||
|
</h4>
|
||||||
|
<span className="text-lg font-semibold text-blue-600">
|
||||||
|
{parseFloat(monthGroup.total_amount.toString()).toFixed(2)} ₽
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{monthGroup.expenses.map((expense) => (
|
||||||
|
<div
|
||||||
|
key={expense.id}
|
||||||
|
className="expense-history-item p-3 rounded-xl shadow-sm border bg-white"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 bg-red-100 rounded-lg">
|
||||||
|
<TrendingDown className="w-4 h-4 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-gray-900 text-lg">
|
||||||
|
{parseFloat(expense.amount.toString()).toFixed(2)} ₽
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>{formatDate(expense.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expense.description && (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-gray-600 expense-description p-2 rounded-lg">
|
||||||
|
<MessageSquare className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{expense.description}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showArchive === category.id && archiveData && (
|
||||||
|
<div className="mt-4 glass-effect p-4 rounded-2xl border-2 border-purple-200">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-gray-800 text-lg flex items-center gap-2">
|
||||||
|
<Archive className="w-5 h-5" />
|
||||||
|
{t('expense.archiveTitle')}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowArchive(null)}
|
||||||
|
className="p-2 hover:bg-white/50 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{archiveData.months.length === 0 ? (
|
||||||
|
<p className="text-center text-gray-500 py-4">{t('expense.noArchive')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||||||
|
{archiveData.months.map((monthGroup) => (
|
||||||
|
<div key={`archive-${monthGroup.year}-${monthGroup.month}`} className="border-l-4 border-purple-500 pl-4">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h4 className="font-bold text-gray-700">
|
||||||
|
{getMonthName(monthGroup.month)} {monthGroup.year}
|
||||||
|
</h4>
|
||||||
|
<span className="text-lg font-semibold text-purple-600">
|
||||||
|
{parseFloat(monthGroup.total_amount.toString()).toFixed(2)} ₽
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{monthGroup.expenses.map((expense) => (
|
||||||
|
<div
|
||||||
|
key={expense.id}
|
||||||
|
className="expense-history-item p-3 rounded-xl shadow-sm border bg-gray-50 opacity-75"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 bg-gray-200 rounded-lg">
|
||||||
|
<TrendingDown className="w-4 h-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-gray-600 text-lg line-through">
|
||||||
|
{parseFloat(expense.amount.toString()).toFixed(2)} ₽
|
||||||
|
</span>
|
||||||
|
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded-full">
|
||||||
|
{t('expense.archived')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>{formatDate(expense.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expense.description && (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-gray-500 expense-description p-2 rounded-lg">
|
||||||
|
<MessageSquare className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="line-through">{expense.description}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-effect rounded-3xl shadow-xl p-6 sm:p-8 max-w-3xl mx-auto">
|
||||||
|
<div className="flex items-center justify-center gap-3 mb-8">
|
||||||
|
<div className="p-3 category-icon rounded-2xl">
|
||||||
|
<DollarSign className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-gray-800">
|
||||||
|
{t('category.management')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddCategory ? (
|
||||||
|
<div className="mb-8 p-6 glass-effect rounded-2xl border-2 border-gray-200">
|
||||||
|
<h3 className="font-bold text-gray-800 mb-5 text-center text-lg">
|
||||||
|
{t('category.newCategory')}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('category.categoryName')}
|
||||||
|
value={newCategoryName}
|
||||||
|
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||||
|
className="w-full px-5 py-4 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder={t('category.categoryLimit')}
|
||||||
|
value={newCategoryLimit}
|
||||||
|
onChange={(e) => setNewCategoryLimit(e.target.value)}
|
||||||
|
className="w-full px-5 py-4 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium text-center"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleAddCategory}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-6 py-4 btn-success text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
{t('common.create')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddCategory(false)}
|
||||||
|
className="px-6 py-4 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-medium"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddCategory(true)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-4 btn-primary text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
{t('category.addCategory')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showShoppingList && familyId && (
|
||||||
<ShoppingListModal
|
<ShoppingListModal
|
||||||
familyId={parseInt(familyId || '0')}
|
familyId={parseInt(familyId)}
|
||||||
onClose={() => setShowShoppingList(false)}
|
onClose={() => setShowShoppingList(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showInviteModal && <InviteModal onClose={() => setShowInviteModal(false)} />}
|
{showInviteModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="glass-effect rounded-3xl shadow-2xl w-full max-w-md p-6 sm:p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 category-icon rounded-2xl">
|
||||||
|
<UserPlus className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-800">{t('invite.title')}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInviteModal(false)}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConfirmModal
|
{!inviteLink ? (
|
||||||
isOpen={confirmState.isOpen}
|
<div className="text-center">
|
||||||
title={confirmState.title}
|
<p className="text-gray-600 mb-6">
|
||||||
message={confirmState.message}
|
{t('invite.description')}
|
||||||
onConfirm={confirmState.onConfirm}
|
</p>
|
||||||
onCancel={cancel}
|
<button
|
||||||
/>
|
onClick={handleCreateInviteLink}
|
||||||
|
disabled={inviteLoading}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-4 btn-primary text-white rounded-2xl hover:shadow-xl transition-all font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{inviteLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
{t('invite.creating')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus className="w-5 h-5" />
|
||||||
|
{t('invite.createLink')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-600 mb-4 text-center">
|
||||||
|
{t('invite.sendLink')}
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-100 rounded-2xl p-4 mb-4">
|
||||||
|
<p className="text-sm text-gray-800 break-all font-mono">
|
||||||
|
{inviteLink.invite_url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyInviteLink}
|
||||||
|
className={`w-full flex items-center justify-center gap-2 px-6 py-4 rounded-2xl transition-all font-semibold ${
|
||||||
|
copied
|
||||||
|
? 'btn-success text-white'
|
||||||
|
: 'btn-primary text-white hover:shadow-xl'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
{t('invite.copied')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-5 h-5" />
|
||||||
|
{t('invite.copyLink')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,75 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { authApi } from '../api/client';
|
import { authApi } from '../api/client';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
import { Loader2, Wallet } from 'lucide-react';
|
import { Loader2, Wallet } from 'lucide-react';
|
||||||
import { FcGoogle } from 'react-icons/fc';
|
import { FcGoogle } from 'react-icons/fc';
|
||||||
|
|
||||||
|
const isTauriEnv = () => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||||
|
|
||||||
|
const DEEP_LINK_SCHEME = 'com.arrelin.family-budget-android://auth';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { setUser } = useStore();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriEnv()) return;
|
||||||
|
|
||||||
|
let unlisten: (() => void) | null = null;
|
||||||
|
|
||||||
|
const setupDeepLink = async () => {
|
||||||
|
const { onOpenUrl } = await import('@tauri-apps/plugin-deep-link');
|
||||||
|
unlisten = await onOpenUrl(async (urls) => {
|
||||||
|
const url = Array.isArray(urls) ? urls[0] : urls;
|
||||||
|
if (!url.startsWith(DEEP_LINK_SCHEME)) return;
|
||||||
|
|
||||||
|
let token: string | null;
|
||||||
|
try {
|
||||||
|
token = new URL(url).searchParams.get('token');
|
||||||
|
} catch {
|
||||||
|
setError(t('login.authError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!token) { setError(t('login.authError')); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await authApi.mobileCallback(token);
|
||||||
|
const me = await authApi.me();
|
||||||
|
setUser(me.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err?.response?.status;
|
||||||
|
const msg = err?.response?.data ? JSON.stringify(err.response.data) : err?.message;
|
||||||
|
setError(`${status ?? 'network'}: ${msg}`);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setupDeepLink();
|
||||||
|
return () => { unlisten?.(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleGoogleLogin = async () => {
|
const handleGoogleLogin = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
|
if (isTauriEnv()) {
|
||||||
|
const { openUrl } = await import('@tauri-apps/plugin-opener');
|
||||||
|
const response = await authApi.getGoogleAuthUrl(undefined, true);
|
||||||
|
await openUrl(response.data.url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentUrl = window.location.origin;
|
const currentUrl = window.location.origin;
|
||||||
const response = await authApi.getGoogleAuthUrl(currentUrl);
|
const response = await authApi.getGoogleAuthUrl(currentUrl);
|
||||||
window.location.href = response.data.url;
|
window.location.href = response.data.url;
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
setError(t('login.authError'));
|
setError(String(err?.message || err));
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
|
|
||||||
<div className="max-w-2xl mx-auto">
|
|
||||||
<button
|
|
||||||
onClick={handleBack}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md mb-6 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-5 h-5" />
|
|
||||||
<span className="font-medium">{t('common.back')}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
|
|
||||||
<UserIcon className="w-12 h-12 text-white" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl font-bold text-white mb-2">{t('profile.title')}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="glass-effect rounded-2xl shadow-lg p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="p-2 category-icon text-white rounded-xl">
|
|
||||||
<UserIcon className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold text-gray-800">{t('profile.info')}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between items-center py-2 border-b border-gray-200">
|
|
||||||
<span className="text-gray-600">{t('profile.username')}</span>
|
|
||||||
<span className="font-medium text-gray-900">{user?.username || '-'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center py-2 border-b border-gray-200">
|
|
||||||
<span className="text-gray-600">{t('profile.email')}</span>
|
|
||||||
<span className="font-medium text-gray-900">{user?.email || '-'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedFamily && (
|
|
||||||
<div className="glass-effect rounded-2xl shadow-lg p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="p-2 btn-success text-white rounded-xl">
|
|
||||||
<Users className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold text-gray-800">{t('profile.family')}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex justify-between items-center py-2 border-b border-gray-200">
|
|
||||||
<span className="text-gray-600">{t('profile.familyName')}</span>
|
|
||||||
{editingName ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newFamilyName}
|
|
||||||
onChange={(e) => 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
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveName}
|
|
||||||
disabled={savingName}
|
|
||||||
className="p-1.5 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
|
|
||||||
>
|
|
||||||
{savingName ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingName(false)}
|
|
||||||
className="p-1.5 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-gray-900">{selectedFamily.name}</span>
|
|
||||||
<button
|
|
||||||
onClick={handleStartEditName}
|
|
||||||
className="p-1.5 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Edit3 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-2">
|
|
||||||
<h3 className="text-sm font-medium text-gray-600 mb-3">{t('profile.members')}</h3>
|
|
||||||
{membersLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
|
|
||||||
</div>
|
|
||||||
) : familyMembers.length === 0 ? (
|
|
||||||
<div className="text-center py-4 text-gray-500 text-sm">
|
|
||||||
{t('profile.noMembers') || 'Нет участников'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{familyMembers.map((member) => (
|
|
||||||
<div
|
|
||||||
key={member.id}
|
|
||||||
className={`flex items-center justify-between p-3 rounded-xl ${member.id === user?.id ? 'member-current border' : 'bg-gray-50'}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-8 h-8 category-icon rounded-full flex items-center justify-center text-white text-sm font-medium">
|
|
||||||
{(member.username || member.email || '?')[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-gray-800">
|
|
||||||
{member.username || member.email || t('profile.unknownUser')}
|
|
||||||
</span>
|
|
||||||
{member.id === user?.id && (
|
|
||||||
<span className="text-xs bg-purple-200 text-purple-700 px-2 py-0.5 rounded-full">
|
|
||||||
{t('profile.you')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{member.is_admin && (
|
|
||||||
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">
|
|
||||||
Admin
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="glass-effect rounded-2xl shadow-lg p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="p-2 btn-primary text-white rounded-xl">
|
|
||||||
<Settings className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold text-gray-800">{t('profile.settings')}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Languages className="w-4 h-4 text-gray-600" />
|
|
||||||
<h3 className="text-sm font-medium text-gray-600">{t('profile.language')}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleLocaleChange('ru')}
|
|
||||||
className={`flex-1 py-3 px-4 rounded-xl font-medium transition-all ${
|
|
||||||
preferences.locale === 'ru'
|
|
||||||
? 'btn-primary text-white shadow-lg'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="mr-2">🇷🇺</span>
|
|
||||||
Русский
|
|
||||||
{preferences.locale === 'ru' && <Check className="w-4 h-4 inline ml-2" />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleLocaleChange('en')}
|
|
||||||
className={`flex-1 py-3 px-4 rounded-xl font-medium transition-all ${
|
|
||||||
preferences.locale === 'en'
|
|
||||||
? 'btn-primary text-white shadow-lg'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="mr-2">🇬🇧</span>
|
|
||||||
English
|
|
||||||
{preferences.locale === 'en' && <Check className="w-4 h-4 inline ml-2" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Palette className="w-4 h-4 text-gray-600" />
|
|
||||||
<h3 className="text-sm font-medium text-gray-600">{t('profile.theme')}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
{THEMES.map((theme) => (
|
|
||||||
<button
|
|
||||||
key={theme.id}
|
|
||||||
onClick={() => handleThemeChange(theme.id)}
|
|
||||||
className={`relative p-1 rounded-xl transition-all ${
|
|
||||||
preferences.theme === theme.id
|
|
||||||
? 'ring-2 ring-purple-500 ring-offset-2'
|
|
||||||
: 'hover:scale-105'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className={`h-12 rounded-lg ${theme.gradient}`} />
|
|
||||||
<span className="text-xs text-gray-600 mt-1 block">{t(`profile.themes.${theme.id}`)}</span>
|
|
||||||
{preferences.theme === theme.id && (
|
|
||||||
<div className="absolute top-2 right-2 w-5 h-5 bg-white rounded-full flex items-center justify-center shadow">
|
|
||||||
<Check className="w-3 h-3 text-purple-600" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedFamily && (
|
|
||||||
<div className="glass-effect rounded-2xl shadow-lg p-6 border-2 border-red-200">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<div className="p-2 btn-danger text-white rounded-xl">
|
|
||||||
<AlertTriangle className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold text-gray-800">{t('profile.dangerZone')}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-600 mb-4">{t('profile.leaveDescription')}</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleLeaveFamily}
|
|
||||||
disabled={leavingFamily}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 btn-danger text-white rounded-xl transition-all font-semibold disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{leavingFamily ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
|
||||||
{t('profile.leaving')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<LogOut className="w-5 h-5" />
|
|
||||||
{t('profile.leaveFamily')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,44 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { User as UserIcon } from 'lucide-react';
|
import { familyApi, userApi, authApi } from '../api/client';
|
||||||
import { familyApi, authApi } from '../api/client';
|
|
||||||
import { useStore } from '../store/useStore';
|
import { useStore } from '../store/useStore';
|
||||||
import { useFamilyMembers, useConfirm } from '../hooks';
|
|
||||||
import type { Theme } from '../types';
|
import type { Theme } from '../types';
|
||||||
import { ProfileHeader } from '../components/profile/ProfileHeader';
|
import {
|
||||||
import { FamilySection } from '../components/profile/FamilySection';
|
User as UserIcon,
|
||||||
import { MembersSection } from '../components/profile/MembersSection';
|
Users,
|
||||||
import { SettingsSection } from '../components/profile/SettingsSection';
|
Settings,
|
||||||
import { ConfirmModal, Card } from '../components/ui';
|
AlertTriangle,
|
||||||
import { showToast } from '../utils/toast';
|
ArrowLeft,
|
||||||
import { showErrorToast } from '../utils/errorHandler';
|
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() {
|
export default function Profile() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, selectedFamily, setSelectedFamily, setUser, preferences, setPreferences } = useStore();
|
const { user, selectedFamily, setSelectedFamily, setUser, preferences, setPreferences, familyMembers, setFamilyMembers } = useStore();
|
||||||
const { members, loading: membersLoading } = useFamilyMembers(user?.family_id || null);
|
|
||||||
const { confirmState, confirm, cancel } = useConfirm();
|
|
||||||
|
|
||||||
|
const [membersLoading, setMembersLoading] = useState(false);
|
||||||
const [leavingFamily, setLeavingFamily] = useState(false);
|
const [leavingFamily, setLeavingFamily] = useState(false);
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const [newFamilyName, setNewFamilyName] = useState('');
|
||||||
|
const [savingName, setSavingName] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.family_id) {
|
if (user?.family_id) {
|
||||||
@@ -29,6 +46,12 @@ export default function Profile() {
|
|||||||
}
|
}
|
||||||
}, [user?.family_id]);
|
}, [user?.family_id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.family_id && selectedFamily) {
|
||||||
|
loadMembers();
|
||||||
|
}
|
||||||
|
}, [user?.family_id, selectedFamily]);
|
||||||
|
|
||||||
const loadFamily = async () => {
|
const loadFamily = async () => {
|
||||||
if (!user?.family_id) return;
|
if (!user?.family_id) return;
|
||||||
try {
|
try {
|
||||||
@@ -39,42 +62,101 @@ 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 () => {
|
const handleLeaveFamily = async () => {
|
||||||
await confirm(t('profile.leaveConfirm'), t('profile.leaveMessage'));
|
if (!confirm(t('profile.leaveConfirm'))) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLeavingFamily(true);
|
setLeavingFamily(true);
|
||||||
const { userApi } = await import('../api/client');
|
|
||||||
await userApi.leaveFamily();
|
await userApi.leaveFamily();
|
||||||
|
|
||||||
const meResponse = await authApi.me();
|
const meResponse = await authApi.me();
|
||||||
setUser(meResponse.data);
|
setUser(meResponse.data);
|
||||||
setSelectedFamily(null);
|
setSelectedFamily(null);
|
||||||
|
setFamilyMembers([]);
|
||||||
|
|
||||||
showToast.success(t('profile.leftFamily'));
|
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
showErrorToast(error);
|
console.error('Error leaving family:', err);
|
||||||
|
alert(t('profile.leaveError'));
|
||||||
} finally {
|
} finally {
|
||||||
setLeavingFamily(false);
|
setLeavingFamily(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThemeChange = (theme: Theme) => {
|
const themeColors: Record<string, string> = {
|
||||||
setPreferences({ ...preferences, theme });
|
light: '#667eea',
|
||||||
showToast.success(t('profile.themeChanged'));
|
dark: '#000000',
|
||||||
|
sunset: '#f97316',
|
||||||
|
ocean: '#3b82f6',
|
||||||
|
forest: '#22c55e',
|
||||||
|
purple: '#8b5cf6',
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLocaleChange = (locale: string) => {
|
const handleThemeChange = (theme: Theme) => {
|
||||||
|
setPreferences({ theme });
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
const metaTheme = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (metaTheme) metaTheme.setAttribute('content', themeColors[theme] ?? '#667eea');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocaleChange = (locale: 'ru' | 'en') => {
|
||||||
|
setPreferences({ locale });
|
||||||
i18n.changeLanguage(locale);
|
i18n.changeLanguage(locale);
|
||||||
setPreferences({ ...preferences, locale: locale as 'ru' | 'en' });
|
};
|
||||||
showToast.success(t('profile.languageChanged'));
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
|
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<ProfileHeader onBack={() => navigate(user?.family_id ? `/family/${user.family_id}` : '/')} />
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md mb-6 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
<span className="font-medium">{t('common.back')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
|
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
|
||||||
@@ -84,63 +166,221 @@ export default function Profile() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<div className="glass-effect rounded-2xl shadow-lg p-6">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="p-2 bg-gradient-to-br from-blue-500 to-blue-600 text-white rounded-xl">
|
<div className="p-2 category-icon text-white rounded-xl">
|
||||||
<UserIcon className="w-6 h-6" />
|
<UserIcon className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold text-gray-800 dark:text-white">{t('profile.info')}</h2>
|
<h2 className="text-xl font-bold text-gray-800">{t('profile.info')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center py-2 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex justify-between items-center py-2 border-b border-gray-200">
|
||||||
<span className="text-gray-600 dark:text-gray-400">{t('profile.username')}</span>
|
<span className="text-gray-600">{t('profile.username')}</span>
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
<span className="font-medium text-gray-900">{user?.username || '-'}</span>
|
||||||
{user?.username || '-'}
|
</div>
|
||||||
</span>
|
<div className="flex justify-between items-center py-2 border-b border-gray-200">
|
||||||
|
<span className="text-gray-600">{t('profile.email')}</span>
|
||||||
|
<span className="font-medium text-gray-900">{user?.email || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center py-2">
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">{t('profile.email')}</span>
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
|
||||||
{user?.email || '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{selectedFamily && (
|
{selectedFamily && (
|
||||||
<Card>
|
<div className="glass-effect rounded-2xl shadow-lg p-6">
|
||||||
<FamilySection
|
<div className="flex items-center gap-3 mb-4">
|
||||||
family={selectedFamily}
|
<div className="p-2 btn-success text-white rounded-xl">
|
||||||
onLeaveFamily={handleLeaveFamily}
|
<Users className="w-6 h-6" />
|
||||||
onFamilyUpdate={loadFamily}
|
</div>
|
||||||
leavingFamily={leavingFamily}
|
<h2 className="text-xl font-bold text-gray-800">{t('profile.family')}</h2>
|
||||||
/>
|
</div>
|
||||||
<div className="mt-4">
|
|
||||||
<MembersSection
|
<div className="mb-4">
|
||||||
members={members}
|
<div className="flex justify-between items-center py-2 border-b border-gray-200">
|
||||||
currentUser={user}
|
<span className="text-gray-600">{t('profile.familyName')}</span>
|
||||||
loading={membersLoading}
|
{editingName ? (
|
||||||
/>
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newFamilyName}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveName}
|
||||||
|
disabled={savingName}
|
||||||
|
className="p-1.5 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
|
||||||
|
>
|
||||||
|
{savingName ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingName(false)}
|
||||||
|
className="p-1.5 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-900">{selectedFamily.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleStartEditName}
|
||||||
|
className="p-1.5 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600 mb-3">{t('profile.members')}</h3>
|
||||||
|
{membersLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
) : familyMembers.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-gray-500 text-sm">
|
||||||
|
{t('profile.noMembers') || 'Нет участников'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{familyMembers.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className={`flex items-center justify-between p-3 rounded-xl ${member.id === user?.id ? 'member-current border' : 'bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 category-icon rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||||
|
{(member.username || member.email || '?')[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
{member.username || member.email || t('profile.unknownUser')}
|
||||||
|
</span>
|
||||||
|
{member.id === user?.id && (
|
||||||
|
<span className="text-xs bg-purple-200 text-purple-700 px-2 py-0.5 rounded-full">
|
||||||
|
{t('profile.you')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{member.is_admin && (
|
||||||
|
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingsSection
|
<div className="glass-effect rounded-2xl shadow-lg p-6">
|
||||||
currentTheme={preferences.theme}
|
<div className="flex items-center gap-3 mb-4">
|
||||||
currentLanguage={preferences.locale}
|
<div className="p-2 btn-primary text-white rounded-xl">
|
||||||
onThemeChange={handleThemeChange}
|
<Settings className="w-6 h-6" />
|
||||||
onLanguageChange={handleLocaleChange}
|
</div>
|
||||||
/>
|
<h2 className="text-xl font-bold text-gray-800">{t('profile.settings')}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Languages className="w-4 h-4 text-gray-600" />
|
||||||
|
<h3 className="text-sm font-medium text-gray-600">{t('profile.language')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleLocaleChange('ru')}
|
||||||
|
className={`flex-1 py-3 px-4 rounded-xl font-medium transition-all ${
|
||||||
|
preferences.locale === 'ru'
|
||||||
|
? 'btn-primary text-white shadow-lg'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="mr-2">🇷🇺</span>
|
||||||
|
Русский
|
||||||
|
{preferences.locale === 'ru' && <Check className="w-4 h-4 inline ml-2" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleLocaleChange('en')}
|
||||||
|
className={`flex-1 py-3 px-4 rounded-xl font-medium transition-all ${
|
||||||
|
preferences.locale === 'en'
|
||||||
|
? 'btn-primary text-white shadow-lg'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="mr-2">🇬🇧</span>
|
||||||
|
English
|
||||||
|
{preferences.locale === 'en' && <Check className="w-4 h-4 inline ml-2" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmModal
|
<div>
|
||||||
isOpen={confirmState.isOpen}
|
<div className="flex items-center gap-2 mb-3">
|
||||||
title={confirmState.title}
|
<Palette className="w-4 h-4 text-gray-600" />
|
||||||
message={confirmState.message}
|
<h3 className="text-sm font-medium text-gray-600">{t('profile.theme')}</h3>
|
||||||
onConfirm={confirmState.onConfirm}
|
</div>
|
||||||
onCancel={cancel}
|
<div className="grid grid-cols-3 gap-3">
|
||||||
/>
|
{THEMES.map((theme) => (
|
||||||
|
<button
|
||||||
|
key={theme.id}
|
||||||
|
onClick={() => handleThemeChange(theme.id)}
|
||||||
|
className={`relative p-1 rounded-xl transition-all ${
|
||||||
|
preferences.theme === theme.id
|
||||||
|
? 'ring-2 ring-purple-500 ring-offset-2'
|
||||||
|
: 'hover:scale-105'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`h-12 rounded-lg ${theme.gradient}`} />
|
||||||
|
<span className="text-xs text-gray-600 mt-1 block">{t(`profile.themes.${theme.id}`)}</span>
|
||||||
|
{preferences.theme === theme.id && (
|
||||||
|
<div className="absolute top-2 right-2 w-5 h-5 bg-white rounded-full flex items-center justify-center shadow">
|
||||||
|
<Check className="w-3 h-3 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedFamily && (
|
||||||
|
<div className="glass-effect rounded-2xl shadow-lg p-6 border-2 border-red-200">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="p-2 btn-danger text-white rounded-xl">
|
||||||
|
<AlertTriangle className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-800">{t('profile.dangerZone')}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-600 mb-4">{t('profile.leaveDescription')}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLeaveFamily}
|
||||||
|
disabled={leavingFamily}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 btn-danger text-white rounded-xl transition-all font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{leavingFamily ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
{t('profile.leaving')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
{t('profile.leaveFamily')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import { categoryApi, expenseApi } from '../api/client';
|
|
||||||
import type { Category, CreateCategoryRequest } from '../types';
|
|
||||||
import { handleApiError } from '../utils/errorHandler';
|
|
||||||
|
|
||||||
export interface CategoryWithRemaining extends Category {
|
|
||||||
remaining_limit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const categoryService = {
|
|
||||||
async getAllByFamily(familyId: number): Promise<CategoryWithRemaining[]> {
|
|
||||||
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<CategoryWithRemaining> {
|
|
||||||
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<Category> {
|
|
||||||
try {
|
|
||||||
const res = await categoryApi.create(familyId, data);
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async update(familyId: number, categoryId: number, data: Partial<CreateCategoryRequest>): Promise<Category> {
|
|
||||||
try {
|
|
||||||
const res = await categoryApi.update(familyId, categoryId, data);
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete(familyId: number, categoryId: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
await categoryApi.delete(familyId, categoryId);
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async resetLimit(familyId: number, categoryId: number, newLimit: number): Promise<Category> {
|
|
||||||
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';
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { expenseApi } from '../api/client';
|
|
||||||
import type { Expense, CreateExpenseRequest } from '../types';
|
|
||||||
import { handleApiError } from '../utils/errorHandler';
|
|
||||||
|
|
||||||
export const expenseService = {
|
|
||||||
async getAllByCategory(familyId: number, categoryId: number): Promise<Expense[]> {
|
|
||||||
try {
|
|
||||||
const res = await expenseApi.getAllByCategory(familyId, categoryId);
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async getById(familyId: number, categoryId: number, expenseId: number): Promise<Expense> {
|
|
||||||
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<Expense> {
|
|
||||||
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<CreateExpenseRequest>): Promise<Expense> {
|
|
||||||
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<void> {
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { familyApi } from '../api/client';
|
|
||||||
import type { Family, CreateFamilyRequest, CreateMyFamilyRequest, CreateMyFamilyResponse, VerifyFamilyPasswordRequest, FamilyMember } from '../types';
|
|
||||||
import { handleApiError } from '../utils/errorHandler';
|
|
||||||
|
|
||||||
export const familyService = {
|
|
||||||
async getAll(): Promise<Family[]> {
|
|
||||||
try {
|
|
||||||
const res = await familyApi.getAll();
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async getById(id: number): Promise<Family> {
|
|
||||||
try {
|
|
||||||
const res = await familyApi.getById(id);
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async create(data: CreateFamilyRequest): Promise<Family> {
|
|
||||||
try {
|
|
||||||
const res = await familyApi.create(data);
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async createMyFamily(data: CreateMyFamilyRequest): Promise<CreateMyFamilyResponse> {
|
|
||||||
try {
|
|
||||||
const res = await familyApi.createMyFamily(data);
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async update(id: number, data: { name: string }): Promise<Family> {
|
|
||||||
try {
|
|
||||||
const res = await familyApi.update(id, data);
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete(id: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
await familyApi.delete(id);
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async verifyPassword(id: number, data: VerifyFamilyPasswordRequest): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const res = await familyApi.verifyPassword(id, data);
|
|
||||||
return res.data.valid;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async getMembers(familyId: number): Promise<FamilyMember[]> {
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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';
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { inviteLinkApi } from '../api/client';
|
|
||||||
import type { CreateInviteLinkRequest, InviteLinkResponse, ValidateInviteResponse, JoinFamilyResponse } from '../types';
|
|
||||||
import { handleApiError } from '../utils/errorHandler';
|
|
||||||
|
|
||||||
export const inviteService = {
|
|
||||||
async create(data: CreateInviteLinkRequest): Promise<InviteLinkResponse> {
|
|
||||||
try {
|
|
||||||
const res = await inviteLinkApi.create(data);
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async getMyLinks(): Promise<InviteLinkResponse[]> {
|
|
||||||
try {
|
|
||||||
const res = await inviteLinkApi.getMyLinks();
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete(token: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await inviteLinkApi.delete(token);
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async validate(token: string): Promise<ValidateInviteResponse> {
|
|
||||||
try {
|
|
||||||
const res = await inviteLinkApi.validate(token);
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async join(token: string): Promise<JoinFamilyResponse> {
|
|
||||||
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',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { shoppingItemApi } from '../api/client';
|
|
||||||
import type { ShoppingItem, CreateShoppingItemRequest, UpdateShoppingItemRequest, MarkAsPurchasedRequest } from '../types';
|
|
||||||
import { handleApiError } from '../utils/errorHandler';
|
|
||||||
|
|
||||||
export const shoppingService = {
|
|
||||||
async getAllByFamily(familyId: number): Promise<ShoppingItem[]> {
|
|
||||||
try {
|
|
||||||
const res = await shoppingItemApi.getAllByFamily(familyId);
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async getById(familyId: number, itemId: number): Promise<ShoppingItem> {
|
|
||||||
try {
|
|
||||||
const res = await shoppingItemApi.getById(familyId, itemId);
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async create(familyId: number, data: CreateShoppingItemRequest): Promise<ShoppingItem> {
|
|
||||||
try {
|
|
||||||
const res = await shoppingItemApi.create(familyId, data);
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async update(familyId: number, itemId: number, data: UpdateShoppingItemRequest): Promise<ShoppingItem> {
|
|
||||||
try {
|
|
||||||
const res = await shoppingItemApi.update(familyId, itemId, data);
|
|
||||||
return res.data;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete(familyId: number, itemId: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
await shoppingItemApi.delete(familyId, itemId);
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async markAsPurchased(familyId: number, itemId: number, isPurchased: boolean): Promise<ShoppingItem> {
|
|
||||||
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<number> {
|
|
||||||
try {
|
|
||||||
const res = await shoppingItemApi.markAllAsPurchased(familyId);
|
|
||||||
return res.data.affected_rows;
|
|
||||||
} catch (error) {
|
|
||||||
handleApiError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async clearAll(familyId: number): Promise<number> {
|
|
||||||
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 };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -7,18 +7,6 @@ const getStoredPreferences = () => {
|
|||||||
return { theme, locale };
|
return { theme, locale };
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CacheEntry<T> {
|
|
||||||
data: T;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CacheState {
|
|
||||||
categories: Map<number, CacheEntry<Category[]>>;
|
|
||||||
members: Map<number, CacheEntry<FamilyMember[]>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CACHE_TTL = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
@@ -28,7 +16,6 @@ interface AppState {
|
|||||||
categories: Category[];
|
categories: Category[];
|
||||||
familyMembers: FamilyMember[];
|
familyMembers: FamilyMember[];
|
||||||
preferences: { theme: Theme; locale: Locale };
|
preferences: { theme: Theme; locale: Locale };
|
||||||
cache: CacheState;
|
|
||||||
|
|
||||||
setUser: (user: User | null) => void;
|
setUser: (user: User | null) => void;
|
||||||
setIsLoading: (loading: boolean) => void;
|
setIsLoading: (loading: boolean) => void;
|
||||||
@@ -38,15 +25,9 @@ interface AppState {
|
|||||||
setFamilyMembers: (members: FamilyMember[]) => void;
|
setFamilyMembers: (members: FamilyMember[]) => void;
|
||||||
setPreferences: (prefs: Partial<{ theme: Theme; locale: Locale }>) => void;
|
setPreferences: (prefs: Partial<{ theme: Theme; locale: Locale }>) => void;
|
||||||
logout: () => 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<AppState>((set, get) => ({
|
export const useStore = create<AppState>((set) => ({
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
@@ -55,10 +36,6 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
categories: [],
|
categories: [],
|
||||||
familyMembers: [],
|
familyMembers: [],
|
||||||
preferences: getStoredPreferences(),
|
preferences: getStoredPreferences(),
|
||||||
cache: {
|
|
||||||
categories: new Map(),
|
|
||||||
members: new Map(),
|
|
||||||
},
|
|
||||||
|
|
||||||
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||||
|
|
||||||
@@ -79,59 +56,6 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
return { preferences: newPrefs };
|
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(() => ({
|
|
||||||
cache: {
|
|
||||||
categories: new Map(),
|
|
||||||
members: new Map(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
logout: () => set({
|
logout: () => set({
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
@@ -139,9 +63,5 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
families: [],
|
families: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
familyMembers: [],
|
familyMembers: [],
|
||||||
cache: {
|
|
||||||
categories: new Map(),
|
|
||||||
members: new Map(),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
export interface ApiError {
|
|
||||||
message: string;
|
|
||||||
status?: number;
|
|
||||||
code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValidationError extends ApiError {
|
|
||||||
field?: string;
|
|
||||||
errors?: Record<string, string[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -29,6 +29,18 @@ export interface Expense {
|
|||||||
amount: number;
|
amount: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyExpenseGroup {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
total_amount: number | string;
|
||||||
|
expenses: Expense[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpenseHistoryResponse {
|
||||||
|
months: MonthlyExpenseGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemainingLimit {
|
export interface RemainingLimit {
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { AxiosError } from 'axios';
|
|
||||||
import { 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';
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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: <T,>(
|
|
||||||
promise: Promise<T>,
|
|
||||||
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)',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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<T extends Record<string, any>>(
|
|
||||||
values: T,
|
|
||||||
rules: Partial<Record<keyof T, (value: any) => string | null>>
|
|
||||||
): Partial<Record<keyof T, string>> {
|
|
||||||
const errors: Partial<Record<keyof T, string>> = {};
|
|
||||||
|
|
||||||
for (const field in rules) {
|
|
||||||
const validator = rules[field];
|
|
||||||
if (validator) {
|
|
||||||
const error = validator(values[field]);
|
|
||||||
if (error) {
|
|
||||||
errors[field] = error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -5,10 +5,27 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
host: process.env.TAURI_DEV_HOST || 'localhost',
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: process.env.TAURI_DEV_HOST
|
||||||
|
? 'https://family-budget.duckdns.org'
|
||||||
|
: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
configure: (proxy) => {
|
||||||
|
if (process.env.TAURI_DEV_HOST) {
|
||||||
|
proxy.on('proxyRes', (proxyRes) => {
|
||||||
|
const cookies = proxyRes.headers['set-cookie'];
|
||||||
|
if (cookies) {
|
||||||
|
proxyRes.headers['set-cookie'] = cookies.map(cookie =>
|
||||||
|
cookie
|
||||||
|
.replace(/;\s*Secure/gi, '')
|
||||||
|
.replace(/;\s*Domain=[^;]*/gi, '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
mobile/.gitignore
vendored
Normal file
24
mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
3
mobile/.vscode/extensions.json
vendored
Normal file
3
mobile/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||||
|
}
|
||||||
245
mobile/package-lock.json
generated
Normal file
245
mobile/package-lock.json
generated
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
{
|
||||||
|
"name": "family-budget-mobile",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "family-budget-mobile",
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"bin": {
|
||||||
|
"tauri": "tauri.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/tauri"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tauri-apps/cli-darwin-arm64": "2.10.1",
|
||||||
|
"@tauri-apps/cli-darwin-x64": "2.10.1",
|
||||||
|
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1",
|
||||||
|
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1",
|
||||||
|
"@tauri-apps/cli-linux-arm64-musl": "2.10.1",
|
||||||
|
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1",
|
||||||
|
"@tauri-apps/cli-linux-x64-gnu": "2.10.1",
|
||||||
|
"@tauri-apps/cli-linux-x64-musl": "2.10.1",
|
||||||
|
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1",
|
||||||
|
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1",
|
||||||
|
"@tauri-apps/cli-win32-x64-msvc": "2.10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
mobile/package.json
Normal file
12
mobile/package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "family-budget-mobile",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"tauri": "tauri",
|
||||||
|
"android:dev": "tauri android dev",
|
||||||
|
"android:build": "tauri android build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
mobile/src-tauri/.gitignore
vendored
Normal file
7
mobile/src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
# will have schema files for capabilities auto-completion
|
||||||
|
/gen/schemas
|
||||||
26
mobile/src-tauri/Cargo.toml
Normal file
26
mobile/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "family-budget-android"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
|
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||||
|
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||||
|
name = "family_budget_android_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-deep-link = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
3
mobile/src-tauri/build.rs
Normal file
3
mobile/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
11
mobile/src-tauri/capabilities/default.json
Normal file
11
mobile/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"opener:default",
|
||||||
|
"deep-link:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
12
mobile/src-tauri/gen/android/.editorconfig
Normal file
12
mobile/src-tauri/gen/android/.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# EditorConfig is awesome: https://EditorConfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
insert_final_newline = false
|
||||||
19
mobile/src-tauri/gen/android/.gitignore
vendored
Normal file
19
mobile/src-tauri/gen/android/.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
.DS_Store
|
||||||
|
build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
key.properties
|
||||||
|
|
||||||
|
/.tauri
|
||||||
|
/tauri.settings.gradle
|
||||||
6
mobile/src-tauri/gen/android/app/.gitignore
vendored
Normal file
6
mobile/src-tauri/gen/android/app/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/src/main/**/generated
|
||||||
|
/src/main/jniLibs/**/*.so
|
||||||
|
/src/main/assets/tauri.conf.json
|
||||||
|
/tauri.build.gradle.kts
|
||||||
|
/proguard-tauri.pro
|
||||||
|
/tauri.properties
|
||||||
70
mobile/src-tauri/gen/android/app/build.gradle.kts
Normal file
70
mobile/src-tauri/gen/android/app/build.gradle.kts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("rust")
|
||||||
|
}
|
||||||
|
|
||||||
|
val tauriProperties = Properties().apply {
|
||||||
|
val propFile = file("tauri.properties")
|
||||||
|
if (propFile.exists()) {
|
||||||
|
propFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = 36
|
||||||
|
namespace = "com.arrelin.family_budget_android"
|
||||||
|
defaultConfig {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
|
applicationId = "com.arrelin.family_budget_android"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||||
|
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
|
isDebuggable = true
|
||||||
|
isJniDebuggable = true
|
||||||
|
isMinifyEnabled = false
|
||||||
|
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getByName("release") {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(
|
||||||
|
*fileTree(".") { include("**/*.pro") }
|
||||||
|
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
|
.toList().toTypedArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rust {
|
||||||
|
rootDirRel = "../../../"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.webkit:webkit:1.14.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||||
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(from = "tauri.build.gradle.kts")
|
||||||
21
mobile/src-tauri/gen/android/app/proguard-rules.pro
vendored
Normal file
21
mobile/src-tauri/gen/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<!-- AndroidTV support -->
|
||||||
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.family_budget_android"
|
||||||
|
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:label="@string/main_activity_title"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<!-- AndroidTV support -->
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||||
|
<intent-filter >
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="com.arrelin.family-budget-android" />
|
||||||
|
<data android:host="auth" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</intent-filter>
|
||||||
|
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.arrelin.family_budget_android
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
|
||||||
|
class MainActivity : TauriActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Hello World!"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user